day32 商户查询缓存、缓存一致性与缓存穿透雪崩

day32 商户查询缓存、缓存一致性与缓存穿透雪崩

一、缓存概述

1.1 什么是缓存

缓存(Cache)是数据交换的缓冲区,数据一般从数据库中获取,存储在内存中。内存的读写性能远高于磁盘,缓存可以防止过高的数据访问量直接冲击数据库,导致系统瘫痪。

1.2 缓存的作用与成本

作用:降低数据库读写压力,提高系统响应速度。

成本:

成本维度 说明
数据一致性 缓存数据来源于数据库,数据库变化时缓存若未同步,会出现一致性问题
代码维护 增加缓存逻辑后,代码复杂度上升
运维 需要维护 Redis 等缓存基础设施

二、商户查询缓存

2.1 缓存模型与流程

标准缓存查询流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
请求查询
↓
查询 Redis 缓存
↓
命中?──是──→ 返回数据
│
否
↓
查询数据库
↓
查到?──否──→ 返回错误
│
是
↓
建立缓存,返回结果

2.2 商户查询缓存实现

项目 ShopServiceImpl 中通过 queryWithPassThrough 方法实现商户查询缓存,同时解决了缓存穿透问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);

// 2. 存在且不为空,直接返回
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
// 3. 不为 null 但为空值,说明之前查过数据库未查到,直接返回 null
if (shopJson != null) {
return null;
}
// 4. 未命中,查询数据库
Shop shop = getById(id);
// 5. 数据库未查到,缓存空值(解决缓存穿透)
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. 查到,建立缓存并设置超时时间
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL + RandomUtil.randomLong(0L, 10L), TimeUnit.MINUTES);
return shop;
}

关键点:

  • 缓存 key 格式为 cache:shop:{id},定义在 RedisConstants 中

  • 正常数据 TTL 为 CACHE_SHOP_TTL(30 分钟)加上 0~10 分钟随机值,避免大量 key 同时失效

  • 空值 TTL 为 CACHE_NULL_TTL(2 分钟),较短的超时时间可以减少短期数据不一致

2.3 商户类型列表缓存

ShopTypeServiceImpl 中的 queryTypeList 方法实现了类似的缓存逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Result queryTypeList() {
String range = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY);
if (range != null && !range.isEmpty()) {
List<ShopType> list = JSONUtil.toBean(range, List.class);
return Result.ok(list);
}
List<ShopType> typeList = query().orderByAsc("sort").list();
if (typeList == null || typeList.isEmpty()) {
return Result.fail("查询店铺类型失败");
}
stringRedisTemplate.opsForValue()
.set(CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(typeList),
CACHE_SHOP_TTL + RandomUtil.randomLong(0L, 10L), TimeUnit.MINUTES);
return Result.ok(typeList);
}

与商户查询缓存流程一致:先查缓存 → 命中返回 → 未命中查数据库 → 建立缓存并返回。区别在于 key 是固定的 cache:shop-type,因为类型列表不按 ID 区分。


三、缓存更新策略

3.1 三种缓存更新策略

策略 说明 一致性 维护成本
内存淘汰 Redis 内存达到 maxmemory 时自动淘汰数据 弱 最低
超时剔除 为 key 设置 TTL,到期后 Redis 自动删除 一般 低
主动更新 编码手动删除或更新缓存 强 高

根据对一致性的要求选择策略,也可以多种结合使用。项目中采用了超时剔除 + 主动更新结合的方式。

3.2 主动更新方案选择

三种主动更新方案:

方案 说明
Cache Aside Pattern 人工编码,调用者在更新数据库后更新缓存(双写方案)
Read/Write Through Pattern 由系统本身处理数据库与缓存的一致性
Write Behind Caching Pattern 调用者只操作缓存,其他线程异步处理数据库

三种方案中,Cache Aside Pattern(开发者编码)最优,可控性最强。

3.3 删除缓存还是更新缓存

在 Cache Aside 方案下,操作数据库后对缓存进行更新还是删除?

操作 问题
更新缓存 频繁更新数据库时,每次都要更新缓存;若期间无查询,中间的更新操作没有意义
删除缓存 频繁更新数据库时,若期间无查询,只需操作数据库,缓存等下次查询时再加载

删除缓存更优:避免无效写操作,查询时再按需加载。

3.4 先删缓存还是先更新数据库

这个问题涉及线程安全,需要分析并发场景下的不一致风险。

方案一:先删缓存,再更新数据库

  1. 线程 1 删除缓存
  2. 线程切换给线程 2(查询操作)
  3. 线程 2 缓存未命中,查询数据库得到旧数据
  4. 线程 2 将旧数据写入缓存
  5. 线程 1 更新数据库
  6. 结果:缓存中是旧数据,数据库是新数据,不一致

方案二:先更新数据库,再删除缓存

  1. 线程 2 查询缓存未命中,从数据库查询到旧数据
  2. 线程切换给线程 1(更新操作)
  3. 线程 1 更新数据库,删除缓存
  4. 线程切换回线程 2,将旧数据写入缓存
  5. 结果:缓存中是旧数据,不一致

结论:先更新数据库,再删除缓存更优。

原因分析:

  • 先删缓存:线程切换发生在一个长时间段(查询数据库、处理数据的过程中)

  • 先更新数据库:线程切换发生在一个极短时间(查到数据库旧数据与写入缓存之间的间隔)

  • 此外,先更新数据库方案发生不一致还需要恰好在一个查询任务执行时进入更新操作,概率较小

3.5 项目中的实现

ShopServiceImpl.updateShop 方法采用了先更新数据库再删除缓存的策略,并使用 @Transactional 保证原子性:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("商户id不能为空");
}
// 1. 先更新数据库
updateById(shop);
// 2. 再删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}

注意:单体系统将数据库操作与缓存操作放在同一事务中,保证两者同时成功或失败。分布式系统则需要使用 TCC 等分布式事务方案。


四、缓存穿透

4.1 什么是缓存穿透

缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,缓存永远不会生效,这些请求会直接到达数据库。如果大量这样的请求同时到来,会给数据库带来巨大压力。

4.2 解决方案

方案 优点 缺点
缓存空对象 实现简单,维护方便 占用额外内存,可能造成短期数据不一致
布隆过滤 内存占用少,没有多余 key 实现复杂,存在误判风险(哈希冲突)

项目中采用缓存空对象方案。

4.3 缓存空对象实现

在 queryWithPassThrough 方法中(见 2.2 节),缓存空值的核心逻辑:

  1. 查询缓存命中且不为 null 且不为空字符串 → 返回数据
  2. 查询缓存命中但不为 null 且为空字符串 → 说明之前查过数据库未查到,直接返回 null,不再查询数据库
  3. 缓存未命中 → 查询数据库
  4. 数据库未查到 → 将空字符串 "" 写入缓存,TTL 为 2 分钟(CACHE_NULL_TTL)
  5. 数据库查到 → 正常建立缓存并返回

StrUtil.isNotBlank(shopJson) 用于区分正常数据和空值缓存:空字符串 "" 不会被 isNotBlank 判定为存在,但也不为 null,因此通过 shopJson != null 可以识别出这是之前缓存的空值。


五、缓存雪崩

5.1 什么是缓存雪崩

缓存雪崩是指在同一时段大量的缓存 key 同时失效或者 Redis 服务器异常,导致大量请求到达数据库,带来巨大压力。

5.2 解决思路

方案 说明
TTL 设置随机数 给不同 key 的 TTL 添加随机值,避免同时失效
Redis 集群 提高服务的可用性,防止单点故障
降级限流 给缓存业务添加降级限流策略
多级缓存 构建多级缓存体系

项目中已实现 TTL 随机值方案:

1
CACHE_SHOP_TTL + RandomUtil.randomLong(0L, 10L)  // 30分钟 + 0~10分钟随机值

Redis 集群、降级限流、多级缓存尚未学习,待后续在 Redis 高级与 SpringCloud 中学习。


今日总结

模块 核心要点
缓存概述 缓存是内存中的数据缓冲区,降低数据库压力;成本包括数据一致性、代码维护和运维
商户查询缓存 查 Redis → 命中返回 → 未命中查 DB → 查到建缓存返回、未查到返回错误
缓存更新策略 内存淘汰、超时剔除、主动更新;项目中采用超时剔除 + 主动更新结合
删除 vs 更新缓存 删除更优,避免无效写操作,查询时再按需加载
先删 vs 先更新 DB 先更新数据库再删缓存更优,线程切换时间短,不一致概率低
缓存穿透 缓存和数据库都不存在的数据导致请求直达数据库;解决:缓存空对象
缓存雪崩 大量 key 同时失效或 Redis 异常;解决:TTL 随机值(已实现)、集群、降级限流、多级缓存(待学习)