本地复现 Redis 缓存穿透、击穿、雪崩。
注意力在缓存层:Compose 只跑 Redis,存储用进程内 FakeDB,用 Go goroutine 打并发。
观测挂在内核上:每秒输出一个生产指标窗口,结束后汇总全程指标,由人根据关联变化判断缓存状态。
- 穿透:
penetrate查不存在的数据。缓存一直空,每次请求都打到存储。 - 击穿:
breakdown单个热点 key 过期,大量并发同时 miss,瞬间打到存储。 - 雪崩:
avalanche大批量 key 同一时刻过期,大面积 miss,存储被打满。
go run . penetrate -n 2000
go run . penetrate -n 2000 -t repeat
go run . penetrate -n 2000 -t repeat -p on 2
go run . breakdown -n 2000
go run . avalanche -n 2000
go run . penetrate -p on
go run . breakdown -p on
go run . avalanche -p on
go run . penetrate -p on 1 2 3
go run . breakdown -p on 1
go run . avalanche -p on 1 2一次请求:cache.Get(id) → Redis GET。
hit 则返回,FakeDB 不动。
miss 则 store.Get(占槽、睡延迟、查 map)。
有值就 Redis SET 带 TTL 再返回;没值只返回空,不写 Redis。
回源= Redis Miss -> 向 DB 发起查询 (不管 Found 还是 Not Found)回源是个动作
- Found:一次
有效回源 - Not Found:一次
无效回源
回源率 = origin_total / redis_miss
回源集中度 = origin_hot_key_total / origin_total
Redis 返回 nil -> 回源查询 DB -> DB 返回结果为空 / 影响行数为 0 -> 缓存穿透捕获一次就是一次穿透
穿透次数 = origin_total ∩ DB Not Found
观测:Server / 全链路监控层 中加入 Redis Miss + DB Not Found 的复合判定埋点.
-
恶意攻击:攻击者构造大量不存在的 ID(如负数 ID、随机字符串)频繁请求接口
-
业务 bug:前端传入了错误的参数,如删除了某条数据后仍然不断查询
-
爬虫扫描:遍历式爬虫尝试访问不存在的资源
-
-t unique:默认。下标 i(从 0 起)打 id1000000+i,每个 ID 只请求一次,模拟不断生成新 ID 的随机扫描。 -
-t repeat:从一组不存在 ID 中均匀抽取并打散后齐发,模拟失效链接、爬虫或攻击脚本同时反复访问一批不存在资源。默认id_pool=100;当-n较小时自动缩小池子,确保会产生重复请求。
两个模式都会齐发。开启方案 2 后,repeat 的首波仍可能在空值写入前并发回源;只有首波结束后的后续请求,才会命中短 TTL 空值缓存。
防击穿组件(SingleFlight / 互斥锁)的拦截:
如果有 1000 个并发请求打过来,Redis 确实记录了 1000 次 Miss。
但如果应用层配置了 singleflight,只有 1 个请求真正去查了 DB,其余 999 个在内存里挂起等待复用。
此时:Redis Miss = 1000 != 真实回源数 = 1
如果拿 Redis Miss 当回源,你会误以为数据库正承受 1000 次冲击,但实际上数据库压力只有 1。
击穿 = 针对同一 Key 的高并发回源(origin_hot_key_max_inflight) ∩ DB Found
origin_total = 106
origin_unique_keys = 1
origin_hot_key = item:1
origin_hot_key_total = 106
origin_hot_key_max_inflight = 105
origin_hot_key_total/origin_total (回源集中度) ≈ 100%
origin_hot_key_max_inflight >> 1针对同一 key 的高并发回源: 106 次回源全部针对 item:1,其中最多有 105 次同时进行。
热点 Key 过期/失效 -> 大量并发请求同时 Redis Miss -> 瞬间并发回源 DB -> DB 成功返回有效数据 -> 缓存击穿预热:
把 item:1 写入 Redis(TTL 用 config 里的值),每 50ms 轮询 EXISTS,直到 key 消失;
时限为 ttl+3s,超时则失败退出。
然后 -n 个 goroutine 同时打 id 1。
回写完成前,其余请求也会 miss,叠到同一条回源上。
雪崩特征 = 大面积 Redis Miss ∩ 多 Key 的有效回源 ∩ DB 满载、排队与请求延迟上升
redis_expired = 200
redis_miss_rate = 56.3%
origin_total = 224
origin_unique_keys = 200
origin_breadth = 89.3%
origin_hot_share = 0.9%
db_found = 180
db_saturation = 91.4%
db_max_waiting = 100
db_wait_p99 = 453.375ms
request_p99 = 503.295ms大面积 Redis Miss:200 个 key 同时过期,Redis Miss 率在该窗口升至 56.3%。、
多 Key 的有效回源:224 次回源覆盖 200 个不同 key,广度为 89.3%;
排除击穿与穿透:db_found=180 表示这些请求查到有效数据,不是缓存穿透。origin_hot_share=0.9% 表示回源没有集中在单一热点 key,因此不是击穿。
DB 满载、排队与请求延迟上升:DB 在该窗口 91.4% 的时间处于满载,最多 100 个请求等待并发槽,DB 等待 P99 为 453.375ms,最终将请求 P99 推高到 503.295ms。
预热:
- 先用同一个绝对过期时间预热
item:1..keys,随后立即启动固定 QPS 的持续流量: 0..ttl:缓存正常命中。- key 集体过期后:多 key 同时 miss、回源,DB 开始满载和排队。
- 回填完成后:Redis 命中恢复,DB 清空积压请求。
avalanche concurrency=2000 planned=2400 qps=400 duration=6s keys=200 expiry=2s
backend redis=127.0.0.1:6379 db_latency=100ms db_concurrency=20 refill_ttl=30s
time done request_total request_p99 request_errors redis_hit redis_miss redis_hit_rate redis_miss_rate redis_expired redis_errors origin_total origin_unique_keys origin_breadth origin_hot_share db_total db_saturation db_max_waiting db_wait_p99 db_found db_not_found
1s 400/2400 400 1.553ms 0 399 0 100.0% 0.0% 0 0 0 0 0.0% 0.0% 0 0.0% 0 0s 0 0
2s 794/2400 399 1.082ms 0 395 5 98.8% 1.2% 6 0 5 5 100.0% 20.0% 5 0.0% 0 0s 0 0
3s 1143/2400 401 506.879ms 0 165 235 41.2% 58.8% 194 0 235 195 83.0% 0.9% 198 96.2% 100 455.679ms 183 0
4s 1600/2400 400 556.031ms 0 400 0 100.0% 0.0% 0 0 0 0 0.0% 0.0% 37 19.4% 0 157.456ms 57 0
5s 1999/2400 399 992µs 0 400 0 100.0% 0.0% 0 0 0 0 0.0% 0.0% 0 0.0% 0 0s 0 0
6s 2400/2400 400 1.109ms 0 400 0 100.0% 0.0% 0 0 0 0 0.0% 0.0% 0 0.0% 0 0s 0 0
result status=ok elapsed=6.001s done=2400/2400 failures=0
request total=2400 errors=0 input_rejected=0 p99=505.855ms overflow=0
redis hit=2160 miss=240 hit_rate=90.00% errors=0 ops_p99=1.074ms overflow=0 expired=200 evicted=0
origin total=240 unique_keys=200 duplicate_reads=40 valid_rate=100.00%
hot_key key=item:155 total=2 share=0.83% max_inflight=2
db total=240 found=240 not_found=0 errors=0 max_waiting=100 wait_p99=455.679ms wait_overflow=0 query_p99=101.055ms query_overflow=0-
penetrate/breakdown/avalanche:本次运行的实验场景。 -
concurrency:-n配置的并发 worker 上限。穿透、击穿会同时启动这些请求;雪崩最多使用这些 worker 消费持续流量,实际并发不会超过计划请求数。 -
planned:计划请求总数。穿透、击穿等于-n;雪崩等于qps × duration。 -
qps:雪崩场景每秒计划发出的请求数,不代表实际 Redis 或 DB QPS。 -
duration:雪崩场景持续发出请求的时间。 -
keys:雪崩场景同时过期并被循环访问的 key 数量,对应item:1..keys。 -
pattern:穿透场景的不存在 ID 分布;unique表示每个 ID 仅请求一次,repeat表示从一个 ID 池重复请求。 -
id_pool:重复型穿透可被请求的不同不存在 ID 数量;unique时等于计划请求数。 -
expiry:持续流量开始后,预热 key 集体过期的时间点。 -
redis:Redis 连接地址。 -
db_latency:FakeDB 单次查询模拟延迟,不包含等待并发槽的时间。 -
db_concurrency:FakeDB 同时执行查询的上限,超过后请求排队。 -
refill_ttl:回源成功后写回 Redis 的 TTL;穿透查询不存在的数据,不会回填。 -
time:当前采样窗口结束的时间;本行计数是上一个采样点到该时间的原始增量。最后一个不足一秒的窗口按实际结束时间显示,例如123ms。 -
done:已经结束的请求数 / 计划请求总数。 -
request_total:当前窗口进入缓存调用链的请求数,不要求请求已经完成。 -
request_p99:当前窗口内已完成请求的端到端延迟 P99,使用 HDR Histogram 统计。 -
request_errors:当前窗口请求错误数。 -
redis_hit:当前窗口 Redis 命中次数。 -
redis_miss:当前窗口 Redis 未命中次数。 -
redis_hit_rate/redis_miss_rate:当前窗口命中或未命中次数占有效 Redis 查询数的比例;Redis 错误不进入分母。 -
redis_expired:当前窗口 Redis 服务器过期 key 增量。 -
redis_errors:当前窗口 Redis 操作错误数;连接错误不计入 miss。 -
origin_total:当前窗口因 Redis miss 发起的回源数。 -
origin_unique_keys:当前窗口发生回源的 distinct key 数量,不是全程累计值。 -
origin_breadth:当前窗口origin_unique_keys ÷ origin_total,表示回源是否扩散到大量不同 key。 -
origin_hot_share:当前窗口回源次数最多的 key 占全部回源的比例。 -
db_total:当前窗口取得 FakeDB 并发槽并开始查询的次数。 -
db_saturation:当前窗口内 DB 并发槽全部占满的累计时间占比。 -
db_max_waiting:整个窗口等待并发槽的查询峰值。 -
db_wait_p99:当前窗口查询等待 DB 并发槽的延迟 P99。 -
db_found:当前窗口完成并返回有效数据的查询数。 -
db_not_found:当前窗口完成并返回「数据不存在」的查询数。 -
result_status:ok表示计划流程正常结束;interrupted表示运行期间收到取消信号。 -
result_elapsed:实验流量执行时间,不包含连接 Redis、预热以及击穿场景等待 key 过期的时间。 -
result_done:最终结束请求数 / 计划请求总数。 -
result_failures:Redis 或 FakeDB 返回的错误数;DB 返回「数据不存在」是正常结果,不算失败。 -
request_total: 全程请求数 -
request_errors:全程失败数 -
input_rejected:全程被参数校验拒绝的请求数;它属于请求层,不属于请求错误。 -
request_p99:端到端延迟 P99 -
request_overflow:超过 HDR 最高量程的样本数. -
redis_hit:全程 Redis 命中总数。 -
redis_miss:全程 Redis 未命中总数。 -
redis_hit_rate:redis_hit / (redis_hit + redis_miss) × 100%。Redis 错误不进入分母。 -
redis_errors/redis_ops_p99/redis_overflow:全程 Redis 错误数、操作延迟 P99 和超过量程的样本数。 -
redis_expired/redis_evicted:实验期间 Redis 过期和淘汰 key 增量。 -
origin_total:全程缓存层发起的回源总数。 -
origin_unique_keys:全程发生过回源的不同 key 数量。 -
origin_duplicate_reads:origin_total - origin_unique_keys,表示同一 key 首次回源之外的重复回源次数。 -
origin_valid_rate:全程found ÷ (found + not_found),区分有效数据回源和无效 key 查询。 -
hot_key_key/hot_key_total/hot_key_share/hot_key_max_inflight:全程回源最集中的 key、次数、占比和同时回源峰值。 -
db_total:全程取得 FakeDB 并发槽并开始执行的查询总数。正常结束时这些查询均已收尾。 -
db_found:FakeDB 返回有效数据的次数。 -
db_not_found:FakeDB 返回「数据不存在」的次数,是识别缓存穿透的核心结果,不属于result_failures。 -
db_errors/db_max_waiting:全程 FakeDB 错误数和排队峰值。 -
db_wait_p99/db_wait_overflow:全程排队等待的 P99 和超过量程的样本数。 -
db_query_p99/db_query_overflow:全程取得槽后查询耗时的 P99 和超过量程的样本数。
go run . penetrate -n 2000 -p on 1
请求进入缓存层后,先校验 ID 是否在本实验的有效范围 1..1000。
不合法的 ID 直接返回「未找到」,不会访问 Redis 或 FakeDB。
观察 input_rejected。正常情况下,它上升时,redis_miss、origin_total 与 db_not_found 应为 0。
这模拟应用层的格式、长度、范围或租户校验;真实生产不能只依赖「小于当前最大 ID」的判断,因为数据可能动态创建。
go run . penetrate -n 2000 -t repeat -p on 2
Redis miss 且 FakeDB 明确返回「不存在」时,写入空值标记,TTL 为 5 秒。 同一 ID 在 TTL 内再次请求时直接命中空值标记并返回「未找到」,不再回源。
齐发首波发生在空值写入前,仍可能全部回源;该方案保护的是首波结束后的后续重复访问,不适合单独抵御不断生成新 ID 的扫描。
go run . penetrate -n 2000 -p on 3
启动时将 FakeDB 已存在的 1..1000 放入 Bloom 过滤器。过滤器明确判定不存在的 ID 会在 Redis 前被拦截;可能存在的 ID 才继续查询缓存与 DB。
Bloom 过滤器允许误判「可能存在」,因此偶尔仍可能回源;但不会遗漏已加入集合的 ID。
go run . breakdown -n 2000 -p on 1
热点 key 过期后,最先到达的请求成为该 key 的回源请求;其余同 key 请求在进程内等待这次查询完成并复用结果。只有第一个请求实际查询 FakeDB 并回填 Redis。
redis_miss 仍可能很高,因为合并发生在 Redis miss 之后;重点观察 origin_total 与 db_total 是否从接近请求数降至接近 1,db_max_waiting 与 db_wait_p99 是否回落。
这是进程内合并,只对同一个应用实例有效;多实例生产环境需要共享的请求合并或分布式协调。
go run . breakdown -n 2000 -p on 2
热点 key miss 后,同 key 请求竞争进程内互斥锁。拿到锁的第一个请求查询 FakeDB 并回填 Redis;等待锁的请求在获得锁后再次读取 Redis,命中回填结果后直接返回。
与关闭防护的同一流量对比,重点观察 origin_total、db_total、db_max_waiting 与 db_wait_p99 是否下降;redis_hit 会因等待者的二次读取而增加。
它同样只在单实例内生效。锁等待会增加部分请求延迟,因此还要结合 request_p99 观察,而不是只看 DB 压力。
go run . breakdown -n 2000 -p on 3
预热值达到逻辑过期时间后,Redis 物理 key 仍保留;请求先返回旧值,同时只启动一个后台任务查询 FakeDB 并刷新该 key。
重点观察 redis_hit 是否保持、redis_miss 是否消失或显著下降,以及 request_p99 是否维持较低。origin_total 与 db_total 仍会出现一次刷新回源,这是用短暂旧数据交换请求可用性。
该方案要求业务允许读取旧值,并且要定义可接受的数据陈旧时间。
go run . avalanche -n 2000 -p on 1
预热时为每个 key 分配确定性的不同物理过期时间,范围是原定过期点前后各 TTL/2。本项目默认 TTL 为 2 秒,因此 200 个 key 会在约 1 到 3 秒间陆续过期,而不是 2 秒同时失效。
观察 redis_expired、redis_miss、origin_total 和 db_total 是否从单个窗口的尖峰分散到多个窗口;同时观察 db_max_waiting、db_wait_p99 和 request_p99 是否下降。
TTL 抖动只分散物理过期时间,不减少总回源量;它降低的是瞬时并发压力。
go run . avalanche -n 2000 -p on 2
key 到达逻辑过期时间后仍保留在 Redis,前台请求继续读取旧值;每个过期 key 只触发一个后台刷新。逻辑过期值的 Redis 物理 TTL 会额外保留一个 refill_ttl,避免前台请求在刷新期间变成 miss。
观察事故窗口的 redis_hit 是否保持、redis_miss 和 request_p99 是否降低。后台刷新仍会体现为 origin_total、db_total 的增长;若没有 TTL 抖动,多个 key 仍可能在同一时刻触发后台刷新,导致 db_max_waiting 与 db_wait_p99 上升。
该方案优先保证前台读取可用性,代价是短时间内读取旧数据。
go run . avalanche -n 2000 -p on 1 2 有效