缓存是 Redis 的第一大用途,也是生产事故的第一大来源。本节把缓存的读写模式与三大经典故障(穿透、击穿、雪崩)逐一拆解,每项给出防御实现;再讲内存满时的淘汰策略八档怎么选。5.1 演练过的"加缓存"只是入门,这一节才是缓存的生产说明书。
**Cache-Aside(旁路缓存)**是最常用的模式:应用读缓存未命中则读库并回填缓存;写时先更库、后删缓存(而非更新缓存)。两个为什么:为什么删而不是更?因为更新后的缓存可能没人读,白写还引入并发写覆盖问题。为什么先库后删?反过来的话,并发下旧值回填缓存的窗口更大。即便如此,先更库后删缓存仍有微小不一致窗口(读请求恰在更库前读到旧缓存),彻底方案是延迟双删或订阅变更日志异步失效,按业务容忍度选。
穿透:查询不存在的数据,缓存永不命中、请求全部打到库——攻击者构造海量随机 ID 即可打穿。防御两招:缓存空值(查不到也把空结果缓存短时间,简单有效);布隆过滤器(所有合法 ID 预先登记,查询前过滤,"一定不存在"的判断零成本,代价是少量误判与不支持删除)。
击穿:某个热点键过期瞬间,并发请求同时未命中、同时回源建库,数据库瞬间被打出尖峰。防御三板斧:互斥重建(只放一个请求去查库回填,其余等待或返回旧值——用 5.6 的 SET NX 锁实现);逻辑过期(键永不过期、值里带过期字段,发现过期后异步刷新,期间返回旧值);热点不过期(运营级热点直接常驻)。
雪崩:大量键同时过期(或 Redis 实例整体宕机),请求洪峰涌向数据库。防御:过期时间加随机偏移(如 30 分钟 ± 5 分钟,把到期打散);多级缓存(本地缓存挡第一波);限流降级预案(缓存全灭时按优先级放行);实例可用性交给主从与哨兵(5.7)。

maxmemory 设定上限后,写不进时怎么办由淘汰策略(maxmemory-policy)决定,八档按语义分组:noeviction(默认,拒绝写入,缓存场景慎用);allkeys-lru / volatile-lru(全键或仅带过期键中淘汰最近最少使用,常规缓存首选 allkeys-lru);allkeys-lfu / volatile-lfu(最少频率使用,适合偶发访问的"历史热键"不再霸占内存);allkeys-random / volatile-random(随机,少用);volatile-ttl(淘汰剩余寿命最短的)。判断口诀:缓存场景 allkeys-lru 或 lfu 起步;混合场景(既有缓存又有不能丢的键)用 volatile 系并给所有可丢键设过期——淘汰只发生带过期键的集合里。
背景:运营活动凌晨整点上线,预热脚本把 2000 个活动键统一设置 2 小时过期;凌晨 2 点整 2000 个键同时过期,瞬间上万请求回源,数据库连接池打满,整个站点接口雪崩 5 分钟。
操作:复盘四步。第一步定位:监控显示数据库 QPS 尖峰与键过期时间点精确重合,雪崩实锤;第二步即时止血:紧急预热 + 回填,临时调大数据库连接池上限扛过残余流量;第三步根因修复:预热脚本改为"基础过期 2 小时 + 随机偏移 0 到 20 分钟",热点 Top 键改逻辑过期;第四步防御补强:接入"缓存命中率低于阈值自动限流"的降级开关,并演练一次 Redis 实例整体重启。
结果:下一场活动同类流量下数据库 QPS 平稳(回源请求按分钟级摊开);演练的实例重启中降级开关按预期生效,站点未雪崩。
解读:复盘链条值得记牢——止血(回填与限流)→ 根因(过期模式)→ 防御(打散与逻辑过期)→ 演练(整体失效预案)。四步缺一不可:只改过期模式不演练,下次实例宕机还是会雪崩;只演练不修根因,问题必然复发。
变式:若活动数据本质是只读商品快照,更省的方案是活动期直接静态化到 CDN,Redis 只留动态部分——缓存是分层体系的一层,不是唯一一层。
第一个是为省内存把过期时间设得过短:命中率崩塌,回源流量反噬数据库;过期时长要用命中率与回源量的监控数据校准,不靠拍脑袋。第二个是布隆过滤器当真理:它说"不存在"才可信,说"存在"可能是误判,业务判断逻辑要按这个语义写。第三个是互斥重建的锁忘记过期:持锁进程崩溃导致所有请求永久等待,锁必须带过期时间与重试上限。第四个是淘汰策略与业务错配:混合场景误用 allkeys 系,把不能丢的分布式锁键也淘汰了——锁类键要么不与缓存同实例,要么用 volatile 系隔离。
内存达到 maxmemory 上限时,Redis 按配置的策略淘汰数据。策略选错,命中率会断崖式下跌。
| 策略 | 淘汰范围 | 特点 | 适用 |
|---|---|---|---|
| noeviction | 不淘汰,写入报错 | 数据绝不丢 | 缓存与存储混用时(不推荐) |
| allkeys-lru | 所有键 | 淘汰最久未访问的 | 纯缓存,最常用 |
| allkeys-lfu | 所有键 | 淘汰访问频次最低的 | 有明显冷热区分 |
| allkeys-random | 所有键 | 随机淘汰 | 访问分布均匀 |
| volatile-lru | 仅设了过期时间的键 | 只淘汰可过期的 | 缓存与持久数据混存 |
| volatile-lfu | 仅设了过期时间的键 | 按频次淘汰可过期的 | 同上,有冷热区分 |
| volatile-ttl | 仅设了过期时间的键 | 优先淘汰剩余寿命短的 | 明确知道哪些数据应先走 |
| volatile-random | 仅设了过期时间的键 | 随机淘汰可过期的 | 少用 |
# 配置:内存上限与淘汰策略 maxmemory 8gb maxmemory-policy allkeys-lru # 观察淘汰与命中情况 INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys" # 命中率 = hits / (hits + misses),持续低于 90% 就该查原因
穿透:查询一个根本不存在的键,每次都打到数据库。防御两招:其一,缓存空值(查到不存在时把空结果也写进缓存,设短过期时间);其二,布隆过滤器(在缓存前加一层,判断键是否存在,不存在的直接返回)。
# 缓存空值:注意过期时间要短,防止恶意刷不同 Key 撑爆内存 SET user:profile:99999999 "{}" EX 60 # 布隆过滤器思路(Redis 4.0+ 可用 BF.ADD / BF.EXISTS 模块,或用 Bitmap 自建) BF.ADD user_filter 90001 BF.EXISTS user_filter 99999999 # 返回 0 表示一定不存在,直接返回,不打数据库
击穿:一个热点键过期的瞬间,大量请求同时打到数据库。防御手段是"互斥重建":缓存未命中时先抢分布式锁,只有抢到锁的线程去数据库加载并回填,其余线程等待后重读缓存。
# 互斥重建(伪命令示意) SET lock:rebuild:hot_key <token> NX PX 3000 # 抢到锁 → 查数据库 → SETEX hot_key 300 <value> → DEL lock # 未抢到 → 短暂 sleep 后重读缓存;超时则返回降级内容
雪崩:大批键在同一时刻集体过期,或缓存集群整体不可用,请求全部涌向数据库。防御三招:过期时间加随机抖动(避免同时过期)、缓存层做高可用(哨兵或集群,见 5.7、5.8)、以及数据库侧的限流与熔断兜底。
# 过期时间加抖动:基准 300 秒 ± 60 秒随机 EXPIRE cache:item:8801 $((300 + RANDOM % 120))
一条总原则:缓存层的设计目标不是"不出问题",而是"出问题时数据库不被打垮"。因此限流、熔断、降级这三样兜底措施,与缓存策略同等重要——它们决定了最坏情况下系统是"慢一点"还是"整体不可用"。
故障防住了,下一节看 Redis 的第二副面孔:消息传递。