Skip to content

About

本地模拟缓存击穿、缓存穿透、缓存雪崩,用于研究redis缓存

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

94 Commits

Folders and files

Repository files navigation

Kill-redis-plan

本地复现 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

穿透 penetrate

Redis 返回 nil -> 回源查询 DB -> DB 返回结果为空 / 影响行数为 0 -> 缓存穿透

捕获一次就是一次穿透

穿透次数 = origin_total ∩ DB Not Found

观测:Server / 全链路监控层 中加入 Redis Miss + DB Not Found 的复合判定埋点.

  • 恶意攻击:攻击者构造大量不存在的 ID(如负数 ID、随机字符串)频繁请求接口

  • 业务 bug:前端传入了错误的参数,如删除了某条数据后仍然不断查询

  • 爬虫扫描:遍历式爬虫尝试访问不存在的资源

  • -t unique:默认。下标 i(从 0 起)打 id 1000000+i,每个 ID 只请求一次,模拟不断生成新 ID 的随机扫描。

  • -t repeat:从一组不存在 ID 中均匀抽取并打散后齐发,模拟失效链接、爬虫或攻击脚本同时反复访问一批不存在资源。默认 id_pool=100;当 -n 较小时自动缩小池子,确保会产生重复请求。

两个模式都会齐发。开启方案 2 后,repeat 的首波仍可能在空值写入前并发回源;只有首波结束后的后续请求,才会命中短 TTL 空值缓存。

Q&A 为什么没有用redis_miss?

防击穿组件(SingleFlight / 互斥锁)的拦截:

如果有 1000 个并发请求打过来,Redis 确实记录了 1000 次 Miss。 但如果应用层配置了 singleflight,只有 1 个请求真正去查了 DB,其余 999 个在内存里挂起等待复用。 此时:Redis Miss = 1000 != 真实回源数 = 1 如果拿 Redis Miss 当回源,你会误以为数据库正承受 1000 次冲击,但实际上数据库压力只有 1。

击穿 breakdown

击穿 = 针对同一 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,叠到同一条回源上。

雪崩 avalanche

雪崩特征 = 大面积 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 清空积压请求。

reports日志 观测格式

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 和超过量程的样本数。

防护模式

penetrate 1 - 参数校验

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」的判断,因为数据可能动态创建。

penetrate 2 - 短 TTL 空值缓存

go run . penetrate -n 2000 -t repeat -p on 2

Redis miss 且 FakeDB 明确返回「不存在」时,写入空值标记,TTL 为 5 秒。 同一 ID 在 TTL 内再次请求时直接命中空值标记并返回「未找到」,不再回源。

齐发首波发生在空值写入前,仍可能全部回源;该方案保护的是首波结束后的后续重复访问,不适合单独抵御不断生成新 ID 的扫描。

penetrate 3 - Bloom 过滤器

go run . penetrate -n 2000 -p on 3

启动时将 FakeDB 已存在的 1..1000 放入 Bloom 过滤器。过滤器明确判定不存在的 ID 会在 Redis 前被拦截;可能存在的 ID 才继续查询缓存与 DB。

Bloom 过滤器允许误判「可能存在」,因此偶尔仍可能回源;但不会遗漏已加入集合的 ID。

breakdown 1 - singleflight

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 是否回落。

这是进程内合并,只对同一个应用实例有效;多实例生产环境需要共享的请求合并或分布式协调。

breakdown 2 - 互斥锁

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 压力。

breakdown 3 - 逻辑过期并异步刷新

go run . breakdown -n 2000 -p on 3

预热值达到逻辑过期时间后,Redis 物理 key 仍保留;请求先返回旧值,同时只启动一个后台任务查询 FakeDB 并刷新该 key。

重点观察 redis_hit 是否保持、redis_miss 是否消失或显著下降,以及 request_p99 是否维持较低。origin_total 与 db_total 仍会出现一次刷新回源,这是用短暂旧数据交换请求可用性。

该方案要求业务允许读取旧值,并且要定义可接受的数据陈旧时间。

avalanche 1 - TTL 抖动

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 抖动只分散物理过期时间,不减少总回源量;它降低的是瞬时并发压力。

avalanche 2 - 逻辑过期并异步刷新

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 有效

About

本地模拟缓存击穿、缓存穿透、缓存雪崩,用于研究redis缓存

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages