2021 年,我负责一个商品详情页的后端。详情页的 QPS 不低,我早早就给它加了 Redis 缓存——商品数据先查 Redis,命中就直接返回;没命中,才去查数据库,再把结果写回 Redis。这套缓存跑了大半年,稳稳当当,我对它很放心。直到某个周六的大促日,出事了。监控告警炸成一片:数据库 CPU 直接干到 100%,大量请求超时,详情页大面积打不开。我的第一反应是去看 Redis——可 Redis 一切正常,内存、连接数、CPU 全都很健康。这就怪了:缓存明明好好的,数据库怎么会被打挂?我扒了一通日志才看明白:为了图省事,我给所有商品缓存设的过期时间,都是统一的「30 分钟」。而这批商品,是大促开始前,我用一个脚本【集中预热】进缓存的——也就是说,它们几乎是在【同一时刻】被写进 Redis 的。30 分钟后,它们又在【几乎同一时刻】,一起过期了。那一瞬间,成千上万个商品的缓存集体失效,海量请求像决堤的潮水,毫无遮拦地、直接冲进了数据库——数据库在一秒钟内,承受了平时几百倍的压力,当场被打垮。这,就是「缓存雪崩」。这件事逼着我把缓存穿透、缓存击穿、缓存雪崩到底有什么区别、各自该怎么防、以及缓存和数据库的一致性该怎么保证,彻底理清了一遍。本文是这份梳理的完整复盘。
问题背景:一次"缓存好好的,数据库却被打挂"的事故
背景:商品详情页,加了 Redis 缓存,跑了大半年稳稳当当
缓存逻辑:先查 Redis,命中直接返回;没命中才查 DB、写回 Redis
某个大促日,出事:
- ★★ 数据库 CPU 干到 100%,大量请求超时,详情页大面积打不开
- ★ 第一反应去看 Redis —— 内存/连接/CPU 全都健康,一切正常
★ 怪事:缓存好好的,数据库怎么会被打挂?
★★ 扒日志才看明白根因:
- 图省事,所有商品缓存过期时间统一设成【30 分钟】
- 这批商品是大促前用脚本【集中预热】的 —— 几乎同一时刻写进 Redis
- 30 分钟后,它们又在【几乎同一时刻】一起过期
- 那一瞬间成千上万个缓存集体失效,海量请求毫无遮拦
直冲数据库 —— DB 一秒承受平时几百倍压力,当场被打垮
★★ 这就是【缓存雪崩】。
★ 本文要做的:把穿透、击穿、雪崩的区别、各自怎么防、
缓存与数据库一致性,彻底讲透。
一次雪崩复盘:先分清穿透、击穿、雪崩
# === ★ 这三个词总被搞混,先一刀切清楚 ===
# === ★ 它们的共同点:都让请求"绕过缓存,砸向数据库" ===
# ★ ★ 缓存的全部意义,是当数据库前面的【挡箭墙】。这三种
# 事故,本质都是这堵墙【出现了缺口】,导致请求穿墙而过、
# 直接砸到后面的数据库上。区别只在于:缺口是【怎么】
# 出现的、有【多大】。
# === ★ 缓存穿透:查一个"根本不存在"的数据 ===
# ★ ★ 用户查询一个【数据库里压根没有】的数据(比如一个
# 不存在的商品 ID)。缓存里当然没有 -> 去查数据库 ->
# 数据库也没有 -> 于是【缓存里始终建立不起来】。
# ★ ★★ 要命的是:下次再用同一个不存在的 ID 来查,会
# 【重复】这个过程。如果有人拿海量的、随机的、不存在的
# ID 来刷,每一个请求都会穿过缓存、砸到数据库 —— 缓存
# 这堵墙,对它们【形同虚设】。
# === ★ 缓存击穿:一个"热点 key"突然失效 ===
# ★ ★ 注意:数据【是存在】的。问题出在【某一个被高并发
# 访问的热点 key】,它的缓存恰好【过期】了。
# ★ ★★ 在它过期的那个瞬间,成百上千个并发请求同时发现
# "缓存没了",于是【同时】涌去查数据库、又【同时】想
# 把它写回缓存。一个热点 key 的过期,瞬间在数据库上
# 砸出一个【尖锐的小洞】。
# === ★★ 缓存雪崩:大批 key 在同一时间一起失效 ===
# ★ ★ 它是"击穿"的【放大版、群体版】。不是一个 key,而是
# 【大量】key,在【几乎同一时刻】集体过期(就像我那次,
# 集中预热 + 统一过期时间);或者更直接 —— 整个 Redis
# 实例【宕机】了。
# ★ ★★ 后果:海量请求在同一瞬间全部失去缓存遮挡,像潮水
# 般砸向数据库。这不是一个小洞,是【整面墙塌了】。
# === ★ 一句话记住三者的区别 ===
# ★ ★ 穿透:查的数据【不存在】,缓存永远建不起来;
# ★ ★ 击穿:数据【存在】,但【一个】热点 key 过期了;
# ★ ★ 雪崩:【一大批】key 同时过期,或 Redis 整个挂了。
# === 小结 ===
# ★ 穿透、击穿、雪崩的共同点:缓存是数据库前面的挡箭墙,
# 这三种事故本质都是这堵墙出现了缺口、请求穿墙而过直接
# 砸到数据库,区别只在缺口怎么出现、有多大。★ 缓存穿透:
# 查一个数据库里压根没有的数据(如不存在的商品 ID),
# 缓存没有就查 DB、DB 也没有于是缓存始终建立不起来;要命
# 的是下次同一个不存在的 ID 再查会重复这个过程,有人拿
# 海量随机不存在的 ID 来刷每个请求都穿过缓存砸数据库、
# 缓存形同虚设。★ 缓存击穿:数据是存在的,问题出在某一个
# 被高并发访问的热点 key 恰好过期了,过期瞬间成百上千个
# 并发请求同时发现缓存没了、同时涌去查 DB 又同时写回缓存,
# 在数据库上砸出一个尖锐的小洞。★★ 缓存雪崩:是击穿的
# 放大版群体版 —— 大量 key 在几乎同一时刻集体过期(集中
# 预热+统一过期时间),或整个 Redis 实例宕机,海量请求
# 同一瞬间全部失去遮挡砸向数据库,不是小洞是整面墙塌了。
# ★ 一句话:穿透查的数据不存在缓存永远建不起来、击穿数据
# 存在但一个热点 key 过期了、雪崩一大批 key 同时过期或
# Redis 整个挂了。
// ★ 最基础的缓存读取:Cache-Aside(旁路缓存)模式
// 这套逻辑本身没错,但它对穿透/击穿/雪崩"裸奔",毫无防御
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;
public Product getProduct(Long id) {
String key = "product:" + id;
// ① 先查缓存
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached; // ★ 命中,直接返回
}
// ② 缓存没有 -> 查数据库
Product product = productMapper.selectById(id);
// ③ 查到了 -> 写回缓存,设 30 分钟过期
if (product != null) {
redisTemplate.opsForValue()
.set(key, product, 30, TimeUnit.MINUTES);
}
// ★★ 裸奔点 1(穿透):product == null 时,什么都不做 ->
// 下次同一个不存在的 id 再来,又走一遍 DB
// ★★ 裸奔点 2(击穿):热点 key 过期瞬间,大量并发同时
// 走到 ② -> 一起砸数据库
// ★★ 裸奔点 3(雪崩):所有 key 都是"30 分钟",集中写入
// 的 key 会集中过期 -> 整批一起砸数据库
return product;
}
// ★ 下面三节,就是逐个把这三个"裸奔点"补上。
缓存穿透:挡住"查不存在数据"的恶意请求
# === ★ 穿透的本质:缓存对"不存在的数据"无能为力 ===
# === ★ 为什么普通缓存挡不住穿透 ===
# ★ ★ 缓存的工作方式是"查到了才缓存"。可一个不存在的
# 数据,你【永远查不到】,所以它【永远进不了缓存】——
# 每一次对它的查询,都注定要落到数据库上。
# ★ ★ 正常用户极少会查不存在的数据;但【恶意攻击者】会:
# 他用脚本生成海量随机的、不存在的 ID 狂刷,每一发都
# 精准绕过缓存、直击数据库。
# === ★ 解法一:把"空结果"也缓存起来 ===
# ★ ★ 思路很朴素:数据库查出来是【空】的,那就把这个
# "空"也【写进缓存】—— 缓存一个特殊的空值标记。
# ★ ★ 这样,下次同一个不存在的 ID 再来,会在缓存里命中
# 这个"空值标记",直接返回,【不再打扰数据库】。
# ★ ★★ 关键细节:空值的过期时间要设得【短】(比如几
# 分钟)。因为这个数据【将来可能会被创建】出来,空值
# 留太久,会导致它真的被创建后、用户还在读到"不存在"。
# === ★★ 解法二:布隆过滤器(Bloom Filter)当"前置门卫" ===
# ★ ★ 缓存空值有个弱点:如果攻击者每次用【不同】的随机
# ID,缓存里就会堆进海量的空值,占内存。
# ★ ★ 更彻底的方案,是在缓存【之前】加一道布隆过滤器。
# 把【所有真实存在】的 ID,预先灌进这个过滤器。请求
# 先问它:"这个 ID 可能存在吗?"
# ★ ★★ 布隆过滤器的特性很特别:它说【不存在,就一定
# 不存在】(可以放心直接拒掉);它说【可能存在,则
# 未必真存在】(有极小的误判率)。但这正好够用 ——
# 攻击者那些瞎编的 ID,会被它在第一道门就【挡掉绝大
# 多数】,根本到不了缓存和数据库。
# === ★ 两个方案怎么选 ===
# ★ ★ 一般业务:用"缓存空值",简单、够用;
# ★ ★ 数据量大、且面临恶意攻击:上"布隆过滤器"做前置
# 拦截,再配合缓存空值兜底。两者不冲突,可以叠加。
# === 小结 ===
# ★ 穿透的本质:缓存"查到了才缓存",而不存在的数据永远
# 查不到、永远进不了缓存,每次查询都注定落到数据库;
# 正常用户极少查不存在的数据,但恶意攻击者会用脚本生成
# 海量随机不存在的 ID 狂刷、每发都精准绕过缓存直击 DB。
# ★ 解法一缓存空值:数据库查出来是空的就把这个"空"也写进
# 缓存(缓存一个特殊空值标记),下次同一个不存在 ID 再来
# 在缓存命中空值标记直接返回不再打扰数据库;关键细节是
# 空值过期时间要设短(几分钟),因为这数据将来可能被创建
# 出来、空值留太久会导致真被创建后用户还读到"不存在"。
# ★★ 解法二布隆过滤器:缓存空值的弱点是攻击者每次用不同
# 随机 ID 会堆进海量空值占内存,更彻底的是在缓存之前加
# 一道布隆过滤器、把所有真实存在的 ID 预先灌进去;它的
# 特性是说"不存在就一定不存在"(可放心拒掉)、说"可能
# 存在则未必真存在"(极小误判率),正好够用 —— 攻击者
# 瞎编的 ID 在第一道门就被挡掉绝大多数。★ 怎么选:一般
# 业务用缓存空值简单够用,数据量大且面临恶意攻击上布隆
# 过滤器做前置拦截再配缓存空值兜底,两者可叠加。
// ★ 防穿透:缓存空值 + 布隆过滤器,双重拦截
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;
// ★ 一个表示"查过了,确实不存在"的空值标记
private static final String NULL_MARKER = "__NULL__";
public Product getProductSafe(Long id) {
String key = "product:" + id;
// === ★★ 第一道门:布隆过滤器 —— 拦掉绝大多数瞎编的 ID ===
// 它说"不存在",就一定不存在 -> 直接拒掉,不碰缓存和 DB
if (!productBloomFilter.mightContain(id)) {
return null;
}
// === ② 查缓存 ===
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
// ★ 命中的是"空值标记" -> 说明这个 id 已确认不存在
if (NULL_MARKER.equals(cached)) {
return null;
}
return (Product) cached;
}
// === ③ 缓存没有 -> 查数据库 ===
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
} else {
// ★★ 关键:查出来是空,也写进缓存 —— 但过期时间设【短】
// (5 分钟),因为这个 id 将来可能真的被创建出来
redisTemplate.opsForValue()
.set(key, NULL_MARKER, 5, TimeUnit.MINUTES);
}
return product;
}
缓存击穿:别让一个热点 key 过期就压垮数据库
# === ★ 击穿:数据是存在的,问题出在"高并发 + 恰好过期" ===
# === ★ 还原一下击穿的瞬间 ===
# ★ ★ 假设一个超级热点商品,缓存 key 每秒被访问 5000 次。
# 某一刻,它的缓存过期了。接下来的那一秒里:
# - 5000 个请求,几乎【同时】查缓存,【全部】没命中;
# - 于是这 5000 个请求,【全部】涌去查数据库;
# - 然后它们【全部】又去把同一个结果写回缓存。
# ★ ★★ 本来,只需要【1 个】请求去查一次数据库、重建缓存
# 就够了。结果因为没有任何控制,变成了 5000 个请求一起
# 去查 —— 数据库被这一下尖锐的并发,瞬间打疼。
# === ★★ 解法一:互斥锁 —— 只放"一个"请求去重建 ===
# ★ ★ 核心思路:缓存失效后,不要让所有请求都冲去查库。
# 而是让它们去【抢一把锁】。
# - ★ 抢到锁的那【一个】请求:去查数据库、重建缓存;
# - ★ 没抢到锁的其他请求:不查库,【稍等一下、重试】——
# 等那个请求把缓存重建好,它们重试时就直接命中了。
# ★ 这样,无论多少并发,落到数据库上的,永远只有【一个】
# 重建请求。这是最常用的防击穿手段。
# === ★ 解法二:逻辑过期 —— 干脆不设物理过期 ===
# ★ ★ 另一种思路:热点 key 在 Redis 里【永不物理过期】。
# 而是在 value 里,自己存一个【逻辑过期时间】字段。
# ★ ★ 读取时,发现"逻辑上已过期",就【异步】起一个线程
# 去重建缓存,而当前请求【先返回那个旧值】。
# ★ ★★ 它的取舍很清晰:用"短暂地返回一点旧数据"作为
# 代价,换来"任何请求都【绝不会】因为缓存过期而被阻塞、
# 而去打数据库"。对一致性要求不高、但绝不能卡的场景
# (如首页热门商品),很合适。
# === ★ 解法三:热点 key 索性"不过期" ===
# ★ ★ 对那种【极少变动】的超级热点(如首页固定的几个
# 推荐位),最简单的办法就是:不设过期时间,数据更新时
# 由代码【主动去更新】这个缓存。从根上杜绝"过期"这件事。
# === 小结 ===
# ★ 击穿:数据是存在的,问题出在高并发+恰好过期。还原
# 击穿瞬间:一个超热点 key 每秒被访问 5000 次,某刻缓存
# 过期,接下来一秒 5000 个请求几乎同时查缓存全部没命中、
# 全部涌去查数据库、又全部去写回缓存 —— 本来只需 1 个
# 请求查一次库重建缓存,因没有控制变成 5000 个一起查,
# 数据库被尖锐并发瞬间打疼。★★ 解法一互斥锁:缓存失效后
# 不让所有请求都冲去查库,而是让它们抢一把锁 —— 抢到锁的
# 那一个去查库重建缓存,没抢到的不查库、稍等重试,等缓存
# 重建好重试时直接命中;无论多少并发落到数据库的永远只有
# 一个重建请求,这是最常用的防击穿手段。★ 解法二逻辑过期:
# 热点 key 在 Redis 里永不物理过期,在 value 里自己存一个
# 逻辑过期时间字段,读取时发现逻辑上已过期就异步起线程
# 重建、当前请求先返回旧值;取舍是用短暂返回旧数据换来
# 任何请求绝不会因缓存过期被阻塞去打数据库,适合一致性
# 要求不高但绝不能卡的场景。★ 解法三热点 key 索性不过期:
# 对极少变动的超级热点不设过期时间、数据更新时由代码主动
# 更新缓存,从根上杜绝"过期"这件事。
// ★ 防击穿:互斥锁 —— 海量并发里,只放【一个】请求去重建缓存
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;
public Product getProductWithLock(Long id) {
String key = "product:" + id;
// ① 查缓存,命中直接返回
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// === ★★ 没命中 -> 抢一把"重建锁",而不是直接冲去查库 ===
String lockKey = "lock:product:" + id;
// ★ setIfAbsent = SETNX:只有第一个请求能设置成功 = 抢到锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// ★ 抢到锁的【那一个】请求:负责查库 + 重建缓存
try {
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue()
.set(key, product, 30, TimeUnit.MINUTES);
}
return product;
} finally {
redisTemplate.delete(lockKey); // ★ 重建完,务必释放锁
}
} else {
// ★★ 没抢到锁的其他请求:不查库,稍等一下再重试 ——
// 等抢到锁那位把缓存重建好,这次重试就直接命中了
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithLock(id); // ★ 重试
}
}
// ★ 无论 5000 并发还是 5 万,落到数据库的,永远只有 1 个
缓存雪崩:别让一整面墙在同一秒塌掉
# === ★ 雪崩的两种成因:要分开治 ===
# === ★ 成因一:大批 key 在同一时刻一起过期 ===
# ★ ★ 这就是我开头那次事故的根因。集中预热(脚本一次性
# 灌进去)+ 统一过期时间(全设 30 分钟)= 它们注定会在
# 同一时刻一起死。
# ★ ★★ 解法朴素到出奇:【过期时间加一个随机抖动】。
# 不要再写死 30 分钟,而是写成「30 分钟 + 随机 0~5 分钟」。
# 这样,即使是同一秒被预热进去的成千上万个 key,它们的
# 过期时刻也会被【打散】在一个 5 分钟的区间里 —— 不再
# 是一堵墙同时塌,而是砖头一块一块、错峰脱落。
# === ★ 成因二:Redis 实例整个宕机 ===
# ★ ★ 上面那招只能防"key 集体过期",防不了"Redis 自己
# 挂了"。Redis 一宕,所有 key 瞬间全部"失效",100%
# 的请求直接砸向数据库 —— 这是最猛的一种雪崩。
# ★ ★ 这一层要靠【架构】来扛,不是靠代码技巧:
# - ★ Redis 高可用:别用单机。上【主从 + 哨兵】或
# 【Cluster 集群】,主节点挂了,从节点能自动顶上。
# - ★ 多级缓存:在 Redis 之前,再加一层进程内的【本地
# 缓存】(如 Caffeine)。Redis 整个挂了,本地缓存还能
# 挡下一部分热点请求,不至于 100% 全打到 DB。
# === ★★ 最后一道兜底:限流 + 降级 ===
# ★ ★ 不管前面防得多好,总要假设"万一真的扛不住了"。
# 这时要保的不是"所有请求都成功",而是"数据库别死"。
# ★ ★ 限流:给数据库查询加一道闸门,比如令牌桶 ——
# 每秒最多放 N 个请求去查库,多出来的【直接拒绝】或
# 排队。宁可少数请求失败,也不能让 DB 被打垮、导致
# 【全部】请求失败。
# ★ ★ 降级:DB 扛不住时,干脆返回一个【兜底默认值】
# (如"商品信息加载中"),或一个稍旧的快照数据。
# 保住核心链路活着,比追求每个数据都最新更重要。
# === 小结 ===
# ★ 雪崩有两种成因要分开治。★ 成因一大批 key 同一时刻
# 一起过期:集中预热+统一过期时间注定它们同时死,解法
# 是过期时间加随机抖动 —— 写成「30 分钟+随机 0~5 分钟」,
# 即使同一秒预热进去的成千上万 key 过期时刻也被打散在
# 5 分钟区间、不再一堵墙同时塌而是砖头错峰脱落。★ 成因二
# Redis 实例整个宕机:随机抖动防不了 Redis 自己挂,一宕
# 所有 key 瞬间全失效 100% 请求砸向 DB,这层靠架构扛 ——
# Redis 高可用上主从+哨兵或 Cluster 集群、多级缓存在
# Redis 之前再加一层进程内本地缓存(Caffeine)、Redis
# 挂了本地缓存还能挡一部分热点。★★ 最后兜底限流+降级:
# 假设万一真扛不住,要保的是"数据库别死"不是"所有请求
# 都成功 —— 限流给 DB 查询加闸门(令牌桶每秒最多放 N 个)
# 多出来的直接拒绝,降级 DB 扛不住时返回兜底默认值或稍旧
# 快照,保住核心链路活着比每个数据都最新更重要。
// ★ 防雪崩第一招:过期时间加随机抖动,把"集体过期"打散
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadLocalRandom;
public class CacheExpireHelper {
// ★★ 核心:基础过期时间 + 一段随机抖动
// base = 30 分钟,jitter 在 0~5 分钟之间随机
public void setWithJitter(String key, Object value,
long baseMinutes, long jitterMinutes) {
long jitter = ThreadLocalRandom.current()
.nextLong(0, jitterMinutes + 1); // ★ 0 ~ jitter 分钟
long ttl = baseMinutes + jitter;
redisTemplate.opsForValue()
.set(key, value, ttl, TimeUnit.MINUTES);
}
// ★ 预热场景:哪怕同一秒灌进 1 万个 key,
// 它们的 ttl 也会被打散成 30~35 分钟里的一万个不同值
public void warmUp(java.util.List<Product> products) {
for (Product p : products) {
// ★★ 关键:每个 key 单独算一次随机 ttl,不要复用
setWithJitter("product:" + p.getId(), p, 30, 5);
}
// ★ 对比:如果这里统一 set(..., 30, MINUTES) ->
// 30 分钟后这 1 万个 key 在同一秒一起过期 = 雪崩
}
private RedisTemplate<String, Object> redisTemplate;
}
// ★ 防雪崩第二招:多级缓存 —— Redis 挂了,本地缓存兜底
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
public class MultiLevelCache {
// ★ L1:进程内本地缓存(Caffeine),容量小、速度极快
private final Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000) // ★ 只存最热的 1 万个
.expireAfterWrite(2, TimeUnit.MINUTES) // ★ 本地缓存 ttl 要短
.build();
public Product getProduct(Long id) {
String key = "product:" + id;
// === ① 先查 L1 本地缓存 ===
Product local = localCache.getIfPresent(key);
if (local != null) {
return local; // ★ 最快路径
}
// === ② L1 没有 -> 查 L2 Redis ===
try {
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
localCache.put(key, cached); // ★ 回填 L1
return cached;
}
} catch (Exception e) {
// ★★ 关键:Redis 整个挂了,会走到这里。此时不要
// 直接抛异常 —— L1 本地缓存里可能还有热点数据,
// 上面 ① 已经兜过一层;这里继续往 DB 兜底
log.warn("Redis 不可用,降级查 DB: {}", key);
}
// === ③ L1、L2 都没有(或 Redis 挂了)-> 查 DB ===
Product product = productMapper.selectById(id);
if (product != null) {
localCache.put(key, product); // ★ 至少回填 L1
}
return product;
}
private RedisTemplate<String, Object> redisTemplate;
private ProductMapper productMapper;
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(MultiLevelCache.class);
}
缓存与数据库一致性:更新数据时,该先动谁?
# === ★ 防住了三大事故,还有一个绕不开的问题 ===
# ★ ★ 数据要【改】的时候 —— 缓存里有一份旧的,数据库里
# 要写一份新的。这两个动作,顺序怎么排?动哪个?
# 排错了,用户就会读到【过期的脏数据】。
# === ★ 先否决一个直觉做法:更新 DB + 更新缓存 ===
# ★ ★ 很多人第一反应:改完数据库,就把新值【写进缓存】。
# 看着很对,但有两个坑:
# - ★ 并发写覆盖:A、B 两个请求都在改这条数据。可能
# 出现 A 先写 DB、B 后写 DB,但缓存里却是 B 先写、A
# 后写 —— 缓存最终留下的是 A 的旧值,和 DB 对不上。
# - ★ 浪费:如果这个数据写得很频繁、读得却很少,你每次
# 写都辛辛苦苦算一遍新值塞进缓存,可能塞进去到过期都
# 没人读 —— 纯属白干。
# === ★★ 标准答案:更新 DB,然后【删除】缓存 ===
# ★ ★ 这就是 Cache-Aside 模式的写法:不更新缓存,而是
# 【直接把它删掉】。让缓存这个 key 暂时"空"在那。
# ★ ★★ 为什么删比更新好:删除是个【幂等】又【便宜】的
# 动作,不存在并发覆盖问题。下一个来读的请求发现缓存
# 没了,自然会去查一次最新的 DB、再把它写回缓存 ——
# 这叫【懒加载】:谁用,谁负责把它重新加载进来。
# 没人读的数据,就让它一直空着,不浪费。
# === ★ 顺序:为什么是"先更 DB,再删缓存" ===
# ★ ★ 反过来"先删缓存,再更 DB"有个明显的坑:删完缓存、
# DB 还没来得及更新的那个【空窗期】,如果有个读请求
# 进来,它会查到【旧的 DB 值】,又把这个旧值写回缓存
# —— 缓存里立刻又是脏的了。
# ★ ★ 所以标准顺序是【先更新 DB,再删除缓存】。
# === ★★ 它仍不是 100% 完美:延迟双删 ===
# ★ ★ "先更 DB 再删缓存"还残留一个极小概率的坑:读请求
# 恰好在"更 DB 之前"读到旧值、却在"删缓存之后"才把这个
# 旧值写回 —— 缓存又脏了。概率极低,但存在。
# ★ ★ 补丁叫【延迟双删】:更新完 DB、删一次缓存后,
# 【再等一小会儿(如 500ms)、补删一次】。把那个可能
# 被写回的脏值,再清理掉一遍。
# ★ ★★ 但要清醒:这些手段都只能【尽量减小】不一致窗口,
# 做不到强一致。缓存的世界里,我们接受的是【最终一致】
# —— 短暂的、秒级的不一致是可容忍的,只要它最终会对齐。
# 真要强一致,那就别用缓存。
# === 小结 ===
# ★ 防住三大事故还有一致性问题:数据要改时缓存有旧的、DB
# 要写新的,这两个动作顺序怎么排、动哪个,排错用户就读到
# 脏数据。★ 先否决"更新 DB+更新缓存":并发写覆盖(A 先写
# DB、B 后写 DB 但缓存里 A 后写,缓存留旧值和 DB 对不上)、
# 浪费(写频繁读少每次都算新值塞缓存到过期都没人读)。
# ★★ 标准答案更新 DB 然后删除缓存:删除是幂等又便宜的
# 动作没有并发覆盖问题,下个读请求发现缓存没了自然查最新
# DB 再写回 = 懒加载谁用谁负责加载,没人读的就空着不浪费。
# ★ 顺序为什么先更 DB 再删缓存:反过来先删缓存再更 DB,
# 删完缓存 DB 还没更新的空窗期有读请求进来会查到旧 DB 值
# 又写回缓存、缓存立刻又脏。★★ 仍不完美用延迟双删:更完
# DB 删一次缓存后再等 500ms 补删一次,清掉可能被写回的
# 脏值;但要清醒这些只能尽量减小不一致窗口做不到强一致,
# 缓存世界接受最终一致 —— 秒级不一致可容忍只要最终对齐,
# 真要强一致就别用缓存。
// ★ 一致性:Cache-Aside 写操作 —— 先更 DB,再删缓存(+ 延迟双删)
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ProductUpdateService {
// ★ 用一个调度线程池来做"延迟补删",别用 Thread.sleep 阻塞主流程
private final ScheduledExecutorService delayExecutor;
@Transactional
public void updateProduct(Product product) {
String key = "product:" + product.getId();
// === ① 先更新数据库(这是"事实"的最终来源)===
productMapper.updateById(product);
// === ② 再删除缓存 —— 注意是"删",不是"写新值" ===
// ★ 删除是幂等的,不怕并发;下次读自然会懒加载最新值
redisTemplate.delete(key);
// === ③ 延迟双删:再等一会儿补删一次 ===
// ★★ 防的是:某个读请求在 ① 之前读到旧值、却在 ②
// 之后才把旧值写回缓存。500ms 后再删一次清掉它
delayExecutor.schedule(() -> {
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.warn("延迟双删失败,key={}", key, e);
}
}, 500, TimeUnit.MILLISECONDS);
}
// ★ 反例:别这么写 —— 更新 DB 后直接写新值进缓存
public void badUpdate(Product product) {
productMapper.updateById(product);
// ★★ 错:并发更新时,缓存里可能留下"先更DB那个请求"的
// 旧值,和 DB 对不上。删除才是对的
redisTemplate.opsForValue().set("product:" + product.getId(), product);
}
private ProductMapper productMapper;
private RedisTemplate<String, Object> redisTemplate;
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(ProductUpdateService.class);
}
工程坑:大 key、热 key、命中率监控、序列化
# === ★ 防住了事故和一致性,还有四个"日常会咬人"的坑 ===
# === ★ 坑一:大 key —— 一个 value 撑得过大 ===
# ★ ★ 往一个 key 里塞了几 MB 的数据(比如把整个分类下
# 所有商品塞进一个 List)。Redis 是【单线程】的,读写
# 一个大 key 会【长时间占住】那唯一的一根线程,期间
# 所有其他请求全部被卡住排队。
# ★ ★ 治:大集合【拆分】成多个小 key;或换用 Hash 结构、
# 只取需要的字段,别一次性 get 整个大 value。
# === ★ 坑二:热 key —— 流量全压在一个 key 上 ===
# ★ ★ 某个爆款商品,它的 key 每秒被打几十万次。Redis
# 是分片的,这个 key 落在哪个分片,哪个分片的那台机器
# 就被单独打爆 —— 其他机器还很闲,它先扛不住了。
# ★ ★ 治:把这个热 key 在多个分片上【复制多份】(key 后
# 缀加随机编号),读的时候随机挑一份;或者直接用上面
# 讲的【本地缓存】把它拦在进 Redis 之前。
# === ★★ 坑三:不监控命中率,等于蒙着眼开车 ===
# ★ ★ 缓存命中率 = 命中次数 /(命中 + 未命中)。这个数字
# 是缓存健康的【体温计】:
# - 命中率突然【掉下来】 -> 可能正在发生穿透,或缓存被
# 大面积清空、正在重建。
# - 命中率一直【偏低】 -> 缓存策略本身有问题(ttl 太短?
# 缓存的根本不是热点数据?)
# ★ ★ INFO stats 里的 keyspace_hits / keyspace_misses
# 就是它。一定要接进监控、配告警。
# === ★ 坑四:序列化 —— 别用 JDK 默认序列化 ===
# ★ ★ JDK 原生序列化生成的字节又大、又慢,而且 value
# 在 redis-cli 里看是一堆乱码,完全没法排查。
# ★ ★ 治:用 JSON(可读、跨语言、好排查)或更紧凑的
# 二进制方案。Spring 里把 RedisTemplate 的 ValueSerializer
# 换成 GenericJackson2JsonRedisSerializer 即可。
# === 认知 ===
# ★ 防住事故和一致性还有四个日常咬人的坑。★ 坑一大 key:
# 一个 value 撑过大(把整个分类商品塞进一个 List),Redis
# 单线程读写大 key 会长时间占住唯一线程、其他请求全卡住
# 排队,治法是大集合拆成多个小 key 或换 Hash 只取需要字段。
# ★ 坑二热 key:爆款 key 每秒打几十万次,Redis 分片这个
# key 落哪个分片哪台机器就被单独打爆、其他机器还闲,治法
# 是热 key 在多分片复制多份(后缀加随机编号)读时随机挑或
# 用本地缓存拦在进 Redis 前。★★ 坑三不监控命中率等于蒙眼
# 开车:命中率=命中/(命中+未命中)是缓存健康的体温计,
# 突然掉下来可能正穿透或缓存被大面积清空重建、一直偏低是
# 策略本身有问题(ttl 太短或缓存的根本不是热点),INFO
# stats 的 keyspace_hits/misses 就是它必须接监控配告警。
# ★ 坑四序列化别用 JDK 默认:原生序列化字节大又慢、value
# 在 redis-cli 看是乱码没法排查,换 JSON 或紧凑二进制,
# Spring 里把 ValueSerializer 换成 GenericJackson2Json。
// ★ 工程坑:RedisTemplate 正确配置 + 命中率监控
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(cf);
// ★★ 关键:key 用 String 序列化 —— redis-cli 里能看懂
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// ★★ 关键:value 用 JSON —— 别用 JDK 默认序列化
// JSON 可读、跨语言、出问题能直接在 redis-cli 里排查
GenericJackson2JsonRedisSerializer json =
new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(json);
template.setHashValueSerializer(json);
template.afterPropertiesSet();
return template;
}
}
// ★ 命中率监控:定时读 INFO stats,算命中率,低了就告警
class CacheHitRateMonitor {
private final RedisTemplate<String, Object> redisTemplate;
CacheHitRateMonitor(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// ★ 建议用 @Scheduled 每分钟跑一次
public void checkHitRate() {
Properties stats = redisTemplate.execute(
(org.springframework.data.redis.core.RedisCallback<Properties>)
conn -> conn.serverCommands().info("stats"));
if (stats == null) return;
long hits = Long.parseLong(stats.getProperty("keyspace_hits", "0"));
long misses = Long.parseLong(stats.getProperty("keyspace_misses", "0"));
long total = hits + misses;
if (total == 0) return;
double hitRate = (double) hits / total;
// ★★ 命中率是缓存健康的体温计:突然掉下来 = 可能正在
// 穿透,或缓存被大面积清空重建
if (hitRate < 0.90) {
log.warn("⚠ 缓存命中率过低: {}% —— 排查穿透/雪崩",
String.format("%.1f", hitRate * 100));
}
}
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(CacheHitRateMonitor.class);
}
一张图:三大事故的防线全景
命令速查
┌──────────────────────┬─────────────────────────────────────────┐
│ 场景 / 命令 │ 说明 │
├──────────────────────┼─────────────────────────────────────────┤
│ INFO stats │ 看 keyspace_hits / keyspace_misses 算命中率│
│ INFO memory │ 看 used_memory,排查内存与大 key │
│ --bigkeys │ redis-cli --bigkeys 扫描大 key │
│ --hotkeys │ redis-cli --hotkeys 扫描热 key(需 LFU) │
│ MEMORY USAGE key │ 查单个 key 占用多少字节 │
│ TTL key │ 查 key 剩余过期时间(-1 永不过期 -2 不存在)│
│ SETNX key val │ 不存在才设置 —— 防击穿的互斥锁基础 │
│ SET key val EX n NX │ 原子地"设值+过期+不存在才设",锁的推荐写法 │
│ SLOWLOG GET │ 看慢命令,大 key 操作通常会出现在这里 │
│ DEBUG SLEEP n │ 模拟 Redis 阻塞 n 秒,演练雪崩降级用 │
│ CONFIG GET maxmemory │ 看内存上限与淘汰策略 maxmemory-policy │
└──────────────────────┴─────────────────────────────────────────┘
★ 一致性写操作顺序:先 UPDATE 数据库 -> 再 DEL 缓存 -> 延迟 500ms 再 DEL 一次
★ 过期时间公式:ttl = 基础时间 + random(0, 抖动区间) —— 永远别写死统一值
★ 排查顺序:命中率掉了先看是不是穿透 -> DB 被打看是不是击穿/雪崩 -> 看预热脚本
避坑清单:缓存上线前过一遍这 10 条
- 过期时间永远别写死统一值。所有 key 都设成一样的 30 分钟,等于给自己埋了一颗雪崩定时炸弹。统一写成「基础时间 + 随机抖动」,从源头打散。
- 预热脚本是雪崩高发区。用脚本集中灌缓存时,如果再配上统一过期时间,这批 key 必然集体同时过期。预热时尤其要给每个 key 单独算随机 ttl。
- 不存在的数据也要缓存。查库为空时,缓存一个空值标记(ttl 设短,几分钟),否则同一个不存在的 ID 反复来查,每次都穿到数据库。
- 面临恶意刷量,上布隆过滤器。缓存空值挡不住"每次用不同随机 ID"的攻击,前面加一道布隆过滤器,把瞎编的 ID 在第一道门挡掉。
- 热点 key 重建必须加互斥锁。不加锁,热点 key 一过期,成百上千并发会同时查库重建。用 SETNX 锁,只放一个请求去重建,其余重试。
- 更新数据时是"删缓存"不是"写缓存"。直接写新值进缓存会有并发覆盖问题。标准做法:先更新数据库,再删除缓存,让下次读懒加载。
- 顺序是"先更 DB 再删缓存"。反过来"先删缓存再更 DB",空窗期的读请求会把旧值写回缓存。要更严格再加延迟双删。
- 别用单机 Redis 扛生产流量。单机一挂就是 100% 雪崩。上主从+哨兵或 Cluster,关键业务再叠一层本地缓存做多级兜底。
- 命中率必须接监控告警。命中率是缓存的体温计。突然下跌往往是穿透或雪崩的最早信号,等数据库告警才发现就晚了。
- 警惕大 key 和热 key。Redis 单线程,一个大 key 操作会卡住所有请求;一个热 key 会打爆单个分片。定期用 --bigkeys / --hotkeys 扫描。
总结:缓存是一堵墙,但墙也会塌
那次大促事故过后,我盯着监控图复盘了很久。最扎心的不是数据库被打挂,而是我一直以为"加了缓存"就等于"做好了缓存"。我给商品加 Redis、写好 Cache-Aside 读逻辑、跑了大半年没出事,就理所当然地觉得这件事已经"完成"了。可那半年的平稳,只是因为流量一直没大到能照见那些缺口——直到大促那天,潮水退去,所有裸奔点同时暴露。
把这件事彻底理清之后,我对缓存的理解变了。缓存的本质,是数据库前面的一堵挡箭墙。但这堵墙不是铁板一块,它有三种典型的塌法:穿透,是查一个根本不存在的数据,缓存永远建不起来,每次查询都注定穿墙而过——治它要靠缓存空值和布隆过滤器,把"查不到的东西"也变成一种能被缓存的结果。击穿,是一个高并发的热点 key 恰好过期,过期的瞬间成百上千请求一起涌进数据库——治它要靠互斥锁,让无论多少并发,落到数据库的永远只有一个重建请求。雪崩,是一大批 key 在同一时刻集体过期,或者 Redis 整个宕机,海量请求同一秒全部失去遮挡——治它要靠过期时间的随机抖动打散集体过期、靠 Redis 高可用和多级缓存防住整体宕机、靠限流降级做最后兜底。三种事故,三道不同的防线,不能用一招通治。
而比这三种事故更隐蔽的,是一致性。缓存和数据库是两份数据,数据一改,它们之间就会出现一个不一致的窗口。我后来接受了一个事实:在用缓存的系统里,你追求的不是"任何时刻两边都分毫不差"的强一致,而是"先更新数据库、再删除缓存、必要时延迟双删"换来的最终一致——短暂的、秒级的不一致是可以容忍的,只要它最终会对齐。如果你的业务连秒级不一致都不能忍,那答案不是把缓存调得更精巧,而是这个场景根本就不该用缓存。
如果说这次事故只让我记住一件事,那就是:缓存不是加上去就一劳永逸的东西,它是一个需要持续运营的东西。过期时间要不要抖动、热点 key 要不要单独保护、命中率有没有接监控、Redis 是不是单点、更新数据时动的是哪一个——这些都不是"写代码时顺手"就能做好的,而是要在上线前一条条问自己、上线后一直盯着的。那堵墙立起来的那一刻,工作只完成了一半;剩下的一半,是确保当真正的潮水来临时,它不会在同一秒里整个塌掉。监控图上那条一度冲到 100% 的数据库 CPU 曲线,后来成了我每次设计缓存时,都会想起的一根标尺。
—— 别看了 · 2026