缓存击穿/穿透/雪崩实测:一把全局锁让 P99 从 8ms 涨到 419ms,是按 key 加锁的 16 倍

发布时间:2026/10/14 10:08:30
缓存击穿/穿透/雪崩实测:一把全局锁让 P99 从 8ms 涨到 419ms,是按 key 加锁的 16 倍
摘要缓存击穿、穿透、雪崩这三个词的解释谁都背得出来但绝大多数文章只给概念和伪代码没有一个数字。我把三种失效模式和对应的防护策略写进同一个可运行的 Python 模型里跑了四轮流量种子想量出三件常被含糊过去的事防护到底把回源压掉多少、代价落在谁身上、以及网传方案在什么攻击形状下直接失效。实测结果里最有意义的一条是锁粒度50 个热点 key 同刻过期时用一把全局互斥锁防击穿回源从 6777~7016 次压到 50 次但客户端 P99 从 8ms 变成 408.3~419.3ms是按 key 加锁的 16 倍。另外两条空值缓存防穿透的收益完全取决于攻击者的 key 重复率攻击者换 key 打时它反而让缓存条目从 1896~1904 涨到 14493~14558布隆过滤器的位数组一旦按当前数据量定死写入量涨 4 倍实测假阳性率从 0.23% 恶化到 16.23%。一、先说清实验口径这决定了数字能不能信必须在读任何结论前先接受这三条限制这是同进程内的语义模拟不是真 Redis 真 MySQL。我用虚拟时钟DB_LAT 8.0ms、CACHE_LAT 0.3ms、TTL 60000ms请求到达时刻由随机种子生成。所以回源次数P99 毫秒是模型量只用于策略之间的相对比较不是线上绝对值。真实测量的是这几项布隆过滤器假阳性率写入 2000 键、探针 20000 次、内存字节sys.getsizeof、单键查询微秒耗时、以及整个脚本的挂钟时间4 轮 0.037~0.038s。结构指标是确定性的同刻并发的击穿实验4 个种子的回源次数完全相同无防护 500、单飞 1因为那里没有随机性有随机的地方我都给出跨种子区间。还有一个诚实的未实测项写在文末单飞/互斥重建失败时等待者如何共享错误、布隆过滤器的删除、分布式锁在 Redis 主从切换下的正确性这三项本文没有数据。二、击穿省掉的是回源不是延迟500 个并发同时打一个刚过期的热点 key到达模式策略回源次数P50P99返回旧值全部同刻无防护500~5008.0ms8.0ms0%全部同刻互斥单飞1~18.0ms8.0ms0%全部同刻逻辑过期1~10.3ms0.3ms100%2ms 内分散无防护500~5008.0ms8.0ms0%2ms 内分散互斥单飞1~17.0~7.1ms8.0ms0%2ms 内分散逻辑过期1~10.3ms0.3ms100%读法单飞把回源压了 500 倍但没有把任何一个请求变快——P99 还是 8ms因为等待者就是在那次重建上排队。它救的是数据库的连接池不是你的尾延迟。逻辑过期才真正把 P99 打到 0.3ms代价是那一瞬间全部 500 个请求读到旧值表里 100%。要不要接受旧值是业务问题不是技术问题库存扣减不行商品详情页可以。并发度提高时单飞的合并率实测50/200/500/2000 并发下回源都是 1 次合并率 98.0%→99.5%→99.8%→100.0%到达都分散在 2ms 内。三、锁粒度这一条是本文最想说的真实场景不会只有一个热点 key。50 个热点 key 同一刻全部过期10000 个请求在 20ms 内均匀到达每个请求随机命中其中一个 key锁策略回源次数P50P99最坏不加锁6777~70168.0ms8.0ms8.0ms一把全局互斥锁50~50398.1~409.2ms408.3~419.3ms408.5~419.5ms按 key 加锁50~507.0~10.3ms24.5~25.6ms27.7~27.9ms两种锁的回源次数一模一样都是 50差别全在延迟上全局锁的 P99 是 408.3~419.3ms按 key 锁是 24.5~25.6ms相差 16 倍。原因就是那句朴素的话——全局锁把 50 次互不相干的重建排成了一条队最 unlucky 的请求要等前面 49 次DB_LAT跑完49 × 8ms ≈ 392ms和实测 P50 的 398ms 对得上。这也是防击穿加个锁这类建议在评审时最该被追问的一句锁的 key 空间是什么我在很多代码里见过把重建锁建成cache_rebuild_lock这种常量名的那就是全局锁。正确写法至少带上缓存 keyinflight{}# key - 重建完成时刻虚拟时钟毫秒defget_with_key_lock(key,now):cached_readyready_at.get(key,0.0)ifnowcached_readyandkeyinready_at:returnread_cache(key)# 已被别人回填直接读busyinflight.get(key)ifbusyisnotNoneandbusynow:returnwait_until(busy)# 只等同一个 key 的重建finnowDB_LAT inflight[key]fin# 我成为这个 key 的 leadertry:valquery_db(key)ready_at[key]finreturnvalfinally:inflight.pop(key,None)# 必须释放否则该 key 永久卡住真实服务里这段要换成 RedisSET NX PX 本地singleflight.GroupGo或按 key 取条目的ConcurrentHashMapK, CompletableFutureJava。这两个语言的写法我在本机没有编译验证只作为结构对照给出。四、穿透空值缓存的收益取决于攻击者怎么打6000 次真实查询2000 个真实 key 恶意不存在 key四种攻击形状跨 4 个种子攻击形状无防护回源空值缓存回源空值缓存条目布隆回源布隆漏放假 key 空间 5000随机打 5000 次6896~69045036~50835036~50831900~19144~1350 个假 key 反复打共 5000 次6896~69041946~19541946~19541896~19040假 key 空间 20000随机打 20000 次21896~2190414493~1455814493~145581944~196543~69200 个假 key 反复打共 20000 次21896~219042096~21042096~21041896~19040四行数字讲一个故事攻击者反复打同一小撮 key第 2、4 行→ 空值缓存非常有效回源 21896→2096。攻击者每次换一个不存在的 key第 3 行→ 空值缓存只剩碰撞重复那点收益20000 次抽取打在 20000 个 key 的空间上按生日碰撞约 63% 不重复约 12642 个不同 key 1896~1904 个真实 key ≈ 14538和实测的 14493~14558 对得上如果攻击者保证每个 key 只用一次空值缓存的省量为 0。更糟的是缓存条目数从 1896~1904 涨到 14493~14558条目跟着攻击面线性膨胀——这就是把缓存变成攻击者的免费存储。布隆在第 3 行把回源压到 1944~1965≈真实 key 总数 1896~1904说明假 key 基本被挡在 DB 之前代价是漏放 43~69 次且缓存条目不随攻击面增长。四种形状里真实 key 被误拒都是 0~0正确实现的布隆没有假阴性这条可以放心用。五、布隆过滤器的两个真实代价其一参数是按数据量定的数据量会变。位数组固定m 2000×16bit 4000B、k 4只改写入量已写入 key 数实测 FPR理论 FPR内存20000.23%0.24%4000 B40002.45%2.40%4000 B60007.84%7.75%4000 B800016.23%15.97%4000 B数据涨 4 倍假阳性涨70 倍内存一个字节没变——所以你看到的漏放会无声增长。理论公式(1-e^(-kn/m))^k和实测最大偏差 0.26 个百分点16.23% vs 15.97%实现可用 blake2b 双哈希单次查询实测1.1~1.3 µs。不同参数的实测/理论对照n2000、探针 20000 次每键 bit 数k实测 FPR理论 FPR内存4415.94%15.97%1000 B4215.69%15.48%1000 B1041.30%1.18%2500 B1070.94%0.82%2500 B1640.23%0.24%4000 B1621.40%1.38%4000 B注意第 1、2 行m 太小时 k 从 2 加到 4 几乎没有收益15.69%→15.94%方向甚至是反的。k 救不了 m位数组容量才是主要矛盾。其二空值缓存和布隆的内存不是一个量级。sys.getsizeof实测 Python 侧10000 个空值条目: dict 自身 202.8 KB key 565.3 KB 768.1 KB, 每键 78.7 B 100000 个空值条目: dict 自身 3754.8 KB key 5750.9 KB 9505.6 KB, 每键 97.3 B 对比布隆 2000 键 × 16bit 4000 B与不存在的 key 数量无关口径只是 dict 与 key 对象的浅层尺寸之和不是进程 RSS也不含 Redis 自身的编码与指针开销。10 万个空值条目每键约 97 B而布隆 2000 键只要 4000 B——用空值缓存扛大攻击面内存账单是按攻击面开的。六、雪崩抖动窗口小于流量抖动就没用预热 10000 个 key 在同一刻灌入模拟 Redis 重启后集中回填TTL 60s30000 次请求均匀打在 0~75s按 100ms 分桶统计回源过期策略总回源单桶峰值100ms峰值占比有回源的桶数固定 TTL4518~455549~531.1%~1.2%150~150TTL ±5% 抖动4414~464942~550.9%~1.2%178~180TTL ±20% 抖动4207~438534~400.8%~0.9%265~266±5%约 ±3s实测基本没削峰单桶峰值 42~55和固定 TTL 的 49~53 区间重叠。±20%±12s才把峰值降到 34~40相对固定 TTL 约降25%~31%49→34 是 -30.6%53→40 是 -24.5%代价是回源被摊到 265~266 个桶里活跃桶 77%。结论很直接抖动幅度要大于流量本身的集中宽度才有意义。一批 key 是在 3s 内被灌进去的你给 ±5% (3s) 的抖动等于没抖。七、决策表是是否/不可预测否单个热点 key不能能大批 key 同时缓存没挡住请求数据本身不存在?穿透攻击者重复打同一批 key?空值缓存 短 TTL布隆: m 按未来容量定并预留重建机制失效范围?击穿能容忍旧值?按 key 单飞/互斥禁止全局一把锁逻辑过期 后台重建雪崩抖动幅度 灌入时间窗 多级缓存/限流降级兜底八、避坑指南防击穿用全局一把锁 把 N 个 key 的重建排成一条队。实测 P99 408.3~419.3ms vs 按 key 锁 24.5~25.6ms16 倍而两者回源次数完全相同。锁名里带不出 key 的基本都是错的。±5%的 TTL 抖动是无效防护。实测单桶峰值 42~55与不抖动的 49~53 重叠。抖动幅度要按这批 key 实际灌入了多久来定不是按 TTL 的百分比拍脑袋。空值缓存要设短 TTL 并计入内存。攻击面 20000 时缓存条目从 1896~1904 涨到 14493~14558每键约 97 B实测浅层 sizeof。不设短 TTL 的空值缓存等于把攻击者的垃圾永久存进 Redis。布隆的 m 要按未来容量而不是当前容量定。实测同一份 4000 B 位数组写入量 2000→8000 时 FPR 从 0.23% 涨到 16.23%。而且标准布隆不支持删除数据下线的累积效应只会让 FPR 继续恶化删除行为本文未实测。k 不是省 m 的旋钮。m1000 B每键 4 bit时 k2 和 k4 实测 FPR 15.69% / 15.94%加 k 没有收益甚至更差m4000 B每键 16 bit时 k2 是 1.40%k4 才降到 0.23%。先按目标 FPR 和数据量算 m再取 k≈ln2·(m/n)。别把单飞当成降延迟手段。实测它的 P99 和裸回源一样是 8ms收益全在 DB 侧500→1。想降尾延迟只能上逻辑过期而那要用旧值换。单飞的等待者共享同一次重建的失败。这条本文没有实测但实现时必须处理leader 查库异常时如果不清inflight该 key 的等待者会永久挂住如果直接把异常广播给所有等待者一次 DB 抖动会放大成整批 5xx。上面代码里的finally: inflight.pop(key)是最低要求。击穿/雪崩的防护不替代限流。上面的实验里所有策略都建立在缓存层逻辑失效这一层Redis 整体不可用时回源保护只剩限流和降级——布隆挡不住所有 key 都 miss。九、结论三种失效模式的防护目标不同击穿保护DB 连接池实测回源 500→1但 P99 不变穿透保护DB 查询量布隆把 21896~21904 压到 1944~1965且不随攻击面涨内存雪崩保护瞬时 QPS 峰值±20% 抖动把单桶峰值从 49~53 压到 34~40约 -25%~-31%。最容易写错的是锁粒度实测代价16 倍 P99最容易被高估的是空值缓存换 key 打时几乎无效和 TTL 小比例抖动实测与不抖无差异。落地上我会按这个顺序做按 key 单飞 → 布隆m 按 3 年容量算→ TTL 抖动幅度按灌入窗口定 → 逻辑过期只给能容忍旧值的读路径 → 最后用限流兜底。复现方式demos/cache_stampede_lab.pyPYTHONIOENCODINGutf-8 python cache_stampede_lab.py运行留档demos/cache_stampede_lab.transcript.txtWindows Python 3.11.54 个流量种子取区间。