Redis(四):缓存三大问题与数据一致性
Redis(四):缓存三大问题与数据一致性
导语:缓存用起来的价值是「快」,用不好的代价是「脏」和「崩」。本篇先解决「缓存被打穿」的三个经典问题(穿透 / 击穿 / 雪崩),再正面回答最难的一题——缓存与数据库的一致性到底怎么保证,并把 Cache-Aside、延迟双删、binlog 订阅这些方案的适用边界讲清楚,共 13 题。
一、缓存三大经典问题
1. 什么是缓存穿透?如何解决?
答: 缓存穿透 = 查询一个「缓存和数据库里都不存在」的数据,导致每次请求都穿过缓存直接打到 DB。
典型来源:恶意攻击(大量 id=-1、随机 UUID)、参数错误、业务上合法的「不存在」查询。
解决方案(按性价比排序):
| 方案 | 做法 | 优点 | 局限 |
|---|---|---|---|
| 缓存空值 | 查不到也把「空」缓存起来,如 SET user:-1 "" EX 60 | 实现最简单,能挡住重复攻击 | 攻击者用大量不同 key 仍会打进来(每个都占一份内存);只能防「同一个不存在的 key」,所以 TTL 必须短 |
| 布隆过滤器 | 缓存前先过一层过滤器,判「一定不存在」直接拒绝 | 空间效率极高,能挡海量随机 key | 有误判(假阳性)、不支持删除、需要预热(见第 2 题) |
| 参数校验与限流 | 接口层校验 ID 格式/范围;网关按 IP、用户维度限流 | 从源头减少非法请求 | 不能防「合法格式但不存在」的数据 |
| 黑名单 | 命中攻击特征直接拦截 | 应对已知攻击 | 被动防御 |
工程上的完整组合通常是:参数校验 → 布隆过滤器 → 缓存空值(短 TTL)→ 接口限流,层层收窄。
易错点:只答「缓存空值」不够——面试官往往会追问「攻击者用一亿个不同的不存在 key 怎么办?」这时要接上「短 TTL + 布隆过滤器 + 限流」。另外缓存空值时不要缓存 null 字符串,用特定占位符(如
""或nil),并保证反序列化时能正确识别。
2. 布隆过滤器的原理、误判与局限?
答: 布隆过滤器(Bloom Filter)= 一个位数组 + k 个哈希函数,用来判断「某个元素一定不存在或可能存在」。
工作原理:
插入 "user:1":
用 k 个哈希函数算出 k 个位置,把位数组对应位置置 1
查询 "user:1":
算出同样 k 个位置:
├─ 只要有任意一位是 0 -> 元素「一定不存在」 (可用作拦截)
└─ k 位全为 1 -> 元素「可能存在」 (可能是别人置的 1,即误判)核心特性:
| 特性 | 说明 |
|---|---|
| 只会有假阳性,没有假阴性 | 说不存在就一定不存在;说存在可能误判——这个「单向可靠」正是它能做拦截的原因 |
| 空间效率极高 | 相比 Set 存原始元素,空间占用可低数个数量级(HyperLogLog 用于计数,布隆用于判存在,两者常被对比) |
| 不支持删除 | 把某位清 0 会影响其他元素的判断结果。要支持删除需用 Counting Bloom Filter(每位置改成计数器,代价是空间)或 Cuckoo Filter(支持删除且查询更快) |
| 不能取出元素 | 只能回答「在不在」,拿不到原始数据 |
| 误判率可调 | 与「位数组大小 m、哈希函数个数 k、元素个数 n」相关:m/n 越大误判率越低;k 存在最优值(约 (m/n)·ln2),k 太大会让位数组更快被填满 |
Redis 中的实现:
- RedisBloom 模块(Redis 8.0 起已内置):
BF.RESERVE创建(指定误判率与容量)、BF.ADD/BF.MADD添加、BF.EXISTS/BF.MEXISTS查询;同家族的 Cuckoo Filter 用CF.*,支持删除; - 手写实现:用
SETBIT/GETBIT自己维护位数组(要自己解决分片与哈希函数的映射,不推荐生产使用)。
工程上的三个坑:
- 必须预热:启动时要把 DB 里已存在的 key 全量灌进去,否则会误拦合法数据(注意:灌入是异步的,上线初期要有开关降级);
- 重建成本高:数据量大时重建慢,通常配合「双写 + 影子过滤器切换」或定时任务;
- 误判必须兜底:布隆说「可能存在」时仍要去查缓存/DB,所以它只是减少无效请求,不能替代缓存空值。
3. 什么是缓存击穿?如何解决?
答: 缓存击穿 = 某一个「热点 key」在过期的瞬间,大量并发请求同时穿过缓存打到 DB,把 DB 压垮。
关键词是「单个热点 key」——这是它与雪崩最本质的区别(雪崩是「大量 key 同时失效」,见第 4 题)。
解决方案:
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 互斥锁(最常用) | 缓存 miss 时用分布式锁只放一个请求去查 DB 并回写,其余线程等待后重试读缓存 | 一致性最好;缺点是等待线程会阻塞、锁竞争有开销,要注意锁超时与重试策略 |
| 逻辑过期(不设物理 TTL) | value 里额外存一个 expireTime,读到「逻辑已过期」时返回旧值,同时异步开一个线程去刷新 | 无阻塞、体验好;缺点是有短暂脏数据,实现复杂(需要线程池与去重控制) |
| 热点 key 永不过期 + 后台刷新 | 不设 TTL,由定时任务在过期前主动续期刷新 | 简单有效,适合可枚举的热点数据(如首页配置) |
| 单飞(singleflight) | 进程内把「同一个 key 的并发回源」合并成一次调用 | 本地层面即可挡住并发,多实例下仍需分布式锁配合 |
互斥锁的最小实现(注意两个细节):
# 加锁:必须原子(NX + 过期),value 用唯一标识便于安全解锁
SET lock:rebuild:user:1 <uuid> NX EX 10
# 解锁:Lua 保证「判断是自己的锁再删」的原子性
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0两个细节:① 拿到锁的线程也要再查一次缓存(双检),避免重复回源;② 未拿到锁的线程要有超时和降级,不能无限自旋。
选型建议:读多、能容忍短暂旧值用逻辑过期;要求强一致或实现简单用互斥锁;热点可预测用永不过期 + 后台刷新。生产上三者常组合使用(如秒杀商品用逻辑过期 + 后台预热)。
4. 什么是缓存雪崩?如何解决?
答: 缓存雪崩 = 在同一时刻大量 key 集中失效,或者 Redis 整体不可用,导致海量请求同时打到 DB,DB 被打垮并引发整个系统连锁故障。
两种诱因要分开答:
诱因一:大量 key 同时过期
- 过期时间加随机抖动:
TTL = base + random(0, base * 0.1~0.3),把「同一秒失效」打散到时间窗口内; - 二级过期:对超热数据用「逻辑过期 + 异步刷新」,不设物理 TTL;
- 预热:系统上线/大促前提前把热点数据加载好(见第 5 题)。
诱因二:Redis 集群/实例宕机
- 高可用:主从 + 哨兵 + Cluster,避免单点(见《Redis(三)》);
- 多级缓存:本地缓存(Caffeine)+ Redis,Redis 挂了本地还能扛一层(见第 13 题);
- 限流与熔断降级:对 DB 侧做保护——这是最后一道防线,宁可拒绝部分请求,也不能让 DB 雪崩(可用 Sentinel/Resilience4j 做熔断,或对回源请求做信号量/令牌桶限流);
- 客户端降级策略:缓存不可用时返回默认值/兜底数据,而不是直接穿透到 DB。
与击穿的区别(一句话):
| 缓存击穿 | 缓存雪崩 | |
|---|---|---|
| 影响范围 | 单个热点 key | 大量 key / 整个 Redis |
| 直接原因 | 热点 key 刚过期,并发集中 | 集中过期 或 实例宕机 |
| 主要解法 | 互斥锁、逻辑过期 | 随机 TTL、高可用、多级缓存、熔断限流 |
5. 什么是缓存预热?怎么做?
答: 缓存预热 = 系统上线/大促/重启前,主动把热点数据提前加载进缓存,避免冷启动瞬间的并发全部打到 DB。
四种做法:
| 方式 | 说明 | 适用 |
|---|---|---|
| 启动时加载 | 应用启动完成(ApplicationRunner)后,异步把热点数据刷入 | 数据量可控的场景 |
| 定时任务刷新 | 定时全量/增量刷新热点 key(如每 5 分钟刷首页配置) | 周期性热点 |
| 手动触发 | 提供运营侧接口,大促前人工点一下预热 | 大促、活动前 |
| binlog 订阅预热 | 监听数据变更,把变更的数据顺手刷新进缓存 | 变更驱动的场景 |
关键要点:
- 预热必须是异步/分批的:几百万 key 一次性
SET会把单线程 Redis 打满,也会把 DB 拖垮——用线程池分批 + 限速; - 预热要设「预热开关」:上线时数据可能还没全,要有降级路径(预热未完成时正常回源,而不是报错);
- 预热的数据要有过期时间与更新机制:否则预热进去的就变成「永不过期的脏数据」;
- 关注预热效果:看缓存命中率、DB QPS 是否在启动后迅速回落。
二、缓存与数据库一致性
6. 缓存更新有哪些模式?Cache-Aside 为什么最常用?
答: 四种经典模式,区别在「谁负责读回源、谁负责写」:
| 模式 | 读 | 写 | 特点 |
|---|---|---|---|
| Cache-Aside(旁路缓存) | 应用先读缓存,miss 则读 DB 并回写缓存 | 应用更新 DB,然后删除缓存 | 最常用:应用自己控制,逻辑清晰,缓存只作副本 |
| Read-Through | 缓存层负责回源(应用只读缓存) | — | 对应用透明,但需要缓存组件/代理支持(如 Spring Cache + 自定义 CacheLoader) |
| Write-Through | 同上 | 写缓存的同时同步写 DB(缓存层代理) | 一致性最好,但写延迟高(要等两次写) |
| Write-Behind(Write-Back) | 同上 | 只写缓存,异步批量刷 DB | 写性能最好;但宕机会丢数据,一致性最难保证 |
为什么 Cache-Aside 最常用,而且写的时候用「删除」而不是「更新」:
- 并发下「更新缓存」的顺序无法保证:两个线程都先写 DB 再写缓存,可能后写 DB 的先写缓存,导致缓存是旧值;
- 缓存值往往是「计算/聚合结果」:例如把多表 join 后的对象缓存起来,要「更新缓存」就得重算,成本高且易错;删除则一次调用搞定;
- 删除是幂等的、天然抗重复:删两次结果一样,重试安全;更新则依赖具体值;
- 懒加载思想:如果这个 key 之后根本不会再被读,删除就省掉了一次无谓的写——「删除 + 下次读时回源」比「每次都更新」更省资源。
所以标准答案就是:读走 Cache-Aside,写用「更新 DB + 删除缓存」。下面几题都在讨论这套方案还有什么漏洞。
7. 先删缓存还是先更新数据库?
答: 两种顺序都有问题,但代价不同——推荐「先更新数据库,再删除缓存」。
方案 A:先删缓存,再更新 DB(不推荐)
时间线:
① 写线程 W:删除缓存
② 读线程 R:缓存 miss -> 读 DB(此时 DB 还是旧值 V1)-> 把 V1 回写缓存 ← 脏数据
③ 写线程 W:更新 DB 为 V2
结果:缓存里是 V1,DB 里是 V2,且缓存是「刚刚写入」的,如果没有 TTL 就会长期脏问题在于「删除」这个动作把缓存的保护伞撤掉了,紧接着的读请求一定会回源,并且回源读到的是还没更新的旧值。这个窗口虽然很短,但读到旧值并回填是必然发生的(不是概率问题),所以此方案必须配合延迟双删兜底。
方案 B:先更新 DB,再删除缓存(推荐,即 Cache-Aside)
① 写线程 W:更新 DB 为 V2
② 写线程 W:删除缓存
③ 后续读请求:缓存 miss -> 读 DB 得到 V2 -> 回写缓存(正确值)风险窗口是「读请求在删缓存之前就读到了旧值,却在删缓存之后才回写」,需要满足「读比写慢 + 读先于写」的巧合,概率远低于方案 A(见第 9 题)。
两个必须补上的兜底(原答案漏掉的关键点):
- 一定要给缓存设 TTL:这样即使出现不一致,也只会脏一个 TTL 周期,而不是永久脏。「删除缓存失败」是最常见的脏数据来源——DB 更新成功了,但删缓存那一步超时/异常,缓存里还是旧值,只有 TTL 能救;
- 删除失败要重试/补偿:用消息队列做重试、或走 binlog 订阅(见第 10 题),不要只靠「大概率下次读会回写新值」这种运气。
一句话结论:更新 DB → 删缓存 →(TTL 兜底 + 删除失败重试)。任何声称「这样就不会不一致」的答案都是不完整的。
8. 什么是延迟双删?
答: 延迟双删 = 在「更新数据库」的前后各删一次缓存,第二次删除故意延迟一段时间,用来清掉中间窗口被回填的旧值。
标准流程:
① 删除缓存
② 更新数据库
③ 休眠一段时间(通常 500ms ~ 1s,甚至更长)
④ 再次删除缓存 ← 这一步清掉「步骤 ①~② 之间被读请求回填的旧值」为什么第二次删除能生效:因为回填旧值的读请求是「删除后、DB 更新完成前」发起的,它把旧值写回缓存需要一段时间(读 DB + 序列化 + 网络往返)。只要延迟时间大于「一次读请求从回源到写缓存的耗时」,第二次删除就一定能清掉它。
它的三个缺陷(面试要主动说):
- 延迟时间只能估,无法精确:写库耗时、GC 停顿、网络抖动都可能超出预期,理论上「窗口无限长」的极端情况无法覆盖——所以它是降低概率,不是消除;
- 休眠会占用线程:
Thread.sleep阻塞业务线程,必须异步化(丢到延时队列/线程池)或改用「删除消息延迟投递」实现; - 第二次删除也可能失败:仍然需要 TTL 兜底与重试机制。
它更适合的场景:作为「先删缓存再更新 DB」方案的补偿。而如果采用的是推荐的「先更新 DB 再删缓存」,一致性问题本就小得多,延迟双删只是进一步降低概率,不是必需品。
面试答法:把「延迟双删解决的是哪个时序问题」讲清楚(回填旧值),再主动说它的三个缺陷和「延迟时间靠估」这个根本局限,比单纯背流程更有说服力。
9. 「更新 DB + 删缓存」就万无一失吗?并发下为什么会不一致?
答: 不是万无一失。这是这一节最值得深挖的一题——即使采用推荐的顺序,仍然存在一个低概率但真实的不一致窗口:
时间线:
① 读线程 R:缓存 miss,开始读 DB(此时 DB 还是旧值 V1)
↓ (R 读得很慢:GC / 网络抖动 / 大查询)
② 写线程 W:更新 DB 为 V2
③ 写线程 W:删除缓存(缓存本来就是空的,删除「成功」但没起作用)
④ 读线程 R:终于读到 V1,把 V1 回写缓存 ← 脏数据落地了
结果:DB = V2,缓存 = V1(且是「刚写入」的,TTL 内一直脏)发生条件(三个都要满足):
- 读请求先发生且读到的是旧值(必须在 W 更新 DB 之前完成读);
- 读请求回写缓存发生在 W 删除缓存之后;
- 该 key 在 TTL 内没有再次被写(否则会被后一次删除/更新纠正)。
要同时满足「读比写慢」+「读先于写」,概率很低(尤其写操作比读操作慢得多时),但在高并发 + 长 GC + 慢查询的组合下确实会发生——这也是为什么不能只靠「顺序正确」来保证一致性。
缓解措施(按可靠性递增):
| 措施 | 效果 |
|---|---|
| 给缓存设 TTL | 兜底:最多脏一个 TTL 周期(必须做) |
| 延迟双删 / 延迟删除消息 | 清掉窗口内的旧值,降低概率 |
| 读请求回写前加校验(如 CAS:写缓存时比对版本号/DB 当前值) | 能彻底堵住,但实现复杂、增加读写开销 |
| binlog 订阅异步删除(最可靠) | 以 DB 的最终状态为准,由 binlog 驱动删除,不依赖业务代码的时序(见第 10 题) |
| 读写串行化(分布式锁) | 彻底解决但牺牲并发,仅在强一致场景使用 |
| 关键数据不走缓存 | 从需求上消灭问题(如账户余额) |
加分表述:「缓存一致性问题的本质是两个数据源没有共享事务。只要不是在同一个事务里同时改 DB 和缓存,就只能靠最终一致(TTL + 重试 + 补偿)去逼近,而不是靠调整代码顺序彻底消除。」
10. 如何实现缓存与数据库的最终一致?(含 Canal 方案)
答: 严格强一致代价极高,工程上追求的是最终一致。方案按可靠性递增排列:
方案一:更新 DB + 删缓存 + TTL 兜底 + 重试
- 最基础,覆盖 90% 的场景;
- 删除失败要重试:把「要删的 key」写入消息队列,消费者重试删除(本地消息表保证不丢);重试仍失败则靠 TTL。
方案二:binlog 订阅(Canal / Maxwell / Debezium)——推荐
业务只写 DB(不碰缓存)
↓
Canal 伪装成 MySQL 从库,解析 binlog
↓
投递到 MQ(Kafka/RocketMQ,保证有序 + 可重放)
↓
缓存同步服务消费消息 -> 删除(或更新)对应缓存 key优点:
- 业务代码零侵入:不用在每个写路径上记得删缓存;
- 可靠:binlog 是 DB 的权威变更记录,不会漏(业务方「忘记删缓存」这类问题从根上消失);
- 有序 + 可重放:按 binlog 顺序处理能避免乱序覆盖;出问题可以重置位点重放;
- 解耦:缓存同步逻辑独立演进,可灰度、可回滚。
缺点/注意点:
- 架构复杂度上升:多了一套 Canal + MQ + 同步服务的运维成本;
- 仍有延迟:binlog 从产生到消费通常有毫秒到秒级延迟,期间缓存是旧值——依然是最终一致;
- 重复消费要幂等:删除操作天然幂等(再删一次无害),这正是「删缓存」而非「更新缓存」的又一个好处;
- DDL 与大批量变更要限流:一次性更新百万行会产生海量 binlog,同步服务要按 key 合并、限速。
方案三:强一致的几种代价高昂的做法(了解即可)
- 读写都加分布式锁:同一 key 的读写串行化,性能大幅下降;
- 读主库 + 不缓存:放弃缓存收益;
- 两阶段提交 / 分布式事务:把 DB 与 Redis 纳入同一事务,实现复杂且 Redis 本身不支持事务回滚(见《Redis(五)》)。
结论:缓存天然是最终一致。业务上应明确「能容忍多长时间的旧数据」,用 TTL(兜底)+ 删除重试 / binlog 订阅(收敛) 把不一致窗口压到业务可接受范围;只有极少数场景才值得为强一致付出性能代价。
11. 为什么不能「先更新缓存,再更新数据库」?
答: 四种失败组合里有三种会脏,而且脏得很彻底:
| 步骤结果 | 后果 |
|---|---|
| 更新缓存成功 + 更新 DB 成功 | 一致(但并发下顺序仍可能错,见下) |
| 更新缓存成功 + 更新 DB 失败 | 缓存是脏数据(DB 回滚了,缓存却留下了新值) |
| 更新缓存失败 + 更新 DB 成功 | 缓存是旧值(这个还算好,靠 TTL 能救) |
| 并发下两个线程乱序 | 永久不一致:线程 A 先写 DB 后写缓存,线程 B 先写缓存后写 DB,最终缓存落地的是 A 的旧值 |
更本质的三个原因:
- 以缓存为准是错的:数据库才是唯一权威数据源,缓存只是副本。副本先于权威更新,就产生了「无源之水」;
- 「更新缓存」的语义更复杂:缓存里的值常常是多表聚合/计算后的结果,要「更新」就得重算,成本高、易漏字段;而「删除」只是让缓存失效,由读时回源重建,永远不会写错值;
- 失败处理更麻烦:删缓存失败只需重试删除(幂等);更新缓存失败要考虑重试、覆盖、版本冲突,复杂度陡增。
还有一种常见变体也要否定:「双写」+ 分布式事务(同时更新 DB 和缓存,用事务包起来)。Redis 不支持回滚,跨系统事务的可用性与复杂度都不可接受,几乎没人这么做。
正确姿势:DB 是唯一数据源,缓存只做副本;写路径「更新 DB + 删除缓存」,读路径 miss 时回源重建。
12. 缓存要不要设过期时间?怎么设?
答: 要设。TTL 是最终一致的兜底机制——不管前面哪个环节漏了(删除失败、binlog 延迟、代码 bug),TTL 到期后缓存自然回源刷新。没有 TTL 的缓存等于给自己埋了个永久脏数据的雷。
怎么设(按数据特征分):
| 数据类型 | TTL 策略 | 说明 |
|---|---|---|
| 变更极少(字典、配置、省份列表) | 长 TTL(小时级)或永不过期 + 变更时主动删 | 主动失效比过期更准 |
| 一般业务数据(用户信息、商品详情) | 中等 TTL(分钟~小时)+ 随机抖动 | 抖动是为了防雪崩 |
| 变更频繁(库存、价格、状态) | 短 TTL(秒级)或逻辑过期 | 短 TTL 提升一致性,代价是回源变多 |
| 超热数据(秒杀商品、首页) | 逻辑过期 / 永不过期 + 后台刷新 | 避免物理过期瞬间被击穿 |
两个必须注意的细节:
- 随机抖动:
TTL = base + random(0, base * 0.1~0.3)(甚至 ±50%),否则同一批写入的 key 会在同一秒集体失效,直接触发雪崩; - 给海量 key 设 TTL 有内存成本:过期时间存在独立的
expires字典里,几千万 key 每个都带 TTL 会显著增加内存占用(见《Redis(二)》第 9 题)。
一句话总结:TTL 是「不确定性的收敛装置」——它的作用不是保证一致性,而是保证「不一致最多持续 TTL 这么久」。所以「TTL 设多长」本质上是一个业务问题:你能容忍多长时间的旧数据。
13. 什么是多级缓存?如何设计?
答: 多级缓存 = 在 Redis 前面再加一层进程内的本地缓存,形成 本地缓存(Caffeine/Guava) → Redis → DB 三级结构。
为什么需要:
- 极热数据:单机 QPS 上万的热点 key,走 Redis 也有网络往返(百微秒级)与单分片瓶颈;本地缓存是纳秒级且完全不吃 Redis 资源;
- 抗 Redis 抖动:Redis 抖动/主从切换期间,本地缓存还能挡一段时间,避免直接穿透到 DB(雪崩防线);
- 降低网络开销:命中本地就完全不发请求。
代价与设计要点:
| 问题 | 应对 |
|---|---|
| 多实例间不一致 | 变更时通过 Redis Pub/Sub 或 MQ 广播失效;同时给本地缓存设短 TTL(秒级)做兜底 |
| 容量有限 | 只放「真正的热点」(Top N),用 Caffeine 的 maximumSize + expireAfterWrite 控制 |
| 首次访问仍会回源 | 应用启动预热 + 异步刷新 |
| 广播丢失 | 短 TTL 兜底;对一致性要求高的 key 不放本地缓存 |
典型实现:
- Spring Cache 多级:
Caffeine+Redis组合(如LayeringCache、阿里jetcache); - 广播失效:变更时
PUBLISH cache:invalidate <key>,各实例订阅后invalidate本地 key(注意 Pub/Sub 消息不持久化,可能丢,需要短 TTL 兜底)。
适用边界:多级缓存适合读极多、变更少、能容忍秒级旧值的数据。库存、余额这类强一致数据不要放本地缓存——多实例不一致会让问题更难排查。
下一篇:《Redis(五)》讲分布式锁与实战——
SET NX EX+ Lua 解锁、Redisson 看门狗与可重入、RedLock 的争议,以及单线程模型、Pipeline/事务/Lua、限流与延迟队列等落地场景。
