← ReJava · 学习目录

REJAVA / LEARNING NOTES

缓存穿透、击穿与雪崩

分清缓存穿透、击穿、雪崩,掌握布隆过滤器、拿锁重建与过期抖动及其代价。

Java 17 / 21更新于 2026.10.12约 31 分钟阅读

根据课程材料整理

本次更新说明
  • 整理为公开学习笔记:用办件详情缓存串起三种问题,含 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]

  1. 应用先去缓存里读 case:detail:20。
  2. 缓存里没有,这叫 cache miss(缓存未命中),应用就去MySQL(关系型数据库)查20号办件。
  3. 应用把查到的结果放进缓存,再返回给调用方。

写的过程是:办件被修改时, 先改数据库,再删掉缓存里的这一项 ;下次有人读,就会走一遍上面的三步,把新数据放回缓存。微软特别说明顺序很重要:如果先删缓存再改库,中间有一小段时间,别的请求读不到缓存、去库里读到旧值又放回缓存,缓存里就留下了旧数据。缓存和数据库怎样保持一致,是下一课的主题。[1]

旁路缓存读路径:应用先查Redis,命中直接返回;未命中由应用查MySQL,再由应用写回Redis并设过期时间应用查20号详情① GETRediscase:detail:20命中:直接返回② 未命中:应用自己去查MySQLMySQL③ 应用写回 SET … EX 600写路径(办件被修改)① 先改MySQL② 再删 case:detail:20下一次读会未命中,按上面三步把新数据放回缓存。
图1 旁路缓存的读写两条路。三个箭头都从“应用”出发:缓存和数据库互相不知道对方,由应用分别去读、去写。本课的三种问题,都是在说“什么时候大量请求走了②这条去MySQL的路”。

这条路径本身没有问题。问题出在:缓存未命中时,每个请求都会去查一次MySQL,这一步常叫“回源”,本课指直接去查MySQL。平时未命中的请求不多,数据库撑得住;但如果在某个时刻,大量请求 同时 未命中,数据库就会被压垮。下一节把这种情况分成三类。

先试着自己解释,再看参考回答

常用的是旁路缓存:读的时候先查Redis(内存数据存储),命中直接返回;没命中再查MySQL,把结果写回Redis(内存数据存储)并设过期时间,然后返回。写的时候先改数据库,再删缓存,让下次读重新加载。顺序不能反,先删缓存再改库,中间会有请求把旧值又放回缓存。这个模式本身不保证缓存和数据库严格一致。

二、三种情况:哪一类请求越过了缓存

“缓存穿透、击穿、雪崩有什么区别?”

三者的共同点是“大量请求越过缓存打到数据库”,区别在于 越过的是哪些请求、为什么越过 :

叫法越过缓存的请求为什么缓存挡不住办件系统里的样子
缓存穿透查 数据库里也不存在 的数据库里查不到,就没有东西放回缓存,下次照样未命中有人用脚本拿随机办件号 9000001、9000002……来查详情
缓存击穿同一个 热门键 ,在它过期的那一刻键刚过期、还没重建好,同时到达的几百个请求都未命中首页置顶的20号公告办件,每秒几百次访问,缓存在09:00:00过期
缓存雪崩大量不同的键 同时失效,或Redis(内存数据存储)整个不可用缓存几乎全空,所有请求都去查库凌晨2点批量预热10万件,过期时间都设1小时,3点整同时过期
三种情况下越过缓存的请求:穿透是不存在的键,击穿是一个过期的热门键,雪崩是大量同时失效的键穿透击穿雪崩查 9000001、9000002…几百个请求都查20号查各种办件缓存正常但这些键从来没有20大片同时过期或Redis不可用MySQL每次都查库、每次都查不到一个键、短时间内大量并发很多键、全部请求
图2 红色箭头是越过缓存的请求。穿透的键根本不在缓存里,也永远放不进去;击穿是一个热门键的短暂空档;雪崩是一大片键同时空了。
先试着自己解释,再看参考回答

三者都是大量请求越过缓存打到数据库,区别在越过的是哪些请求。穿透是查数据库里也不存在的数据,查不到就没东西放回缓存,每次都查库,常见于恶意刷不存在的编号。击穿是一个热门键刚过期、还没重建好时,大量并发请求同时未命中,一起去查库。雪崩是大量不同的键同时失效,或者Redis(内存数据存储)整个不可用,几乎所有请求都去查库。

三、缓存穿透:查的东西根本不存在

“缓存穿透怎么解决?布隆过滤器有什么问题?”

有人写脚本拿 9000001、9000002…… 这些不存在的办件号来查。每个请求都是:缓存未命中 → 查库查不到 → 没东西写回缓存 → 下一个请求照样走一遍。缓存对这类请求完全没用。下面三种办法按实施成本从低到高讲;请求实际经过的顺序是参数校验 → 布隆过滤器 → 缓存(含空值),见图3。

第一层:参数校验,挡掉明显不合法的

办件编号是正整数、有长度范围,负数、超长数字、非数字字符在接口层直接拒绝,根本不进缓存和数据库。这一层成本最低,但挡不住“格式合法、只是不存在”的编号。

第二层:把“查不到”也缓存起来,过期时间设短

查库得到空结果时,写一个占位值进缓存,比如 SET case:detail:9000001 __NULL__ EX 60。下次同一个编号再来,命中占位值就直接返回“不存在”,不再查库。

这一招要讲清楚取舍,因为官方材料的态度并不一样:微软的示例代码里写着“避免缓存 null(空值) 值”;Spring Data Redis(Spring 的内存数据存储访问模块)的缓存配置默认 会 缓存 null(空值);AWS 主张把下游的错误响应用一个单独的、不同的过期时间缓存起来,这叫 negative cache(负缓存),理由是“如果出错时缓存里什么都没有,上游会不停地冲击下游”。这里的“下游”指被调用的服务,在本课就是MySQL。[1] [10] [3] 把这个思路用在“查不到的办件”上,是本课的教学推演。它的两个代价要说出来:

  1. 攻击者每次换一个新编号,空值缓存就挡不住,还会堆积大量占位键。 所以占位值的过期时间要短(比如60秒),让它们很快自己消失。
  2. 数据后来真的有了,占位值会挡住它。 比如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,再按第一节的旁路缓存去查。不存在的编号里约有千分之一会被误判为“可能存在”,它们会多查一次库、查不到,再由空值缓存兜住。

三层挡穿透:参数校验挡非法编号,布隆过滤器挡一定不存在的编号,空值缓存兜住误判请求办件号 X① 参数校验格式不对→拒绝② BF.EXISTS0→一定不存在③ 查缓存含空值占位④ 查MySQL每一层挡掉一类:①挡负数、超长、非数字;②挡从没出现过的编号;③挡“刚查过、确实没有”的编号和布隆过滤器的误判;能走到④的,基本只剩真实存在、刚好不在缓存里的办件。注意:布隆过滤器的键本身丢了,②会对所有编号返回0。
图3 从左到右是一个请求要过的关。布隆过滤器负责“一定不存在”的那一大片,空值缓存负责它误判的少数和刚查过的编号。

布隆过滤器的三个坑,也是常见追问:

  1. 不能删除。 官方给的布隆过滤器命令里没有删除命令;办件被删除后,它仍会对这个编号回答“可能存在”,只会多一次查库,靠空值缓存兜住。如果确实需要删除,官方给的替代是 Cuckoo filter(布谷鸟过滤器),它支持删除。[6]
  2. 要提前装入全部已有编号,并随新增同步。 启动时把已有编号全部加进去,新建办件时同步 BF.ADD。漏加的编号,过滤器会如实回答“没加入过”,应用就把真实办件当成不存在拒掉了,错的不是过滤器,是我们没加。元素数超过 BF.RESERVE 设定的容量后,过滤器会自动扩出子过滤器,让误判率尽量接近设定值,代价是查询变慢。[5]
  3. 键不存在时也返回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 讲的是更一般的情况,原文没有专门写“热门键过期”;国内常说的“缓存击穿”是其中一种触发方式,这一对应是本课的归纳。

击穿时间线:不加锁时50毫秒内的几百个请求都查库;加锁后只有一个请求查库,其他请求稍等后命中缓存不加处理:09:00:00 键过期请求1请求2请求3…… 第300个请求每个都查库、都写缓存:约300次相同的查询数据库连接池被占满拿锁重建:只让一个请求查库请求1 拿到锁 → 查库写回缓存,释放锁请求2~300 没拿到锁:稍等再读缓存命中,直接返回查库次数从约300次降到1次;代价是其他请求要等到缓存写回。0 约1秒 时间 →
图4 上半是不加处理时,约1秒查库窗口里的请求全部去查库;下半是加锁后,只有拿到锁的请求查库,其他请求等缓存被写回后直接命中(示意数字)。

办法一:拿锁重建(常叫“互斥锁”)

未命中时,先用第16课的 SET … NX 抢一把这个办件专用的锁;抢到的去查库、写缓存,没抢到的等一会儿再读缓存。官方的 单实例锁 写法有三个细节,缺一不可:[8] [7]

  1. 拿锁和设过期时间是同一条命令。 SET lock:case:20 随机值 NX PX 10000。如果分成 SETNX 和 EXPIRE 两条,中间应用崩溃,锁就永远不过期,这个办件以后谁也重建不了,和第16课登录计数的问题一样。
  2. 值是一个每次都不同的随机值。 官方要求它“在所有客户端、所有加锁请求中唯一”。
  3. 释放时先比较值,相等才删。 否则会出现:请求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秒之间,而不是全部相同。

统一过期时间时10万个键在3点整同时过期;加随机抖动后过期时间分散在3点到3点10分之间统一 TTL 3600 秒10万个键3:00:00 同时过期3:003:053:10TTL 3600 + 随机 0~599 秒过期分散到10分钟里,每秒只有一两百个
图5 横轴是过期时刻,柱高是这一刻同时过期的键数(示意)。总数一样,分散以后数据库每秒承接的未命中请求少得多。

同理,预热(提前把数据写进缓存)本身也可以分批进行,不要在同一秒全部写入。图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位。运维给了三段现象,彼此独立,场景都和正文的例子不同。

  1. 现象一: 夜里数据库查询量突然升高,日志里大量请求的办件号是8到9位的随机数字,全部返回“办件不存在”;缓存命中率从95%降到30%。
  2. 现象二: 门户网站首页的“最新政策”列表整体存成一个缓存键,过期时间30分钟,每周一更新后访问量极高;某周三15:20:07数据库连接池被占满,约1秒后恢复,慢查询日志里同一条“查最新政策列表”的SQL出现了几百次。
  3. 现象三: 每周一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(最终清理代码块),保证异常时也释放。

缺的一步: 拿到锁之后没有再查一次缓存。在“读缓存未命中”和“抢锁”之间的空档里,前一个请求有可能已经写好缓存并释放了锁,不再查一次就会重复查库。另外,失败后递归调用自己没有次数上限,应改为有上限的循环。

八、即时检验:先判断,再读解释

每题只问一个主要判断。点击后会显示反馈,不需要反复刷到全对才继续学习。

  1. 判断类型

日志里大量请求查的办件号是9位随机数字,数据库里都查不到;缓存命中率明显下降,其他办件的访问量没有变化。

问题: 这属于哪一种情况?

  • A. 缓存击穿
  • B. 缓存雪崩
  • C. 缓存穿透
查看答案与解释

答案:C

查的是数据库里也不存在的数据,查不到就没东西放回缓存,每次都会回源,这是穿透。击穿是一个热门键过期;雪崩是大量键同时失效或Redis(内存数据存储)不可用。

  1. 布隆过滤器返回1

布隆过滤器 case:ids 已正常建立并装好全部编号。对办件号 7123 执行 BF.EXISTS case:ids 7123,返回1。

问题: 这说明什么?

  • A. 一定已经加入过
  • B. 可能已经加入过
  • C. 一定没有加入过
查看答案与解释

答案:B

官方说明:返回0表示一定没加入;返回1只表示可能加入过,有一定的误判率。所以返回1时还要继续查缓存和数据库。

  1. 过滤器的键丢了

Redis(内存数据存储)重启后没有加载到持久化数据,布隆过滤器 case:ids 这个键不存在了,应用还没有重建它。这时对真实存在的20号办件执行 BF.EXISTS case:ids 20。

问题: 会发生什么?

  • A. 返回0,被当成不存在
  • B. 返回1,照常放行查询
  • C. 报错,提示键不存在了
查看答案与解释

答案:A

官方说明 BF.EXISTS 在键不存在时也返回0,不会报错。按“返回0就是不存在”的逻辑,所有真实办件都会被拒掉,所以过滤器要持久化或重启后先重建再放行。

  1. 拿到锁后再查一次

按第四节的代码:请求2读缓存,未命中;它正准备执行下一行去抢锁。就在这两行之间,另一个请求完成了它的工作。随后请求2抢锁成功。

问题: 请求2拿到锁后,为什么要先再查一次缓存?

  • A. 数据库可能未提交
  • B. 锁的值可能被改了
  • C. 缓存可能已被写好
查看答案与解释

答案:C

在“读缓存未命中”和“抢锁”之间,前一个拿锁的请求可能已经写好缓存并释放了锁。请求2再查一次就能直接命中返回,不必重复查库。这一步常叫双重检查。

  1. 释放锁要比较值

请求1用 SET lock:case:20 随机值 NX PX 10000 拿到锁,查库花了12秒;第10秒时锁已过期,请求2拿到了新锁。

问题: 请求1查完后,为什么不能直接 DEL 这个锁?

  • A. 会让数据库重复提交
  • B. 会删掉别人后来的锁
  • C. 锁的过期时间被延长
查看答案与解释

答案:B

此时锁属于请求2。直接DEL会删掉请求2的锁,让第三个请求又能拿锁。释放前要比较值是不是自己的随机值,相等才删,并且比较和删除用一条命令完成。

  1. 每天同一时刻飙高

每天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最多推迟的时间;被推迟的命令还在内存里,磁盘很慢时实际可能超过一秒。回答时先说一秒,再补这个边界。

官方来源与配套材料

访问量、时间点、查库耗时均为教学设定;命令和代码按下列官方文档核对,没有实际运行。

  1. Cache-Aside pattern(微软 Azure 架构中心):读写路径、写路径顺序、过期时间、预热与不过期、不保证一致性、示例代码不缓存 null。

  2. AWS 白皮书 Database Caching Strategies Using Redis:Cache Validity(页首标注仅供历史参考):TTL(剩余存活时间) 加随机抖动。

  3. Caching challenges and strategies(Amazon Builders’ Library):对缓存上瘾、缓存不可用时回源的冲击、本地缓存兜底与削峰、soft/hard TTL(剩余存活时间)、negative cache(负缓存)、thundering herd(惊群) 与 request coalescing。

  4. Probabilistic、What’s new in Redis(内存数据存储) 8.0:概率型结构内置于 Redis(内存数据存储) Open Source。

  5. Bloom filter(布隆过滤器)、BF.RESERVE、BF.EXISTS:无假阴性有假阳性、误判率与容量、自动扩容、键不存在时返回0。

  6. Cuckoo filter(布谷鸟过滤器):支持删除,与Bloom的对比。

  7. SET 命令页 Patterns:SET NX 锁被标为不推荐、随机值与比较后删除的Lua脚本。

  8. Distributed Locks with Redis(内存数据存储):单实例锁的适用条件、SET … NX PX、唯一随机值、DELEX(8.4起)。

  9. Spring Framework:Declarative Annotation-based Caching与 Cacheable Javadoc:sync 的作用、只是提示、不能与 unless 同用。

  10. Spring Data Redis(Spring 的内存数据存储访问模块):Redis(内存数据存储) Cache:默认缓存 null(空值)、默认不过期、entryTtl、disableCachingNullValues。

  11. Redis Sentinel(哨兵)、Redis Cluster(集群):高可用与自动故障转移、分片与部分故障。

  12. JavaGuide:Redis(内存数据存储)常见面试题总结(下)(第三方):三类问题的国内常见答法;其“Sentinel是集群实现方案”“布隆过滤器不容易删除”等措辞与官方不同,以上面的官方文档为准。

本课速查表 · 完整术语表 · 第18课

学完后,记下判断理由和仍不确定的地方,例如“布隆过滤器为什么说不存在就一定不存在”“锁过期后别人拿到锁,原来的请求还在查库会怎样”。复习时按同一个办件系统的时间线推一遍,不只背三个名词的定义。