
改完昵称刷新两遍才生效、头像换了还是旧的、下单后列表页显示的还是昨天的库存——这类问题我遇到过太多次最后排查下去十有八九不是缓存没命中而是缓存更新策略从一开始就没想清楚。缓存更新策略这四个字听起来像是八股文里的一个名词但它真正决定的是当数据库里的那一份数据发生变化时缓存里的那一份副本什么时候、由谁、以什么方式跟着变。这件事在单机单线程的世界里根本不叫问题一旦放到高并发、多实例部署、主从架构的环境里它就变成了一个持续存在的时间窗口问题。这篇内容我打算把三种最常见的缓存更新策略——Cache Aside旁路缓存、Read/Write Through读写穿透、Write Behind异步写回——从执行模型、适用边界、实现细节到真实踩坑全部摊开讲。写代码的、做架构的、正在被线上脏数据折磨的应该都能从中找到对得上号的部分。下面所有代码示例以 Redis 作为缓存层语言用 Python 演示逻辑换成 Java、Go 完全一致重点是推演过程而不是语法。1. 缓存更新这件事难在两份数据谁说了算1.1 缓存不是数据库的影子它是一个独立副本很多人第一次接触缓存时会有一种错觉缓存是数据库的投影数据库一变缓存跟着变就行了。这个理解在低并发下是对的但在真实系统里缓存是一份独立存储的数据副本它有自己的生命周期、自己的读写路径、自己的失效时机。数据库和缓存之间的同步本质上是一次跨进程、跨网络的数据复制只要有复制就有延迟只要有延迟就有窗口只要有窗口就有一致性问题。举个很具体的例子。假设用户表里有一条记录id1001, nickname老张缓存 key 是user:1001过期时间设了 30 分钟。用户把昵称改成了张三数据库更新成功但如果没人去动缓存接下来的 30 分钟里所有读请求都会拿到老张。这 30 分钟就是一份陈旧副本的存活时间。缓存更新策略要解决的核心问题就是把这段陈旧副本存活时间压缩到业务能接受的范围以内同时尽量不牺牲读写的吞吐能力。1.2 写数据库前后动缓存的四种组合为什么只剩三种留下来在讨论三大策略之前先看清最原始的排列组合。一次写操作涉及两个动作写数据库、动缓存。动缓存又分更新和删除两种再加上先后顺序理论上有下面这几条路径。组合方式具体动作主要问题先更新数据库再更新缓存写库后把新值写进缓存并发写时缓存值可能被旧值覆盖缓存值需要复杂计算时浪费算力先更新缓存再更新数据库写缓存后写库数据库写失败时缓存已脏且无法回滚先删除缓存再更新数据库删缓存后写库删除后到写库之间读请求会把旧值重新加载回缓存先更新数据库再删除缓存写库后删缓存存在极小的并发窗口是当前主流做法更新缓存这条路径被淘汰原因不在理论而在工程。第一很多缓存值不是数据库字段的简单映射而是多表聚合、排序、计数之后的结果每次写都要重新算一遍成本可能比直接删掉还高。第二并发写场景下两个写请求各自算出自己的新值再写缓存谁最后落地完全看调度很容易出现后写的旧值覆盖了先写的新值。第三写多读少的场景里更新缓存做的全是无效功因为写完之后可能根本没人来读。删除缓存则天然规避了这三个问题删除是幂等的成本恒定且遵循懒加载原则——没人读就不回填。所以最终留下来的三条路本质上是三种不同的责任分配方式Cache Aside 把责任交给业务代码Read/Write Through 把责任交给缓存层Write Behind 把责任交给一个异步队列。理解了这一点后面所有的细节都是在推演这三种责任分配各自的代价。1.3 一致性、性能、复杂度这三个变量永远互相拉扯选策略之前必须想清楚一件事你到底要哪种一致性。强一致意味着任何时刻读到的都是数据库最新值代价是写路径必须同步、必须加锁、必须串行最终一致意味着允许一个短暂窗口内读到旧值窗口多长取决于你的实现。绝大多数互联网业务其实只需要秒级最终一致但很多团队出于保守心理在代码里加了分布式锁、加了双写、加了对账复杂度上去了性能下来了实际上并没有解决根本问题——因为窗口依然存在只是从 500 毫秒缩到了 5 毫秒。下面这张表是我自己在做方案评审时常用来对齐预期的先放出来后面每个策略的章节会逐项展开。维度Cache AsideRead/Write ThroughWrite Behind一致性强度最终一致秒级窗口最终一致写路径同步最终一致窗口不可控读性能高命中缓存高命中缓存高写性能中写库删缓存中低写库写缓存同步极高只写缓存实现复杂度低中需要缓存层封装高需要队列与重试数据丢失风险无无有对业务代码侵入高低低2. Cache Aside最常用的策略坑也最多2.1 读路径未命中回填写路径先库后删Cache Aside 也叫旁路缓存是绝大多数团队事实上在用的策略哪怕很多人并不知道它叫这个名字。它的执行模型只有两句话读的时候先看缓存没有再查数据库并回填写的时候先更新数据库再删除缓存。读路径拆开是这样的用业务主键拼出缓存 key向缓存发起一次查询。命中且值有效直接反序列化返回整个链路不碰数据库。未命中去数据库查询。查到了序列化后写回缓存同时设置过期时间返回给调用方。数据库也没有这就是一次空命中需要按策略决定是缓存空值还是直接返回。写路径更简单先执行数据库的 update/insert/delete成功之后再去删缓存对应的 key。注意这里写库和删缓存是两次独立的操作没有事务包裹也不可能用事务包裹——数据库事务管不到 Redis。一个可以直接拿去改的 Python 实现长这样里面已经处理了空值缓存和过期时间打散import json import random import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) NULL_MARK __NULL__ BASE_TTL 1800 def get_user(user_id): key fuser:{user_id} cached r.get(key) if cached is not None: if cached NULL_MARK: return None return json.loads(cached) row db.query_one(select id, nickname, avatar from t_user where id %s, user_id) if row is None: # 空值也缓存但 TTL 要短避免数据后续被创建时长期读不到 r.set(key, NULL_MARK, ex60 random.randint(0, 30)) return None # 过期时间打散防止同一批 key 同时失效 r.set(key, json.dumps(row), exBASE_TTL random.randint(0, 300)) return row def update_user(user_id, nickname): key fuser:{user_id} affected db.execute( update t_user set nickname %s where id %s, nickname, user_id ) if affected 0: return False try: r.delete(key) except Exception: # 删除失败不能吞掉交给补偿队列重试 mq.push(cache_delete_retry, {key: key, retry: 0}) return True这段代码里有两个细节值得单独说。第一空值缓存必须配一个远小于正常值的 TTL否则会出现数据明明已经插入了接口一直返回空的诡异现象。第二正常值的 TTL 加了一个随机偏移量这是防止缓存雪崩最便宜的手段比任何复杂的预热方案都实在。2.2 删缓存而不是更新缓存代价换来了什么前面提过更新缓存被淘汰的三个理由这里再补一个更贴近实际的观察缓存里存的东西和数据库里的东西往往不是一一对应的。我见过一个商品详情页的缓存value 里塞了商品基础信息、店铺名、三个营销活动的标签、实时的销量数字还有一段加工过的富文本。这种缓存你让写路径去更新就得把这一整套聚合逻辑在写路径里再跑一遍而写路径本来只关心价格改了一下这一件事。删除就不一样了删掉之后下一次读自然会触发完整的回填逻辑聚合代码只有一份不会出现读路径和写路径各算一遍、算出两种结果的情况。代价也很明确删除之后紧接着的第一次读一定是慢的因为它要回源。如果这个 key 是热点 key删掉瞬间会有大量并发请求同时穿透到数据库。这个问题在 Cache Aside 下几乎是必然出现的解决办法不是改策略而是在回填那一层加互斥——同一时刻只允许一个请求去数据库加载其余请求短暂等待或者直接返回旧值。def get_user_with_lock(user_id): key fuser:{user_id} cached r.get(key) if cached is not None: return None if cached NULL_MARK else json.loads(cached) lock_key flock:{key} # set nx ex 是原子的拿到锁的请求才允许回源 got_lock r.set(lock_key, 1, nxTrue, ex3) if not got_lock: # 没抢到锁短暂等待后重试一次避免直接把压力打给数据库 time.sleep(0.05) cached r.get(key) if cached is not None: return None if cached NULL_MARK else json.loads(cached) return None try: row db.query_one(select * from t_user where id %s, user_id) ... finally: r.delete(lock_key)2.3 一个具体的并发时序把脏数据窗口推演出来先写库后删缓存在单线程下是完美的问题全出在并发。下面这个时序是我在评审时最常画给同事看的四个步骤每一步都完全合法最后结果却是错的。步骤线程 A读请求线程 B写请求数据库 nickname缓存 user:10011读缓存未命中-老张空2查数据库读到老张-老张空3-更新数据库为张三张三空4-删除缓存本来就是空张三空5把老张写回缓存-张三老张第 5 步发生的时候缓存里是旧值而且这个旧值会一直留到 TTL 到期。窗口大小等于线程 A 从查数据库到写回缓存的耗时通常是几毫秒到几十毫秒。窗口很小但只要有足够的并发量这个窗口每天会被踩中若干次。要让这个窗口出现需要满足三个条件同时成立缓存刚好失效或刚好被删、读请求在写请求之前读到旧值、读请求的回填动作发生在写请求删缓存之后。概率不高但绝不是零。这也是为什么很多团队在 Cache Aside 之上还要叠加一层延迟双删或者binlog 订阅删除。需要注意的是先删缓存后写库的顺序反而更糟因为它在写库之前就把缓存清空了中间任何一次读都会把旧值重新加载进来窗口比先写库后删缓存大得多。所以标准写法永远是先库后缓存。2.4 延迟双删能缩小窗口但治不了根延迟双删的思路很直接写完数据库删一次缓存然后等一段时间再删一次把可能在窗口期被回填的旧值再清掉。def update_user_with_double_delete(user_id, nickname, delay_ms500): key fuser:{user_id} db.execute(update t_user set nickname %s where id %s, nickname, user_id) r.delete(key) # 延迟一段时间后二次删除覆盖读请求回填旧值的窗口 schedule_after(delay_ms, lambda: r.delete(key))这里最关键也最容易被忽略的是delay_ms怎么定。业界常见的说法是大于一次读请求的耗时但一次读请求的耗时在不同数据量、不同 SQL、不同负载下差异巨大500ms 在低峰期绰绰有余在大促高峰期可能连一次慢查询都不够。我自己的经验值是先按 P99 读耗时乘以 2 来估上线后通过监控观察脏读投诉量再调整。更稳妥的做法是把这个延迟做成配置项按业务线分别设置。延迟双删还有一个隐藏成本它需要一次延迟任务调度。用线程池 sleep 在单机会有内存泄漏风险用消息队列的延迟消息更可靠但要引入 MQ 依赖。如果只是为了这一件事上一个 MQ投入产出比并不好看。真正能治根的是订阅数据库变更日志在 binlog 被应用之后异步删除缓存。这条路把删缓存从业务代码里彻底解耦写路径不再关心缓存缓存的失效由一条独立的消费链路负责。代价是需要引入变更捕获组件运维复杂度上升而且消费延迟本身也是一个新的窗口。我在几个数据量大的系统里用过这个方案效果不错但前提是团队有能力维护那条链路否则出了问题比不做更麻烦。2.5 落地时必须处理的几个边界Cache Aside 看起来简单真正做到不出事还得处理下面这几件事每一条都是我或者身边同事实际踩过的。删除失败必须有补偿。r.delete(key)抛异常的概率不低——网络抖动、连接池耗尽、Redis 主从切换都会导致失败。如果代码里是try: r.delete(key) except: pass那这条缓存就会一直脏到 TTL 到期。正确做法是失败后写进重试队列消费者按指数退避重试重试若干次仍失败就告警人工介入。大 key 用 UNLINK 而不是 DEL。如果一个 key 的 value 有几十 MB比如一个商品的全量 SKU 列表DEL 是在主线程同步回收内存的会造成几百毫秒的阻塞。Redis 4.0 之后提供了 UNLINK把内存回收放到后台线程删除动作本身只做引用摘除几乎无感。这个细节在小数据量下完全体现不出来等数据量上来之后再改就要停机了。写路径要注意事务边界。如果数据库写是在一个大事务里缓存删除必须放在事务提交之后不能放在事务内部。放在事务内部的话事务还没提交其他连接已经能看到缓存没了读请求回源读到的是未提交前的旧值回填之后事务提交缓存又脏了。读路径要注意回填顺序。有些实现是先回填缓存再返回有些是先返回再异步回填。前者一致性更好但延迟高后者响应快但回填失败无感知。我一般选前者因为异步回填一旦失败缓存永远不命中等于缓存白做。3. Read/Write Through把一致性责任交给缓存层3.1 和 Cache Aside 的本质区别在谁去碰数据库Cache Aside 里业务代码明确知道数据库和缓存两个东西的存在读的时候自己判断先查哪个写的时候自己决定先写哪个再删哪个。Read/Write Through 把这种判断收进缓存层业务代码只跟缓存打交道数据库对业务代码是透明的。这个区别听起来只是代码组织方式不同实际上影响很大。Cache Aside 的一致性逻辑散落在每一个业务方法里十个接口就有十份相似的代码某个人写漏了一次删除就埋了一个坑。Read/Write Through 把逻辑收敛到一个地方改一次全局生效出问题也只可能出一处。代价是缓存层必须知道数据从哪里来、怎么写回去。这意味着缓存层要持有数据访问的引用要和数据库的连接、事务、异常处理绑在一起。在微服务架构下缓存层和数据库往往不在同一个进程里这个封装就没那么好做了。3.2 Read Through 的回源动作由缓存层完成Read Through 的执行模型是业务代码向缓存要数据缓存有就返回没有就由缓存层自己去数据源加载加载完回填并返回。业务代码完全不感知未命中这件事。用一个抽象类来演示这个结构业务侧只依赖Cache接口class Cache: def __init__(self, loader, writer, ttl): self.loader loader # 从数据源加载 self.writer writer # 写回数据源 self.ttl ttl def get(self, key): value self.backend_get(key) if value is not None: return value value self.loader(key) if value is not None: self.backend_set(key, value, self.ttl) return value def put(self, key, value): # 写穿透缓存与数据源同步写 self.writer(key, value) self.backend_set(key, value, self.ttl)注意put方法里writer和backend_set是两次操作。真正的 Write Through 要求这两次操作要么都成功要么都失败实践中很难做到绝对原子通常是先写数据源成功再写缓存缓存写失败就当作未命中处理逻辑上仍然正确只是性能损失。如果反过来先写缓存再写数据源数据源失败时缓存就脏了而且没有回滚手段。Redis 原生并不提供 Read/Write Through 能力它就是一个纯粹的 KV 存储。要实现这套模型要么在应用层自己包一个 Repository 层要么引入支持这套模型的缓存组件。我见过的落地方式基本都是前者——写一个带load和persist回调的缓存包装类所有业务代码通过它访问数据。3.3 Write Through 的同步写回成本藏在延迟里Write Through 的关键词是同步。业务调用put的时候必须等数据源写完、缓存写完才能返回。这带来的直接后果是写延迟等于数据库写耗时 缓存写耗时比 Cache Aside 的数据库写耗时 缓存删耗时要略高一些因为删操作通常比写操作快而且删的数据量小。写延迟上升在高频写场景下会被放大。假设一个接口原本 P99 是 20msWrite Through 之后变成 26ms看起来只多了 6ms但如果这个接口每秒被调用几千次多出来的这部分就是实打实的资源占用。另一个成本是故障传播。Cache Aside 里缓存删除失败顶多是脏一段时间业务本身是成功的。Write Through 里缓存写失败时必须决定是抛异常回滚还是忽略。回滚意味着要引入补偿事务忽略意味着缓存和数据源可能不一致。这个决策点很别扭而 Cache Aside 根本不需要面对它。3.4 这套模型什么时候才值得上我的判断标准比较粗暴只有当你能把它封装成团队统一的基础设施时Read/Write Through 才值得上。如果只是某个业务模块自己写一套那还不如老老实实用 Cache Aside因为封装成本摊不开收益也体现不出来。适合上的场景有两类。一类是数据访问模式高度统一的中台服务所有读写都走同一个 Repository封装一次全团队受益。另一类是缓存价值极高、业务代码又极度不希望被缓存逻辑污染的场景比如一些对外的开放接口接入方不应该知道内部用了什么缓存。不适合的场景也很明确写操作少、数据结构简单的业务直接 Cache Aside 十几行代码就完事了上穿透模型属于过度设计。我见过一个小团队为了架构优雅把所有数据访问都套上穿透层结果调试的时候要跨越三层才能找到一条 SQL排查效率大幅下降最后又拆回去了。4. Write Behind用吞吐量换一致性风险4.1 只写缓存异步批量落库Write Behind 也叫 Write Back执行模型是写请求只写缓存就返回缓存层在后台把变更异步、批量地刷进数据库。它的性能优势是碾压性的。一次数据库 UPDATE 可能涉及行锁、索引维护、WAL 写入、主从同步耗时在毫秒级而一次 Redis 的 SET 是纯内存操作耗时在微秒级。差了两个数量级。更关键的是批量——1000 次独立的数据库更新如果能合并成一次批量提交数据库侧的压力会下降一个数量级以上。一个简化的计数器实现def incr_view(article_id): key fview:count:{article_id} # 只操作缓存立即返回 return r.incr(key) def flush_views(): # 后台任务定时执行扫描有变更的 key for key in scan_dirty_keys(view:count:*): article_id key.rsplit(:, 1)[1] count r.getset(key, 0) if int(count) 0: db.execute( update t_article set view_count view_count %s where id %s, int(count), article_id )getset在这里很关键它把读当前值和清零做成一个原子操作避免 flush 过程中新来的增量被覆盖。如果换成get再set 0中间这几微秒里的增量就会丢。4.2 点赞、浏览、排行榜这类场景它是唯一解不是所有数据都值得走数据库。浏览量、点赞数、分享次数、在线时长、位置轨迹、心跳上报这些数据的共同特征是写入极其频繁、单次价值极低、允许少量误差、几乎不需要强一致。让它们直接打数据库数据库会被写穿让它们走 Cache Aside每次点赞都要写一次库性能瓶颈依然在数据库。Write Behind 在这类场景下几乎是唯一选择。拿浏览量来说一篇热门文章一分钟可能有几万次浏览如果每次都同步更新数据库这一行会被反复加锁形成热点行冲突数据库连接池很快被打满。用 Redis 承接所有增量每 10 秒批量刷一次数据库压力直接下降三个数量级。4.3 崩溃丢数据这件事得提前想清楚能不能接受Write Behind 最大的风险是数据丢失。缓存是内存存储进程重启、机器宕机、机房断电内存里的增量就没了。Redis 虽然有 RDB 和 AOF 两种持久化但 RDB 是定时快照两次快照之间的数据必然丢失AOF 的everysec模式最多丢 1 秒always模式性能又退化成同步写跟直接写数据库差不多。所以要不要用 Write Behind本质上是回答一个问题这批数据丢几秒业务能不能接受。浏览量丢几秒没人会发现点赞数丢几秒用户刷新一下又点一次也还好订单状态丢几秒那就是生产事故。我见过有团队把用户积分变动也放进 Write Behind结果一次重启丢了十几个用户的积分最后靠对账补回来折腾了整整两天。这种数据就应该老老实实同步写。如果业务确实需要 Write Behind 的吞吐又不能接受丢数据折中方案是双写写缓存的同时把变更写进一个持久化的队列比如本地磁盘的 WAL 文件或者一个持久化消息队列后台刷库成功后从队列里删除。这样即使缓存进程挂了队列里的记录还在重启后可以重放。代价是实现复杂度陡增需要有幂等设计——重放的时候同一条变更可能被应用两次如果更新逻辑是count count 1就会多算一次。所以要么改写成幂等的形式要么在队列里带一个唯一的变更 ID 做去重。4.4 批量合并的坑顺序、覆盖与延迟Write Behind 的批量逻辑有几个容易翻车的地方都是我在实际项目里或者复盘别人的事故时见过的。覆盖顺序。如果同一个 key 在一次 flush 周期内被改了多次比如昵称从 A 改成 B 再改成 C最终落库的必须是 C。如果缓存层用的是记录每次变更再批量提交的模型就必须保证按时间顺序提交或者干脆只保留最后一次的值。用getset那种增量模型天然没这个问题因为增量是可加的但如果是覆盖式的字段就必须做合并。刷库延迟。批量周期设长了数据新鲜度差设短了批量效果就没了。10 秒是我比较常用的起点具体要看业务对延迟的容忍度。有些系统要求秒级可见那就得压到 1 秒这时候批量合并的收益已经很有限了得重新评估是不是还值得用这个策略。异常时的重试。刷库失败怎么办如果直接丢弃数据就丢了如果无限重试可能阻塞后面所有批次。比较稳妥的做法是失败后写进死信表告警人工处理同时让这个 key 的增量保留在缓存里下次再刷。这要求刷库逻辑本身是幂等的否则重试会产生重复累加。5. 三种策略的横向对比与选型判断5.1 按一致性要求分级比按技术栈选更靠谱选型的时候最容易犯的错是拿技术栈当理由我们用的是 Redis所以只能用 Cache Aside。实际上三种策略都能在 Redis 上实现区别只在封装成本。真正该拿来做判断的是业务对一致性的要求。我把一致性需求粗分成三档。第一档是强一致读到的必须是最新值比如余额、库存、订单状态。这类数据不适合只靠缓存策略解决Cache Aside 加短 TTL 只是兜底核心还是要在数据库层做并发控制。第二档是准实时允许几百毫秒到几秒的延迟大部分用户资料、商品基础信息都在这一档Cache Aside 完全够用。第三档是最终一致允许分钟级延迟只要求最终收敛浏览量、点赞数、推荐分数属于这一档是 Write Behind 的主场。5.2 读写比例决定了很多事情读多写少比如 100:1的场景Cache Aside 是最优解。缓存命中率会很高删除操作带来的那点窗口在大量读操作面前被稀释了而且写路径简单维护成本低。读写比接近 1:1 甚至写更多比如 1:10的场景Cache Aside 就开始吃力了。每次写都要删缓存删完之后紧接着的读又要回源缓存命中率被写操作反复打穿等于缓存没起到作用。这种场景要么改用 Write Behind 合并写要么在业务层做聚合把多次写合并成一次。读写都极其频繁、且对延迟敏感的场景比如计数器、排行榜直接的答案是 Write Behind没有别的选择。5.3 一份数据的不同字段可以用不同策略实际系统里很少有整张表用同一种策略的情况更多是同一份数据的不同部分用不同处理方式。拿一个商品详情举例。商品标题、价格、类目这些字段变更频率低、要求准实时可见用 Cache Aside写后删缓存。商品的浏览量、收藏数这些高频变更的统计字段用 Write Behind 在 Redis 里累加定时刷库。商品的推荐分数是离线算好的每天更新一次用定时任务直接覆盖缓存连写策略都不需要。这种混合模式在实现上要注意隔离——不同策略用不同的 key 前缀避免一次删除操作误伤到另一部分数据。比如item:base:1001存基础信息item:stat:1001存统计计数删除基础信息缓存的时候不要顺手把统计也删了否则计数会丢。判断维度倾向选择数据准确性要求高绝对不能丢Cache Aside配短 TTL 兜底希望业务代码干净、统一管控Read/Write Through写频率极高、可容忍误差Write Behind读远多于写Cache Aside写远多于读Write Behind 或业务层合并写团队缺少基础设施维护能力Cache Aside6. 我在实际项目里踩过的几个真实问题6.1 删除失败被 except pass 吞掉的那一次有一段时间某个页面的用户昵称出现改了但部分用户看到旧的的反馈量不大每天几例查了两天才定位到。原因很朴素写路径里删除缓存的代码被包在一个try/except/pass里而那一周 Redis 恰好有过一次主从切换切换窗口内的删除请求全部失败失败后没有任何重试缓存就一直脏到了 TTL 到期。修复方案是把删除失败写进一个本地重试表由一个后台任务每分钟扫一次重新删。这个改动只有几十行代码但解决了一类问题。后来我在所有涉及缓存删除的地方都加了这个模式并且加了一个监控指标删除失败次数。这个指标平时是 0一旦有波动就说明缓存链路有问题比等用户投诉要早得多。6.2 一个大 key 的删除导致了接口毛刺有个配置类的缓存value 里存了整个业务线的所有配置项序列化之后有十几 MB。用 DEL 删除的时候Redis 主线程需要同步释放这块内存会出现 100ms 到 300ms 的阻塞。这个阻塞在平时完全感知不到因为配置变更很少。直到有一次批量刷新配置一次性删了几十个这类 key接口的 P99 直接从 40ms 跳到 800ms告警响了。改成 UNLINK 之后问题消失。但更根本的修复是把大 key 拆小——按配置分组拆成多个 key每个 key 只有几十 KB。大 key 这个问题删除只是表象存储结构本身才是根因。判断标准很简单value 超过 10KB 就应该考虑拆分超过 100KB 基本可以确定要拆。6.3 过期时间集中导致的雪崩早期做缓存的时候我习惯性地把 TTL 统一设成 30 分钟。上线之后发现每天上午十点左右会有一波数据库慢查询告警持续一两分钟。排查下来是因为系统在凌晨做了一次全量预热所有 key 在同一秒写入30 分钟后又在同一秒集体失效失效瞬间几千个请求同时穿透到数据库。修复方式就是前面代码里那个random.randint(0, 300)。加了随机偏移之后失效时间被打散在 5 分钟的区间里穿透压力下降了一个数量级。这个改动成本几乎为零但效果立竿见影。现在我的习惯是任何批量写的缓存都必须带随机 TTL这是硬性规范。6.4 主从读写分离下的删了还是读到旧值这个坑比较隐蔽。缓存集群做了主从读写分离写操作走主节点读操作走从节点。删除缓存是在主节点上执行的但主从复制是异步的从节点上可能还有这个 key 的旧值。删除之后立刻有读请求打到从节点读到了旧值并且把这个旧值当成有效数据返回了。更麻烦的是这个旧值在从节点上还有完整的 TTL也就是说它会在从节点上继续存活到自然过期。表现就是缓存明明删了部分请求还是返回旧数据。解决方式有两个方向。一是不在缓存层做读写分离所有操作都走主节点Redis 单节点的吞吐其实足够支撑绝大多数业务。二是在删除之后的一小段时间内把这个 key 的读请求也强制打到主节点。前者简单粗暴后者更精细但需要在客户端维护路由逻辑。我倾向于前者因为缓存层做读写分离带来的那点读吞吐提升在大多数场景下并不值得引入这种一致性风险。6.5 多级缓存下的删除顺序本地缓存加 Redis 的两级结构很常见本地缓存的优势是纳秒级响应能扛住极高的读并发。但它也带来一个额外问题删除的时候先删哪个。假设删除顺序是先删本地再删 Redis。删完本地之后、删 Redis 之前如果有一个读请求进来本地未命中去读 RedisRedis 里还是旧值于是旧值被重新加载进本地缓存。结果就是本地缓存重新脏了而且要脏到本地缓存的 TTL 到期。反过来顺序是先删 Redis再删本地。中间窗口里读请求会命中本地缓存拿到旧值但因为本地缓存马上就被删了这个错误窗口极短而且不会导致本地缓存被旧值重新填满。两害相权先删远端再删本地是更安全的选择。如果本地缓存分布在多个实例上还需要一个广播机制通知所有实例一起失效否则每个实例的本地缓存都要各自等 TTL。常见的做法是基于消息主题发一条失效通知所有实例订阅后各自清理。这个机制本身也会引入延迟和消息丢失的可能所以本地缓存的 TTL 不能设太长一般不超过 60 秒把它当作最后一道兜底。6.6 一个反直觉的结论TTL 才是最终的一致性保障不管用哪种策略做了多少层补偿、加了多少次删除最后真正保证数据不会永远错的是 TTL。所有删除逻辑都可能因为网络、因为异常、因为代码疏漏而失效只有过期时间是 Redis 自己执行的不依赖任何外部代码。所以我现在的做法是任何缓存 key 都必须设置 TTL没有例外。TTL 的长度按业务能容忍的最长脏数据时间来定而不是按数据多久变一次来定。用户资料能容忍 30 分钟旧值TTL 就是 30 分钟商品价格只能容忍 1 分钟TTL 就是 1 分钟。前面所有的删除、双删、订阅都只是把这个窗口从 30 分钟缩短到毫秒级TTL 是那个永远不会失守的底线。这个认知改变了我设计缓存的方式。以前总想着怎么把删除做得完美现在更愿意先把 TTL 定对再在这个基础上做优化。删除失效了最多脏一个 TTL 周期TTL 设错了或者忘了设那就是永久脏数据只能靠人工介入。最后分享一个小习惯每次写完缓存相关的代码我会在本地跑一个简单的注入测试手动让删除操作抛异常观察接口返回的数据会不会在下一个 TTL 周期内自动恢复正确。这个测试只需要十几行代码但能提前发现绝大多数缓存一致性设计上的漏洞比上线之后靠用户反馈要省事得多。