· 王思腾 · 系统架构 · 8 分钟阅读
缓存设计实战:穿透、击穿、雪崩与一致性
缓存是高并发系统的第一道防线,但用不好会引入数据不一致、缓存穿透、击穿、雪崩等问题。本文系统梳理缓存读写策略(Cache Aside/Read/Write Through)、三大经典问题的成因与解法、热点 key、缓存与数据库一致性,以及 Redis 在生产中的实践要点。

“性能不够,缓存来凑”——缓存是提升读性能、抵抗高并发最直接的手段:把热点数据放在内存(Redis 等),避免每次都打到数据库。但缓存绝非“加个 Redis”那么简单,用不好会带来数据不一致和一连串经典故障。
一、缓存放在哪、缓存什么
多级缓存是常见做法:
flowchart LR
C1["浏览器 / CDN 缓存"] --> C2["本地缓存<br/>Caffeine / 进程内"] --> C3["分布式缓存<br/>Redis"] --> DB[("数据库")]- 本地缓存:最快但不共享、容量小,适合变更极少的配置/字典数据;
- 分布式缓存(Redis):多实例共享、容量大,是主力;
- 缓存对象:读多写少、可容忍短暂不一致的热点数据(商品详情、用户信息、配置、排行榜)。
二、缓存读写策略
最常用的是 Cache Aside(旁路缓存):
- 读:先读缓存,命中就返回;未命中则读数据库,写入缓存后返回;
- 写:先更新数据库,再删除缓存(而不是更新缓存)。
flowchart TB
subgraph Read["读流程(Cache Aside)"]
R0["读请求"] --> R1{"缓存命中?"}
R1 -- 是 --> R2["直接返回"]
R1 -- 否 --> R3["读数据库"] --> R4["回填缓存"] --> R2
end
subgraph Write["写流程"]
W1["更新数据库"] --> W2["删除缓存<br/>下次读时回填最新值"]
end为什么写操作是“删缓存”而不是“更新缓存”?
- 避免并发写导致缓存与库不一致;
- 很多缓存值是复杂计算结果,删了等下次读时懒加载更省事;
- 防止写多读少时白做无用功。
其他策略(Read Through / Write Through / Write Behind)把缓存和库的同步封装在缓存层,业务侧较少直接用,理解旁路模式即可应对大多数场景。
三、三大经典问题
1. 缓存穿透(Penetration)
查一个数据库里根本不存在的数据,缓存永远不命中,请求全打到数据库。恶意攻击时尤其危险。
解法:
- 缓存空值:查不到也缓存一个空结果(设较短 TTL);
- 布隆过滤器(Bloom Filter):在缓存前加一层,快速判断“这个 key 一定不存在”,直接拦截;
- 参数校验,非法请求提前拒绝。
2. 缓存击穿(Breakdown)
某个热点 key 恰好过期的瞬间,大量并发请求同时涌入,都发现缓存没命中,一起打到数据库重建缓存。
解法:
- 互斥锁 / 单飞(singleflight):只让一个线程去查库重建缓存,其他线程等待或返回旧值;
- 逻辑过期:缓存不设物理 TTL,值里带逻辑过期时间,发现过期后异步更新,期间返回旧值;
- 热点 key 永不过期 + 后台异步刷新。
3. 缓存雪崩(Avalanche)
大量 key 在同一时刻集中过期,或缓存节点整体宕机,导致请求洪流全部压向数据库。
解法:
- 过期时间加随机抖动(如 TTL = 基础值 + random),避免集中失效;
- 缓存高可用:Redis 哨兵/集群,避免单点;
- 限流降级 + 兜底:数据库扛不住时限流,保护核心链路;
- 关键数据提前预热,别等流量来了才加载。
四、缓存与数据库一致性
这是缓存最容易出问题的地方。Cache Aside 的“先更新库、再删缓存”在极端并发下仍可能不一致:
- 典型场景:读线程读库(旧值)→ 写线程更新库并删缓存 → 读线程把刚才读到的旧值写回缓存 → 缓存长期脏数据。
工程上的应对:
- 删除缓存失败要重试(用消息队列/订阅 binlog 异步补偿);
- 设置过期时间兜底:即使出现不一致,TTL 后也能自愈;
- 强一致要求高的数据,干脆不缓存或加锁;
- 进阶:通过订阅数据库 binlog(如 Canal) 来驱动缓存失效,把一致性收敛到一条可靠链路。
记住:缓存本质是用“短暂的不一致”换性能,要评估业务能否容忍、能容忍多久。
五、热点 key 与大 key
- 热点 key:单个 key 访问量极高(如秒杀商品),可能打满单个 Redis 节点网卡。处理:本地缓存兜底、热点 key 拆分(多副本分摊)、读多副本。
- 大 key:一个 value 过大(几 MB 的 hash/list),导致单次操作阻塞、迁移卡顿。处理:拆分成小 key、分页存取、避免存大对象。
六、Redis 生产实践要点
- 数据结构选对:String、Hash、ZSet(排行榜/延迟队列)、Set(去重)、List,别什么都用 String 存 JSON;
- 设置合理 TTL:几乎所有 key 都应有过期时间,防止冷数据常驻;
- 避免热 key/大 key,避免用
KEYS *、FLUSHALL等阻塞命令,用 SCAN; - 持久化与高可用:RDB/AOF、哨兵或 Cluster;
- 内存与淘汰策略:设
maxmemory和合适的淘汰策略(如 allkeys-lru); - 缓存不是数据库:别把唯一真实数据只放缓存。
七、一张速查表
| 问题 | 现象 | 主流解法 |
|---|---|---|
| 穿透 | 查不存在的数据 | 缓存空值、布隆过滤器、参数校验 |
| 击穿 | 热点 key 过期瞬间被打爆 | 互斥锁重建、逻辑过期、热点不过期 |
| 雪崩 | 大量 key 同时失效/缓存宕机 | TTL 加随机、高可用、限流降级、预热 |
| 不一致 | 缓存与 DB 数据不符 | 先更库再删缓存、删除重试、TTL 兜底、binlog |
| 热点/大 key | 单点过热/操作阻塞 | 本地缓存、key 拆分、避免大对象 |
八、小结
- 主力模式是 Cache Aside:读未命中回源并回填,写时先更新库再删缓存;
- 三大故障要分清——穿透(查不存在的)、击穿(热点过期)、雪崩(集中失效/宕机),解法各不同;
- 一致性靠“删缓存 + 失败重试 + TTL 兜底”,强一致场景慎用缓存;
- 关注热点 key、大 key、TTL 和高可用,把缓存当作可失效的加速层而非数据源。
缓存设计的本质,是在性能、成本和一致性之间做清醒的权衡——先想清楚数据能容忍多大的不一致,再决定怎么缓存。
本文由 AI 辅助创作,所有内容仅供交流与参考。



