先把三个问题分清楚:成因不同,解法不同
缓存击穿(也常被叫做热点 Key 失效)指的是某一个访问量极高的键恰好在某一刻过期。由于这个键背后的回源逻辑代价昂贵,比如要聚合多张表或者调用外部服务,此刻所有并发请求同时发现缓存为空,于是同一时刻有几百个请求打到数据库。数据库并没有真正垮掉,但它瞬间承接了本该由缓存承担的读流量,连接池被打满,延迟从毫秒级跳到秒级。
缓存穿透是另一回事:查询的是一个根本不存在的键。因为缓存里从来没有过这个键,缓存永远不会命中,数据库也就永远不会被打热,于是每一个这样的请求都会完整地走一遍「查缓存 → 未命中 → 查库」的全过程。攻击者只要用一个随机的 ID 序列请求你的接口,就能在不触发任何缓存命中的情况下把数据库打成筛子。它跟并发无关,跟流量大小也无关,只跟「键不存在」这个事实有关。
缓存雪崩通常是大规模的失效事件:你在凌晨两点批量写入了一批缓存,TTL 全部设成了 3600 秒,那么凌晨三点整这批键会同时过期,海量请求在同一秒内涌向数据库。另外一种形态是 Redis 实例本身宕机或网络分区,此时所有流量无处可去,效果和雪崩一样,只是成因从「到期时间撞车」变成了「存储不可用」。
把这三者的判定方法记牢,排障时能省下大量时间。击穿的特征是:监控上看到数据库 QPS 出现一个窄而高的尖峰,同时热点键的命中率曲线在那一瞬间掉到零。穿透的特征是:数据库 QPS 稳步上升但总请求量并不高,且大量查询返回空结果,Redis 侧的 keyspace 几乎不增长。雪崩的特征是:命中率断崖式下跌,且下跌的时间点与某个批量写入或实例事件在时间上可对齐。
# ① 击穿:单点瞬时高 QPS + 该 key 命中率归零
redis-cli --no-raw OBJECT FREQ "hot:config:global" # LFU 计数,用于识别热点键
redis-cli --no-raw INFO stats | grep -E "keyspace_(hits|misses)"
redis-cli --no-raw TTL "hot:config:global" # 剩余生存时间
# ② 穿透:命中率正常/偏高,但库里有大量「查不到」的查询
redis-cli --no-raw DBSIZE # 观察 keyspace 是否随流量增长
redis-cli --no-raw SLOWLOG GET 10 # 定位被穿透打中的慢查询
# ③ 雪崩:命中率整体掉头,DB 侧出现成批同构慢查询
redis-cli --no-raw INFO memory | grep -E "used_memory_human|maxmemory_policy"
redis-cli --no-raw INFO persistence | grep -E "rdb_last_bgsave_status|aof_last_write_status"
redis-cli --no-raw INFO replication | grep -E "role|master_link_status"击穿应对:互斥锁重建与逻辑过期
最直接的做法是给回源加一把互斥锁:缓存未命中时,先用 `SET lock:key token NX EX 10` 抢锁,抢到的那个请求去执行重建,其余请求要么短暂等待后重试缓存,要么直接返回一个降级值。这样无论多少并发,真正打到数据库的永远只有一个请求。注意锁的 value 必须是一个唯一标识(常见做法是 UUID),释放锁时要用 Lua 脚本做「比对 token 再删除」的原子操作,否则一个超时了的持有者会误删别人后来者的锁。
等待方不要无脑 sleep 固定时长。更常见的实现是短暂自旋几次,每次间隔几十毫秒重试读缓存,命中就立即返回;重试若干次仍未命中,再根据业务容忍度选择返回旧值、返回默认值或者透传到数据库。把重试次数和间隔写成可配置项,因为它们的合理取值完全取决于重建代价和流量规模,别的项目的经验值对你往往不适用。
逻辑过期(也常被称为 soft expire)是另一种思路,直接绕开了「等待」这件事:缓存里同时存放数据值和一个逻辑过期时间戳,读的时候如果发现时间戳已过期,就返回这个可能已经过时的旧值,同时在后台异步触发一次重建。也就是说,读者永远不会被阻塞,代价是可能读到有界时间内的脏数据。
逻辑过期的价值在于它天然免疫击穿:过期判定和重建发起是分离的,无论多少并发同时看到过期,也只有一次重建会被真正执行。它的代价是必须额外定义「能容忍多旧」这个业务参数,并且要求调用方能够接受偶尔的不一致。商品详情、首页推荐这类读多写少、允许秒级陈旧的场景非常适合;而余额、库存这类强一致场景绝对不能这么干。
# 互斥锁重建:抢锁 -> 重建 -> 放锁(Node 风格伪代码,语义与 redis-cli 一致)
# 1) 抢锁,NX 保证只有一个赢家,EX 保证持有者崩溃后锁会自动释放
redis-cli SET "lock:user:1001" "b1f2c0de-uuid" NX EX 10
# 返回 OK 表示抢到,nil 表示已有别人在重建
# 2) 等待方:短间隔自旋,先看缓存是否已被别人填好
redis-cli GET "cache:user:1001"
redis-cli EXISTS "lock:user:1001"
# 3) 释放锁必须用 Lua,保证「比对 + 删除」原子,
# 否则持有者超时后会误删新持有者的锁
redis-cli EVAL "if redis.call(\"get\",KEYS[1])==ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end" 1 "lock:user:1001" "b1f2c0de-uuid"
# 4) 逻辑过期写法:值里带时间戳,过期也照常返回,由后台重建
redis-cli SET "cache:user:1001" \n "{\"data\":{\"name\":\"Alice\"},\"expireAt\":1758000000000}" EX 300穿透应对:空值缓存与布隆过滤器
空值缓存是成本最低、见效最快的一招。查询数据库没查到数据时,不要返回「未命中」让下一次请求继续回源,而是把「这个键不存在」这个事实也写进缓存,只是给它一个很短的 TTL,比如 30 到 60 秒。这样攻击者用随机 ID 扫接口时,每个不存在的键最多只会穿透一次数据库,60 秒之后该键的负缓存过期,如果攻击还在继续就再穿透一次,但频率被 TTL 死死限制住。
负缓存的 TTL 要短,这一点和正缓存的思路正好相反:正常数据的缓存可能要几分钟甚至几小时才更新一次,而「不存在」这个判断的时效性要求很高,误判成不存在会让真实存在的数据迟迟不出现。所以宁可多漏几次也不要长时间缓存空值。
当负缓存也不够用时(比如攻击者只用会在你系统里「逻辑合法但业务上无效」的 ID 反复请求,或者键空间极大导致负缓存本身也吃内存),就该上布隆过滤器。布隆过滤器是一组位加若干哈希函数,把所有「已知存在的键」预先映射到位图里;查询时先问过滤器,如果对应位是 0,那这个键一定不存在,可以直接返回,连负缓存都不用写。
布隆过滤器的两个前提要牢记。第一是只会有假阳性没有假阴性:说「不存在」就一定不存在,说「存在」可能只是碰巧撞上了位图,所以过滤器之后仍然要走真正的缓存或数据库查询。第二是标准布隆过滤器不支持删除;需要删除就得用计数布隆过滤器(Cuckoo Filter 等变体)之类的结构,代价是内存占用更高。往过滤器里加键需要和业务写入路径强耦合,漏加就会造成「明明存在却被判定为不存在」的严重故障,所以这一层通常在数据创建时同步写入,而不是事后批量补。
# 空值缓存(负缓存):给不存在的键一个短 TTL
redis-cli SET "cache:article:999999" "__nil__" EX 45
redis-cli GET "cache:article:999999"
# 布隆过滤器:用 EXISTS 代替逐个试探,标准 BF 支持 FEXISTS 扩展
redis-cli BF.RESERVE "bf:article_id" 0.01 1000000
redis-cli BF.ADD "bf:article_id" "1001"
redis-cli BF.EXISTS "bf:article_id" "1001" # 返回 1:可能存在,需继续查缓存/库
redis-cli BF.EXISTS "bf:article_id" "999999" # 返回 0:一定不存在,直接返回空结果
redis-cli BF.INFO "bf:article_id" # 查看容量、错误率、已插入元素数
# 判定顺序:Bloom 过滤器 -> 缓存 -> 负缓存 -> 数据库雪崩应对:TTL 抖动与多级降级
雪崩最廉价也最有效的防线是 TTL 抖动。给键设置过期时间时,在基准值上叠加一个随机量,例如 `600 + random(0, 300)` 秒。这样即使你批量写入了一万个键,它们的过期时刻也会散落在十分钟的时间窗里,而不是在同一个秒数上同时引爆。同理,随机量的比例通常取基准值的 10% 到 20% 就够,不必太大——抖得太厉害会削弱缓存的命中率,得不偿失。
第二个防线是「不把全部鸡蛋放在一个篮子里」。给缓存加内存上限并设置淘汰策略,当内存打满时 Redis 会按策略驱逐旧键;如果缓存本身就经常处在内存边缘,那么一次实例重启带来的影响就会小得多,因为此时它已经在常态化的驱逐中运行了。需要注意的是 `allkeys-lru` 会把热键也一起纳入驱逐范围,牺牲命中率换取存活,而 `volatile-lru` 只驱逐设了 TTL 的键,更适合「所有缓存键都该有 TTL」的场景。
第三个防线是应用侧降级。当 Redis 真的不可用时,应用必须能自己撑住:限制回源并发数(比如用一个信号量把打向数据库的请求卡在几十个),对重复的失败回源做短时间负缓存,必要时返回上一次的成功结果或一个静态兜底页。关键点是缓存不可用不能变成异常向上冒泡,一个 Redis 超时导致的 500 往往比直接查库更糟。
最后,「防雪崩」不只是防止键同时过期,还包括防止你主动制造它们。批量预热缓存时给每个键加上不同的偏移量;发布前先在预发环境跑一遍 keyspace 增量估算,估算「这批数据会同时产生多少缓存键」,如果在流量高峰期批量重建,风险要提前评估。一个常被忽略的场景是运维批量删除缓存做验证,这和雪崩的破坏力完全等价,应当尽量挪到低峰并分批执行。
# TTL 抖动:为基准值叠加随机量,避免批量键同时到期
# shell 伪代码:base=600, jitter=120
for key in $(cat .ai-temp/reports/hot_keys.txt); do
ttl=$(( base + RANDOM % (base / 5) ))
redis-cli SETEX "$key" "$ttl" "$(cat .ai-temp/downloads/payload.json)" > /dev/null
done
# 内存上限与淘汰策略:让实例常态下就工作在驱逐状态
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy volatile-lru
redis-cli CONFIG GET maxmemory-policy
redis-cli --no-raw INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
redis-cli --no-raw INFO stats | grep evicted_keys
# 观察命中情况:命中率长期低于 90% 就该重新评估 TTL 与 key 设计
redis-cli CONFIG RESETSTAT
redis-cli --no-raw INFO stats | grep -E "keyspace_hits|keyspace_misses"热点 Key:本地二级缓存与打散
当单个键的访问量高到让单实例的 CPU 或网络成为瓶颈时,缓存本身就成了问题。这时候加多少互斥锁都没用,因为瓶颈在 Redis 侧而不在回源侧。第一个手段是本地二级缓存:在应用进程里放一个容量很小、过期时间很短(几秒到几十秒)的内存缓存,把热点键的值再复制一份。热点键的返回往往不是毫秒敏感的,几秒钟的陈旧完全可以接受,换来的是把绝大部分读请求在进程内就消化掉。
本地二级缓存要格外注意一致性和内存。实现上最常见的是双重检查:先查本地,未命中查 Redis 并回填本地;更新数据时主动删除本地副本,或者干脆设置一个很短的 TTL 让它自然过期,防止不同实例之间出现长时间的不一致。同时本地缓存必须有容量上限(典型的 LRU 加上最大条目数),否则遇到大量不重复的键时会变成内存泄漏。
第二个手段是打散。一个热点键被拆成 N 个副本键,例如 `cache:config:1` 到 `cache:config:8`,读的时候由调用方传入一个随机偏移量选取副本;写的时候要么写全部副本(简单可靠,代价是写放大),要么只写一份并靠短 TTL 自然刷新(写便宜,代价是短暂不一致)。对于「写少读多」的数据,写全部副本几乎总是更好的选择。
最后是识别。没有识别就没有治理。`OBJECT FREQ` 配合 LFU 策略可以给出每个键的访问频次,是定位热点键最直接的手段;生产环境要注意 `OBJECT` 系列命令在某些部署形态下可能因权限或代理层而受限,此时可以退化到 `MONITOR` 采样(只跑几秒、绝不在高峰跑)或客户端埋点统计。把「找出本周 Top N 热点键」做成例行任务,比在故障时才手工排查有效得多。
# 识别热点键:LFU 策略下用 OBJECT FREQ 看访问频次
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli --no-raw OBJECT FREQ "hot:config:global"
redis-cli --no-raw SCAN 0 COUNT 500 | head -50 # 粗略抽样 key 空间
# 命中率与逐出观察
redis-cli --no-raw INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys"
redis-cli --no-raw INFO commandstats | grep -E "cmdstat_(get|set|evict)"
# 排障时抓一段慢查询(SCAN 不会阻塞,但生产慎用)
redis-cli SLOWLOG GET 20上线前的观测清单
缓存系统的故障往往不是「挂了」,而是「悄悄变得很慢或很不准」。因此上线前要准备好三个维度的指标,而不只是可用性。命中率类指标看 `keyspace_hits / (keyspace_hits + keyspace_misses)` 的时间序列,重点看分位数而不是平均值——一个 99% 的平均值可能掩盖着一段 50% 的糟糕区间。逐出类指标看 `evicted_keys` 是否持续增长,持续增长意味着内存上限已经低于真实工作集大小,你只是把压力转移到了数据库上。
延迟类指标按命令分开采集:`GET`、`SET`、`EVAL` 的 p99 分别看。锁相关的 `EVAL` 如果 p99 明显高于普通命令,往往说明锁竞争激烈、重建逻辑里存在长时间的持锁。相比之下 `SLOWLOG GET` 适合在已经出事时做事后归因,不要用它做常态监控。
还要准备一份「降级开关」。缓存逻辑在代码里应当被一个配置项控制,出问题时可以不改代码、不重新发布就把缓存整体关掉,退化为直接读库(慢,但不会 500)。反之,回源的并发上限、单飞锁的等待次数上限、负缓存的 TTL 也都应该做成可动态调整的配置,而不是硬编码常量——出事的时候你需要的是调参,不是发版。
最后是回归验证。每次改动缓存逻辑后,都要跑一遍针对性用例:热点键并发压测、随机 ID 扫描的穿透模拟、批量预热后的到期时刻分布观察、以及实例重启后的恢复行为。把这几条做成脚本放进 CI 或定时任务,它们的价值远大于任何一次性的手工验证。
# 命中率(分时段采样,避免只看累计平均值)
redis-cli --no-raw INFO stats | grep -E "keyspace_hits|keyspace_misses"
# 逐出速率:持续增长 = 内存上限已不足
redis-cli --no-raw INFO stats | grep evicted_keys
# 延迟与命令统计
redis-cli --no-raw LATENCY LATEST
redis-cli --no-raw INFO commandstats | grep cmdstat_get
# 内存与碎片
redis-cli --no-raw INFO memory | grep -E "used_memory_human|maxmemory_human|allocator_frag_ratio"