资讯详情

缓存策略的并发正确性:击穿、穿透、雪崩在高并发下的量化验证

📅 2026/10/23 9:09:46 | 华诺云谱 👁 阅读
缓存策略的并发正确性:击穿、穿透、雪崩在高并发下的量化验证
缓存策略的并发正确性击穿、穿透、雪崩在高并发下的量化验证1. 先把要解决的问题说清楚这篇文章围绕一件具体的工程任务展开在高并发读取场景下把缓存层做成“错了能看出来、改对了能量出来”的样子。也就是说不只是把 Spring Cache 注解贴上去、把 Redis 接进来而是能做到两件事发生缓存击穿、穿透、雪崩时能沿着一条清晰的因果链定位是谁把压力放进了下游改动前后能用命中率、P99 延迟、下游 QPS 等指标做可重复的对比而不是凭感觉说“加了锁就快了”。“缓存”先按普通中文理解把相对昂贵的结果放在离调用者更近、更便宜的地方下次直接拿。它解决的问题是重复读取太贵它引入的新问题是——数据可能不在缓存里或者在缓存里的东西是错的。这三个场景都从“不命中”开始但后果和修法不同。1.1 三个中文名分别指什么缓存击穿cache breakdown某个热点键过期或被删除的瞬间大量并发请求同时发现没有缓存于是同时去查数据库。它的特征是“单个热点键 高并发 同一时刻”。缓存穿透cache penetration请求查的根本是不存在的数据缓存永远命中不了每次都会落到数据库。它的特征是“键本身不存在 反复请求”。缓存雪崩cache avalanche大量键在相近时间失效或者缓存层整体不可用导致流量成片地涌向下游。它的特征是“大批键 时间集中”或“整层故障”。三者的共同点是请求绕过了缓存直接施加到下游不同点是击穿针对一个热点键穿透针对不存在的数据雪崩针对一批键或整个缓存层。把这三者混为一谈就会写出“用互斥锁解决穿透”这类逻辑上根本不对的方案。1.2 为什么叫“并发正确性”单线程下这三个场景几乎不出现没命中就查一次、写回缓存世界很安静。真正的问题是并发语义多个线程同时看到“没有”然后同时做出“去数据库拿”的决定。并发正确性在这里包含四层含义结果正确返回的数据与数据库当前状态一致或者至少符合明确的允许陈旧窗口副作用正确回源、重建缓存、删除缓存的次数在可控范围内不会出现旧值被写回覆盖新值隔离正确一个热点键的重建不会把整个线程池拖死可观测命中、未命中、回源、重建、失败都有计数和延迟可以事后解释。下面所有讨论都围绕这四层展开而不是围绕某个具体注解展开。2. 环境与兼容版本本文的示例基于以下版本。这里给出的是我用来描述机制的目标组合不是“已经跑过的实测环境”凡涉及运行结果的地方都会明确标为预期或示意。组件版本说明JDKJava 21 (LTS)示例使用record、CompletableFuture、虚拟线程Spring Boot3.5.x要求 Java 17 起步21 可用Spring Data Redis随 Boot 3.5 BOM 管理使用StringRedisTemplate与Lettuce连接Redis7.x使用SET NX PX、GET、DEL语义压测工具Apache JMeter 或 Gatling使用固定并发与固定持续时间指标Micrometer Actuator暴露命中率、回源计数与延迟版本敏感点先说明虚拟线程在 Java 21 是正式特性JEP 444但在 Spring Boot 3.5 中需要显式开启如spring.threads.virtual.enabledtrue。虚拟线程会改变“阻塞等待”的成本却不改变对下游数据库的连接数约束。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 管理版本 --dependenciesdependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-actuator/artifactId/dependencydependencygroupIdio.micrometer/groupIdartifactIdmicrometer-core/artifactId/dependency/dependenciesspring-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{privatefinalMapLong,StringdatanewConcurrentHashMap();privatefinalAtomicLongcallsnewAtomicLong();privatefinalDurationlatency;publicItemRepository(Durationlatency){this.latencylatency;// 只放少量真实数据其余 id 视为不存在用于制造穿透data.put(1001L,item-1001);data.put(1002L,item-1002);}publicOptionalStringfindById(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{privatestaticfinalDurationTTLDuration.ofSeconds(60);privatestaticfinalDurationNULL_TTLDuration.ofSeconds(10);privatestaticfinalDurationLOCK_TTLDuration.ofSeconds(3);privatestaticfinalStringNULL_VALUE__NULL__;privatefinalStringRedisTemplateredis;privatefinalItemRepositoryrepository;privatefinalDefaultRedisScriptLongreleaseScript;publicItemCacheService(StringRedisTemplateredis,ItemRepositoryrepository){this.redisredis;this.repositoryrepository;this.releaseScriptnewDefaultRedisScript(if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end,Long.class);}publicOptionalStringget(longid){Stringkeyitem:id;Stringcachedredis.opsForValue().get(key);if(NULL_VALUE.equals(cached)){returnOptional.empty();}if(cached!null){returnOptional.of(cached);}returnrebuildWithLock(key,id);}privateOptionalStringrebuildWithLock(Stringkey,longid){StringtokenUUID.randomUUID().toString();Booleanlockedredis.opsForValue().setIfAbsent(key:lock,token,LOCK_TTL);if(!Boolean.TRUE.equals(locked)){// 未拿到锁短暂等待后重读缓存避免无限等待returnwaitAndRead(key);}try{OptionalStringvaluerepository.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);}}privateOptionalStringwaitAndRead(Stringkey){for(inti0;i5;i){try{Thread.sleep(20);}catch(InterruptedExceptione){Thread.currentThread().interrupt();returnOptional.empty();}Stringcachedredis.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;privatefinalFunctionString,Integer[]hashes;SafeVarargspublicSimpleBloomFilter(intsize,FunctionString,Integer...hashes){this.bitsnewBitSet(size);this.sizesize;this.hasheshashes;}publicvoidadd(Stringkey){for(FunctionString,Integerh:hashes){bits.set(Math.floorMod(h.apply(key),size));}}publicbooleanmightContain(Stringkey){for(FunctionString,Integerh:hashes){if(!bits.get(Math.floorMod(h.apply(key),size))){returnfalse;// 一定不存在}}returntrue;// 可能存在需要继续查}}它的语义是mightContain返回false时可以安全拒绝返回true时仍要查缓存和数据库。误判率与位数组大小、哈希函数个数、元素数量有关需要按容量估算不能随手设一个数字。4.6 过期抖动雪崩的主要缓解手段是让 TTL 分散。常见写法是在基础 TTL 上加一个随机偏移// 抖动示意基础 60 秒额外 0~30 秒随机longbase60;longjitterjava.util.concurrent.ThreadLocalRandom.current().nextLong(31);longttlSecondsbasejitter;注意这里的取舍抖动让键的失效时间分散降低同一时刻的未命中峰值代价是同一批数据的缓存时长不一致最长的可能到 90 秒。对一致性要求高的数据这个陈旧窗口需要单独评估。4.7 执行与观察启动 Redis 与应用后用固定并发压测# 示意命令200 并发、30 秒、全部请求同一个热点键jmeter-n-thotspot.jmx-Jthreads200-Jduration30-Jpath/item/1001观察点与预期以下为预期结果非本次实测策略回源次数预期量级命中率预期P99 延迟预期无缓存与请求数同量级0%受仓库延迟主导仅缓存、无互斥TTL 到期瞬间出现尖峰高但有周期性抖动抖动处升高互斥重建每个 TTL 周期约 1 次高且平稳等待方略高但有上限空值缓存不存在键只回源一次/窗口期对重复键有效稳定布隆过滤不存在键几乎不回源对随机键有效稳定这些数字是示意量级真实值取决于仓库延迟、连接池大小、Redis 往返时间和压测工具本身的开销。它们的用途是建立“哪种策略改变哪个指标”的对照而不是给出可照抄的绝对值。4.8 资源清理实验结束后要清理redis-cli--scan--patternitem:*|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 反例二删除锁不校验 tokenA 拿锁后卡顿超过锁 TTLB 拿到新锁并开始重建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.htmlSpring Framework 官方文档Cache 抽象https://docs.spring.io/spring-framework/reference/integration/cache.htmlOpenJDKJEP 444 虚拟线程https://openjdk.org/jeps/444Micrometer 官方文档https://micrometer.io/docsCaffeine 官方文档https://github.com/ben-manes/caffeine/wiki布隆过滤原始论文Burton H. Bloom, 1970可通过学术数据库检索如需在线入口可查 https://en.wikipedia.org/wiki/Bloom_filter 的综述与引用。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑