TT Lab
开始
学习 学习路径 课程

Redis 与缓存

防住缓存击穿

在 TT Lab 中继续学习

目标

亲手制造缓存踩踏(cache stampede),亲眼确认回源调用的暴增,再分别加上锁、抖动和 stale-while-revalidate,用数字对比防御效果。

为什么重要

缓存踩踏是只有在缓存运转良好时才会发生的事故。这源于一个悖论:越是热门的键越危险——一个每秒被读取数千次的键,在它过期的那一瞬间,数千个请求同时遭遇未命中,全部涌向源端。原本一次查询就够了,结果变成了数千次,源端被压垮之后又产生更多的未命中,于是连锁崩溃。这个实验里最容易忽略的是第 5 步。如果只给锁设置 TTL 而没有所有权令牌,那么在 TTL 到期之后,别的请求抢到的锁,会被迟迟才醒来的原所有者释放掉。这样一来,两个请求就会同时进入临界区。这是自己实现分布式锁时最常被遗漏的部分,所以专门为它安排了一整步。

步骤

  1. 启动 /opt/app/slowdb.py(8151),用 /root/sp/naive.py 在删除热门键之后立刻并发发出 50 个请求。在 /root/sp/naive.out 中写入 concurrency=50 origin_calls=<n>,n 必须大于等于 20。
  2. /root/sp/lock.py 在未命中时,只有用 SET lock:<키> <토큰> NX EX 5(占位符依次为键与令牌)拿到锁的请求才会去回源。在相同条件下,在 /root/sp/lock.out 中写入 concurrency=50 origin_calls=<n>,n 必须小于等于 3。
  3. 没有拿到锁的请求,最多短暂睡眠 2 秒,再回头查看缓存。50 个请求都必须拿到值。同时在 /root/sp/lock.out 中写入 served=50。
  4. 锁的键必须设置 TTL。在 /root/sp/lockttl.txt 中写入 lock_ttl=<초>(占位符为秒数),其值必须大于等于 1 且小于等于 30。
  5. 只有所有权令牌一致时才释放锁。在 /root/sp/owner.out 中写入 wrong_token_release=0 right_token_release=1。源码中必须有令牌比较。
  6. 用 /root/sp/jitter.py 以 base 300 秒、幅度 60 秒的抖动填充 100 个键。在 /root/sp/jitter.txt 中写入 min_ttl=<n> max_ttl=<n> distinct=<n>,distinct 必须大于等于 20,max 与 min 的差必须大于等于 30。
  7. /root/sp/swr.py 会把逻辑过期时间与值一起保存,过期之后仍立即返回旧值,同时在后台只刷新一次。在 /root/sp/swr.out 中写入 served_from_stale=<n> origin_calls=<n>,origin_calls 必须小于等于 3。
  8. 在 /root/sp/compare.md 中写一个 Markdown 表格。行标题是 무방비(韩文,意为“无防护”)、싱글플라이트(韩文,意为“singleflight”)、stale-while-revalidate 三个,并且必须有 원본호출(韩文,意为“回源调用”)列。

参考

重现缓存踩踏

启动 /opt/app/slowdb.py(8151),用 /root/sp/naive.py 在删除热门键之后立刻并发发出 50 个请求。在 /root/sp/naive.out 中写入 concurrency=50 origin_calls=<n>,n 必须大于等于 20。

删除热门键之后,立刻倾泻并发请求就行了。回源查询次数会增加到与并发请求数相当。

加上 singleflight 锁

/root/sp/lock.py 在未命中时,只有用 SET lock:<키> <토큰> NX EX 5(占位符依次为键与令牌)拿到锁的请求才会去回源。在相同条件下,在 /root/sp/lock.out 中写入 concurrency=50 origin_calls=<n>,n 必须小于等于 3。

未命中时只让第一个请求回源。获取锁必须是原子的。

让等待锁的请求也能拿到值

没有拿到锁的请求,最多短暂睡眠 2 秒,再回头查看缓存。50 个请求都必须拿到值。同时在 /root/sp/lock.out 中写入 served=50。

没有拿到锁的请求不能直接失败。让它们短暂睡眠,再回头查看缓存。

用锁的 TTL 防止死锁

锁的键必须设置 TTL。在 /root/sp/lockttl.txt 中写入 lock_ttl=<초>(占位符为秒数),其值必须大于等于 1 且小于等于 30。

如果锁的所有者死了,就没有人能进去了。给锁本身设定生存时间。

用所有权令牌保护别人的锁

只有所有权令牌一致时才释放锁。在 /root/sp/owner.out 中写入 wrong_token_release=0 right_token_release=1。源码中必须有令牌比较。

TTL 到期之后,别的请求抢到的锁,不能被迟迟才醒来的原所有者释放。

用 TTL 抖动分散同时过期

用 /root/sp/jitter.py 以 base 300 秒、幅度 60 秒的抖动填充 100 个键。在 /root/sp/jitter.txt 中写入 min_ttl=<n> max_ttl=<n> distinct=<n>,distinct 必须大于等于 20,max 与 min 的差必须大于等于 30。

把在同一时刻填充的键的过期时间分散开。确认 100 个键的 TTL 分布。

实现 stale-while-revalidate

/root/sp/swr.py 会把逻辑过期时间与值一起保存,过期之后仍立即返回旧值,同时在后台只刷新一次。在 /root/sp/swr.out 中写入 served_from_stale=<n> origin_calls=<n>,origin_calls 必须小于等于 3。

即使过期了,也立即给出旧值,并在背后只刷新一次。把值和过期时间分开存放就行。

对比三种方式的回源调用次数

在 /root/sp/compare.md 中写一个 Markdown 表格。行标题是 무방비(韩文,意为“无防护”)、싱글플라이트(韩文,意为“singleflight”)、stale-while-revalidate 三个,并且必须有 원본호출(韩文,意为“回源调用”)列。

用相同的负载对比无防护、锁和 stale-while-revalidate。表格的格式和行标题是评分标准。