第二章:商户查询缓存模块
📺 对应集数:p035~p047(实战篇)
2.1 认识缓存
2.1.1 什么是缓存
缓存(Cache) = 数据交换的缓冲区,存储数据的临时地方,读写性能较高。
💡 核心要点:缓存的价值在于”读写性能高”,这是它被用作数据交换缓冲区的根本原因。
经典案例 - CPU 缓存: - CPU 运算能力远超内存/磁盘的读写能力 - CPU 从内存/磁盘读数据的速度成为性能瓶颈 - 解决方案:在 CPU 内部添加缓存,将常用数据存入 - 衡量 CPU 性能的标准之一:CPU 缓存大小
2.1.2 Web 应用中的缓存层级
请求流向:
客户端(浏览器) → Nginx(反向代理) → Tomcat(应用层) → Redis → MySQL → 磁盘
↓ ↓ ↓ ↓
浏览器缓存 Nginx缓存 应用层缓存 数据库缓存
(静态资源) (反向代理缓存) (Map/Redis) (索引缓存)| 层级 | 缓存方式 | 示例 |
|---|---|---|
| 浏览器缓存 | 本地缓存静态资源 | CSS、JS、图片 |
| 应用层缓存 | 内存/Redis 缓存 | 商户信息、用户数据 |
| 数据库缓存 | 索引缓存 | MySQL 聚簇索引 |
2.1.3 缓存的作用与成本
✅ 带来的好处:
- 降低后端负载:请求直接从缓存返回,减轻数据库压力
- 提高读写效率:Redis 读写延迟在微秒级别,大大缩短响应时间
- 应对高并发:高用户量业务中,缓存可有效解决高并发问题
❌ 带来的成本:
- 数据一致性成本:数据库更新后缓存未同步,导致数据不一致
- 开发维护成本:解决一致性问题需要复杂编码(穿透、击穿等)
- 运维成本:缓存集群的部署、维护需要额外人力和硬件成本
⚠️ 决策原则:企业需要权衡缓存带来的好处能否弥补成本。中小型初创企业用户量不大时,不一定需要引入缓存。
2.2 实现商户缓存查询
2.2.1 缓存工作模型
无缓存:
客户端 → 数据库 → 返回
有缓存:
客户端 → Redis(命中)→ 直接返回
↓ (未命中)
数据库 → 返回 + 写入Redis关键点: - 未命中时查数据库,查到后写回 Redis,确保下次能命中 - 随着请求增多,Redis 命中率逐渐提高,形成良性循环
2.2.2 业务流程图
开始 → 提交商铺ID
↓
查询 Redis 缓存
↓
判断是否命中?
├─ 命中 → 直接返回商铺信息
└─ 未命中 → 根据ID查询数据库
↓
判断是否存在?
├─ 不存在 → 返回 404
└─ 存在 → 写入Redis → 返回商铺信息2.2.3 代码实现
Service 层核心代码:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1. 从Redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue()
.get("cache:shop:" + id);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,查询数据库
Shop shop = getById(id);
if (shop == null) {
// 5. 不存在,返回错误
return Result.fail("店铺不存在!");
}
// 6. 存在,写入Redis
stringRedisTemplate.opsForValue()
.set("cache:shop:" + id, JSONUtil.toJsonStr(shop));
// 7. 返回
return Result.ok(shop);
}
}Key 设计规范:
格式:cache:shop:{id}
示例:cache:shop:1💡 两个关键点: 1. 查询时先查 Redis 2. 未命中时查数据库,然后写回 Redis
2.3 缓存更新策略
2.3.1 三种策略对比
| 策略 | 一致性 | 维护成本 | 说明 |
|---|---|---|---|
| 内存淘汰 | 差 | 几乎为0 | Redis 内存不足时自动淘汰部分数据 |
| 超时剔除 | 一般 | 低 | 给 key 设置 TTL,到期自动删除 |
| 主动更新 | 好 | 高 | 自定义逻辑,更新数据库时同步更新缓存 |
2.3.2 策略选择建议
- 低一致性需求:使用内存淘汰机制(如店铺类型,几乎不变)
- 高一致性需求:使用主动更新 + 超时剔除兜底(如店铺详情、优惠券)
2.3.3 主动更新的三种模式
| 模式 | 说明 | 优缺点 |
|---|---|---|
| Cache Aside(旁路缓存) | 由开发者自己编码,更新数据库同时更新缓存 | 可控性强,企业常用 |
| Read/Write Through | 缓存数据库整合成一个服务,对外透明 | 对调用者简单,但服务开发复杂 |
| Write Behind Caching | 只操作缓存,异步线程定时将缓存数据同步到数据库 | 写效率高,但一致性难保证 |
2.3.4 Cache Aside 的关键问题
问题一:删除缓存 vs 更新缓存?
选择删除缓存: - 更新缓存:每次更新数据库都要更新缓存,写多读少时有大量无效操作 - 删除缓存:更新数据库时删缓存,有人查询时才更新,属于延迟加载
问题二:如何保证原子性?
- 单体系统:利用数据库事务,保证更新数据库和删除缓存同时成功或失败
- 分布式系统:使用分布式事务方案(如 TCC)保证原子性
问题三:先删缓存还是先操作数据库?
选择先操作数据库,再删缓存: - 方案一(先删缓存再操作数据库):线程安全问题发生概率较高 - 方案二(先操作数据库再删缓存):线程安全问题发生概率极低
方案二线程安全分析:
正常情况:更新数据库(20) → 删缓存 → 用户查询 → 查数据库(20) → 写缓存(20) ✅
异常情况(概率极低,需满足3个巧合): 1. 两个线程并发执行 2. 查询线程恰好缓存失效 3. 在查询线程写缓存的微秒级时间内,更新线程完成了数据库更新+删缓存
因为缓存写操作是微秒级,而数据库更新较慢,所以同时满足这3个巧合的概率极低。
2.3.5 最终最佳实践
写操作:
更新数据库 → 删除缓存(保证原子性,先操作数据库再删缓存)
读操作:
查询Redis → 命中直接返回
↓ 未命中
查询数据库 → 写入Redis(设置TTL作为兜底)→ 返回2.4 实现缓存同步(代码)
2.4.1 查询业务(添加 TTL)
// 写入Redis时设置过期时间(30分钟)
stringRedisTemplate.opsForValue()
.set("cache:shop:" + id, JSONUtil.toJsonStr(shop),
30, TimeUnit.MINUTES);2.4.2 更新业务(先更新DB,再删缓存)
@Transactional
@Override
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1. 更新数据库
updateById(shop);
// 2. 删除缓存
stringRedisTemplate.delete("cache:shop:" + id);
return Result.ok();
}⚠️
@Transactional确保更新数据库和删除缓存的原子性
2.5 缓存穿透
2.5.1 问题描述
缓存穿透:客户端请求的数据在缓存和数据库中都不存在,请求永远无法建立缓存,每次都会打到数据库。
恶意攻击场景:
用户传入不存在的ID → Redis未命中 → 数据库未命中 → 返回空
↓
再次请求相同ID → 同样的流程 → 数据库持续承压2.5.2 解决方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空值 | 查询不存在时缓存 null 值 | 实现简单 | 额外内存消耗、短期不一致 |
| 布隆过滤 | 在 Redis 前加过滤层,基于位图判断 | 内存占用少 | 实现复杂、存在误判 |
2.5.3 方案一:缓存空值(代码实现)
流程变化:
原流程:数据库不存在 → 返回 404
新流程:数据库不存在 → 写入空值到Redis → 返回错误
↓
命中时判断:是空值 → 返回错误;是真实数据 → 返回数据核心代码:
@Override
public Result queryById(Long id) {
String shopJson = stringRedisTemplate.opsForValue()
.get("cache:shop:" + id);
// 1. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断是否命中的是空值
if (shopJson != null) {
// 命中空值,返回错误
return Result.fail("店铺不存在!");
}
// 2. 查询数据库
Shop shop = getById(id);
if (shop == null) {
// 不存在,缓存空值(设置较短TTL,如2分钟)
stringRedisTemplate.opsForValue()
.set("cache:shop:" + id, "",
2, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 3. 存在,写入Redis
stringRedisTemplate.opsForValue()
.set("cache:shop:" + id, JSONUtil.toJsonStr(shop),
30, TimeUnit.MINUTES);
return Result.ok(shop);
}2.5.4 其他主动防御措施
- 增加 ID 复杂度:避免简单数字 ID(如
0),采用一定格式规范(长度、前缀) - 基础参数校验:在进入业务逻辑前检查 ID 格式是否合法
- 用户权限管理:要求登录、限制访问频率
- 热点参数限流:对空值访问进行限流
2.6 缓存雪崩
2.6.1 问题描述
缓存雪崩:同一时段内大量缓存 key 同时失效,或 Redis 服务宕机,导致大量请求到达数据库。
两种场景: 1. 大量 key 同时失效:缓存预热批量导入时 TTL 设置相同,到期一起失效 2. Redis 宕机:所有请求直接打到数据库,数据库裸奔
2.6.2 解决方案
| 方案 | 针对问题 | 说明 |
|---|---|---|
| TTL 添加随机值 | key 同时失效 | 批量导入时给 TTL 加随机偏移(如 30+随机1~5分钟) |
| Redis 集群(哨兵) | Redis 宕机 | 主从集群 + 哨兵自动故障转移,保证高可用 |
| 服务降级和限流 | 严重故障 | Redis 故障时快速失败,拒绝服务,保护数据库 |
| 多级缓存 | 综合保护 | 浏览器 → Nginx → Redis → JVM本地缓存 → 数据库 |
💡 多级缓存就像穿5件防弹衣,一层一层保护,即使某一环崩了还有其他缓存。
2.7 缓存击穿
2.7.1 问题描述
缓存击穿(热点 key 问题):被高并发访问且缓存重建业务复杂的热点 key 过期,导致大量请求同时涌入数据库。
特点: 1. 高并发访问:该 key 被大量请求同时访问 2. 缓存重建耗时长:可能需要关联多个表、复杂运算,耗时几十到数百毫秒
问题场景:
热点key过期 → 第1个请求查缓存未命中 → 开始重建(耗时200ms)
↓
在重建的200ms内 → 无数请求涌入 → 全部未命中 → 全部打到数据库 → 数据库崩溃2.7.2 两种解决方案对比
| 对比维度 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|
| 实现方式 | 利用 SETNX 实现互斥锁 | 数据永不过期,逻辑上维护过期时间 |
| 一致性 | ✅ 强一致性 | ❌ 不保证(可能返回旧数据) |
| 性能 | ❌ 等待导致性能下降 | ✅ 无需等待,性能好 |
| 额外内存 | 无 | 需存储过期时间字段 |
| 代码复杂度 | 简单 | 复杂 |
| 死锁风险 | 有 | 无 |
2.7.3 方案一:互斥锁
原理:只有一个线程能获取锁执行缓存重建,其他线程等待/重试。
线程1(获取锁成功):
查询缓存未命中 → 获取锁成功 → 查数据库 → 写入Redis → 释放锁 → 返回
线程2(获取锁失败):
查询缓存未命中 → 获取锁失败 → 休眠50ms → 重试查询 → 命中 → 返回实现互斥锁(基于 SETNX):
// 获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}💡
SETNX命令:只有 key 不存在时才能设置成功,天然适合做互斥锁。 必须设置 TTL(如10秒)防止程序异常导致锁永远无法释放。
完整代码实现:
public Shop queryWithMutex(Long id) {
String key = "cache:shop:" + id;
String lockKey = "lock:shop:" + id;
// 1. 查Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否命中
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopJson != null) {
return null; // 命中空值
}
// 3. 未命中,尝试获取互斥锁
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock) {
// 获取锁失败,休眠后重试
Thread.sleep(50);
return queryWithMutex(id); // 递归重试
}
// 获取锁成功,查询数据库
shop = getById(id);
if (shop == null) {
// 缓存空值(解决穿透)
stringRedisTemplate.opsForValue()
.set(key, "", 2, TimeUnit.MINUTES);
return null;
}
// 写入Redis
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(shop),
30, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
return shop;
}2.7.4 方案二:逻辑过期
原理:数据存储时不设置 TTL(永不过期),在 value 中额外存储一个逻辑过期时间字段。
数据结构:
@Data
public class RedisData<T> {
private LocalDateTime expireTime; // 逻辑过期时间
private T data; // 实际数据
}缓存预热(活动前提前加载):
public void saveShopToRedis(Long id, Long expireSeconds) {
// 1. 查询数据库
Shop shop = getById(id);
// 2. 封装逻辑过期时间
RedisData<Shop> redisData = new RedisData<>();
redisData.setData(shop);
redisData.setExpireTime(
LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入Redis(不设置TTL)
stringRedisTemplate.opsForValue()
.set("cache:shop:" + id, JSONUtil.toJsonStr(redisData));
}查询逻辑:
private static final ExecutorService CACHE_REBUILD_EXECUTOR =
Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
String key = "cache:shop:" + id;
// 1. 查Redis
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 未命中(非热点key),直接返回null
if (StrUtil.isBlank(json)) {
return null;
}
// 3. 命中,反序列化
RedisData<Shop> redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断逻辑是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回旧数据
return shop;
}
// 5. 已过期,尝试获取互斥锁
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 获取锁成功,开启独立线程重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShopToRedis(id, 30L); // 重建缓存
} finally {
unlock(lockKey); // 释放锁
}
});
}
// 6. 返回旧数据(无论是否获取到锁)
return shop;
}💡 关键区别: - 互斥锁:未获取到锁的线程等待重试,保证强一致性 - 逻辑过期:未获取到锁的线程直接返回旧数据,保证高性能
2.8 工具类封装
2.8.1 四个核心方法
| 方法 | 功能 | 解决问题 |
|---|---|---|
set(key, value, time, unit) |
存储任意对象到Redis,带TTL | 基础存储 |
setWithLogicalExpire(key, value, time, unit) |
存储带逻辑过期时间的数据 | 热点key预热 |
queryWithPassThrough(...) |
查询并解决缓存穿透 | 缓存穿透 |
queryWithLogicalExpire(...) |
查询并解决缓存击穿 | 缓存击穿 |
2.8.2 核心代码
@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 存储数据(带TTL)
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(value), time, unit);
}
// 存储数据(带逻辑过期时间)
public void setWithLogicalExpire(String key, Object value,
Long time, TimeUnit unit) {
RedisData<Object> redisData = new RedisData<>();
redisData.setData(value);
redisData.setExpireTime(
LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(redisData));
}
// 解决缓存穿透的查询方法
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
if (json != null) {
return null; // 命中空值
}
// 查询数据库
R r = dbFallback.apply(id);
if (r == null) {
// 缓存空值
this.set(key, "", time, unit);
return null;
}
// 写入Redis
this.set(key, r, time, unit);
return r;
}
// 解决缓存击穿的查询方法(逻辑过期)
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {
return null;
}
RedisData<R> redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return r; // 未过期,返回旧数据
}
// 已过期,尝试获取锁重建
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R r1 = dbFallback.apply(id);
this.setWithLogicalExpire(key, r1, time, unit);
} finally {
unlock(lockKey);
}
});
}
return r;
}
}2.8.3 封装技巧
难点一:泛型的使用
返回值类型不确定时,使用泛型:
// R = 返回值类型,ID = 参数类型
public <R, ID> R queryWithPassThrough(...)难点二:函数式编程
数据库查询逻辑不确定,由调用者传递函数:
// Function<ID, R>:有参有返回值的函数
Function<ID, R> dbFallback
// 使用时传入 Lambda
cacheClient.queryWithPassThrough(
"cache:shop:", id, Shop.class,
Shop::getById, // 函数引用:根据ID查数据库
30L, TimeUnit.MINUTES
);2.9 总结
知识点全景图
商户查询缓存
├── 1. 认识缓存
│ ├── 什么是缓存(缓冲区、读写性能高)
│ ├── 缓存层级(浏览器 → Nginx → 应用 → 数据库)
│ └── 作用与成本(降低负载 / 一致性、开发、运维成本)
│
├── 2. 实现缓存查询
│ ├── 工作模型(先查缓存,未命中查DB并写回)
│ └── 代码实现(String + JSON)
│
├── 3. 缓存更新策略
│ ├── 内存淘汰(Redis自动,一致性差)
│ ├── 超时剔除(TTL,一致性一般)
│ └── 主动更新(Cache Aside,先更新DB再删缓存)
│
├── 4. 缓存穿透(数据都不存在)
│ ├── 缓存空值(简单,有额外内存开销)
│ └── 布隆过滤(内存少,有误判)
│
├── 5. 缓存雪崩(大量key同时失效/Redis宕机)
│ ├── TTL随机值
│ ├── Redis集群高可用
│ └── 降级限流 + 多级缓存
│
├── 6. 缓存击穿(热点key过期)
│ ├── 互斥锁(强一致,性能差,有死锁风险)
│ └── 逻辑过期(高性能,不一致,无死锁)
│
└── 7. 工具类封装
├── 泛型处理不确定类型
└── 函数式编程传递查询逻辑最佳实践速查
| 场景 | 策略 |
|---|---|
| 读操作 | 先查Redis → 未命中查DB → 写回Redis(带TTL) |
| 写操作 | 先更新DB → 再删Redis(保证原子性) |
| 缓存穿透 | 缓存空值(TTL 2分钟) + 参数校验 |
| 缓存雪崩 | TTL随机值 + Redis集群 + 降级限流 |
| 缓存击穿(一致性优先) | 互斥锁 |
| 缓存击穿(性能优先) | 逻辑过期 |
📝 本章要点:缓存是提升系统性能的关键手段,但引入缓存后需要妥善处理一致性、穿透、雪崩、击穿等问题。核心原则是”先更新数据库,再删除缓存”,配合 TTL 兜底,形成完整的缓存最佳实践。