做后端这些年缓存这块我踩的坑比写的代码多。早些年接手一个商品详情服务接口 QPS 一上去数据库就顶不住我第一反应是加 Redis 缓存代码三下五除二就写完了读的时候先查缓存没有再查库查到了塞回去。上线一周风平浪静第二周运营改了商品价格用户端看到的还是老价格客诉直接打到了产品那边。回头一查写操作里我更新了数据库却顺手也去set了一遍缓存两个并发写请求把缓存刷成了旧值躺了整整两天才被业务发现。那次之后我才认真去梳理缓存的设计模式。所谓三种设计模式说的是Cache Aside旁路缓存、Read/Write Through读写穿透、Write Behind写回/异步写这三条路径。听起来像是教科书上的名词实际上每一种都对应着不同的数据一致性边界、不同的故障模型、不同的运维成本。选错了轻则脏数据重则丢数据选对了很多问题在架构层面就被消掉了根本不用靠业务代码里堆if去补。这篇内容适合谁看写过 Redis 读写缓存但没系统梳理过模式的同学、正在做缓存方案评审的同学、以及被缓存和数据库不一致折腾过的同学。我会把三种模式的读写链路拆开讲重点讲清楚为什么必须这么设计再补上我在实际项目里遇到的坑和判断依据。Redis 只是载体模式本身换成任何 KV 存储都成立。1. 缓存模式争的从来不是性能而是数据一致性的责任归属很多人把缓存设计模式当成一个性能选型题觉得哪种快就用哪种。这个理解方向就偏了。三种模式的读性能差异其实不算大真正拉开差距的是谁负责保证缓存与数据库的一致以及不一致窗口有多长、能不能接受。1.1 三种模式的本质差异谁在管数据同步Cache Aside 把同步责任交给了业务代码。应用程序自己决定什么时候读缓存、什么时候写数据库、什么时候删缓存。缓存层是哑的它不知道数据库的存在只负责存和取。Read/Write Through 把同步责任交给了缓存层。应用只跟缓存打交道缓存层内部去访问数据库。对业务代码来说数据库像是被藏起来了。这要求缓存组件本身具备回源和落库的能力。Write Behind 更进一步把写入时机也解耦了。数据先落到缓存缓存层按自己的节奏批量刷回数据库。应用写完就返回根本不等数据库。这个差异决定了三件事代码复杂度、一致性窗口、故障时的数据风险。业务代码承担的越多可控性越强但越容易写错缓存层承担的越多代码越干净但对中间件能力要求越高。这三种模式不是互斥的。同一个系统里订单主表用 Cache Aside商品浏览量用 Write Behind配置类数据用 Read Through 的变体非常常见。别指望一套模式打天下。1.2 一张表看清三条读写路径的分叉点我把三种模式的关键差异整理成表这张表我在做方案评审时经常直接贴出来用维度Cache AsideRead/Write ThroughWrite Behind读路径应用查缓存→未命中查库→回填应用查缓存层→缓存层回源同左写路径应用写库→删缓存应用写缓存层→缓存层同步写库应用写缓存→异步批量刷库一致性窗口毫秒到秒级取决于删除时机毫秒级同步写库秒到分钟级可能丢数据实现复杂度低纯业务代码中高需中间件支持高需处理刷盘、幂等、补偿写吞吐受数据库限制受数据库限制远高于数据库典型风险并发写导致脏缓存缓存层成为单点进程崩溃丢未刷数据适合场景绝大多数读多写少的业务表有统一数据访问层的中大型系统计数、埋点、状态类弱一致数据看清楚这张表后面的讨论就有抓手了。大部分人纠结的其实是第一列和第二列的区别第三列属于另一类问题。1.3 一个常被忽略的前提缓存到底该存什么在讨论模式之前有个更基础的问题经常被跳过——你到底该缓存什么。我见过太多团队上来就讨论用哪种模式结果缓存的是一张 50 个字段的全量宽表每次更新任何一个字段就得整体失效命中率上不去一致性还特别难保。我的习惯是把缓存对象按更新频率和一致性敏感度切成几类价格、库存这类强一致且高频变的走 Cache Aside 且过期时间要短商品标题、类目这类低频变的过期时间可以放到几小时页面聚合结果这类派生数据干脆接受分钟级延迟用 Write Behind 的思路异步刷。分完类再谈模式方案会清晰很多。2. Cache Aside九成业务的默认答案也是最容易被写歪的那个如果只能选一种模式选 Cache Aside。它不需要中间件支持纯业务代码就能落地出问题也好排查。但它有两个经典的错误写法我在代码评审里几乎每次都能碰到。2.1 为什么必须更新库 删缓存而不是更新库 更新缓存先说结论写操作应该是先更新数据库再删除缓存而不是更新缓存。这个结论背后有两层理由。第一层是并发安全。假设两个写请求 A 和 B 几乎同时到达A 把数据库改成值 1B 改成值 2。如果采用更新缓存的策略A 和 B 更新缓存的顺序完全可能与更新数据库的顺序相反最终缓存里留下的是值 1数据库里是值 2脏数据就此产生而且不会自动消失。改成删除缓存就没这个问题——删两次和删一次的效果一样天然幂等。第二层是收益成本。很多缓存的值是经过聚合计算的结果比如商品详情店铺信息优惠券拼成的 DTO。写操作发生时你未必知道该重算哪些聚合结果就算知道重算的代价可能比等下一次读请求回源还高。直接删掉让读请求按需重建是最省事也最不容易错的做法。那为什么不能先删缓存再更新库这里有个明显的时间窗删完缓存之后数据库还没更新完此时来一个读请求未命中从库里读到旧值回填到缓存。等写请求把库更新完缓存里躺的是旧数据直到过期才会被纠正。窗口大小等于数据库更新耗时 回填耗时在慢 SQL 场景下可能长达几百毫秒足够出问题。反过来先更新库再删缓存并发窗口就小得多。唯一残留的经典场景是读请求 A 未命中缓存去查库拿到旧值此时写请求 B 更新库并删除缓存A 才把旧值回填进去。这个场景要求 A 的查库回填完整地跨过 B 的整个写操作概率很低——除非缓存刚好过期且那一瞬间正好有写操作这属于低概率叠加。工程上通常认为可以接受真接受不了就上延迟双删。2.2 延迟双删能解决问题但不是银弹延迟双删的做法是在更新数据库前后各删一次缓存中间 sleep 一段时间public void updateProduct(Product p) { // 第一次删除尽量让后续读请求回源到新数据 redis.del(product: p.getId()); // 更新数据库 productMapper.updateById(p); // 延迟一段时间后再删一次清掉窗口期内可能被回填的旧值 executor.schedule(() - redis.del(product: p.getId()), 500, TimeUnit.MILLISECONDS); }这里的 500 毫秒不是拍脑袋来的它应该大于一次读请求从查库到回填完成的耗时上限。取值方法很朴素看你的读接口 P99 耗时再留一倍余量。如果 P99 是 200ms就设 400~500ms。但这个方案有三个现实问题很多文章不讲。一是延迟任务本身不可靠用本地线程池调度服务重启就丢了用消息队列延迟消息又多了一套依赖。二是sleep 时间靠猜业务慢查询一多500ms 也不够。三是它解决不了删除失败的问题——如果第二次删除因为网络抖动失败了脏数据照样留着。所以我个人的使用原则是延迟双删只作为过渡方案用在已经出过事故、需要快速止血的场景。真要长期稳定还是得靠删除失败重试 合理过期时间兜底。2.3 删除失败了怎么办重试、订阅、过期三层兜底redis.del是会失败的网络抖一下、Redis 主从切换一下都可能返回异常。如果代码里try-catch吞掉异常就算了脏数据就会一直留到过期。第一层兜底是过期时间。这是最原始也最有效的手段——不管你前面怎么折腾TTL 到了数据总会刷新。所以我给所有缓存 key 都强制要求设置 TTL代码评审时看到没有过期时间的 key 直接打回。TTL 的具体取值按业务容忍度来价格类 5 分钟商品描述类 2 小时配置类可以到天级。第二层兜底是删除失败重试。简单做法是把失败的 key 丢进一个本地队列或消息队列由后台任务重试若干次超过次数就告警人工介入。更彻底的做法是订阅数据库的变更日志binlog由独立的同步服务去删缓存——这样即使业务代码里的删除逻辑漏了、挂了同步服务也能补上。这套方案的成本不低一般在核心交易链路上才值得上。第三层兜底是监控。我在项目里会给缓存删除失败单独打点并且定期抽样比对缓存和数据库的关键字段。抽样比对听起来土但确实抓到过好几次线上不一致。做法很简单每天低峰期随机抽 1000 个 key回查数据库比对不一致就告警。一个实操细节大批量失效用unlink而不是del。del是同步释放内存大 key 会阻塞主线程unlink是后台异步回收对线上更友好。这个改动在 key 体积大的场景下能明显减少毛刺。3. Read/Write Through把一致性责任移交出去之后的代价Read/Write Through 的思路是好的业务代码只跟缓存层交互一致性由缓存层统一保证业务侧再也不用到处写先查缓存再查库的样板代码。但它在实际落地时的难度比理论描述大得多。3.1 读穿透的真实调用链路和它的隐藏成本读穿透的逻辑是应用查缓存缓存未命中时由缓存层自己去查数据库回填后返回。注意这里的主体是缓存层不是应用。问题在于标准 Redis 根本不知道你的数据库在哪、表结构是什么。所以要么你在 Redis 前面架一层代理自己写的服务或者带这种能力的中间件要么用支持这种模式的缓存框架。前者意味着多一跳网络、多一个需要运维的服务后者意味着你被框架绑定了。更现实的一个坑是缓存层变成单点。Cache Aside 下缓存挂了业务代码可以降级直接查库虽然慢但能活。Read Through 下缓存层挂了整个读路径就断了降级逻辑得额外设计。我见过一个项目把读穿透做在自研代理里代理一重启全站读接口 500比数据库挂了还严重。还有一个成本容易被忽略缓存的 key 设计从业务视角变成了数据视角。Cache Aside 下你想缓存什么就缓存什么拼装、裁剪、脱敏都行。Read Through 下缓存层得知道怎么根据 key 去取原始数据通常是一 key 对应一表一主键聚合逻辑被推回了业务层等于白忙一场。3.2 Spring Cache 用的是哪种模式一个高频误解这里必须澄清一个特别常见的误解。很多人用Cacheable、CacheEvict就以为自己在用 Read/Write Through其实 Spring Cache 的默认行为是Cache Aside。Cacheable的处理流程是切面拦截方法 → 查缓存 → 命中返回 → 未命中执行原方法 → 把返回值写缓存。注意最后一步是业务方法自己查的数据库框架只是帮你把缓存逻辑包了一层根本不存在缓存层调用数据库这回事。CacheEvict也一样它是在方法执行之后帮你调del本质还是旁路缓存。想清楚这一点很重要因为很多人误以为用了注解就自动有了一致性保证放松了警惕。实际上注解只是省了样板代码CacheEvict的删除时机、失败处理、批量失效策略该考虑的一样不少。另外提醒一个高频 bug同一个类内部的方法互相调用Cacheable是不生效的。原因是 Spring 的缓存基于 AOP 代理自调用绕过了代理对象。这个坑我至少帮三个同事排查过症状都是注解明明加了缓存就是不命中。3.3 落地形态本地缓存 分布式缓存的分层组合真正意义上的读写穿透在工程上最常见的落地形态其实是多级缓存L1 是进程内的本地缓存类似 Caffeine 这类L2 是 RedisL3 才是数据库。读请求依次穿透 L1、L2、L3写请求则要负责逐层失效。这种分层能带来实实在在的收益。本地缓存命中只要几十纳秒比一次 Redis 往返快两个数量级热点 key 打在这一层Redis 的压力会降一个量级。我做过一个商品详情接口加了本地缓存之后Redis 的 QPS 从 8 万降到 1.2 万接口 P99 从 12ms 降到 3ms。但代价也很明确本地缓存的一致性最难保。多个实例各有一份数据一个实例更新了怎么通知其他实例失效常见做法是通过消息广播比如发布订阅各实例收到消息后清掉本地 key。广播本身有延迟还会有丢消息的情况所以本地缓存的 TTL 必须设得很短通常 1~5 秒靠短 TTL 兜住广播漏掉的场景。4. Write Behind用一致性换吞吐账要算清楚Write Behind 的核心是先写缓存异步刷库。它能把写吞吐提升一到两个数量级但代价是数据可能丢。这个交易划不划算完全看业务。4.1 批量合并的吞吐收益从哪来数据库的写性能瓶颈往往不在单条 SQL 的执行而在事务开销、网络往返、索引维护这些固定成本上。写一条和写一百条固定成本的差距远小于数量差距。Write Behind 正是利用了这一点把短时间内的多次写操作攒起来合并成一次批量写。举个具体例子。某互动业务需要频繁给内容累加热度值高峰期每秒 3 万次更新。直接写库每秒 3 万个事务数据库肯定扛不住。改成先incr到 Redis 的 Hash 里后台每 200ms 扫一次把这一批增量合并成一条update ... set hot hot N执行实际落库的 SQL 变成每秒 5 次。这个量级数据库毫无压力。更进一步的优化是合并同 key 的写。同一篇文章被 1000 个用户点了赞不需要 1000 次累加缓存里累加到 1000落库时只写一次。这就是合并带来的额外收益也是 Write Behind 在计数类场景特别香的原因。4.2 数据丢失窗口有多大怎么兜风险也很直接数据在缓存里还没刷到数据库这时候 Redis 挂了、或者消费进程崩了这段数据就没了。丢失窗口的大小等于你的刷盘间隔200ms 刷一次就是 200ms 的窗口。能不能接受看数据性质。点赞数丢了 3 个用户根本感知不到订单状态丢了就是资损事故。所以我给这条线划得很清楚涉及钱、涉及库存扣减、涉及状态流转的一律不许用 Write Behind。如果一定要用至少做三件事。第一缓存要开持久化用 AOF 的everysec策略把丢失窗口压缩到 1 秒以内虽然这不解决刷库间隔的问题但至少进程崩溃时不至于全丢。第二刷库任务要幂等重放同一条增量不能出错。累加类操作天然幂等加两次就多算了所以要用读取当前值再覆盖或者带版本号的增量覆盖类操作也天然幂等最怕的是先查后改这种非原子操作。第三落库失败要有重试和死信队列不能吞掉异常。还有一种更稳的变体写缓存成功后立即发一条消息由消费者异步落库。这样即使刷库进程挂了消息还在队列里恢复后能重放。代价是多了一个消息队列的依赖和运维成本但可靠性比纯内存批处理好得多。4.3 什么数据适合写回什么数据碰都别碰我把判断标准整理成了一张清单实际评审时直接对照数据特征是否适合 Write Behind说明计数累加浏览量、点赞数适合少量丢失不影响业务合并收益巨大用户行为埋点、日志适合本身就是弱一致数据在线状态、心跳时间适合本身就有 TTL过期即无意义订单状态、支付结果严禁丢失即资损库存扣减严禁超卖风险不可控账户余额严禁对账成本极高需要强一致的配置下发不建议延迟生效会引发线上问题这张表的关键在于区分数据丢了会不会有人受损。不会有人受损的放心用会有人受损的老老实实同步写。5. 我在实际项目里的选型依据和几个印象深刻的坑讲了这么多模式落到选型上其实没那么复杂。我的判断顺序是先看一致性要求再看写吞吐压力最后看团队运维能力。三步走完答案基本就定了。5.1 三步判断和一个降级兜底的兜底第一步问自己缓存和数据库不一致超过 1 秒业务能不能接受。不能接受的别犹豫Cache Aside 加短 TTL别想着用花哨方案绕过去。能接受的进入第二步。第二步看写入 QPS 和数据库的承载能力。如果写 QPS 在数据库能扛的范围内我一般按压测值的 60% 作为安全线直接 Cache Aside简单可靠。如果写 QPS 超出承载且数据属于弱一致类型才考虑 Write Behind。第三步看有没有能维护复杂链路的人。Write Behind 意味着多一个刷盘服务、多一套监控、多一份故障预案。小团队、人手紧张的项目我一般劝退宁可花钱升配数据库。不管选哪种有一条必须做降级路径。缓存整体不可用时业务要能直接查库哪怕慢。我见过太多项目把降级逻辑写在文档里代码里压根没实现真出故障时只能干等。实现方式很简单包一层统一的缓存访问工具类捕获异常后打日志、打点、直接走数据库同时用熔断器控制住流量避免打垮数据库。5.2 三个真实踩过的坑都是配置和细节层面坑一更新缓存而不是删除缓存导致永久脏数据。就是开头提到的那个商品价格问题。当时的代码是先update数据库再set缓存两个写请求并发时缓存被刷成旧值而 TTL 设的是 24 小时。修复方式是改成del并把 TTL 压到 10 分钟。教训是写操作不要更新缓存只删。坑二缓存过期时间集中在整点引发周期性雪崩。一批配置数据在凌晨 0 点统一刷新代码里写的是expireAt(次日零点)结果每天零点整几千个 key 同时失效请求全打到数据库数据库 CPU 直接冲到 100%。修复方式是在过期时间上加随机抖动比如基础 TTL 上叠加 0~300 秒的随机值把失效时间打散。坑三大 key 删除阻塞主线程。有个业务把整个商品列表几千条序列化成一个 key 塞进 Redis某次运营批量改价代码里循环执行delRedis 主线程被卡了将近 2 秒所有请求超时。修复方式是换成unlink并且把大 key 拆成小 key。这次事故让我彻底记住了单个 value 超过 10KB 就要开始警惕超过 100KB 必须有拆分方案。5.3 上线前的自查清单每次做缓存方案我都会走一遍这份清单五分钟能省掉后面几天的排查所有 key 是否都设置了 TTL有没有漏网之鱼写操作是del还是set有没有更新缓存的写法缓存删除失败有没有重试和告警key 的命名是否有统一前缀能不能按业务线批量清理有没有大 key、热 key 的监控阈值是多少缓存整体不可用时的降级路径是否真实可用有没有演练过缓存和数据库的一致性有没有抽样比对机制过期时间是否做了随机打散最后再分享一个小体会。缓存这块方案越复杂出问题的概率越高而最难排查的往往不是模式选错而是某个 key 的 TTL 忘了设、某个del的异常被吞了这类细节。我现在写完缓存代码习惯性地先问自己一句如果这个缓存里的数据是错的它会在多久之后自动变对如果答不上来那说明这套缓存还缺一条兜底路径得回去补。