分布式缓存架构实战:三大经典问题与解决方案
分布式缓存架构实战:三大经典问题与解决方案
标签:
缓存Redis高并发分布式锁布隆过滤器
一、为什么需要缓存
缓存是互联网系统的"性能加速器"。它把热数据挡在数据库前,让绝大多数读请求不落到 DB 上,从而支撑起比数据库吞吐量高几个数量级的并发。
但缓存从来不是银弹。引入缓存后,系统多了一层存储,也顺势引入了三个绕不开的经典问题:
| 问题 | 一句话描述 |
|---|---|
| 缓存一致性 | 缓存和数据库是两个独立存储,写入时可能"一边成功一边失败" |
| 缓存穿透 | 大量查询根本不存在的 key,缓存永远兜不住,请求直击 DB |
| 缓存雪崩 | 大量 key 同一时刻失效,瞬时海量请求同时打到 DB |
下面逐一拆解每个问题的根源与对策。
二、问题一:缓存与数据库的双写不一致
2.1 现象与根源
无论采用"先写缓存后写数据库",还是"先写数据库后写缓存",都可能因为网络抖动、事务失败、并发交错,出现一方成功、另一方失败的情况,最终导致缓存里的数据和 DB 对不上。
根源在于:缓存与数据库是两个独立存储,天然无法保证原子性,而错误的更新顺序还会放大脏数据。
2.2 推荐方案:Cache-Aside(旁路缓存)模式
- 读:先读缓存 → 命中直接返回;未命中则读 DB,成功后回填缓存。
- 写:先写 DB → 成功后删除缓存(而非更新缓存)。
读流程:查缓存 → 未命中 → 查DB → 回填缓存
写流程:写DB → 删除缓存
为什么"删除"优于"更新"?
- 删除操作幂等且轻量,避免了并发更新时互相覆盖造成的脏写。
- 下一次读请求会自然地触发 DB 查询并重建缓存,保证拿到的数据最新。
三、问题二:缓存穿透(海量不存在的 Key)
3.1 现象
大量请求查询系统中根本不存在的 key(例如恶意攻击随机生成的 ID 或业务里从未出现过的值)。这些 key 缓存永远无法命中,于是每个请求都穿透缓存直接打到数据库,最终压垮 DB。
3.2 初级方案:缓存空值(恶意工具撑爆内存)
查 DB 后发现数据不存在,也往缓存里写一个 key → null,并设一个较短的过期时间,避免同一 key 反复穿透。
缺点:攻击者可以穷举出无限多的不存在 key,空值缓存本身就会撑爆内存。
3.3 终极方案:布隆过滤器
在访问缓存之前,先用布隆过滤器判断这个 key "是否可能存在":
- 判断为不存在 → 直接返回,压根不碰缓存和 DB;
- 判断为可能存在 → 再走正常的缓存 → DB 流程。
优点:内存占用极小(bit 数组),判断复杂度 O(1)。
注意:布隆过滤器有极小概率的误判(把不存在误判为存在),可接受;需定期重建或增量更新。
布隆过滤器的本质是"用极小空间换掉绝大多数无效查询",把穿透挡在系统最外层。
四、问题三:缓存雪崩(集体失效 + 瞬时爆量)
4.1 现象
以下任一情况都可能引发雪崩:
- 大量 key 设置了相同的过期时间,同一时刻集体失效;
- 缓存服务重启,内存被清空;
- 大量不存在的 key 穿透(此条已由布隆过滤器解决)。
此时,原本命中缓存的流量瞬间全部落到 DB,引发连接池耗尽、服务宕机,形成"雪崩"。
4.2 三种化解思路
针对缓存雪崩,常见有三种化解手段:
- 串行化更新:利用互斥锁(如 SETNX)控制只有一个线程去查库回填,其余线程等待或直接返回旧数据,把并发 DB 冲击转成串行读库操作。
- 打散失效时间:为 TTL 增加随机偏移量(如
基础时间 ± 随机秒数),让过期时间均匀分布,避免同一时刻集体失效。 - 多级缓存:增设本地缓存(一级)配合分布式缓存(二级),二级失效时本地仍可短暂兜底,大幅减少穿透到 DB 的请求量。
4.3 归纳为两种本质策略
如果要把上面三招提炼成"两种不同思路",可以这样归纳:
| 思路 | 本质 | 对应手段 |
|---|---|---|
| 方案一(控制并发量) | 把"并发查库"变成"串行查库" | 加排它锁/队列,使缓存更新串行化执行 |
| 方案二(分散与冗余) | 把"同时失效"错开,并增加兜底层 | 随机错开 key 失效时间 + 本地缓存层兜底 |
两种思路可组合使用,效果更佳。
五、核心实战:串行化更新(Java + Redis)
前文的"串行化更新"是解决雪崩最关键的武器,这里给出三种由"底层"到"最简"的 Spring 写法。
先用大白话把流程说清楚(后面三段代码都只是在用不同工具实现它):
1. 查缓存 → 有数据?直接返回
2. 没数据 → 大家去抢一把锁(lock:商品ID)
3. 抢到的那 1 个 → 再查一次缓存(双重检查)→ 查 DB → 写回缓存 → 释放锁
4. 没抢到的 N 个 → 不查库,等 50ms 重试,醒来时缓存里已经有了
5.1 方式一:Spring Data Redis(StringRedisTemplate,看懂机制)
Spring Boot 项目默认就有的写法,无需额外依赖。
@Service
public class ProductService {
@Autowired
private StringRedisTemplate redis; // Spring Boot 自动装配,连接配置写在 application.yml 的 spring.data.redis 下
/**
* 解锁脚本:必须是"先比对 value,再删除"两步,用 Lua 保证原子性。
*
* 为什么不能直接 DEL?
* 场景:线程 A 抢到锁 → A 卡顿,锁 5 秒后自动过期 → 线程 B 抢到锁
* → A 恢复过来执行 DEL,把【B 的锁】删掉了 → 锁彻底失效
* 所以删除前必须比对 value:确认这把锁还是自己那把,才允许删。
*
* 为什么必须用 Lua?
* "GET 比对"和"DEL 删除"是两条命令,中间仍可能被打断(上面同样的时序问题)。
* Redis 执行 Lua 脚本是单线程原子的,整段脚本等同于一条命令。
*
* KEYS[1] = 锁的 key,ARGV[1] = 自己的 lockValue
*/
private static final String UNLOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
public String getById(String id) {
String key = "product:" + id;
// ===== 第 1 步:查缓存 =====
// opsForValue() = 操作 Redis String 类型的入口
// (Redis 五种基本类型各有一个入口:opsForHash / opsForList / opsForSet / opsForZSet)
String cache = redis.opsForValue().get(key);
if (cache != null) {
return cache; // 命中缓存直接返回,不进入加锁逻辑
}
// ===== 第 2 步:缓存未命中 → 抢分布式锁 =====
String lockKey = "lock:" + key; // 锁 key 带上业务 key,保证不同商品互不阻塞
String lockValue = UUID.randomUUID().toString(); // 锁的"主人标识",解锁时用来证明"这把锁是我的"
// 【本段核心】等价于一条 Redis 命令:SET lock:product:123 <uuid> NX EX 5
// · NX(Not eXists)→ 只有 key 不存在时才写入成功,即"抢锁"
// · EX 5 → 5 秒后自动过期,兜底防止服务宕机造成死锁
// · 返回值 Boolean → true = 抢到锁;false = 已被别人持有
//
// ⚠️ 为什么必须"一条命令"完成?
// 早期写法是 SETNX + EXPIRE 两条命令,若第一条成功后进程崩溃,
// 第二条永远执行不了 → 锁没有过期时间 → 死锁,这个 key 再也无人能抢到。
// Redis 2.6.12 起 SET 支持 NX/EX 组合参数,setIfAbsent(k, v, timeout) 就是它的封装,
// "加锁 + 设过期"在一个原子操作里完成。
//
// ⚠️ 为什么用 Boolean 而不是 boolean 接收?
// 某些客户端实现下返回值可能为 null,用 boolean 会自动拆箱抛 NPE,
// 所以下面统一用 Boolean.TRUE.equals(locked) 判断。
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
// ===== 第 3 步:双重检查(Double Check)=====
// 排队等锁的这几十毫秒里,上一个抢到锁的线程可能已经把数据写进 Redis 了。
// 不检查就会白白再查一次 DB —— 而那正是加锁要保护的动作
cache = redis.opsForValue().get(key);
if (cache == null) {
// ===== 第 4 步:查库 + 回写缓存(被锁保护的核心区域)=====
cache = queryDB(id);
if (cache == null) {
// 数据库里确实没有 → 写短 TTL 空值,防止该 key 被反复穿透(见 3.2 节)
// ⚠️ 若之后 DB 新增了这条数据,要主动删掉空值缓存,否则 TTL 内一直读到"不存在"
redis.opsForValue().set(key, "", Duration.ofMinutes(2));
return null;
}
// 写回缓存。TTL 建议叠加随机偏移量(如 10 分钟 ± 随机 60 秒),
// 避免大量 key 同一时刻集体失效引发雪崩(见 4.2 节)
redis.opsForValue().set(key, cache, Duration.ofMinutes(10));
}
return cache;
} finally {
// ===== 第 5 步:释放锁 =====
// 执行上面的 Lua 脚本:GET 比对 value 一致才 DEL,且整个过程原子
// execute(RedisScript, keys, args):
// · DefaultRedisScript 指定脚本内容和返回值类型(DEL 返回删除条数,用 Long)
// · 第二个参数是 KEYS 列表,第三个是可变参数,对应 ARGV
redis.execute(new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
Collections.singletonList(lockKey), lockValue);
}
}
// ===== 没抢到锁 =====
// 说明已有别的线程正在查库,这里【绝对不能】直接查 DB
try {
Thread.sleep(50); // 短暂休眠,50ms 后缓存大概率已写好
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标记,不要吞掉异常
}
return getById(id); // 递归重试(生产建议改成 for 循环 + 最大重试次数,防止栈溢出)
}
}
一句话记住分布式锁的本质:利用 Redis 单线程执行命令的特性,让多台机器上的线程去"抢"同一个 key,
谁setIfAbsent成功谁就获得锁。NX保证了只有一个赢家,EX保证了赢家消失后锁能自动释放。
5.2 方式二:Redisson(生产推荐,自动续期)
方式一要自己处理"锁过期时间"和"释放原子性",很容易写错。Redisson 把这些都封装好了,还提供看门狗自动续期(业务没跑完就自动延长锁时间)。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
@Service
public class ProductService {
@Autowired
private RedissonClient redisson; // 引入 redisson-spring-boot-starter 后自动注入,连接配置写在 application.yml
public String getById(String id) {
String key = "product:" + id;
// ===== 第 1 步:查缓存 =====
// RBucket 是 Redisson 对 Redis String 的封装,等价于 GET 命令
RBucket<String> bucket = redisson.getBucket(key);
String cache = bucket.get();
if (cache != null) {
return cache; // 命中缓存直接返回。绝大多数请求都走这里,不会进入加锁逻辑
}
// ===== 第 2 步:缓存未命中,抢分布式锁 =====
// 锁 key 带上业务 key → 不同商品的请求各抢各的锁,互不阻塞;
// 只有"同一个商品的并发重建"才会被串行化(见 6.3 节的前提 2)
RLock lock = redisson.getLock("lock:" + key);
boolean locked = false;
try {
// tryLock(等待时间, 锁自动释放时间, 时间单位)
// · waitTime = 3 秒 → 最多排队 3 秒去抢锁,抢不到就放弃,避免线程无限堆积
// · leaseTime = 10 秒 → 拿到锁后 10 秒自动释放,兜底防止服务宕机造成死锁
//
// ⚠️ 关键坑:一旦显式传了 leaseTime,Redisson 的看门狗(watchdog)就【不会】自动续期!
// 如果 queryDB 可能超过 10 秒,改用 lock.tryLock(3, TimeUnit.SECONDS)(不传 leaseTime),
// 看门狗会每 10 秒检查一次,业务没跑完就自动把锁续到 30 秒
locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
// 没抢到锁 → 说明已有别的线程正在查库,这里【绝对不能】直接查 DB
Thread.sleep(50); // 短暂休眠,50ms 后缓存大概率已写好
return getById(id); // 递归重试(生产建议改成 for 循环 + 最大重试次数,防止栈溢出)
}
// ===== 第 3 步:双重检查(Double Check)=====
// 为什么还要再查一次?因为在排队等锁的这几十毫秒里,
// 上一个抢到锁的线程很可能已经把数据写进 Redis 了。
// 不检查就会白白再查一次 DB —— 而那正是加锁要保护的动作
cache = bucket.get();
if (cache == null) {
// ===== 第 4 步:查库 + 回写缓存(被锁保护的核心区域)=====
cache = queryDB(id);
if (cache == null) {
// 数据库里确实没有 → 写短 TTL 空值,防止该 key 被反复穿透(见 3.2 节)
// 注意:bucket.set(null) 会抛 NPE,所以判空后写空字符串
// ⚠️ 若之后 DB 新增了这条数据,要主动删掉空值缓存,否则 TTL 内一直读到"不存在"
bucket.set("", 2, TimeUnit.MINUTES);
return null;
}
// 写回缓存。TTL 建议叠加随机偏移量(如 10 分钟 ± 随机 60 秒),
// 避免大量 key 在同一时刻集体失效引发雪崩(见 4.2 节)
bucket.set(cache, 10, TimeUnit.MINUTES);
}
return cache;
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标记,不要吞掉
return null; // 被中断:降级返回,也可直接抛业务异常
} finally {
// ===== 第 5 步:释放锁 =====
// 两个判断缺一不可:
// · locked → 只有自己抢到过锁才需要释放,否则抛 IllegalMonitorStateException
// · lock.isHeldByCurrentThread() → 锁可能已因 leaseTime 到期被自动释放,此时再 unlock 同样报错
// Redisson 内部用 Lua 保证"校验 value + 删除"的原子性,不会误删别人的锁
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
方式一(手写
StringRedisTemplate)里的每一步,方式二都做了,只是把"加锁/解锁/续期"三件最容易写错的事交给了 Redisson —— 这也是生产环境推荐它的原因。
5.3 方式三:Spring Cache 注解(日常最简,但有局限)
如果只是普通的热key缓存,不需要手写上面几十行,一个注解就够了:
先做一次全局配置:
@Configuration
@EnableCaching // 开启 Spring 缓存抽象。少了它,下面所有 @Cacheable / @CacheEvict 都不生效
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// entryTtl:全局默认过期时间
// ⚠️ 坑:所有 key 共用同一个 TTL,正是"雪崩"的温床。
// 生产建议按业务分别配置 TTL,或在 TTL 上叠加随机值(见 4.2 节)
.entryTtl(Duration.ofMinutes(10))
// 禁止缓存 null
// 默认情况下 Spring 会把 null 也写进缓存(用一个 NullValue 占位符),
// 关掉是为了避免"空值长期占着内存"。穿透防护请用 3.2 节的短 TTL 空值方案
.disableCachingNullValues()
// 序列化:默认是 JDK 序列化,Redis 里是一堆二进制乱码,没法排查问题。
// 改成 JSON 后可读,也方便跨语言读同一份缓存
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
// 统一 key 前缀,方便按业务批量清理 / 隔离环境
.prefixCacheNameWith("app:");
return RedisCacheManager.builder(factory)
.cacheDefaults(config) // 作为所有 @Cacheable 的默认配置
.build();
}
}
然后业务方法上一个注解就够:
/**
* @Cacheable:方法执行【前】先查缓存
* · 命中 → 直接返回缓存值,方法体一行都不执行
* · 未命中 → 执行方法体(查库),并把返回值写进缓存
*
* cacheNames = "product" → 缓存分区名,最终 key = app:product::123
* key = "#id" → SpEL 表达式,取方法参数 id 当 key;也可写 #user.id、#p0 等
* sync = true → 未命中时加锁,保证同一时刻只有一个线程执行方法体
*
* ⚠️ sync=true 的具体局限见下方表格
*/
@Cacheable(cacheNames = "product", key = "#id", sync = true)
public String getById(String id) {
return queryDB(id); // 只有缓存未命中时才会走到这里
}
配套的写操作必须用 @CacheEvict 删缓存(呼应 2.2 节的 Cache-Aside):
/**
* @CacheEvict:方法执行【后】删除缓存,等下一次读请求自己回填
*
* ⚠️ 不要用 @CachePut(更新缓存):
* 1. 并发写时两次更新可能互相覆盖,产生脏数据;
* 2. 有些字段没参与本次更新,写回去反而是旧值。
* "删除"幂等且轻量,是更安全的做法(见 2.2 节)
*/
@CacheEvict(cacheNames = "product", key = "#product.id")
public void update(Product product) {
updateDB(product); // 先写 DB,方法成功返回后再删缓存
}
最容易踩的坑:自调用失效。
@Cacheable、@CacheEvict靠 Spring AOP 代理生效,
同一个类里this.xxx()的内部调用不会走代理,注解直接失效。
解决方式:把方法拆到另一个 Service,或注入自己(AopContext.currentProxy())。
| 参数 | 作用 |
|---|---|
key = "#id" |
缓存 key = product::123 |
sync = true |
缓存未命中时加锁,只允许一个线程查库 |
⚠️ 两个必须知道的坑:
sync = true只是单个 JVM 进程内的本地锁。多实例部署时,N 台机器仍可能各查一次库——它防不住真正的分布式雪崩,只防单机并发。- 注解版解决不了缓存穿透(不存在的 key 依然每次打到 DB),也解决不了空值缓存的时序问题。
结论:注解版适合日常 CRUD 提速;热点 key + 高并发重建场景,必须上方式二的分布式锁。
5.4 要点说明
| 要点 | 说明 |
|---|---|
| 锁的作用 | 确保只有一个线程执行 queryDB(),其余线程排队等待,从而把并发 DB 请求串行化 |
| 双重检查 | 获取锁后再次检查缓存,避免重复查库 |
| 锁超时 | 设置合理过期时间,防止线程挂起导致死锁(Redisson 会自动续期) |
| 锁粒度 | 锁绑定到单个 key(lock: + key),不同 key 互不干扰 |
| 释放锁 | 必须用 value 比对 + Lua 原子删除,否则可能误删别人的锁 |
| 缓存空值 | queryDB 返回 null 时也要写短 TTL 空值,否则该 key 会持续穿透(见 3.2 节) |
5.5 三种方式怎么选
| 方式 | 代码量 | 适用场景 |
|---|---|---|
方式三 @Cacheable |
1 行 | 日常缓存提速,单机并发不高 |
方式一 StringRedisTemplate |
40 行 | 想理解底层机制,或项目不能引入新依赖 |
方式二 Redisson |
20 行 | 生产环境首选,有自动续期、可重入、看门狗 |
六、常见误解:串行化 ≠ 定时任务
很多人第一次看这段代码会误以为是"定时任务查库 → 逐个加锁写缓存 → 释放锁"。这是理解反了。
6.1 定时批量刷新(错误理解)
定时任务查库 → 遍历所有 key → 加锁写入 → 释放锁
这样做的问题:
- 定时任务本身就在全表扫描式查库,恰恰制造了我们想避免的数据库压力;
- 锁在这里毫无意义——因为只有一个定时任务在跑,根本没有并发竞争。
6.2 串行化更新的真实场景:被动触发
它发生在"高并发读请求"到达的那一瞬间,而不是后台定时跑。假设同一秒有 10 万个请求同时查询 Key=A,而此刻 Redis 里 Key=A 刚好失效:
- 10 万个请求同时发现 Redis 里没数据;
- 它们同时尝试获取分布式锁(
lock:A); - 只有 1 个请求抢到锁,进入数据库查询——DB 只被查了 1 次;
- 其余 99,999 个请求没抢到锁,不查库,而是等待(自旋重试或阻塞);
- 抢到锁的请求查完 DB、写入 Redis、释放锁;
- 其他等待的请求醒来,再去 Redis 一查,数据已经有了,直接返回。
6.3 一个生活化类比
| 场景 | 类比 |
|---|---|
| 定时任务 | 每天定时去菜市场把所有菜买回来放冰箱,不管吃不吃 |
| 串行化更新 | 1000 个人同时要了同一道菜(Redis 没菜),大家抢一把厨房门钥匙,只有 1 个人去菜市场买菜(查 DB),买回来放冰箱后开门,其余 999 人直接去冰箱拿菜 |
6.4 小结
- 触发时机:请求实时到达时触发,不是后台定时跑;
- 锁的作用:把"查库 + 写缓存"这个危险动作,从并发 10 万次压缩成串行 1 次,保护数据库;
- 不用定时任务的原因:无法预知用户会查哪个 key,也不可能把所有 key 定时全量刷新(数据量巨大且浪费)。
七、总结
| 问题 | 核心对策 |
|---|---|
| 双写不一致 | 先写 DB,后删除缓存(Cache-Aside) |
| 缓存穿透 | 布隆过滤器前置拦截 |
| 缓存雪崩 | TTL 随机化 + 分布式锁串行化更新 + 多级缓存 |
架构师心法:缓存设计要防患于未然——把"高并发冲击"转化为"有秩序的单点操作",再辅以兜底策略。没有银弹,只有组合拳。


浙公网安备 33010602011771号