# 缓存不是加个 Cacheable读写链路设计与三大缓存问题一、榜单把带宽打满之后第 01 篇结尾留了一个现场Top 100 榜单直读 RedisQPS 一上来承载榜单的那个 key 把所在节点带宽打满商品详情页跟着超时。我们的第一反应是给榜单加本地缓存。问题确实缓解了两天然后换了一副面孔回来本地缓存把 Redis 的压力卸掉了商品详情页却开始整点抖动——所有商品 key 的 TTL 都设成 30 分钟上线时间是 23:30于是每天 0 点几万个 key 同一秒全部失效请求整齐地砸向数据库。两次故障一次是读得太集中一次是失效得太集中。它们对应两条最容易被忽略的规则热点要打散失效要错开。这篇把缓存当成一条链路来设计而不是给方法上加个注解。二、先看清有哪几种模式模式读路径写路径适用Cache Aside未命中就查库、回填更新库 删缓存绝大多数业务默认答案Read Through缓存层内部回源只写库有统一缓存中间件时Write Through同上同步写缓存缓存层同步写库写少读多、缓存层可靠Write Behind同上只写缓存异步批量落库计数、日志等可容忍丢失国内业务几乎都选 Cache Aside理由不是它更先进而是三点现实不依赖额外中间件缓存整体故障时业务能降级直查库逻辑在业务代码里出问题能改。代价也很明确——一致性要自己兜这就是后面所有麻烦的来源。三、写下缓存的那行代码才是问题开始的地方3.1 为什么是删不是更新更新缓存看起来更实时但会引入三个新问题写放大缓存值常是多表聚合的结果写多读少的场景下大量计算白做。乱序覆盖A 把库更新成 1、B 更新成 2但写缓存的顺序可能是 2 → 1缓存最终停在 1与库不一致。无效缓存写进去的数据可能根本没人读。删除的本质是懒加载——下次真正有人读时才重建。代价是删除后第一次读必然 miss可用预热缓解。3.2 先删还是后删画一遍时序[图Cache Aside 读路径未命中→查库→回填与写路径更新库→删缓存的时序标出两个不一致窗口]先删缓存、再更新库窗口在这里T1 写请求 DEL cache T2 读请求 未命中 T3 读请求 查库读到旧值 T4 写请求 更新库提交 T5 读请求 回填旧值 ← 缓存从此是脏的直到过期窗口 T2 到 T5也就是一次查库 回填的耗时。高并发下这个窗口几乎必然被踩到而且脏数据会一直留到 TTL 到期。先更新库、再删缓存窗口变成读请求必须在 T1 之前读到库、又在 T2 之后才回填也就是要跨越整个写事务。这种交错明显更难凑齐而且就算发生也有 TTL 兜底。结论两种顺序都存在窗口只是后者小得多。默认用更新库 删缓存别信先删缓存能规避并发问题。3.3 删除失败怎么办删缓存这一步会失败网络、超时、实例切换三种补救手段的复杂度完全不同手段解决什么代价同步重试偶发失败拖长写接口 RT重试几次要定MQ 补偿删除动作不丢要保证消息一定发出通常得配本地消息表订阅 binlogCanal漏删、删失败、业务代码侵入多一套组件binlog 投递有延迟必须说清一点binlog 订阅解决的是删不掉不是删的顺序。它把删除动作从事务里摘出来、由 binlog 触发天然避开了事务没提交就写缓存这种错误但投递延迟依然存在3.2 那个窗口一个都没少。这两件事经常被混为一谈。3.4 延迟双删能救多少标准时序是删缓存 → 更新库 → 延迟 N → 再删缓存。第二次删除是为了清掉 3.2 里那个读请求回填的旧值。关键在 N 怎么定它要覆盖读请求从查库到回填完成的整段时间通常取业务读耗时上限的 1~2 倍具体值必须按你系统的实际压测结果定没有通用数字。它在读写分离下会打折第二次删除之后从库可能还没同步到新值新的读请求打到从库又读到旧值并回填——双删只对读主库有效。要处理就得让写后一段时间内的读强制走主库或者等主从位点追上。还有两个成本sleep占线程异步化后要保证延迟任务不丢。所以它是缩小窗口的补丁不是一致性银弹。3.5 先定一致性等级再选方案等级实现方式适用数据强一致不用缓存或读也加锁余额、库存扣减会话内一致写后删缓存 短时间读主库用户刚改的昵称、刚下的单最终一致TTL 兜底接受窗口内旧值商品标题、榜单、推荐位先定级再动手。绝大多数缓存一致性的争论本质是两个人对同一份数据定了不同的级。3.6 哪些数据压根不该进缓存定级之后还有一刀要砍强一致的数据余额、可用库存不该进缓存极低频的数据一年改两次的配置也不该——命中率低只是多占一份内存、多一份失效维护反而增加出错面。至于预热它不是优化是消除冷启动的第一波冲击。新实例上线、缓存被清空、大批 key 同时过期之后如果不预热第一波请求会全量回源。做法很简单——发布后由定时任务按热点清单批量加载别等用户来触发。四、三大问题穿透、击穿、雪崩4.1 穿透查一个根本不存在的东西请求的 key 在库里也不存在缓存永远不会命中每次都打到库。三招要叠加使用不是三选一空值缓存把__EMPTY__写进缓存TTL 设短如 60 秒按业务定。风险是攻击者用随机 key 刷等于把 DB 的压力转移成 Redis 的内存压力必须配合限流。布隆过滤器Bloom Filter用位数组判断这个 key 可能存在吗。误判方向是单向的——说不存在就一定不存在说存在可能不存在。它不支持删除元素计数布隆可缓解但要维护计数。参数校验 限流id 0直接拒别让它走到缓存再按 IP/用户维度限流。4.2 击穿一个热点 key 失效的瞬间定义很具体单个热点 key 在失效的那一刻大量并发同时回源。第 01 篇那个榜单 key 就是标本——它一过期所有读榜单的请求在同一秒查库。两种解法取舍不同互斥重建缓存失效时只允许一个请求去查库并回填其余请求等待或返回旧值数据新鲜但等待方必须有超时和降级路径否则一个慢查询会把整批请求拖死。逻辑过期value 里包一层{data, expireAt}物理上永不过期由业务判断是否异步刷新可用性最好代价是会返回旧值而且实现上要兼容历史数据格式。互斥重建逻辑过期数据新鲜度高重建后即新有旧值窗口可用性依赖锁抢不到要降级始终可返回实现复杂度中锁 超时 释放高包装类型 异步刷新 清理4.3 雪崩失效得太整齐大量 key 同时失效或 Redis 整体不可用。手段有五个TTL 随机化base 随机偏移、多级缓存、限流降级数据库侧也要有连接池上限保护、缓存预热、Redis 高可用第 04 篇。开场那次整点抖动就是标准雪崩不是 Redis 挂了是失效时间被人为对齐了。4.4 热点 key回到第 01 篇那个现场本地缓存 短 TTL把热点挡在进程内TTL 要短秒级因为多实例之间没有失效广播。key 打散把hot:article:1001复制成 N 份读时随机挑一份写时全写。代价是读写都放大 N 倍且 N 份之间的一致性要自己保证。读写分离的局限加从库只能分担读单个 key 的写仍是单点还额外引入主从延迟。它不是热点写问题的解。五、代码从能跑到能扛住失效环境同第 01 篇Redis 7.x、JDK 17、spring-boot-starter-data-redisLettuce、jackson-databind布隆过滤器需额外引入redisson-spring-boot-starter。5.1getProduct()完整失败分支privatestaticfinalStringEMPTY__EMPTY__;privatestaticfinalDurationNULL_TTLDuration.ofSeconds(60);// 空值缓存要短// 每次回填重新取随机偏移若写成 static final 常量类加载时只算一次所有 key 的 TTL 还是同一个值privatestaticDurationdataTtl(){returnDuration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextInt(300));}// 位于 Service 中redis / mapper 为注入字段log 由类上的 Slf4jLombok生成// 下面两行是桩体后文每一段都用到它们先给出实现免得读者复制后编译不过privatefinalExecutorServiceasyncExecutorExecutors.newFixedThreadPool(4);// 仅 5.3 用生产换成有界队列的 ThreadPoolExecutorprivateProductloadFromDb(longid){returnproductDao.selectById(id);}// productDao 为注入的 DAO查不到返回 nullpublicProductgetProduct(longid){if(id0)returnnull;// 参数校验挡掉一部分穿透Stringkeyproduct:id;Stringcached;try{cachedredis.opsForValue().get(key);}catch(RedisConnectionFailureExceptione){// 缓存故障不该变成业务故障降级直查库log.warn(redis down, fallback to db. key{},key,e);returnloadFromDb(id);}if(EMPTY.equals(cached))returnnull;// 空值标记命中if(cached!null){ProductpparseQuietly(key,cached);// 直接复用清理脏数据的逻辑if(p!null)returnp;// 解析成功才返回失败走下面回源}returnloadAndBackfill(key,id);}privateProductloadAndBackfill(Stringkey,longid){ProductploadFromDb(id);try{if(pnull){redis.opsForValue().set(key,EMPTY,NULL_TTL);}else{redis.opsForValue().set(key,mapper.writeValueAsString(p),dataTtl());}}catch(Exceptione){log.warn(backfill failed, ignore. key{},key,e);// 回填失败不影响本次返回}returnp;}privateProductparseQuietly(Stringkey,Stringraw){try{returnmapper.readValue(raw,Product.class);}catch(JsonProcessingExceptione){log.warn(cached value broken, evict. key{},key,e);// 静默删除会让脏数据事故查无实据redis.delete(key);// 脏数据删掉当作 missreturnnull;}}注意这里没有互斥。商品详情这种低并发 key 够用一旦是榜单那种热点失效瞬间就会集体回源——这正是 4.2 要解决的。5.2 互斥重建只放一个请求进库// 第 01 篇的 buildScript() 需改为接收脚本路径参数privatestaticfinalRedisScriptLongUNLOCKbuildScript(lua/unlock.lua);// unlock.lua校验 value 再删避免误删他人的锁// if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endpublicProductgetProductWithMutex(longid){Stringkeyproduct:id;Stringcachedredis.opsForValue().get(key);if(cached!null)returnEMPTY.equals(cached)?null:parseQuietly(key,cached);StringlockKeylock:product:id;StringtokenUUID.randomUUID().toString();for(inti0;i3;i){// 锁过期时间设为 10 秒生产环境必须大于查库回填的最大耗时第 05 篇详解看门狗续期Booleangotredis.opsForValue().setIfAbsent(lockKey,token,Duration.ofSeconds(10));if(Boolean.TRUE.equals(got)){try{returnloadAndBackfill(key,id);// 只有这一个线程查库}finally{redis.execute(UNLOCK,List.of(lockKey),token);}}try{Thread.sleep(50);// 短暂自旋等别人回填}catch(InterruptedExceptione){Thread.currentThread().interrupt();break;}cachedredis.opsForValue().get(key);if(cached!null)returnEMPTY.equals(cached)?null:parseQuietly(key,cached);}// 抢不到锁、缓存仍是空降级不能死等log.warn(rebuild timeout, degrade to db. key{},key);returnloadFromDb(id);}失败分支有三处抢锁超时后降级直查库必须在入口配限流否则降级就是打库、回填失败、锁释放用 Lua 保证原子。buildScript要从第 01 篇那个无参版本改成带路径参数否则UNLOCK一行编译不过privatestaticRedisScriptLongbuildScript(Stringpath){try{StringluanewClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);returnnewDefaultRedisScript(lua,Long.class);}catch(IOExceptione){thrownewIllegalStateException(path 加载失败,e);}}锁超时为什么给 10 秒而不是随手填个 3 秒一旦查库序列化回填超过锁的过期时间锁会在业务还没做完时自动释放第二个线程进来重复回源更糟的是第一个线程的finally这时可能删掉别人刚加的锁。所以过期时间必须明显大于这条链路的最大耗时——具体填多少只能拿自己的 P99 去定没有通用值。长尾靠看门狗续期第 05 篇不是把数字往上堆。5.3 逻辑过期永不失效异步刷新staticclassCacheWrapperT{Tdata;longexpireAt;// 毫秒时间戳业务判断用}publicProductgetProductLogical(longid){Stringkeyproduct:id;Stringrawredis.opsForValue().get(key);if(rawnull)returnloadAndBackfillWrapped(key,id);// 冷启动仍同步加载一次CacheWrapperProductwparseWrapper(key,raw);if(wnull)returnloadAndBackfillWrapped(key,id);// 如果数据是 null也必须走过期刷新逻辑否则数据库后来新增了数据缓存里永远是 nullif(w.expireAtSystem.currentTimeMillis()){StringlockKeylock:refresh:product:id;if(Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(3)))){asyncExecutor.execute(()-{try{loadAndBackfillWrapped(key,id);}catch(Exceptione){log.warn(async refresh failed. key{},key,e);}finally{redis.delete(lockKey);}});}}returnw.data;// 无论是否触发刷新本次都返回手上这份可能是旧值或 null}privateCacheWrapperProductparseWrapper(Stringkey,Stringraw){try{returnmapper.readValue(raw,newTypeReferenceCacheWrapperProduct(){});}catch(JsonProcessingExceptione){redis.delete(key);// 旧格式或脏数据删掉下次同步重建returnnull;}}privateProductloadAndBackfillWrapped(Stringkey,longid){ProductploadFromDb(id);CacheWrapperProductwnewCacheWrapper();w.datap;w.expireAtSystem.currentTimeMillis()Duration.ofMinutes(5).toMillis();try{redis.opsForValue().set(key,mapper.writeValueAsString(w));// 不设 TTL物理永不过期}catch(JsonProcessingExceptione){log.warn(wrapped backfill failed. key{},key,e);}returnp;}代价要写清楚key 物理上永不过期就不会被自动清理得额外做低频清理历史数据是旧格式时解析失败必须能降级重建否则上线当天就会炸。还有一个我自己踩过的坑值得单独说这个方法的早期版本里有这么一行——if(w.datanull)returnnull;// 库里也没有别把它当热点反复刷新看起来是省了一次异步刷新实际上制造了死缓存。设想一个上架时间在未来的商品第一次访问时库里查不到缓存里写进{data: null, expireAt: T}等商品真的上架了expireAt早就过了但这行守卫在刷新之前就把请求挡回去于是这条 key 永远返回 null只能等人工删 key 或低频清理任务扫到。空值能不能缓存取决于这个 null 会不会变而逻辑过期这条路径上没有任何 TTL 兜底所以只能让它照常刷新。真正要防的无效 key 被反复刷新应该交给空值短 TTL5.1 的NULL_TTL或布隆过滤器5.4而不是在这里拦。5.4 布隆过滤器只挡一定不存在BeanpublicRBloomFilterLongproductBloom(RedissonClientclient){RBloomFilterLongfilterclient.getBloomFilter(bloom:product);// 预计元素数、误判率tryInit 只在 key 不存在时生效已有 key 不会重建filter.tryInit(1_000_000L,0.01);returnfilter;}publicProductgetProductWithBloom(longid){if(id0)returnnull;if(!productBloom.contains(id))returnnull;// 一定不存在直接挡掉returngetProduct(id);}两个坑tryInit对已存在的 key 无效容量要一次估够Cluster 下布隆 key 要带 hash tag 固定到同一 slot否则contains直接报跨 slot 错误。它不支持删除元素所以商品下架后仍会占位只能靠定期重建。5.5 两级缓存先删 Redis 还是先删本地顺序脏读窗口先删本地再删 Redis删本地后别的请求回源读到 Redis 旧值并回填本地然后 Redis 才被删——本地会长期脏到下次失效先删 Redis再删本地只在本实例还有旧本地值的毫秒级窗口内可能读到旧数据推荐先删 Redis、再删本地多实例场景再补一条广播失效Redis pub/sub 或 MQ否则别的实例的本地缓存只能等 TTL 自然过期。TTL 因此要设短。六、踩坑清单不看窗口就先删缓存—— 我写过。以为删了就干净了结果并发一压就脏。以为缓存了null就解决了穿透—— 攻击者用随机 id每次都是新 key、照样打到库还顺带在 Redis 里塞进一堆空值 key把内存占掉。雪崩只加 TTL 不随机—— 开场那次整点抖动就是把几万个 key 的过期时间对齐到了同一秒。加了分布式锁就认为数据一致了—— 锁只保证同时只有一个请求重建缓存不保证缓存和库一致写路径该删的缓存一步都不能少。把延迟双删当银弹—— 读写分离下从库延迟会让它失效而很多人根本没意识到自己在读从库。事务没提交就写缓存—— 我见过在Transactional方法里回填缓存的事务回滚后缓存成了脏数据而且没人发现。给所有接口统一加Cacheable—— 写接口和强一致接口加这个注解等于主动制造 bug。七、小结Cache Aside 的默认写法是更新库 删缓存两种顺序都有窗口选窗口小的那个再用 TTL 兜底。穿透、击穿、雪崩是三种不同的病不存在的数据、单个热点失效、大批同时失效。用错药比不吃药更糟。一致性先定级再选方案。定不出级说明这份数据还不该进缓存。下一篇换个方向缓存丢了可以重建那存储层的数据丢了怎么办RDB 快照和 AOF 日志到底能保住多少、fork的代价是什么、内存被谁吃掉了——第 03 篇讲持久化与内存管理。自检三问你系统的写路径是更新库 删缓存还是先删缓存如果读请求正好卡在中间脏数据会留多久你最大的那个缓存 key失效时会有多少并发同时回源现在是靠锁、靠逻辑过期还是靠运气你所有 key 的 TTL 是怎么设的有没有一批是在同一分钟、同一小时集体到期的
企业数字化 ERP 产品动态
相关推荐
关于力扣基础题目的解题办法以及自己的感悟 写的时候不知道他是自己输入值的,还在自己写输入函数哈哈哈哈哈哈哈哈哈总结下学到的range()这个函数比如range(5)就是从0开始到4的,range(5,10)就是从5开始到9的,遵从包前不包后的原则。另外感觉到了光听课是不够的,不… · 2026/11/4 23:01:05
Sue E. Goodman. Beginning Topology Sue E. Goodman所著的《Beginning Topology》是美国数学学会(AMS)2005年出版的本科拓扑学入门教材,属于《Pure and Applied Undergraduate Texts》丛书第10卷,ISBN为978-0-8218-4796-1 。
📚 教材核心定位
面向本科阶… · 2026/11/4 23:00:03
消息队列核心技术解析与主流中间件选型指南 1. 消息队列的本质与核心价值 消息队列(Message Queue,MQ)作为分布式系统架构中的关键组件,其重要性在当今互联网应用中愈发凸显。作为一名经历过多个分布式系统架构设计的开发者,我深刻体会到消息队列在系统解耦、异步… · 2026/11/4 23:37:04
数字化转型下的软件测试挑战与AI自动化实践 1. 数字化转型下的软件测试挑战与应对策略 当前企业数字化转型浪潮中,软件测试与质量保障团队正面临前所未有的压力。根据我十五年测试架构师的经验,最大的挑战来自三个方面:技术迭代加速、业务复杂度提升以及质量要求的指数级增长。
1.1 技… · 2026/11/4 23:37:04
生成式AI时代,用AI高效学习与应用23种设计模式的实操指南 设计模式这话题,放在生成式AI时代聊,其实挺微妙的。一边是很多人觉得“AI都能自动写代码了,谁还学设计模式”,另一边是真正在项目里把AI用的顺手的人发现,不懂设计模式,连给AI提需求都提不明白。这篇是“生… · 2026/11/4 23:37:04
Model Ops实战:AI治理与MLOps融合的架构设计 1. 项目概述:当AI治理遇上Model Ops最近三年,我参与了七家不同规模企业的AI治理体系建设项目,发现一个共性现象:90%的AI模型在投产三个月后就会遇到性能衰减、数据漂移或合规风险问题。这背后暴露的正是传统MLOps(机器… · 2026/11/4 23:37:04
32位MinGW版FFmpeg 3.4接入Qt与解码编码实践 简介:这是一份面向32位Windows平台、可配合Qt与MinGW环境使用的FFmpeg 3.4轻量级开发包,定位于帮助C/C开发者接入音视频编解码、转码、封装与解封装能力,适用于播放器、转码工具和媒体处理软件的原型开发与课程实践。压缩包共170个文件&#… · 2026/11/4 23:37:04
Eclipse MAT堆转储分析:从OOM定位到内存泄漏排查 简介:Eclipse MAT堆内存分析工具包,面向Java开发者、测试与运维人员,用于排查应用内存泄漏、优化对象占用并梳理JVM引用关系。压缩包共5442个文件,约133.56MB,内容以html帮助文档、png图片、jar插件及class组件为主&am… · 2026/11/4 23:36:00
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
AI辅助技术栈评估:提升研发决策效率60% 1. 项目背景与核心价值技术决策一直是研发团队面临的重大挑战。随着技术生态的快速演进,技术栈的选择、维护和迭代变得愈发复杂。我们团队在过去两年里,先后经历了三次重大技术栈迁移,每次都需要投入大量人力进行技术评估和风险分析。直到去年… · 2026/11/4 0:00:17
从生成Token到直接决策:Jev如何重塑AI Agent工程实践 1. 从"生成文字"到"拍板决策":Jev 到底在解决什么问题大多数人第一次接触大模型,脑子里建立的模型是"输入问题、输出文字"。你问它今天天气怎么样,它给你一段描述;你让它写个周报,它给你… · 2026/11/4 0:00:17
32G统一内存跑大模型:内存账、TPS实测与端云分流 想在一台 32G 统一内存的小主机上把大模型跑成"日常可用",这件事我前后折腾了小半年。标题里说的这台 Mac mini M6,是我们这次讨论的目标平台,具体到手规格以实物为准;但下面这套推算方法和实测思路,对任何统… · 2026/11/4 0:00:17
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38