Redis 缓存设计实战:穿透、击穿、雪崩与一致性
用好了是加速器,用不好就是"数据库被缓存拖垮"的事故现场。这篇讲清三件事:怎么读写才不出脏数据、三大经典故障怎么防、以及容量与高可用怎么做。
一、先想清楚:缓存买的是什么,代价是什么
缓存是性能优化里性价比最高的一招:一次内存命中顶掉一次数据库查询,QPS 提升一个数量级。买到的是更低延迟、更高吞吐、更好的可用性(数据库抖动时缓存能顶一阵);付出的是一致性复杂度(缓存与数据库两份数据)、内存成本,以及新的故障模式——三大经典故障都源于"缓存层本身会出问题"。
结论:缓存不是"加上就快"的银弹,它是拿一致性复杂度换性能。接受不了最终一致的业务(如强一致的账户余额),要么不加缓存,要么加锁保证强一致(成本明显更高)。
缓存的设计哲学:假设它随时会失效、随时会挂、随时不一致——然后为每一种假设写好防线。
二、读写策略:Cache Aside 是默认答案
| 策略 | 读路径 | 写路径 | 适用 |
|---|---|---|---|
| Cache Aside(旁路缓存) | 先查缓存,未命中查库并回填 | 更新数据库,删除缓存 | 绝大多数场景的默认选择 |
| Read Through | 缓存层封装回源逻辑 | 同旁路缓存 | 缓存框架支持时更省事 |
| Write Through | 同旁路缓存 | 同时写缓存与数据库 | 写少、要求读写一致 |
| Write Behind | 同旁路缓存 | 只写缓存,异步刷库 | 写吞吐优先、可容忍丢数据 |
绝大多数团队用 Cache Aside 就够了,但它的两个细节才是事故高发区。写路径为什么是「删缓存」而不是「更新缓存」?因为并发写时"更新缓存"会以错误顺序落地(先发的慢请求后落,覆盖掉新值);而"删除"让下一次读自然回填最新值,还避免了无效写放大。为什么是「先更新库、再删缓存」?反过来在并发读下更容易把旧值刷回缓存。极端并发下仍有极小概率不一致,兜底手段是延迟双删:
1. 更新数据库
2. 删除缓存
3. 延迟几百毫秒,再删一次(覆盖期间被读请求刷回的旧值)
延迟双删是兜底而非银弹,更工程化的做法是订阅数据库变更(binlog)来驱动缓存失效。
三、穿透:查不存在的数据
成因:请求的数据在缓存和数据库里都不存在(比如伪造的 ID),缓存永远不命中,每个请求都穿透到数据库。防御三板斧:缓存空值——查不到也写入一个空标记,设较短 TTL(如 60 秒),简单有效,注意防止空值占满内存;布隆过滤器——用位数组预判"这个 key 一定不存在",空间效率极高,代价是极小误判率(可能放过,不会错杀);参数校验——明显非法的 ID 在入口就拦掉。
四、击穿:热点 key 过期的那一秒
成因:某个热点 key 突然过期,同一瞬间大量请求发现未命中,全部涌向数据库去重建缓存。防御两板斧。一是互斥重建:只让一个请求去查库重建,其余等待或短暂重试:
if 缓存未命中:
if SETNX(lock:key): # 抢到重建权
查库并回填缓存
DEL(lock:key)
else:
短暂等待后重试读缓存 # 或直接返回旧值
二是逻辑过期:热点 key 干脆不设物理 TTL,值里带一个逻辑过期时间;读时发现逻辑过期,先返回旧值,再异步触发一个请求去刷新——用「短暂旧数据」换「没有重建洪峰」。
五、雪崩:集体失效或缓存层宕机
成因有两类:大量 key 在同一时刻过期(凌晨批量预热、统一 TTL),或者缓存集群整体不可用。防御四件事:TTL 加随机抖动(基础 TTL 加 0 到 10% 的随机值,把过期时间打散);高可用(主从加哨兵或集群模式,同时明确缓存只是加速层,宕机时要有降级路径);限流与熔断(缓存不可用时,入口限流、对数据库调用熔断——保住数据库,就是保住系统);多级缓存(进程内本地缓存当第一层,挡住最热的那部分流量)。
| 故障 | 一句话成因 | 典型症状 | 主防御 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 库 QPS 异常升高、命中率骤降 | 空值缓存、布隆过滤器 |
| 击穿 | 单个热点 key 过期 | 某一个 key 引发库洪峰 | 互斥重建、逻辑过期 |
| 雪崩 | 大批 key 同时失效或集群宕机 | 全站数据库被打满 | TTL 抖动、高可用、限流熔断 |
六、容量与高可用:那些没人看却会爆的指标
- 大 key:单个 key 几 MB(比如全量列表),读写都会阻塞网络与线程。治理:拆分成多个小 key、只存必要字段、删除改用异步删除(UNLINK)
- 热 key:单个 key 每秒几十万次访问,单分片扛不住。治理:本地缓存兜一层、key 加后缀打散到多个分片
- 淘汰策略:内存不够时的行为要显式配置——缓存场景常用 allkeys-lru 或 allkeys-lfu;别用 noeviction 等着写入失败
- 必看的四个指标:命中率、被淘汰键数、慢日志、内存碎片率——命中率跌破基线通常预示穿透或雪崩
七、实践清单
- 读写走 Cache Aside:读先缓存、写先库后删缓存
- 所有 key 必须有 TTL,且带随机抖动
- 热点 key 上互斥重建或逻辑过期
- 查空结果缓存短 TTL,高风险查询上布隆过滤器
- 缓存不可用时要有降级与限流预案,同时监控命中率与淘汰数
- 大 key 与热 key 定期巡检,内存淘汰策略显式配置
速查卡
| 问题 | 防线 |
|---|---|
| 脏数据 | 先更新库再删缓存,必要时延迟双删 |
| 穿透 | 空值缓存 + 布隆过滤器 |
| 击穿 | 互斥重建 / 逻辑过期 |
| 雪崩 | TTL 抖动 + 高可用 + 限流熔断 |
| 热 key | 本地缓存 + key 打散 |
| 大 key | 拆分 + 异步删除 |
| 内存失控 | 显式配置 LRU/LFU 淘汰策略 |
写在最后
缓存的设计哲学一句话:假设缓存随时会失效、随时会挂、随时不一致——然后为每一种假设写好防线。做完这层心理建设,Redis 就从「加速器」变成了「系统里最稳的一环」。
评论(0)