缓存策略的并发正确性:击穿、穿透、雪崩在高并发下的量化验证
1. 先把要解决的问题说清楚
这篇文章围绕一件具体的工程任务展开:在高并发读取场景下,把缓存层做成“错了能看出来、改对了能量出来”的样子。也就是说,不只是把 Spring Cache 注解贴上去、把 Redis 接进来,而是能做到两件事:
- 发生缓存击穿、穿透、雪崩时,能沿着一条清晰的因果链定位是谁把压力放进了下游;
- 改动前后,能用命中率、P99 延迟、下游 QPS 等指标做可重复的对比,而不是凭感觉说“加了锁就快了”。
“缓存”先按普通中文理解:把相对昂贵的结果放在离调用者更近、更便宜的地方,下次直接拿。它解决的问题是重复读取太贵;它引入的新问题是——数据可能不在缓存里,或者在缓存里的东西是错的。这三个场景都从“不命中”开始,但后果和修法不同。
1.1 三个中文名分别指什么
- 缓存击穿(cache breakdown):某个热点键过期或被删除的瞬间,大量并发请求同时发现没有缓存,于是同时去查数据库。它的特征是“单个热点键 + 高并发 + 同一时刻”。
- 缓存穿透(cache penetration):请求查的根本是不存在的数据,缓存永远命中不了,每次都会落到数据库。它的特征是“键本身不存在 + 反复请求”。
- 缓存雪崩(cache avalanche):大量键在相近时间失效,或者缓存层整体不可用,导致流量成片地涌向下游。它的特征是“大批键 + 时间集中”或“整层故障”。
三者的共同点是:请求绕过了缓存,直接施加到下游;不同点是:击穿针对一个热点键,穿透针对不存在的数据,雪崩针对一批键或整个缓存层。把这三者混为一谈,就会写出“用互斥锁解决穿透”这类逻辑上根本不对的方案。
1.2 为什么叫“并发正确性”
单线程下这三个场景几乎不出现:没命中就查一次、写回缓存,世界很安静。真正的问题是并发语义:多个线程同时看到“没有”,然后同时做出“去数据库拿”的决定。并发正确性在这里包含四层含义:
- 结果正确:返回的数据与数据库当前状态一致,或者至少符合明确的允许陈旧窗口;
- 副作用正确:回源、重建缓存、删除缓存的次数在可控范围内,不会出现旧值被写回覆盖新值;
- 隔离正确:一个热点键的重建不会把整个线程池拖死;
- 可观测:命中、未命中、回源、重建、失败都有计数和延迟,可以事后解释。
下面所有讨论都围绕这四层展开,而不是围绕某个具体注解展开。
2. 环境与兼容版本
本文的示例基于以下版本。这里给出的是我用来描述机制的目标组合,不是“已经跑过的实测环境”;凡涉及运行结果的地方,都会明确标为预期或示意。
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | Java 21 (LTS) | 示例使用record、CompletableFuture、虚拟线程 |
| Spring Boot | 3.5.x | 要求 Java 17 起步,21 可用 |
| Spring Data Redis | 随 Boot 3.5 BOM 管理 | 使用StringRedisTemplate与Lettuce连接 |
| Redis | 7.x | 使用SET NX PX、GET、DEL语义 |
| 压测工具 | Apache JMeter 或 Gatling | 使用固定并发与固定持续时间 |
| 指标 | Micrometer + Actuator | 暴露命中率、回源计数与延迟 |
版本敏感点先说明:
- 虚拟线程在 Java 21 是正式特性(JEP 444),但在 Spring Boot 3.5 中需要显式开启(如
spring.threads.virtual.enabled=true)。虚拟线程会改变“阻塞等待”的成本,却不改变对下游数据库的连接数约束。 StringRedisTemplate的setIfAbsent(key, value, timeout)对应SET key value NX PX timeout,这是互斥重建的基础原语。它保证只有一个客户端拿到锁,但不保证锁的持有者不会崩溃,所以需要过期时间作为兜底。Redis的单命令原子性不等于业务原子性:SET NX成功、随后查库失败、再删锁,这是一段有中间状态的流程,必须按失败路径处理。
3. 核心机制:从“不命中”到“下游压力”的因果链
3.1 一次普通读的流程
先看单次请求在缓存层的典型路径:
- 请求到达应用;
- 用业务键查缓存;
- 命中则直接返回;
- 未命中则回源查数据库;
- 把结果写回缓存;
- 返回结果。
在第 4 步和第 5 步之间,存在一个时间窗口。并发请求如果都在这个窗口内到达,就会同时执行第 4 步。窗口越长、并发越高,重复回源越多。这是击穿与穿透共同的物理基础。
3.2 击穿的因果推演
假设热点键item:1001的缓存 TTL 是 60 秒,数据库单次查询耗时 20 毫秒。
- 平时缓存命中,QPS 5000 全部在缓存层消化;
- 第 60 秒 TTL 到期,缓存里没有这个键;
- 假设这 20 毫秒内到达 100 个并发请求(5000 QPS 约等于每毫秒 5 个请求,20 毫秒约 100 个);
- 这 100 个请求全部未命中,全部回源;
- 数据库瞬时承受约 100 次相同查询。
如果数据库连接池只有 20 个连接,那么第 21 个开始的请求要排队,排队导致响应变慢,慢响应又让更多线程被占用,最终可能把线程池和连接池一起拖住。这就是“击穿”从性能问题升级为可用性问题的路径。
关键因果是:不是“过期”本身可怕,而是“过期瞬间的并发量 × 回源耗时”决定了重复回源次数。回源耗时越长,窗口越大,重复越多。
3.3 互斥重建的机制
互斥重建的思路是:只允许一个请求真正回源,其余请求等待或短暂退避。它解决的问题是“重复回源”,代价是“等待”。
用 Redis 实现时,常见流程是:
- 未命中;
SET lock:item:1001 <token> NX PX 3000;- 成功者查库、写缓存、删锁;
- 失败者短睡后重试读缓存,若超时仍未命中则按降级策略处理。
这里藏着三个容易写错的点:
- 锁的过期时间必须大于“正常回源耗时”,否则锁会提前失效,互斥失效;
- 删除锁要校验 token,否则可能删掉别人的锁(A 超时、B 拿锁、A 醒来删锁);
- 等待方必须有上限,否则一个慢查询会把大量线程拖住。
用一段 Lua 脚本做“校验后删除”是常见做法,因为“比较再删除”不是单条 Redis 命令的原子操作:
-- release_lock.lua(示意,需由 Redis 执行)-- KEYS[1] = 锁键,ARGV[1] = 持有者 tokenifredis.call('GET',KEYS[1])==ARGV[1]thenreturnredis.call('DEL',KEYS[1])elsereturn0end这段脚本解决的问题是“只删自己的锁”。它不是银弹:如果锁已过期,而原持有者还在查库,那么新的持有者已经在重建,此时两个线程可能都在写缓存——这是互斥重建的已知边界,下文 8.3 节会单独讨论。
3.4 穿透的因果推演
请求查询item:999999,数据库中没有这条记录。
- 缓存未命中;
- 回源;
- 数据库返回空;
- 如果代码“查到空就不写缓存”,那么下一次相同请求仍然未命中;
- 反复请求就会反复回源。
穿透的危险不在于单个请求,而在于攻击者或错误客户端可以构造大量不同的不存在键。互斥锁对这种场景帮助有限:因为键各不相同,锁也各不相同,每个键都能拿到自己的锁,然后各自回源。
应对穿透有两条正交的思路:
- 空值缓存:把“不存在”这个结论也缓存一段时间,让相同键的重复请求被挡住;
- 布隆过滤(Bloom filter):在缓存前加一个概率型集合,判断“这个键是否可能存在”,不存在则直接拒绝。
“布隆过滤”用普通中文解释:它是一个很省空间的位数组,能明确告诉你“这个元素一定不在集合里”,也可能误判“在”,但不会漏判“在”。因此它适合做前置削减,不适合做精确判断。
3.5 雪崩的因果推演
假设 10 万个键在服务启动时统一写入,TTL 都是 30 分钟。30 分钟后,它们几乎同时过期。
- 同一时刻,大量键未命中;
- 回源流量成片到达数据库;
- 数据库连接池被占满;
- 等待时间上升,更多请求超时重试;
- 重试进一步放大压力。
雪崩还有另一条路径:缓存层整体不可用(网络分区、Redis 主从切换、实例重启),此时所有键同时未命中,效果与“同时过期”类似,但成因不同,修法也不同。
过期时间抖动(jitter)解决的是“同时过期”,不解决“整层不可用”。后者需要限流、降级、本地兜底缓存等组合手段。
3.6 三类问题的机制对照
| 维度 | 击穿 | 穿透 | 雪崩 |
|---|---|---|---|
| 触发对象 | 单个热点键 | 不存在或恶意构造的键 | 大批键或整个缓存层 |
| 并发特征 | 同一时刻大量相同请求 | 大量不同键的重复或扫描请求 | 成片未命中 |
| 主要放大因子 | 回源耗时 × 并发数 | 键空间不受限 | 失效时间集中或缓存层故障 |
| 互斥重建 | 有效 | 基本无效 | 有效但范围有限 |
| 空值缓存 | 不适用 | 有效 | 不适用 |
| 布隆过滤 | 不适用 | 有效 | 不适用 |
| 过期抖动 | 不适用 | 不适用 | 有效 |
| 限流降级 | 辅助 | 辅助 | 必要 |
这张表表达的是主要适用面,不是“只能这样”。例如击穿场景下也需要限流兜底,雪崩场景下也可能需要对个别热点键做互斥重建。
4. 贯穿示例:一个可复现的实验骨架
下面用一个“商品详情读取”的合成场景,把上述机制串起来。它不针对任何真实业务系统,数据是示意。目标是让你能在本地复现“未命中 → 回源 → 指标变化”的链路,并对比不同策略。
4.1 场景设定
- 接口:
GET /item/{id}; - 数据源:一个可人为注入延迟的
ItemRepository(模拟数据库),默认每次 20 毫秒; - 缓存:Redis;
- 压测:固定 200 并发,持续 30 秒;
- 观测:命中率、回源次数、P50/P99 延迟、Redis 命令数。
4.2 项目依赖
<!-- pom.xml 片段(示意):Spring Boot 3.5 管理版本 --><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency><dependency><groupId>io.micrometer</groupId><artifactId>micrometer-core</artifactId></dependency></dependencies>spring-boot-starter-data-redis引入 Lettuce 客户端;micrometer-core用于自定义计数器与计时器。版本由 Spring Boot 3.5 BOM 统一管理,不手写具体版本号可以减少不兼容。
4.3 数据模型与仓库
// ItemRepository.java(示意,可编译的最小骨架)importjava.time.Duration;importjava.util.Map;importjava.util.Optional;importjava.util.concurrent.ConcurrentHashMap;importjava.util.concurrent.atomic.AtomicLong;publicclassItemRepository{privatefinalMap<Long,String>data=newConcurrentHashMap<>();privatefinalAtomicLongcalls=newAtomicLong();privatefinalDurationlatency;publicItemRepository(Durationlatency){this.latency=latency;// 只放少量真实数据,其余 id 视为不存在,用于制造穿透data.put(1001L,"item-1001");data.put(1002L,"item-1002");}publicOptional<String>findById(longid){calls.incrementAndGet();sleep(latency);returnOptional.ofNullable(data.get(id));}publiclongcallCount(){returncalls.get();}privatestaticvoidsleep(Durationd){try{Thread.sleep(d.toMillis());}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}这个仓库把“数据库耗时”显式化,并用calls计数记录回源次数。calls是验证互斥重建是否生效的关键观测点:如果 200 并发只让calls增加 1 次,说明互斥起作用;如果增加很多次,说明锁没生效或窗口太长。
4.4 缓存读取服务
// ItemCacheService.java(示意):带互斥重建与空值缓存importjava.time.Duration;importjava.util.Optional;importjava.util.UUID;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.data.redis.core.script.DefaultRedisScript;importorg.springframework.stereotype.Service;@ServicepublicclassItemCacheService{privatestaticfinalDurationTTL=Duration.ofSeconds(60);privatestaticfinalDurationNULL_TTL=Duration.ofSeconds(10);privatestaticfinalDurationLOCK_TTL=Duration.ofSeconds(3);privatestaticfinalStringNULL_VALUE="__NULL__";privatefinalStringRedisTemplateredis;privatefinalItemRepositoryrepository;privatefinalDefaultRedisScript<Long>releaseScript;publicItemCacheService(StringRedisTemplateredis,ItemRepositoryrepository){this.redis=redis;this.repository=repository;this.releaseScript=newDefaultRedisScript<>("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",Long.class);}publicOptional<String>get(longid){Stringkey="item:"+id;Stringcached=redis.opsForValue().get(key);if(NULL_VALUE.equals(cached)){returnOptional.empty();}if(cached!=null){returnOptional.of(cached);}returnrebuildWithLock(key,id);}privateOptional<String>rebuildWithLock(Stringkey,longid){Stringtoken=UUID.randomUUID().toString();Booleanlocked=redis.opsForValue().setIfAbsent(key+":lock",token,LOCK_TTL);if(!Boolean.TRUE.equals(locked)){// 未拿到锁:短暂等待后重读缓存,避免无限等待returnwaitAndRead(key);}try{Optional<String>value=repository.findById(id);if(value.isPresent()){redis.opsForValue().set(key,value.get(),TTL);}else{redis.opsForValue().set(key,NULL_VALUE,NULL_TTL);}returnvalue;}finally{redis.execute(releaseScript,java.util.List.of(key+":lock"),token);}}privateOptional<String>waitAndRead(Stringkey){for(inti=0;i<5;i++){try{Thread.sleep(20);}catch(InterruptedExceptione){Thread.currentThread().interrupt();returnOptional.empty();}Stringcached=redis.opsForValue().get(key);if(cached!=null){returnNULL_VALUE.equals(cached)?Optional.empty():Optional.of(cached);}}returnOptional.empty();// 降级:不查库,直接返回空}}这段代码里,输入是商品id;状态变化有三处:缓存键的写入、锁键的获取与释放、空值标记的写入。结果之所以可预期,是因为:
- 未命中后只有拿到锁的线程回源,其余线程在
waitAndRead里最多等 5 次、每次 20 毫秒; - 空结果被写成
__NULL__并设较短 TTL,避免把“暂时不存在”长期固化; - 锁在
finally中校验 token 后释放,避免异常路径遗留锁。
边界也要说清:
- 等待方在 100 毫秒后放弃,如果回源超过 100 毫秒,这些请求会拿到空结果,属于有意的降级;
- 空值 TTL 10 秒是示意,真实值要结合“数据多久后可能出现”来定;
- 如果 Redis 不可用,这段代码会抛异常,需要外层有降级分支。
4.5 用布隆过滤挡穿透
空值缓存能挡住“相同不存在键”的重复请求,但挡不住“大量不同的不存在键”。这时可以在前面加布隆过滤:
// 仅示意逻辑,生产应使用成熟库或 RedisBloom 模块importjava.util.BitSet;importjava.util.function.Function;publicclassSimpleBloomFilter{privatefinalBitSetbits;privatefinalintsize;privatefinalFunction<String,Integer>[]hashes;@SafeVarargspublicSimpleBloomFilter(intsize,Function<String,Integer>...hashes){this.bits=newBitSet(size);this.size=size;this.hashes=hashes;}publicvoidadd(Stringkey){for(Function<String,Integer>h:hashes){bits.set(Math.floorMod(h.apply(key),size));}}publicbooleanmightContain(Stringkey){for(Function<String,Integer>h:hashes){if(!bits.get(Math.floorMod(h.apply(key),size))){returnfalse;// 一定不存在}}returntrue;// 可能存在,需要继续查}}它的语义是:mightContain返回false时可以安全拒绝;返回true时仍要查缓存和数据库。误判率与位数组大小、哈希函数个数、元素数量有关,需要按容量估算,不能随手设一个数字。
4.6 过期抖动
雪崩的主要缓解手段是让 TTL 分散。常见写法是在基础 TTL 上加一个随机偏移:
// 抖动示意:基础 60 秒,额外 0~30 秒随机longbase=60;longjitter=java.util.concurrent.ThreadLocalRandom.current().nextLong(31);longttlSeconds=base+jitter;注意这里的取舍:抖动让键的失效时间分散,降低同一时刻的未命中峰值;代价是同一批数据的缓存时长不一致,最长的可能到 90 秒。对一致性要求高的数据,这个陈旧窗口需要单独评估。
4.7 执行与观察
启动 Redis 与应用后,用固定并发压测:
# 示意命令:200 并发、30 秒、全部请求同一个热点键jmeter-n-thotspot.jmx-Jthreads=200-Jduration=30-Jpath=/item/1001观察点与预期(以下为预期结果,非本次实测):
| 策略 | 回源次数(预期量级) | 命中率(预期) | P99 延迟(预期) |
|---|---|---|---|
| 无缓存 | 与请求数同量级 | 0% | 受仓库延迟主导 |
| 仅缓存、无互斥 | TTL 到期瞬间出现尖峰 | 高,但有周期性抖动 | 抖动处升高 |
| 互斥重建 | 每个 TTL 周期约 1 次 | 高且平稳 | 等待方略高但有上限 |
| 空值缓存 | 不存在键只回源一次/窗口期 | 对重复键有效 | 稳定 |
| 布隆过滤 | 不存在键几乎不回源 | 对随机键有效 | 稳定 |
这些数字是示意量级,真实值取决于仓库延迟、连接池大小、Redis 往返时间和压测工具本身的开销。它们的用途是建立“哪种策略改变哪个指标”的对照,而不是给出可照抄的绝对值。
4.8 资源清理
实验结束后要清理:
redis-cli--scan--pattern'item:*'|xargs-rredis-cli del否则残留的缓存和锁键会影响下一轮实验,导致“明明改了代码却看不出差异”。
5. 指标:命中率与延迟怎么读
5.1 命中率不是越高越好
命中率 = 命中次数 / 总读取次数。它衡量的是“有多少请求没去下游”,但单独看会误导:
- 空值缓存会提高命中率,但它命中的是“不存在”,业务上可能是错误请求;
- 长 TTL 会提高命中率,但会放大陈旧窗口;
- 把不存在的键拒绝在布隆过滤层,不会计入“缓存命中”,但同样减少了下游压力。
所以命中率必须和回源次数、下游 QPS、陈旧时间一起看。推荐把“缓存命中”“空值命中”“布隆拒绝”“回源”“降级返回”分成五个计数器,而不是一个笼统的命中率。
5.2 延迟要看分位数和来源
平均延迟会被长尾稀释。更重要的是:
- P99 延迟升高,可能是等待方在自旋等待锁;
- P99 升高但 P50 不变,通常是少数键重建慢;
- P50 也升高,可能是连接池或线程池被打满。
建议同时记录“缓存层耗时”和“回源耗时”两个计时器。否则你无法判断变慢是 Redis 慢还是数据库慢。
6. 本地缓存与分布式缓存的一致性
6.1 为什么要本地缓存
即使 Redis 命中只要 1 毫秒,对超高 QPS 的热点键来说,网络往返仍是成本。本地缓存(如 Caffeine)把热点数据放在进程内,命中耗时在纳秒到微秒级。它解决的问题是“极热点键的 Redis 往返成本”。
6.2 一致性代价
多实例部署时,每个实例的本地缓存是独立的。数据更新后,如果只删 Redis,本地缓存仍可能返回旧值。常见处理是:
- 更新数据库后,删除 Redis,并广播失效消息(如 Redis Pub/Sub 或消息队列);
- 各实例收到消息后删除自己的本地键;
- 本地缓存设置较短 TTL 作为兜底。
这引入两个边界:
- 广播是异步的,存在短暂不一致窗口;
- 消息可能丢失,所以本地 TTL 不能省。
6.3 写路径的顺序问题
“先删缓存还是先写库”是经典问题。简化结论:
- 先写库、再删缓存,通常比先删缓存、再写库更安全,因为后者在“删完、写完”之间读到旧值并回填;
- 但“写库成功、删缓存失败”仍会留下旧值,需要重试或依赖 TTL 兜底;
- 延迟双删是工程折中,不是严格一致方案。
这些都是特定条件下的工程建议,不是所有系统都必须遵守的定律。强一致场景应考虑缓存旁路或加版本号校验。
7. 失败与反例
7.1 反例一:锁没有过期时间
如果SET NX不带PX,持有者崩溃后锁永久存在,所有后续请求都拿不到锁,只能走降级路径。现象是“缓存永远不重建”,命中率持续下降。修法是给锁设置大于正常回源耗时的过期时间,并把回源耗时纳入监控。
7.2 反例二:删除锁不校验 token
A 拿锁后卡顿超过锁 TTL,B 拿到新锁并开始重建,A 醒来直接DEL,删掉了 B 的锁,C 又拿到锁。结果是多个线程同时回源,互斥形同虚设。修法是校验 token 后删除。
7.3 反例三:空值缓存没有上限
如果每个不存在的键都被缓存,攻击者可以用海量随机键把 Redis 内存打满。空值缓存必须配合键空间限制、布隆过滤或请求限流,否则只是把穿透问题从数据库转移到了缓存层。
7.4 反例四:把布隆过滤当精确判断
布隆过滤返回“可能存在”时,数据仍可能不存在。如果代码在mightContain返回 true 时直接返回“存在”,就会产生错误结果。它只能用来削减,不能用来确认。
7.5 反例五:抖动当成雪崩的完整方案
抖动只解决“同时过期”。当 Redis 整层不可用时,所有键同时未命中,抖动毫无作用。此时需要限流、熔断、本地兜底或静态降级。把抖动说成“解决雪崩”是不完整的。
8. 适用条件与结论边界
8.1 什么时候这些手段值得用
- 读多写少,且单个键的读取频率显著高于写入频率;
- 下游数据库无法承受失效瞬间的并发回源;
- 可以接受明确的陈旧窗口(例如秒级);
- 有指标系统能观测命中、回源和延迟。
如果写入频率与读取相当,或者数据强一致要求极高,缓存本身可能不是优先选项。
8.2 互斥重建的边界
互斥重建降低了回源次数,但引入了等待。在虚拟线程下,等待的成本下降,但下游连接池仍然有限。互斥只改变“谁去查”,不改变“下游能承受多少”。当回源本身超过锁 TTL 时,互斥会退化,需要更长的锁或更细的键粒度。
8.3 双写与失效的边界
缓存与数据库之间没有分布式事务。任何“写库 + 删缓存”的组合都存在失败窗口。工程上能做的是缩小窗口、增加重试、设置 TTL 兜底,并接受最终一致。声称“完全一致”的方案需要额外的版本号和校验机制,且会带来读放大。
8.4 指标驱动的结论
最终结论不应是“我加了互斥锁”,而应是:
- 在 200 并发、30 秒、仓库延迟 20 毫秒的条件下,回源次数从与请求数同量级降到每 TTL 周期 1 次;
- P99 延迟的上升来自等待方,且有等待上限;
- 空值缓存把重复不存在键的回源降到每个窗口 1 次;
- 布隆过滤对随机不存在键的削减率取决于误判率配置。
这些都需要在你自己的环境中复现。本文给出的是机制、实验骨架和预期量级,不是可直接引用的生产数据。
8.5 尚需复核的问题
- 你的 Redis 部署模式(单机、哨兵、集群)下,
SET NX PX与 Lua 脚本的行为是否一致; - 你的连接池大小与压测并发是否匹配,否则测出来的是连接池瓶颈;
- 虚拟线程开启后,阻塞式 Redis 客户端调用是否仍会占用载体线程;
- 本地缓存的失效广播在实例扩缩容时是否可靠。
把这些问题逐条验证,才算真正完成了“量化验证”,而不是把别人的结论抄进自己的系统。
9. 参考资料
- Redis 官方文档:
SET命令与NX、PX选项,以及 Lua 脚本原子性说明:https://redis.io/docs/latest/commands/set/ - Redis 官方文档:
EVAL与脚本:https://redis.io/docs/latest/commands/eval/ - Spring Boot 官方文档:Spring Data Redis 与 Lettuce 配置:https://docs.spring.io/spring-boot/reference/data/nosql.html
- Spring Framework 官方文档:Cache 抽象:https://docs.spring.io/spring-framework/reference/integration/cache.html
- OpenJDK:JEP 444 虚拟线程:https://openjdk.org/jeps/444
- Micrometer 官方文档:https://micrometer.io/docs
- Caffeine 官方文档:https://github.com/ben-manes/caffeine/wiki
- 布隆过滤原始论文(Burton H. Bloom, 1970),可通过学术数据库检索;如需在线入口可查 https://en.wikipedia.org/wiki/Bloom_filter 的综述与引用。