
前阵子排查线上问题接口延迟从 200ms 一路飙到 3.7 秒数据库连接池被打满页面大面积超时。最后查下来不是 SQL 写得差也不是机器不够而是一次发布顺手改掉了缓存 key 的版本号所有热点缓存一夜之间集体失效流量在凌晨两三点直接灌进数据库。这种场面你应该不陌生缓存加了数据库照样被打穿缓存没挂数据却经常对不上缓存集群很稳内存成本却高得吓人。这些都是缓存设计里最常见的问题但很少有人系统地把它们讲清楚。这篇文章我准备从缓存类型选择、key 设计、更新顺序、穿透击穿雪崩的防御再到容量估算和监控完整梳理一遍我这些年在这个领域踩过的坑和沉淀下来的做法。无论你是刚把 Redis 接入项目的新手还是维护过一整套缓存方案的开发者都能找到对得上号的场景。我把话放在前面缓存这东西难点永远不在“怎么读怎么写”而在“什么时候失效、谁更新、多旧的数据能接受”。把这几个问题想明白了缓存才算真正用起来了。1. 缓存选型数据是“热”还是“烫”决定了本地缓存还是集中缓存很多人一提到加缓存第一反应就是上 Redis。这没有错Redis 确实是最通用的选择但它不是所有场景的最优解。我见过不少架构明明数据是单机上的配置字典为了一点“缓存的意思”硬是引入了一整套 Redis 集群结果网络延迟、运维成本、故障恢复全都要背在身上。1.1 本地缓存与集中式缓存的分界线先摆一张我常用的对比表这里面有几个关键维度选型的时候跑一遍就能得出结论。维度本地缓存集中式缓存如 Redis访问延迟纳秒级无网络开销毫秒级受网络影响数据一致性每实例各自一份多实例天然不一致全局一份各实例读到相同数据容量受单机内存限制可横向扩展支撑 GB 到 TB 级重启影响缓存随进程丢失可通过持久化恢复适用场景配置、静态字典、低频变化数据共享状态、热点数据、跨实例协同这个表的第一行最关键。如果你的数据是“这台机器上独有”的比如本地配置文件、模板片段、算法模型参数那用本地缓存是天经地义的事情。它和省一次网络时间有关但更重要的是它压根不需要别人来同步。如果是多实例共享的数据比如商品详情、用户画像、订单状态那必须走集中式缓存否则每个实例各存一份数据很快就会对不上。我自己见过一个比较典型的翻车案例某团队把用户标签缓存做成了本地缓存每台机器启动时去数据库拉一次全量标签。上线初期只有两台实例看不出问题后来扩容到八台运营修改了某个标签的分值结果有的实例还是旧值有的实例已经拉到新数据同一个用户在不同请求下看到的标签居然不一样。这就是典型的“选错了缓存层级”。1.2 用两个维度选型读频率与更新容忍度抛开上面的极端情况绝大多数业务数据选缓存只需要回答两个问题第一个问题这个数据的读频率到底有多高一个接口每秒被请求几千次和一天总共被请求几百次方案完全不同。读得越频繁缓存带来的收益越明显也越值得投入复杂度。第二个问题数据更新之后系统能容忍多久的延迟这里要区分两件事多久能感知到数据变了以及多久必须让用户看到新数据。有些数据晚几秒没问题比如排行榜、推荐位有些数据晚几秒就是线上事故比如订单状态、支付结果。按照这两个维度我一般的判断逻辑是这样的读频率高且允许短时间不一致的数据优先上集中式缓存读频率高但要求强一致的数据缓存只能做加速手段不能做数据权威来源核心查询必须回源数据库读频率低但修改频繁的数据压根不要缓存直接查库更省事。这里还有一个我强烈建议的组合策略本地缓存和集中式缓存不是二选一而是可以串成一条链路。先查本地缓存命中就返回没命中再查 Redis Redis 也没有才查数据库然后逐层回填。这样做的好处是Redis 挂了本地缓存还能挡住一部分流量不至于一次缓存抖动就拖垮数据库。当然本地缓存只适合放那些“能接受轻微不一致”的数据不能把订单状态这类东西也塞进去。2. key 设计从一次串数据事故开始把缓存键从“能跑”改到“不踩坑”缓存 key 是全网最容易被忽视的东西。很多人觉得 key 嘛随便拼个字符串能查得到就行。但真实线上环境里key 设计不合理导致的串数据、数据陈旧、命中率低下排查起来往往比业务 bug 痛苦十倍。2.1 一次典型的 key 串数据事故复盘先讲一个我印象很深的事故。当时做的是用户标签服务最初只服务一个应用缓存 key 设计成user:tag:{userId}里面存的是该用户的标签 ID 列表。后来平台要复用这套服务第二个应用也接了过来同样传 userId 过去。问题很快就来了新应用的用户体系和第一个应用是两套用户的 userId 编号完全可能重叠。A 应用的用户 10086 是个金牌用户 B 应用的用户 10086 是个新注册的普通号。两个应用共享同一个缓存 keyuser:tag:10086结果 B 应用的用户拉回了 A 应用的标签运营配置的专享活动直接给错了人。这类串数据事故的特征非常明显不是每次必现而是只有两个系统的 userId 撞车时才出现极其隐蔽。当时排查的时候我们一度以为是接口传参错了后来看了 Redis 里的 key 才发现同一个 key 被两个业务方共用写入方互相覆盖。2.2 key 的组装规则业务域、版本、唯一标识那次事故之后我强制团队统一了 key 的组装规范。到现在我每写一个缓存 key都会按照下面的格式来[业务域]:[场景]:[版本号]:[唯一标识]:[可选维度]举几个实际例子mall:goods:detail:v2:1001—— 商品详情trade:order:status:v1:20240301001:user_10086—— 订单状态带用户维度promo:activity:config:v3:618—— 促销活动配置这几段的作用分别是业务域和场景解决的是“这个 key 属于哪个功能”的问题。即使两个功能存的是同一个对象也不要共用同一个 key否则一个改了 key 结构另一个就被牵连。版本号解决的是“数据结构升级”的问题。只要缓存里存的数据结构变了比如原来存字符串后来改存 JSON版本号就要加一。这样新老数据自然隔离旧数据等 TTL 过期清掉就行不会出现新代码读到老结构然后反序列化失败的问题。唯一标识很好理解但要注意不只是主键那么简单。如果一个数据存在多个业务维度要把所有影响读写的维度都拼进去。我见过不少 key 只拼了一个 userId结果漏掉了 appId、cityId、channelId最后数据互相污染。这类问题只能靠“写之前先罗列查询维度”来避免。可选维度那段是给同一个缓存对象的不同视角留的位置。比如商品详情可能按用户等级展示不同价格那 key 里就得加一个level字段不然不同等级的用户会互相读到对方的专属价。还要提醒一点别把 key 设计得太长。一个几千字节的 key 不仅浪费内存还会拖慢 Redis 的查找速度。上面这套规则拼出来的 key 一般控制在几十到一百字节以内完全够用。如果某个数据天然就需要很长的标识可以考虑对标识部分做哈希但要注意可读性下降后排查问题会比较麻烦。3. 更新顺序四个缓存写策略的真实代价与脏数据窗口选好了缓存类型和 key 规则真正的麻烦才刚刚开始。缓存的数据从哪来、更新的时候先动哪边这几个问题几乎每天都会遇到。业界总结出来的缓存写策略主流的就四种但每一种都不是免费的。策略写流程一致性强度故障风险适用场景Cache-Aside旁路缓存写 DB 后删缓存读 miss 时回填最终一致低但删除可能失败多数读多写少场景最通用Read-Through应用只读缓存缓存组件负责查 DB 回填最终一致中缓存层复杂度高缓存组件完整封装的场景Write-Through写操作同步更新 DB 和缓存较强低但写放大一致性要求较高的低频写场景Write-BehindWrite-Back先写缓存异步批量写 DB弱一致/最终一致高缓存丢数据即丢业务高吞吐写、可容忍丢数据的场景3.1 Cache-Aside 写后删和删后写到底差在哪先看最常用的 Cache-Aside。读流程很清晰先读缓存命中直接返回没命中就查数据库再把结果写回缓存。这个流程基本不会出大问题真正容易踩坑的是写流程到底是“先更新数据库再删缓存”还是“先删缓存再更新数据库”我见过不少人凭直觉选了先删缓存再更新数据库理由是“先把旧缓存干掉数据库慢慢更新总不会读到脏数据了吧”。但这个流程在并发下会出现一个非常恶心的窗口线程 A 要更新商品价格它先删掉了缓存线程 B 正好在读价格缓存 miss 后去数据库查到一条旧价格然后回填缓存线程 A 这时候才把新价格写入数据库。结果就是数据库里的价格已经更新了缓存里的旧价格却被 B 线程重新放了回去而且这个旧价格要一直待到 TTL 过期期间所有用户看到的都是错的价格。后删缓存的流程先更新 DB 再删缓存也会有一个短暂的不一致窗口更新完数据库之后删除缓存之前如果来了一个读请求它命中的还是那个旧缓存读到旧数据。但这个窗口非常小而且等删除缓存执行完数据就恢复了不会长期脏下去。所以两害相权大多数团队最终都会选择“先更新数据库再删除缓存”。3.2 延迟双删与异步删除什么时候值得加每次讲到这里都会有人问那先更新 DB 再删缓存中间那几十毫秒读到旧数据就不算事故吗那要看业务容忍度。如果这个数据是商品库存、订单状态用户多刷一次就能看到最新结果那几十毫秒的窗口通常可以忽略。但如果是支付回调、对账数据就必须尽量压缩这个窗口。我实际用过的方案有两种。第一种是延迟双删先更新数据库然后删除缓存等几百毫秒之后再删除一次缓存。上面那个“B 线程在删除前回填了旧缓存”的场景第二次删除会把这份旧值清掉把脏数据时间压到极短。这个 delay 时间怎么设一般取一个略大于“慢查询 网络传输 回填缓存”的时间500ms 到 1 秒对我来说够用。注意 delay 不是越长越好越长时间段内系统越可能再次出现脏读。第二种是异步删除更新数据库后把“删除这个缓存 key”的消息发给一个队列由专门的消费者去删删除失败会自动重试。这个方案适合删除缓存的操作经常因为网络抖动失败的情况。如果只是 delete 偶发失败可以在业务代码里重试两三次如果还担心失败可以扫 Redis 的日志或者读数据库变更记录来补偿。再往后进阶是对强一致性要求极高的场景有人会直接订阅数据库 binlog在数据变更后异步清理对应缓存。这套方案比双删干净因为它是从数据源头出发的不依赖业务代码里删缓存是否执行成功。但复杂度也会明显上升不是所有项目都需要。最后总结一下我现在的默认选择90% 的场景我直接用“先更新 DB再删除缓存”如果并发写很频繁且存在旧值回填风险我会加延迟双删如果删除失败可能性大我会引入异步删除队列极端高一致场景才考虑订阅 binlog 做补偿。这个顺序从简单到复杂每一步的引入都有明确信号不会一上来就把架构搞得很重。4. 穿透、击穿、雪崩三道防线应该按什么顺序搭说到缓存一定会提到穿透、击穿、雪崩这三个词。很多人把它们混为一谈但三个问题的本质完全不同防御手段也完全不一样。我建议每个做缓存的人都把这三兄弟放在一起理解因为它们极可能在同一个系统里同时出现。穿透是指请求想查的数据在缓存和数据库里都不存在于是一次次地穿过缓存直接打到数据库。最典型的例子是用户疯狂请求一个不存在的商品 ID每个请求都 miss每个请求都查库数据库的连接池很快就满了。击穿是指某一个热点 key 过期瞬间大量请求同时进入全部 miss一起打到数据库。这个和穿透的区别在于穿透查的数据没有击穿查的数据有只是缓存刚好没了。雪崩是指大量 key 同时过期或者整个缓存集群不可用导致大量请求在短时间内集中打到数据库数据库直接被压垮。4.1 穿透空值缓存与布隆过滤器的边界对付穿透最直接的做法是查数据库发现没有这个数据也写一个空值进缓存设置一个比较短的 TTL比如 30 到 60 秒。这样同一个不存在的 key 再次被请求时会直接命中空值缓存不再穿透到数据库。这个方案简单有效但有一个副作用如果一个 key 本来不存在后来在数据库中真的创建了而空值缓存还没过期那这期间它仍然是查不到的。所以空值缓存一定要设置短 TTL不能图省事设成几小时。另一种手段是布隆过滤器在请求进来之前先判断这个 key 是不是一个可能存在的 key。布隆过滤器判断“不存在”是绝对准确的判断“存在”则可能误判。所以它的定位是挡掉大部分不存在的请求而不是替代空值缓存。我见过的常见做法是把系统里所有合法 ID 都打进布隆过滤器请求来了先过过滤器如果判断不存在直接返回如果判断存在才走正常的缓存查询流程查不到再做空值缓存兜底。这里有一个非常容易忽略的细节布隆过滤器的容量和误判率是绑定的。一开始我把容量设得太小数据量上来之后误判率越飙越高大量其实不存在的 key 被放行到了数据库。后来我把容量预估为“未来两年预期数据量的十倍”并且宁可多占一点内存也要把误判率压到 1% 以下。上线之后才发现配置一个合理的布隆过滤器比事后给数据库加索引省心得多。4.2 击穿互斥重建与逻辑过期的两种路线击穿的核心问题是热点 key 失效后并发请求全部打到数据库。最直接的对策是互斥重建同一时刻只允许一个线程去数据库查数据其他线程等待这个线程把缓存写回去然后再从缓存里读。互斥重建的实现有很多种Redis 分布式锁是其中一种。我贴一段常用的伪代码完全可以直接抄进项目里试def rebuild_cache_with_lock(key, redis_client, build_func): data redis_client.get(key) if data: return data lock_key flock:{key} # 抢锁成功才允许重建ex为锁过期时间 if redis_client.set(lock_key, 1, nxTrue, ex30): try: # 二次检查防止等待期间别人已经重建 data redis_client.get(key) if data: return data data build_func() redis_client.set(key, data, ex300) return data finally: redis_client.delete(lock_key) else: # 抢不到锁的请求等一下再读缓存 time.sleep(0.1) return redis_client.get(key)互斥重建的问题在于抢不到锁的那些请求都在等待如果数据库查询很慢等待时间会拉长接口整体响应时间会变差。所以这个方案比较适合“缓存重建成本不高、热点明确、并发瞬间极高”的场景。另一种思路是逻辑过期。缓存里的数据并不真的等到 TTL 过期才失效而是业务层自己给数据加一个逻辑过期时间。比如商品详情实际 TTL 设成一天但 Value 里存一个 expireAt 字段写上 10 分钟。每次读的时候判断 expireAt 是否已过没过就正常返回过了就立刻返回当前缓存数据同时异步触发一个后台任务去刷新缓存并更新 expireAt。逻辑过期最大的优点是请求永远不会阻塞用户即使拿到旧数据也是秒回代价是数据不一致的时间窗口会比 TTL 更大而且异步刷新如果没有做好防重同样会造成数据库压力。我会把它用在那些“响应时间不可牺牲、数据可以短期不精确”的场景比如首页推荐内容、运营配置的营销文案。4.3 雪崩随机过期时间只是最低要求雪崩的经典原因是同一个时间点太多 key 一起过期。最基础的对策是给 TTL 加一个随机数比如所有 key 原本是 600 秒过期现在生成缓存时按600 random(0, 300)来设置。这样做能让失效时间分散开数据库不会被一个集中失效点打爆。但随机过期时间只能预防“自己设计出来的雪崩”防不住“缓存集群整体挂了”这种外部故障。后者需要的是更高一层的防御机制。我在实践中主要做三件事一是多级缓存。前面已经提到本地缓存做第一层Redis 做第二层DB 做第三层。即使 Redis 整体不可用本地缓存也能挡掉一部分热点流量。二是服务降级。如果数据库压力已经快扛不住直接把缓存读接口的返回值降级为一个简化的默认数据或者返回上一次成功的数据。不要在雪崩发生时还去执着于“把数据取到最新”先把系统保住。三是熔断限流。当下游 DB 的响应变慢、错误率升高时缓存服务要主动减少进入 DB 的请求数量而不是让更多的请求继续涌入。限流可以直接在网关层做也可以在每个服务的调用链路上做。这三个机制一层一层叠上去才能称得上“防雪崩”。单纯靠 Redis 加大内存遇到集群故障同样无能为力。我自己的习惯是先确保随机过期时间这个基础工作做到位再逐步引入多级缓存和降级策略不要反过来先做很复杂的模块结果基础的随机 TTL 都没配。5. 容量估算与监控如何判断缓存是“真的在干活”还是“白占内存”很多团队的缓存上线之后就再也没有人看它一眼。只有线上出问题了才翻一下监控平时命中率是多少、内存涨到多少、有没有大量淘汰一概不管。这样做的后果是缓存慢慢从“加速器”变成了“隐形炸弹”。5.1 命中率是必要条件但不是健康标准最常见的监控指标是命中率。命中率当然是越高越好一般业务缓存能到 90% 以上就算不错。但如果你只盯着命中率很容易被骗。举个例子一个商品详情缓存TTL 设成 30 分钟白天高峰期命中率可以达到 95% 以上。但到了深夜请求量下降大量缓存因为 TTL 过期而清空凌晨的活动预热流量一进来全部 miss命中率会瞬间跌到 50% 以下。这种命中率波动是正常的不代表系统出了问题。反过来也一样命中率高不等于缓存健康。如果某个缓存 key 的命中率很高但对应的数据几乎没人读那不过是在白白占内存。所以除了命中率我会额外看三个指标缓存读延迟分布、数据库 QPS 走势、以及 Redis 的内存淘汰数量。缓存读延迟和数据库 QPS 是最能反映“缓存有没有真正挡住数据库压力”的指标。如果缓存命中率很高但数据库 QPS 仍然很高那大概率是某些 key 没有走到缓存或者查询绕过了统一缓存服务这就要去查代码链路了。内存淘汰数量evicted_keys暴涨说明内存容量不够开始主动淘汰 key 了这时候命中率可能不降但淘汰掉的 key 会让后续请求重复回源数据库DB 压力会悄悄上升。5.2 一个可用的容量估算方法和压测准备还没有上线的缓存提前估算内存是很有必要的。否则等缓存上线后内存被撑爆再扩容中间的抖动很难受。我这里提供一个非常实用的估算公式缓存所需内存 ≈ 活跃key数量 × (key长度 value长度 平均额外开销)这个“平均额外开销”包括 Redis 的对象头、字典节点、过期指针等一般可以按 100 字节左右来估。举个例子一个商品详情缓存key 平均 30 字节value 是一个 2KB 的 JSON活跃 key 数量大概 100 万条那么估算出来100万 × (30 2048 100) ≈ 2.18 GB这个结果已经考虑了基本开销压测之后如果发现实际占用偏差很大再回来修正系数。记住一点估算的是“活跃 key 数量”不是“key 的总量”。很多业务里 key 的总量很大但真正在有效期内被频繁读取的只有一小部分所以按总量估算会严重偏大。上线之后我还建议做一件很多人没做的事压测缓存节点。不是拿 Redis 自己提供的 benchmark 工具随便跑一下而是用接近业务真实读写比例的流量去打。看两个数据缓存节点在 QPS 达到预期的多少时开始出现延迟毛刺以及内存使用率达到多少时开始触发淘汰。这两个数据是容量规划的底线知道了底线后续做扩容和限流才有的放矢。6. 最后分享一个让缓存过期时间不乱掉的实践经验刚才讲的都是技术方案最后我想聊一个偏工程管理的小经验过期时间千万不要散落在代码的各个角落里。我见过太多项目同一个商品详情缓存有人写 10 分钟过期有人写 30 分钟过期还有人写 1 小时过期。业务上线的时候大家相安无事一旦大促活动策划要求“商品修改后 5 分钟内生效”你会发现代码里全是魔法数字根本不知道从哪里改起。我的做法是把所有缓存 key 的 TTL 配置统一收敛到一个配置中心按业务场景命名并支持运行时调整。配置文件大概是这个样子cache: rules: goods-detail: 600 goods-sales-rank: 120 user-tag: 0这里的规则名称对应代码里的缓存 key 前缀TTL 单位是秒。user-tag: 0表示这个缓存不设置 TTL完全由业务逻辑控制失效。把 TTL 做成可配置之后最大的好处是大促期间想临时把商品详情缓存调到 60 秒不用发版不用改代码配置中心推送一下就生效了。另外一个相关的小习惯是每次发版涉及数据结构变化时强制把对应的版本号加一并且把旧 key 的清理动作纳入发布计划。很多缓存事故回头看都是因为“升级了结构但忘了换 key 版本号”。把这些流程固定下来虽然看起来没有解决某个具体的技术难点但在我的实际经验里它比任何一套复杂的缓存淘汰算法都更能避免线上故障。