REJAVA / LEARNING NOTES
缓存穿透、击穿与雪崩
分清缓存穿透、击穿、雪崩,掌握布隆过滤器、拿锁重建与过期抖动及其代价。
根据课程材料整理
本次更新说明
- 整理为公开学习笔记:用办件详情缓存串起三种问题,含 5 张图示、应用题与隔日复测。
- 注明 Redis 8.4 起的 DELEX 与 Redis 8 内置布隆过滤器的版本边界。
热身 · 一 缓存怎么用 · 二 三种情况 · 三 穿透 · 四 击穿 · 五 雪崩 · 六 怎么选 · 七 应用 · 检验 · 表达 · 追问 · 隔日复测
本课要解决什么问题
学完本课,给你一段线上现象,比如“数据库每天凌晨3点CPU(处理器)飙高”“大量请求查的办件号在数据库里都不存在”“热门办件缓存过期那一刻连接池被打满”,你能判断它是缓存穿透、击穿还是雪崩;能说出每种情况的两三种处理办法,并讲清各自的代价;能写出“拿锁重建缓存”的关键代码,并指出锁必须带过期时间、带随机值、释放前要先比较值。
“缓存穿透、击穿、雪崩分别是什么,怎么解决?”是学Redis(内存数据存储)缓存时最常遇到的问题之一。真正要讲清的不是三个定义,而是: 哪一类请求越过了缓存、为什么越过、你怎样挡住它、挡住的代价是什么 。本课沿用第16课的办件详情缓存 case:detail:{办件编号},用同一个系统把三件事串起来。
先说清名称: “穿透、击穿、雪崩”是国内社区的叫法,本课引用的英文官方文档都不用这三个词。官方材料讲的是对应的现象和做法:cache-aside(旁路缓存)、给过期时间加随机抖动、request coalescing(请求合并)、negative cache(负缓存)等。课上把它们对应起来,这种对应是教学归纳,会逐处注明。
前置: 第16课的 SET … NX EX、过期时间、“INCR和EXPIRE是两条命令”;第18课的持久化。 必学: 第一至七节、六道选择题和四问表达。第三节后可暂停。Cuckoo过滤器、Spring缓存注解的细节放在折叠拓展里。本课的Redis(内存数据存储)命令和Java代码按官方文档核对,没有实际运行。
主读: 微软 Azure 架构中心:Cache-Aside pattern(旁路缓存模式)。先读开头的三步读路径和“Issues and considerations”中的 Lifetime、Priming、Consistency 三段;再读 Amazon Builders’ Library:Caching challenges and strategies 里讲 negative cache(负缓存) 和 thundering herd(惊群) 的段落。其余官方文档见文末来源列表。
热身:先回忆第18课的三个易错点
先凭记忆回答,再展开核对。刚看完第18课就答,只算即时回忆。最后一题回到第16课,它和本课第四节的锁是同一类问题。
第18课:Redis(内存数据存储)进程崩溃、kill -9、正常关闭(SIGTERM)、整机断电,四种情况在 everysec 下各丢多少?哪一种会写RDB(快照文件)?
正常关闭:配置了save规则时先写一份RDB(快照文件)再退出,不丢。进程崩溃和 kill -9:来不及写RDB(快照文件),但已经write给操作系统的数据由操作系统刷盘,最多丢一条。整机断电:操作系统缓存一起丢,最多约一秒。对照第18课课后问答。
第18课:7.0起AOF(追加写日志)重写期间,新的写命令由谁记到哪里?最后有没有“合并”这一步?
父进程打开一个新的增量文件继续写;子进程只生成新的基础文件;两者就绪后用清单文件原子切换,没有合并。“先缓冲、重写完再追加”是7.0以前的做法。
第18课:在只开RDB(快照文件)的实例上开启AOF(追加写日志),第一步执行什么命令?
CONFIG SET appendonly yes,确认AOF(追加写日志)生成并写入正常后,再更新配置文件。
第16课:登录失败计数用“INCR,返回1时再EXPIRE”。这两条命令之间应用崩溃,会出什么事?
键没有过期时间,一直留着,账号被永久锁住。官方建议把两步写成一段Lua脚本作为一条命令执行。本课第四节的锁会遇到同一类问题:拿锁和设过期必须是一条命令。
一、缓存怎么用:先查缓存,没有再查库
“一个系统里缓存通常是怎么用的?读和写各是什么顺序?”
回到第16课的办件详情页。最常见的用法叫 cache-aside(旁路缓存)。微软架构中心把读的过程写成三步:[1]
- 应用先去缓存里读
case:detail:20。 - 缓存里没有,这叫 cache miss(缓存未命中),应用就去MySQL(关系型数据库)查20号办件。
- 应用把查到的结果放进缓存,再返回给调用方。
写的过程是:办件被修改时, 先改数据库,再删掉缓存里的这一项 ;下次有人读,就会走一遍上面的三步,把新数据放回缓存。微软特别说明顺序很重要:如果先删缓存再改库,中间有一小段时间,别的请求读不到缓存、去库里读到旧值又放回缓存,缓存里就留下了旧数据。缓存和数据库怎样保持一致,是下一课的主题。[1]
这条路径本身没有问题。问题出在:缓存未命中时,每个请求都会去查一次MySQL,这一步常叫“回源”,本课指直接去查MySQL。平时未命中的请求不多,数据库撑得住;但如果在某个时刻,大量请求 同时 未命中,数据库就会被压垮。下一节把这种情况分成三类。
先试着自己解释,再看参考回答
常用的是旁路缓存:读的时候先查Redis(内存数据存储),命中直接返回;没命中再查MySQL,把结果写回Redis(内存数据存储)并设过期时间,然后返回。写的时候先改数据库,再删缓存,让下次读重新加载。顺序不能反,先删缓存再改库,中间会有请求把旧值又放回缓存。这个模式本身不保证缓存和数据库严格一致。
二、三种情况:哪一类请求越过了缓存
“缓存穿透、击穿、雪崩有什么区别?”
三者的共同点是“大量请求越过缓存打到数据库”,区别在于 越过的是哪些请求、为什么越过 :
| 叫法 | 越过缓存的请求 | 为什么缓存挡不住 | 办件系统里的样子 |
|---|---|---|---|
| 缓存穿透 | 查 数据库里也不存在 的数据 | 库里查不到,就没有东西放回缓存,下次照样未命中 | 有人用脚本拿随机办件号 9000001、9000002……来查详情 |
| 缓存击穿 | 同一个 热门键 ,在它过期的那一刻 | 键刚过期、还没重建好,同时到达的几百个请求都未命中 | 首页置顶的20号公告办件,每秒几百次访问,缓存在09:00:00过期 |
| 缓存雪崩 | 大量不同的键 同时失效,或Redis(内存数据存储)整个不可用 | 缓存几乎全空,所有请求都去查库 | 凌晨2点批量预热10万件,过期时间都设1小时,3点整同时过期 |
先试着自己解释,再看参考回答
三者都是大量请求越过缓存打到数据库,区别在越过的是哪些请求。穿透是查数据库里也不存在的数据,查不到就没东西放回缓存,每次都查库,常见于恶意刷不存在的编号。击穿是一个热门键刚过期、还没重建好时,大量并发请求同时未命中,一起去查库。雪崩是大量不同的键同时失效,或者Redis(内存数据存储)整个不可用,几乎所有请求都去查库。
三、缓存穿透:查的东西根本不存在
“缓存穿透怎么解决?布隆过滤器有什么问题?”
有人写脚本拿 9000001、9000002…… 这些不存在的办件号来查。每个请求都是:缓存未命中 → 查库查不到 → 没东西写回缓存 → 下一个请求照样走一遍。缓存对这类请求完全没用。下面三种办法按实施成本从低到高讲;请求实际经过的顺序是参数校验 → 布隆过滤器 → 缓存(含空值),见图3。
第一层:参数校验,挡掉明显不合法的
办件编号是正整数、有长度范围,负数、超长数字、非数字字符在接口层直接拒绝,根本不进缓存和数据库。这一层成本最低,但挡不住“格式合法、只是不存在”的编号。
第二层:把“查不到”也缓存起来,过期时间设短
查库得到空结果时,写一个占位值进缓存,比如 SET case:detail:9000001 __NULL__ EX 60。下次同一个编号再来,命中占位值就直接返回“不存在”,不再查库。
这一招要讲清楚取舍,因为官方材料的态度并不一样:微软的示例代码里写着“避免缓存 null(空值) 值”;Spring Data Redis(Spring 的内存数据存储访问模块)的缓存配置默认 会 缓存 null(空值);AWS 主张把下游的错误响应用一个单独的、不同的过期时间缓存起来,这叫 negative cache(负缓存),理由是“如果出错时缓存里什么都没有,上游会不停地冲击下游”。这里的“下游”指被调用的服务,在本课就是MySQL。[1] [10] [3] 把这个思路用在“查不到的办件”上,是本课的教学推演。它的两个代价要说出来:
- 攻击者每次换一个新编号,空值缓存就挡不住,还会堆积大量占位键。 所以占位值的过期时间要短(比如60秒),让它们很快自己消失。
- 数据后来真的有了,占位值会挡住它。 比如9000001号办件一分钟后被新建,这一分钟内查详情都会显示“不存在”。所以新建办件时,要把对应的占位键删掉(就是第一节的写路径:先改库,再删缓存)。
第三层:布隆过滤器,先问“这个编号可能存在吗”
Redis(内存数据存储) 8 起,概率型数据结构直接内置在 Redis(内存数据存储) Open Source 里,不用再单独装模块。[4] 其中的 Bloom filter(布隆过滤器)用很小的固定内存,回答“某个元素是否在集合里”。它的回答有一个不对称的特点,官方原话是: 说“不存在”时一定不存在;说“存在”时可能是错的 :回答“存在”时有一小部分是错的,比例由创建时设定的误判率决定。[5]
# 准备一个能装200万个编号、误判率0.1%的布隆过滤器
BF.RESERVE case:ids 0.001 2000000
# 启动时把已有的办件编号全部放进去;之后每新建一件就 BF.ADD 一次
BF.MADD case:ids 18 20 35 41 52
BF.ADD case:ids 9001
# 查详情前先问:返回0就直接回“不存在”,不查缓存也不查库
BF.EXISTS case:ids 9000001 # 0:一定没加入过
BF.EXISTS case:ids 20 # 1:可能加入过,继续走缓存和数据库
验证思路:命令和参数按官方 BF.RESERVE、BF.EXISTS 页面核对,本课没有实际运行。有 Redis(内存数据存储) 8 测试实例时,执行后 BF.EXISTS case:ids 9000001 应返回0、BF.EXISTS case:ids 20 应返回1。0.001 是误判率(千分之一),2000000 是打算放入的元素个数。
请求先过布隆过滤器:返回0,说明这个编号从来没被加入过,直接回“不存在”;返回1,再按第一节的旁路缓存去查。不存在的编号里约有千分之一会被误判为“可能存在”,它们会多查一次库、查不到,再由空值缓存兜住。
布隆过滤器的三个坑,也是常见追问:
- 不能删除。 官方给的布隆过滤器命令里没有删除命令;办件被删除后,它仍会对这个编号回答“可能存在”,只会多一次查库,靠空值缓存兜住。如果确实需要删除,官方给的替代是 Cuckoo filter(布谷鸟过滤器),它支持删除。[6]
- 要提前装入全部已有编号,并随新增同步。 启动时把已有编号全部加进去,新建办件时同步
BF.ADD。漏加的编号,过滤器会如实回答“没加入过”,应用就把真实办件当成不存在拒掉了,错的不是过滤器,是我们没加。元素数超过BF.RESERVE设定的容量后,过滤器会自动扩出子过滤器,让误判率尽量接近设定值,代价是查询变慢。[5] - 键不存在时也返回0。 官方说明,
BF.EXISTS返回0的情况包括“元素一定没加入”,也包括“这个键根本不存在”。如果Redis(内存数据存储)重启后布隆过滤器的键丢了(比如第18课说的没开持久化),所有真实办件都会被判成“不存在”。所以过滤器要和数据一起持久化,或者重启后先重建、再放行。[5]
拓展:要支持删除就用 Cuckoo filter(布谷鸟过滤器) 吗?
官方对比是:Bloom 在插入时通常性能更好、更容易扩容,适合经常新增元素的场景;Cuckoo 查询更快,而且支持删除(命令 CF.DEL)。办件编号只增不删、偶尔删除也能靠空值缓存兜住,用Bloom就够;如果删除很频繁、又不想让已删除的编号一直“可能存在”,再考虑Cuckoo。[6]
此外还可以按用户或IP限流、把恶意来源拉进黑名单,属于接口层防护,限流的具体做法本课不展开。[12]
先试着自己解释,再看参考回答
穿透是查数据库里也不存在的数据,查不到就没东西放回缓存,请求每次都打到库上。我会分三层挡:先做参数校验,挡掉格式不合法的编号;再用布隆过滤器,启动时把所有已有编号放进去,新增时同步加入,查询前先BF.EXISTS,返回0说明一定不存在,直接返回;最后对查库查不到的结果缓存一个空值占位,过期时间设短,比如60秒,新增数据时删掉占位。布隆过滤器说不存在一定不存在,说存在可能误判,误判的少数由空值缓存兜住;它不能删除,要支持删除可以用Cuckoo过滤器;还要注意过滤器的键丢了时BF.EXISTS也返回0,会把真实请求全拒掉,所以要持久化或重启后先重建。
这里可以暂停。先能说清“穿透为什么缓存挡不住、三层分别挡什么”,再继续看击穿。
四、缓存击穿:热门键过期的那一瞬间
“热点key(键)过期了,大量请求同时打到数据库怎么办?”
首页置顶的20号公告办件每秒被查约300次,缓存过期时间10分钟。平时查库只要50毫秒;假设09:00:00这个键过期,最先到达的十几个请求同时去查同一条SQL,数据库变慢,单次查询被拖到约1秒。这1秒里到达的约300个请求,看到的都是“缓存里没有”,于是 每个都去查一次库、每个都写一次缓存 。查的是同一件办件,约300次查询里只有一次是有用的。
AWS 把这类情况叫 thundering herd(惊群):很多请求几乎同时需要同一个还没缓存的资源,全部涌向下游;他们的做法是 request coalescing(请求合并),保证对一个未缓存的资源,同一时刻只有一个请求去取。Spring 的缓存注解文档也讲了同一件事:默认不加锁,同一个值可能被重复计算好几次,“让缓存失去了意义”。[3] [9] AWS 讲的是更一般的情况,原文没有专门写“热门键过期”;国内常说的“缓存击穿”是其中一种触发方式,这一对应是本课的归纳。
办法一:拿锁重建(常叫“互斥锁”)
未命中时,先用第16课的 SET … NX 抢一把这个办件专用的锁;抢到的去查库、写缓存,没抢到的等一会儿再读缓存。官方的 单实例锁 写法有三个细节,缺一不可:[8] [7]
- 拿锁和设过期时间是同一条命令。
SET lock:case:20 随机值 NX PX 10000。如果分成 SETNX 和 EXPIRE 两条,中间应用崩溃,锁就永远不过期,这个办件以后谁也重建不了,和第16课登录计数的问题一样。 - 值是一个每次都不同的随机值。 官方要求它“在所有客户端、所有加锁请求中唯一”。
- 释放时先比较值,相等才删。 否则会出现:请求1拿锁后查库太慢,锁先过期了;请求2拿到了新锁;请求1查完直接
DEL,删掉的是请求2的锁。比较和删除要作为一条命令执行:Redis(内存数据存储) 8.4起可以用DELEX 键 IFEQ 随机值,更早的版本用官方给的Lua脚本。
// stringRedisTemplate 由 Spring Boot 注入;CaseDetail、caseMapper、toJson、parse 为示意
private static final DefaultRedisScript<Long> UNLOCK = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
Long.class);
public CaseDetail getCaseDetail(long id) throws InterruptedException {
String key = "case:detail:" + id;
String lockKey = "lock:" + key;
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
for (int i = 0; i < 100; i++) { // 最多等约5秒,要长于一次重建的耗时
String json = ops.get(key);
if (json != null) return parse(json); // 命中(parse 遇到 __NULL__ 返回 null)
String token = UUID.randomUUID().toString();
Boolean locked = ops.setIfAbsent(lockKey, token, Duration.ofSeconds(10)); // SET NX+过期:一条命令
if (Boolean.TRUE.equals(locked)) {
try {
json = ops.get(key); // 拿到锁后再查一次:等锁期间别人可能已写回
if (json != null) return parse(json);
CaseDetail d = caseMapper.selectById(id);
ops.set(key, d == null ? "__NULL__" : toJson(d),
d == null ? Duration.ofSeconds(60) : Duration.ofMinutes(10));
return d;
} finally {
stringRedisTemplate.execute(UNLOCK, List.of(lockKey), token); // 值相等才删
}
}
Thread.sleep(50); // 没拿到锁:稍等再读缓存
}
throw new IllegalStateException("缓存重建超时:" + key);
}
验证思路:Lua脚本与官方 SET 页面“Patterns”一节一致;setIfAbsent(K, V, Duration) 按第16课核对过的 Javadoc;DefaultRedisScript 构造器与 execute(RedisScript, List, Object...) 本课没有对照官方原文逐条核对。这段代码没有编译运行。注意两个时长要对得上:等待的总时长(100次×50毫秒≈5秒)要长于一次重建,锁的过期时间(10秒)又要更长。可以在测试环境用压测工具对同一个办件号并发请求,确认慢查询日志或数据库查询计数里只出现一次查询。
注意代码里“拿到锁后再查一次缓存”这一步(常叫双重检查)。它防的是一个很短的空档:请求2读缓存未命中;就在它下一步抢锁之前,请求1写好了缓存并释放了锁;请求2接着抢锁成功。这时缓存里已经有数据,请求2如果不再查一次,就会把库再查一遍。
代价: 其他请求要等;锁的过期时间要比查库时间长,否则会有第二个请求拿到锁;官方也说明,这种单实例锁只适合“偶尔出现竞争可以接受”的场景,并把简单 SET NX 锁标为不推荐、建议用 Redlock。对缓存重建来说,偶尔多查一次库是可以接受的,这正是它适用的场景;真正需要严格互斥的业务锁,要另用分布式锁方案,本课不展开。[8] [7]
办法二:热门数据不过期,或者过期了也先用旧值(常叫“逻辑过期”)
微软的建议是:静态的数据集如果放得下,启动时就预热进缓存,并且设置成不过期。[1] 公告类办件内容很少变,可以不设Redis(内存数据存储)的过期时间,办件被修改时按写路径删除缓存。要注意两点:删除之后第一次重建时,并发请求仍会一起未命中,所以“不过期”最好和拿锁重建一起用;“不过期”也不等于“不会被删”,内存不够时Redis(内存数据存储)的淘汰策略仍可能删掉它,微软原文也写的是“过期或被淘汰”。
如果数据会变、又不想让请求等,可以把“过期时间”写进缓存的值里,而不是交给Redis(内存数据存储):读到值后看里面的时间,没过期就直接用;过期了,由拿到锁的那个请求另起一个线程去重建缓存,它自己和其他请求都 先返回旧值 。AWS 的做法类似,用两个过期时间 soft TTL 和 hard TTL:过了软过期时间就尝试刷新,下游不可用时继续用旧数据,直到硬过期时间。[3] 代价是这段时间里用户会看到旧数据,适合“短时间内旧一点也没关系”的内容。
拓展:Spring 的 @Cacheable(sync = true) 能代替手写锁吗?
Spring 文档说明:加上 sync = true 后,会让底层的缓存提供者在计算值时锁住这个条目,只有一个线程去算,其他线程等它写好。但官方同时说它“实际上只是一个提示”,选用的缓存提供者不一定真的同步执行,要查提供者的文档;而且用了 sync 就不能再用 unless。[9] 实际项目里应用服务器往往有多台,提供者若只在本机内同步,每台仍会各查一次库。是否跨机器同步,取决于你用的缓存提供者,本课没有核对 Spring Data Redis(Spring 的内存数据存储访问模块) 的具体实现。
先试着自己解释,再看参考回答
击穿是一个热门键过期的瞬间,大量并发请求同时未命中,全部去查库,查的是同一份数据。一种办法是拿锁重建:未命中时用SET NX抢这个键专用的锁,抢到的查库写缓存,没抢到的稍等再读缓存。锁要三个细节:拿锁和过期时间用一条命令设,否则中间崩溃锁永不过期;值用随机值;释放时比较值相等才删,用Lua脚本或8.4的DELEX,避免删掉别人的锁;拿到锁后还要再查一次缓存。另一种是热门数据不过期,或者在值里存逻辑过期时间,过期后一个请求后台重建、其他请求先返回旧值,代价是短时间读到旧数据。
五、缓存雪崩:一大片同时空了,或者Redis(内存数据存储)整个挂了
“缓存雪崩怎么预防?Redis(内存数据存储)挂了怎么办?”
雪崩有两种来源,处理办法完全不同,回答时要先分开说。
来源一:大量键在同一时刻过期
每天凌晨2点有个任务把10万件办件详情预热进缓存,过期时间统一设3600秒。于是3点整这10万个键同时过期,接下来的请求几乎都未命中。AWS 白皮书用商品信息举了同样的例子:数据同时过期、系统又在高负载时,数据库要承接全部请求;办法是给过期时间加一点随机抖动, TTL = 初始TTL + 随机值 ,这样还能减少缓存引擎集中删除过期键的CPU(处理器)开销。[2]
// 预热时:3600 秒基础上随机多加 0~599 秒
long ttl = 3600 + ThreadLocalRandom.current().nextLong(600);
ops.set("case:detail:" + id, json, Duration.ofSeconds(ttl));
验证思路:示意代码,没有编译运行。预热完成后对一批键执行 TTL(剩余存活时间),刚预热完时,返回值应分布在3600到4199秒之间,而不是全部相同。
同理,预热(提前把数据写进缓存)本身也可以分批进行,不要在同一秒全部写入。图5假设10万件在一分钟内写完。
来源二:Redis(内存数据存储) 整个不可用
这时问题不在过期时间,而是缓存这一层没了。Redis(内存数据存储)挂掉时,ops.get 默认会抛出连接异常;只有代码捕获异常后改去查库,才会出现“所有请求直接回源”。AWS 的经验很直接:服务一旦“对缓存上瘾”,缓存就从锦上添花变成了必不可少;缓存长时间故障时,如果所有请求直接回源,会给下游造成异常的流量尖峰,把下游也拖垮。他们的做法是:在外部缓存之外再加一层 本地内存缓存 ,外部缓存不可用时退到本地;或者 削峰限流 ,限制发往下游的最大请求速率,超出的请求直接返回“稍后再试”或简化结果,这常叫“降级”。他们还建议在缓存关闭的情况下做压测,验证系统扛得住。[3]
让Redis(内存数据存储)本身不容易整体不可用,靠的是高可用部署:Redis Sentinel(哨兵)在不使用Redis Cluster(集群)时提供高可用,主节点出问题时自动把副本提升为新主节点并通知应用;Redis Cluster(集群)把数据自动分片到多个节点,部分节点故障时在一定程度上仍能服务,但多数主节点不可用时整个集群也会不可用。[11] 这两种部署方式本课不展开,只要知道“高可用降低整体挂掉的概率,但不能代替本地缓存和限流兜底”。
先试着自己解释,再看参考回答
雪崩要分两种。一种是大量键同时过期,比如批量预热时过期时间都设成一样,到点一起失效,办法是给过期时间加随机抖动,预热也分批做,热点数据可以不过期。另一种是Redis(内存数据存储)整个不可用,所有请求直接回源会把数据库打垮,办法是加本地缓存兜底、对数据库限流降级,平时用Sentinel或Cluster做高可用降低整体挂掉的概率,还要在关掉缓存的情况下压测,确认系统扛得住。
六、怎么选:先判断类型,再说代价
“实际系统里,这三个问题各先做什么、再加什么?”
| 情况 | 先做什么 | 再加什么 | 代价 |
|---|---|---|---|
| 穿透 | 参数校验;查不到时缓存短过期的空值 | 布隆过滤器;限流和黑名单 | 空值占内存、新建数据要删占位;布隆过滤器误判、不能删、要预热和持久化 |
| 击穿 | 热门数据预热并不过期,修改时删缓存(删后首次重建仍需加锁) | 拿锁重建(锁带过期、随机值、比较后删);或逻辑过期先返回旧值 | 请求要等;逻辑过期会短暂返回旧数据 |
| 雪崩:同时过期 | 过期时间加随机抖动;预热分批 | 热点数据不过期 | 过期时间不再整齐,排查时要知道有抖动 |
| 雪崩:Redis(内存数据存储)不可用 | 本地缓存兜底;对数据库限流降级 | Sentinel或Cluster高可用;关掉缓存做压测 | 本地缓存数据可能更旧;限流时部分请求被拒绝 |
怎样讲清楚: 先一句话区分三者:不存在的数据、一个热门键过期、大量键同时失效或Redis(内存数据存储)不可用;再对被问到的那一种说两三种办法,最后说代价。如果在自己的测试环境里实现过其中一种并比较过数据库查询次数,可以用测到的数字说明效果;没有实测,就只讲机制和代价,不给具体数字。
七、应用:判断类型,审查代码
“给你一段监控现象或一段代码,你能看出问题吗?”
A.三段监控现象,各是哪一种?
背景: 办件系统用旁路缓存,详情缓存默认过期时间10分钟;现有办件编号最大7位。运维给了三段现象,彼此独立,场景都和正文的例子不同。
- 现象一: 夜里数据库查询量突然升高,日志里大量请求的办件号是8到9位的随机数字,全部返回“办件不存在”;缓存命中率从95%降到30%。
- 现象二: 门户网站首页的“最新政策”列表整体存成一个缓存键,过期时间30分钟,每周一更新后访问量极高;某周三15:20:07数据库连接池被占满,约1秒后恢复,慢查询日志里同一条“查最新政策列表”的SQL出现了几百次。
- 现象三: 每周一8点有任务把5万条政策解读导入缓存,过期时间统一写死为2小时;连续几周,每周一10点整数据库CPU(处理器)都飙到90%以上,几分钟后回落,其他时间正常。
请回答: 每段现象属于穿透、击穿还是雪崩?依据是现象里的哪句话?各给出你的首选处理办法。
完成后核对过程
现象一是穿透。 依据:查的办件号“全部不存在”,数据库查不到、缓存放不进去。随机编号每次都不同,空值缓存基本挡不住。首选:参数校验直接拒绝超过7位的编号;再加布隆过滤器和按IP限流;空值缓存只用来兜住过滤器的误判。
现象二是击穿。 依据:同一个热门键、同一条SQL在1秒内出现几百次,说明是这个键过期后大量并发同时回源。首选:拿锁重建,只让一个请求查库;列表内容只在每周一更新,也可以不设过期、更新时删缓存,但删除后的首次重建仍要加锁。
现象三是雪崩(同时过期)。 依据:5万个键在8点统一写入、过期时间统一2小时,10点整同时过期,而且只在每周一10点出现。首选:过期时间加随机抖动,比如2小时再加0到10分钟,导入分批进行。
B.审查一段缓存代码
背景: 同事为了防止击穿,写了下面的代码。caseMapper.selectById 查不到时返回null(空值)。
// ops 即 stringRedisTemplate.opsForValue()
public CaseDetail getCaseDetail(long id) throws InterruptedException {
String key = "case:detail:" + id;
String json = ops.get(key);
if (json != null) return parse(json);
Boolean locked = ops.setIfAbsent("lock:" + key, "1"); // ①
if (Boolean.TRUE.equals(locked)) {
CaseDetail d = caseMapper.selectById(id);
if (d != null) {
ops.set(key, toJson(d), Duration.ofMinutes(10)); // ②
}
stringRedisTemplate.delete("lock:" + key); // ③
return d;
}
Thread.sleep(50);
return getCaseDetail(id);
}
验证思路:这是练习题给定的有问题的代码,请先自己找问题,再展开核对;对照第四节的写法逐行比较。
请回答: 标①②③的三行各有什么问题?会在什么情况下造成什么后果?怎样改?另外还缺了哪一步?
完成后核对过程
① 锁没有过期时间,值也是固定的"1"。拿到锁的请求如果在查库时崩溃(或在③之前抛出异常),锁永远不会释放;之后查这个办件的请求每50毫秒递归重试一次,直到客户端超时或栈溢出,被占住的工作线程越来越多,可能拖垮整个应用,只有人工删掉锁键才能恢复。改为 setIfAbsent(lockKey, 随机值, Duration.ofSeconds(10)),一条命令同时设值和过期。
② 查不到时什么都不写,正是穿透:有人拿不存在的编号来查,每次都会拿锁、查库、查不到。改为查不到时写一个短过期的空值占位。
③ 直接DEL,没有比较值。一旦①加上了过期时间,就会出现“锁已过期、别人拿到新锁、我再把别人的锁删掉”的情况。改为比较值相等再删(Lua脚本或8.4的DELEX),并放进 finally(最终清理代码块),保证异常时也释放。
缺的一步: 拿到锁之后没有再查一次缓存。在“读缓存未命中”和“抢锁”之间的空档里,前一个请求有可能已经写好缓存并释放了锁,不再查一次就会重复查库。另外,失败后递归调用自己没有次数上限,应改为有上限的循环。
八、即时检验:先判断,再读解释
每题只问一个主要判断。点击后会显示反馈,不需要反复刷到全对才继续学习。
- 判断类型
日志里大量请求查的办件号是9位随机数字,数据库里都查不到;缓存命中率明显下降,其他办件的访问量没有变化。
问题: 这属于哪一种情况?
- A. 缓存击穿
- B. 缓存雪崩
- C. 缓存穿透
查看答案与解释
答案:C
查的是数据库里也不存在的数据,查不到就没东西放回缓存,每次都会回源,这是穿透。击穿是一个热门键过期;雪崩是大量键同时失效或Redis(内存数据存储)不可用。
- 布隆过滤器返回1
布隆过滤器 case:ids 已正常建立并装好全部编号。对办件号 7123 执行 BF.EXISTS case:ids 7123,返回1。
问题: 这说明什么?
- A. 一定已经加入过
- B. 可能已经加入过
- C. 一定没有加入过
查看答案与解释
答案:B
官方说明:返回0表示一定没加入;返回1只表示可能加入过,有一定的误判率。所以返回1时还要继续查缓存和数据库。
- 过滤器的键丢了
Redis(内存数据存储)重启后没有加载到持久化数据,布隆过滤器 case:ids 这个键不存在了,应用还没有重建它。这时对真实存在的20号办件执行 BF.EXISTS case:ids 20。
问题: 会发生什么?
- A. 返回0,被当成不存在
- B. 返回1,照常放行查询
- C. 报错,提示键不存在了
查看答案与解释
答案:A
官方说明 BF.EXISTS 在键不存在时也返回0,不会报错。按“返回0就是不存在”的逻辑,所有真实办件都会被拒掉,所以过滤器要持久化或重启后先重建再放行。
- 拿到锁后再查一次
按第四节的代码:请求2读缓存,未命中;它正准备执行下一行去抢锁。就在这两行之间,另一个请求完成了它的工作。随后请求2抢锁成功。
问题: 请求2拿到锁后,为什么要先再查一次缓存?
- A. 数据库可能未提交
- B. 锁的值可能被改了
- C. 缓存可能已被写好
查看答案与解释
答案:C
在“读缓存未命中”和“抢锁”之间,前一个拿锁的请求可能已经写好缓存并释放了锁。请求2再查一次就能直接命中返回,不必重复查库。这一步常叫双重检查。
- 释放锁要比较值
请求1用 SET lock:case:20 随机值 NX PX 10000 拿到锁,查库花了12秒;第10秒时锁已过期,请求2拿到了新锁。
问题: 请求1查完后,为什么不能直接 DEL 这个锁?
- A. 会让数据库重复提交
- B. 会删掉别人后来的锁
- C. 锁的过期时间被延长
查看答案与解释
答案:B
此时锁属于请求2。直接DEL会删掉请求2的锁,让第三个请求又能拿锁。释放前要比较值是不是自己的随机值,相等才删,并且比较和删除用一条命令完成。
- 每天同一时刻飙高
每天23:00的任务把全部经办人的月度统计写入缓存,过期时间统一为7200秒;每天凌晨1点整数据库CPU(处理器)飙高,几分钟后回落,其他时间正常。
问题: 最直接有效的改法是?
- A. 过期时间加随机抖动
- B. 过期时间统一改更短
- C. 把预热改到下午两点
查看答案与解释
答案:A
问题是全部统计键在23:00统一写入、过期时间相同,于是凌晨1点同时过期。改短或换个时间点,它们仍然同时过期;给过期时间加随机值,过期时刻才会分散开。
九、用完整回答检验理解
连续回答下面四问,可以写下来,也可以先口述再整理成文字。每问先区分是哪一种,再说办法,最后说代价。写完再对照各节的参考回答。
第一问:三者的区别
问题:“缓存穿透、击穿、雪崩分别是什么?”
请回答: 用“哪一类请求越过了缓存、为什么越过”分别说明三者,各举一个办件系统里的例子。
第二问:穿透与布隆过滤器
问题:“穿透怎么解决?布隆过滤器的原理和问题是什么?”
请回答: 至少说出两种办法和各自的代价;布隆过滤器“说存在”和“说不存在”分别意味着什么;它不能做什么;上线时要注意什么(预热、新增同步、键丢失时的返回值)。
第三问:击穿与拿锁重建
问题:“热点key(键)过期了怎么办?用锁的话要注意什么?”
请回答: 说出拿锁重建的流程,以及锁的三个细节和“拿到锁后再查一次”;再说一种不用锁的办法和它的代价。
第四问:Redis(内存数据存储)挂了
问题:“如果Redis(内存数据存储)整个挂了30秒,数据库会怎样?怎样设计才能扛住?”
请回答: 说明为什么这和“大量键同时过期”是两回事;给出至少两层兜底;说明高可用部署能解决什么、不能解决什么;怎样提前验证。
练习:请在自己的笔记中写出答案(可附第七节的判断过程),再展开参考内容核对。
有条件时,可以在自己的测试环境里实现其中一种做法并压测,比较处理前后的数据库查询次数。能独立解释、能实际实现、隔几天还记得,是三件不同的事,看过材料不等于三件都做到了。
十、常见追问:把容易混的地方分开
先自己解释,再展开核对
布隆过滤器和空值缓存,只用一种行不行?
可以,但各有缺口。只用空值缓存:攻击者每次换新编号就挡不住,还会堆积占位键。只用布隆过滤器:有误判,误判的编号每次都会查库,而且办件被删除后它还会说“可能存在”。两者配合:过滤器挡掉“一定不存在”的大部分,空值缓存兜住误判和刚查过的编号。
击穿和雪崩都和“过期”有关,怎么区分?
看过期的是一个键还是很多键。击穿是 一个 热门键过期、大量并发都在查它;雪崩是 很多 不同的键同时过期,或者Redis(内存数据存储)整个不可用。对应的办法也不同:击穿靠拿锁或不过期,雪崩靠随机抖动和兜底。
拿锁重建时,锁的过期时间设多长?
要明显长于一次查库加写缓存的时间,否则查库还没完成锁就过期,第二个请求又会拿锁查库。也不能太长:拿锁的请求崩溃后,其他请求要等锁过期才能重建。本课示例用10秒,实际按慢查询日志里这条SQL的耗时来定。
SET NX 锁官方不是不推荐吗,为什么这里还用?
官方把简单的 SET NX 锁标为不推荐,建议用 Redlock;同时也说明,单实例锁在“偶尔出现竞争可以接受”的场景下是可行的。缓存重建正是这种场景:偶尔多一个请求查库,结果仍然正确。需要严格互斥的业务(比如防止重复审批),要另用分布式锁方案,本课不展开。
缓存空值时,Spring Data Redis(Spring 的内存数据存储访问模块) 默认会缓存 null(空值) 吗?
会。它的 RedisCacheConfiguration 默认“缓存 null(空值):是”“键过期:无”,可以用 disableCachingNullValues() 关闭缓存null(空值),用 entryTtl(...) 设过期时间。注意默认不过期,用注解缓存时要记得配置过期时间。
十一、隔日复测:换一个业务对象
下一次学习前用五分钟回答;约一周后再换场景。今天刚看完就复述,只算即时练习。
1. 商城商品详情接口被人用不存在的商品编号刷,每次都查数据库。这是哪一种?给出两种办法和各自的代价。
穿透。参数校验加缓存短过期空值:换新编号挡不住、会堆积占位键、新上架要删占位。布隆过滤器:说不存在一定不存在、说存在可能误判,不能删除,要预热、随上架同步、注意键丢失时全返回0。
2. 秒杀活动的商品缓存在活动开始后第5分钟过期,瞬间几千个请求查同一个商品。这是哪一种?用锁处理时要注意哪四点?
击穿。拿锁和设过期用一条命令;锁的值用随机值;释放时比较值相等才删(Lua或DELEX);拿到锁后再查一次缓存。也可以在活动期间不设过期。
3. 新闻首页每天早上8点一次性写入1万条缓存,过期时间都是30分钟,每天8点30分数据库报警。怎么改?
雪崩(同时过期)。过期时间改为30分钟加一个随机值,比如再加0到5分钟;写入也分批进行。
第18课复习:appendfsync(追加日志刷盘策略) everysec 下,“最多丢2秒”的说法对吗?
官方只说最多丢一秒。“两秒”是fsync(强制同步到磁盘)没完成时write最多推迟的时间;被推迟的命令还在内存里,磁盘很慢时实际可能超过一秒。回答时先说一秒,再补这个边界。
官方来源与配套材料
访问量、时间点、查库耗时均为教学设定;命令和代码按下列官方文档核对,没有实际运行。
Cache-Aside pattern(微软 Azure 架构中心):读写路径、写路径顺序、过期时间、预热与不过期、不保证一致性、示例代码不缓存 null。
AWS 白皮书
Database Caching Strategies Using Redis:Cache Validity(页首标注仅供历史参考):TTL(剩余存活时间) 加随机抖动。Caching challenges and strategies(Amazon Builders’ Library):对缓存上瘾、缓存不可用时回源的冲击、本地缓存兜底与削峰、soft/hard TTL(剩余存活时间)、negative cache(负缓存)、thundering herd(惊群) 与 request coalescing。
Probabilistic、What’s new in Redis(内存数据存储) 8.0:概率型结构内置于 Redis(内存数据存储) Open Source。
Bloom filter(布隆过滤器)、BF.RESERVE、BF.EXISTS:无假阴性有假阳性、误判率与容量、自动扩容、键不存在时返回0。
Cuckoo filter(布谷鸟过滤器):支持删除,与Bloom的对比。
SET 命令页 Patterns:SET NX 锁被标为不推荐、随机值与比较后删除的Lua脚本。
Distributed Locks with Redis(内存数据存储):单实例锁的适用条件、SET … NX PX、唯一随机值、DELEX(8.4起)。
Spring Framework:Declarative Annotation-based Caching与 Cacheable Javadoc:sync 的作用、只是提示、不能与 unless 同用。
Spring Data Redis(Spring 的内存数据存储访问模块):Redis(内存数据存储) Cache:默认缓存 null(空值)、默认不过期、entryTtl、disableCachingNullValues。
Redis Sentinel(哨兵)、Redis Cluster(集群):高可用与自动故障转移、分片与部分故障。
JavaGuide:Redis(内存数据存储)常见面试题总结(下)(第三方):三类问题的国内常见答法;其“Sentinel是集群实现方案”“布隆过滤器不容易删除”等措辞与官方不同,以上面的官方文档为准。
学完后,记下判断理由和仍不确定的地方,例如“布隆过滤器为什么说不存在就一定不存在”“锁过期后别人拿到锁,原来的请求还在查库会怎样”。复习时按同一个办件系统的时间线推一遍,不只背三个名词的定义。