防住缓存击穿
目标
亲手制造缓存踩踏(cache stampede),亲眼确认回源调用的暴增,再分别加上锁、抖动和 stale-while-revalidate,用数字对比防御效果。
为什么重要
缓存踩踏是只有在缓存运转良好时才会发生的事故。这源于一个悖论:越是热门的键越危险——一个每秒被读取数千次的键,在它过期的那一瞬间,数千个请求同时遭遇未命中,全部涌向源端。原本一次查询就够了,结果变成了数千次,源端被压垮之后又产生更多的未命中,于是连锁崩溃。这个实验里最容易忽略的是第 5 步。如果只给锁设置 TTL 而没有所有权令牌,那么在 TTL 到期之后,别的请求抢到的锁,会被迟迟才醒来的原所有者释放掉。这样一来,两个请求就会同时进入临界区。这是自己实现分布式锁时最常被遗漏的部分,所以专门为它安排了一整步。
步骤
- 启动
/opt/app/slowdb.py(8151),用/root/sp/naive.py在删除热门键之后立刻并发发出 50 个请求。在/root/sp/naive.out中写入concurrency=50 origin_calls=<n>,n 必须大于等于 20。 /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。在
/root/sp/lockttl.txt中写入lock_ttl=<초>(占位符为秒数),其值必须大于等于 1 且小于等于 30。 - 只有所有权令牌一致时才释放锁。在
/root/sp/owner.out中写入wrong_token_release=0 right_token_release=1。源码中必须有令牌比较。 - 用
/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。 /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三个,并且必须有원본호출(韩文,意为“回源调用”)列。
参考
- 原子锁:
SET lock:key <uuid> NX EX 5——返回 OK 就表示获取成功。 - 安全的释放必须是先比较值再删除,而且这两步也必须是原子的才算完整(Lua 脚本)。
- 并发请求:
for i in $(seq 50); do curl -s ... & done; wait - 常见错误 1:让等待锁的请求直接失败——一半的用户会看到错误。
- 常见错误 2:释放锁时无条件使用
DEL——可能释放别人的锁。
重现缓存踩踏
启动 /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。表格的格式和行标题是评分标准。