缓存策略与一致性
缓存能让读取快几十倍,也能制造出最难查的 bug:
- 管理员改了商品价格,用户看到的还是旧价格,要过一会儿才变;
- B 公司的员工打开页面,看到的是 A 公司的数据;
- 某个热门商品的缓存过期的那一秒,数据库被瞬间打满;
- Redis 挂了几分钟,整个服务跟着不可用。
缓存的本质是一份数据的副本:读它很快,但它可能已经过期,也可能根本不是你要的那份。用好缓存,就是管理好这份副本。
读完这篇你会
- 理解缓存旁路模式的读写顺序和背后的并发问题
- 设计包含全部维度的缓存键
- 处理击穿、穿透、雪崩和缓存不可用等异常情况
1. 原理:缓存是贴在显眼处的便利贴
把常用的电话号码写在便利贴上贴在显示器旁边,比每次翻通讯录快得多。但如果对方换了号码,便利贴上的还是旧号码,你就会打错。
缓存就是这张便利贴。最常用的使用方式叫缓存旁路(Cache-Aside):
text
读取:先看缓存 → 有就直接返回(命中)
→ 没有(未命中)→ 查数据库 → 写入缓存 → 返回
更新:先更新数据库 → 再删除缓存(下次读取时自然会重新加载最新数据)js
async function getProduct(id) {
const key = `product:${id}`
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const product = await productRepo.findById(id)
if (product) await redis.set(key, JSON.stringify(product), 'EX', 600) // 10 分钟过期
return product
}
async function updateProduct(id, changes) {
await productRepo.update(id, changes)
await redis.del(`product:${id}`) // 删除而不是更新缓存
}更新时选择删除缓存而不是更新缓存,因为复杂对象的局部更新很容易漏字段,而删除之后下一次读取会从数据库加载完整的最新数据。
2. 什么值得缓存
| 适合缓存 | 原因 |
|---|---|
| 配置、字典、地区列表 | 很少变化,读取频繁 |
| 热门商品详情、首页推荐 | 读多写少,数据库压力大 |
| 计算成本高的结果(统计、排行) | 避免重复计算 |
| 会话、验证码、临时令牌 | 天然有过期时间 |
| 要谨慎 | 原因 |
|---|---|
| 库存、余额、权限 | 读到旧值会造成实际损失或安全问题 |
| 和用户、租户、权限强相关的数据 | 缓存键稍有遗漏就会串号(见坑二) |
没有确认存在读性能问题之前,不要先加缓存。 先把查询、索引优化好,很多时候就不需要缓存了。
3. 坑一:更新后,偶尔还是读到旧数据
现象:改了商品价格,大部分时候页面立刻显示新价格,但偶尔会一直显示旧价格,直到缓存过期。
原因一:操作顺序反了。如果先删缓存、再更新数据库:
text
时刻 1 请求 A(更新):删除缓存
时刻 2 请求 B(读取):缓存未命中 → 从数据库读到【旧值】
时刻 3 请求 A(更新):更新数据库为新值
时刻 4 请求 B(读取):把【旧值】写进缓存 ← 之后所有人都读到旧值所以要先更新数据库,再删除缓存。
原因二:即使顺序对了,极端情况下仍可能出现:一个读请求在数据库更新前读到了旧值,却在缓存被删除之后才把旧值写回缓存。这种情况概率很低,但存在。
修复:
- 先更新数据库,再删除缓存;
- 一定要设置过期时间,让任何意外写入的旧值最多存活一段时间;
- 对一致性要求高的数据,可以在更新后延迟几百毫秒再删除一次缓存(延迟双删),或者干脆不缓存。
4. 坑二:缓存键少了维度,用户看到了别人的数据
现象:多租户系统里,B 公司的员工打开一篇文档,看到的是 A 公司的内容;英文用户看到了中文版;普通用户看到了管理员版本的数据。
js
const key = `article:${id}` // ❌ 只用了文章 ID原因:缓存键是判断“是不是同一份数据”的唯一依据。只要键相同,就返回同一份缓存,不管请求来自哪个租户、什么语言、什么权限。这类 bug 不会报错,数据看起来也“正常”,往往要等用户投诉才被发现,而且属于数据泄露。
修复:把所有会影响结果的维度都放进键里:
js
const key = `article:${id}:tenant:${tenantId}:lang:${lang}:role:${roleVersion}`怎么避免再犯:设计缓存键时,对每个参数问一句:“这个参数的值不同,返回的结果会不同吗?”会,就必须进键。把缓存键的拼接集中写在一个函数里,不要在各处手写字符串。
5. 坑三:热门数据过期的瞬间,数据库被打满(击穿)
现象:首页推荐的缓存每 10 分钟过期一次。过期的那一瞬间,几千个请求同时发现缓存未命中,同时去查数据库,数据库 CPU 飙升,接口大面积超时。
修复:同一个键同一时间只允许一个请求去数据库加载,其他请求等待这个结果:
js
const loading = new Map() // 正在加载中的键 → Promise
async function getWithSingleFlight(key, loader, ttl = 600) {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
if (!loading.has(key)) {
const task = loader()
.then(async (value) => {
await redis.set(key, JSON.stringify(value), 'EX', ttl)
return value
})
.finally(() => loading.delete(key))
loading.set(key, task)
}
return loading.get(key) // 并发请求共享同一次加载
}这个写法只在单个进程内生效。部署了多台服务器时,每台最多各查一次数据库,通常已经足够;还不够的话,可以用分布式锁,或者让热门数据在过期前由后台任务提前刷新。
6. 坑四:查询不存在的数据,每次都打到数据库(穿透)
现象:有人用脚本不断请求 /products/随机ID,这些商品根本不存在,缓存里永远没有,每次请求都直接打到数据库。
修复:
- 先校验参数格式,明显非法的 ID 直接拒绝;
- 数据库查不到时,也缓存一个“空结果”,设置较短的过期时间(比如 1 分钟);
- 数据量巨大时,可以用布隆过滤器快速判断“这个 ID 一定不存在”。
7. 坑五:大量缓存在同一时刻过期(雪崩)
现象:系统每天凌晨批量预热了一万个缓存,过期时间都是 1 小时。一小时后它们同时过期,数据库被瞬间涌入的请求压垮。
修复:给过期时间加上随机值,让它们分散过期:
js
const ttl = 3600 + Math.floor(Math.random() * 600) // 1 小时 ±10 分钟内随机8. 坑六:Redis 挂了,整个服务跟着挂
现象:Redis 发生故障,所有接口都卡住或报错,即使数据库完全正常。
原因:代码假设缓存一定可用:没有设置超时,连接卡住时请求一直等待;缓存报错直接导致整个请求失败。
修复:缓存是“锦上添花”的加速层,不应该成为单点故障:
- 缓存操作设置较短的超时时间;
- 缓存读取失败时,降级到直接查数据库(同时注意数据库能否承受全部流量,必要时对非核心功能限流);
- 缓存写入失败只记录日志,不影响主流程。
9. 一致性是业务选择
不同数据对“旧”的容忍度不同,要和业务一起明确:
| 数据 | 能接受多旧 | 策略 |
|---|---|---|
| 推荐列表、阅读数 | 几分钟 | 较长的过期时间 |
| 商品详情 | 几秒到一分钟 | 更新时删除缓存 + 过期时间兜底 |
| 库存、余额、权限 | 不能接受 | 不缓存,或只用于展示、真正判断时查数据库 |
把“允许旧多久、读到旧数据会有什么后果”写清楚,缓存才不会变成隐形的风险。
排查思路
- 读到旧数据:查看缓存键的剩余过期时间(Redis 的
TTL key),对比缓存值和数据库值;检查更新代码里删除缓存的时机和键名是否一致。 - 串号:列出影响这个接口返回结果的所有参数,对照缓存键看漏了哪个。
- 数据库压力突增:看缓存命中率曲线,命中率骤降的时刻通常就是大量键同时过期或热点键失效的时刻。
- 内存占用过高:用
redis-cli --bigkeys找出体积过大的键;检查是否有键没有设置过期时间。 - 调试时可以在日志里记录每次请求的缓存“命中 / 未命中”,问题会更直观。
检查清单
- [ ] 确认存在读性能问题后才引入缓存
- [ ] 更新时先写数据库、再删除缓存
- [ ] 所有缓存都有过期时间,并加上随机值
- [ ] 缓存键包含所有影响结果的维度,并集中生成
- [ ] 热点数据有防击穿措施,不存在的数据有防穿透措施
- [ ] 缓存故障时能降级,不会拖垮整个服务
- [ ] 和业务约定了每类数据能接受的过期程度