1. 三种模式怎么选先把缓存的位置摆正聊 Redis 缓存的三种设计模式绕不开 Cache Aside、Read/Write Through、Write Behind 这三个名字。但说实话光背这三个词没什么用我见过太多团队把“用了缓存”当成性能指标结果上线后数据对不上、库存超卖、用户看到已删除的内容还在页面上挂着最后回头查半天问题往往不在 Redis 本身而在于当初根本没想清楚缓存放在这场读写链路里的哪个位置、由谁负责和数据库对齐。我自己的判断标准很朴素缓存设计模式的本质是回答“谁在什么时候、以什么顺序去碰数据库”这个问题。业务代码自己管数据库、缓存只是旁边的一层加速层那是 Cache Aside业务代码只跟缓存打交道、缓存层自己负责落库那是 Read/Write Through业务代码写完缓存就返回、落库交给后台异步冲刷那是 Write Behind。三种模式在吞吐、一致性、实现复杂度上是一条明显的滑尺你往左边挪一点一致性就更强、吞吐就更低往右挪则反过来。这篇内容适合谁看如果你是刚开始接触 Redis 缓存、写业务代码时被“更新数据库还是先删缓存”绕晕的同学这篇会把三条链路拆开讲清楚并给出可以直接抄的代码如果你已经在线上跑着缓存、遇到过数据不一致但又说不清根因第 5 章和第 6 章的参数计算与排查表应该对你更有用。全文基于我在几个模拟项目里的实践整理其中的业务场景均为虚构代称代码是示意实现参数需要结合你自己的压测结果调整不要照搬数字。1.1 缓存到底解决什么问题又会带来什么新问题先把收益和代价摆平。缓存带来的收益很直接把原本要打到数据库的读请求拦在内存里把响应时间从毫秒级压到亚毫秒级把数据库的连接数和 CPU 占用降下来。一个典型的读多写少场景比如某内容平台的文章详情页读请求可能是写请求的几十倍甚至上百倍加一层缓存之后数据库的 QPS 压力能降一个数量级。但缓存不是白拿的。它引入了第二份数据而两份数据就必然存在时间窗口内的不一致。这个不一致窗口有多长、能不能被业务容忍才是选模式的真正依据。金融账户余额、库存扣减这类场景窗口哪怕几十毫秒也可能出事故而文章阅读数、商品评论列表这种几秒钟的延迟用户根本感知不到那就完全可以用更激进的模式换吞吐。还有一个常被忽略的代价缓存让故障面变大了。原来只有数据库一个依赖现在多了一个 Redis它可能超时、可能被限流、可能内存打满触发淘汰、可能主从切换丢数据。所以真正成熟的写法里缓存层永远是“可降级”的——Redis 挂了业务降级回源数据库慢但能活而不是整个接口 500。注意在动手选模式之前先问自己三个问题——这份数据的读写比是多少不一致窗口业务能容忍多久缓存整体不可用时业务能不能降级回源这三个答案基本就把模式定下来了。1.2 三种模式的本质区别与选型判断用一句话把三种模式的区别说透模式数据流向一致性强度吞吐能力实现复杂度典型场景Cache Aside业务代码分别操作 DB 和缓存中有短暂窗口中高低绝大多数业务读缓存Read/Write Through业务只操作缓存缓存层同步落库较高中中高缓存与存储强绑定的中间件场景Write Behind业务只写缓存异步批量落库低有丢失风险极高高计数、日志、点赞、行为埋点Cache Aside 是绝大多数系统的默认答案因为它最“土”也最可控业务代码知道自己在干什么出问题好排查。Read/Write Through 的关键特征是缓存层屏蔽了数据库业务侧看不到 DB 调用所有同步落库逻辑收敛在缓存组件内部好处是业务代码干净、一致性由统一入口保证坏处是这个组件要自己写或者依赖成熟的缓存框架出了问题排查链路更长。Write Behind 则是拿一致性换吞吐写请求全部落在内存后台按批次刷进数据库适合那些“丢一两条也不致命、但写入量极大”的数据。下面三章我会逐个拆开讲每一章都会回答同一个问题这条链路上数据在哪个瞬间可能不一致以及怎么把这个窗口压到业务能接受的范围。2. Cache Aside 旁路缓存九成业务系统的默认答案Cache Aside 也叫旁路缓存名字里的“旁路”指的是缓存位于数据库旁边业务代码是那个同时操作两边的人。读的时候先查缓存命中就返回没命中就查数据库然后回填缓存写的时候先更新数据库再让缓存失效删除而不是更新。这套流程看起来平淡无奇但魔鬼全在细节里。我见过最常见的错误写法是“更新数据库 更新缓存”看起来更直觉实际上问题更多两个并发写请求A 先更新 DB 再更新缓存B 后更新 DB 再更新缓存如果因为网络抖动导致 B 的缓存更新先落地、A 的后落地缓存里就留下了 A 的旧值而且这个旧值会一直待在那儿直到过期。改成删除缓存之后这个“后写覆盖先写”的概率大幅下降因为删除是幂等的谁先谁后结果都一样都是不存在。2.1 读链路命中、回填与空值兜底读链路的骨架只有四步但每一步都有坑。查缓存命中直接返回。未命中查数据库。数据库有值写回缓存并设置过期时间。数据库也没值写一个空值标记进缓存过期时间设短一些。第 4 步是很多新手会漏掉的。如果有人拿一个不存在的 ID 疯狂请求每次都穿透到数据库这就是典型的缓存穿透。空值缓存的处理方式很直接把null或者一个特殊标记比如字符串__NULL__写进缓存TTL 给个 30 到 120 秒。为什么不能给太长因为如果这个 ID 后来真的被创建了空值缓存会让它在一段时间内查不到业务上表现为“我刚创建的数据查不到”这是很典型的客诉来源。空值缓存的 TTL 怎么定我的经验是按“业务能容忍的新数据可见延迟”反推。比如某后台系统要求新建数据 1 分钟内必须能查到那就把空值 TTL 定在 60 秒以内。同时要给空值缓存加一个总量上限防止有人用随机 ID 把内存打爆比如限制这类 key 最多占缓存总量的 5%超出就走布隆过滤器兜底。2.2 写链路为什么是“先更新库再删缓存”这个顺序不是拍脑袋定的。假设反过来先删缓存再更新数据库会有一个经典的并发窗口线程 A 删除缓存准备更新数据库。线程 B 来读缓存未命中读到数据库里的旧值回填缓存。线程 A 完成数据库更新。结果缓存里是旧值数据库是新值而且这个旧值会一直存在到 TTL 到期。这个窗口在“先更新库再删缓存”的方案下要小得多因为删除动作发生在数据库提交之后缓存里最多存在“数据库已更新、缓存还没删”的极短时间。但请注意这只是“概率更小”不是“绝对没有”。真正极端的并发下依然可能出问题读请求在缓存失效的瞬间回源读到了旧值此时写请求完成了数据库更新并删除了缓存然后读请求把旧值回填了进去。这个窗口通常只有几毫秒但如果你面对的是强一致要求的场景就必须上延迟双删或者更重的方案。2.3 延迟双删把回填的旧值再删一次延迟双删的思路很朴素更新数据库之后删一次缓存等一小段时间再删一次把这段时间内可能被其他线程回填的旧值清掉。关键在“等多久”。这个时间要覆盖两件事一是主从复制的延迟如果你的读请求打到从库二是读请求回源加回填的耗时。我的做法是先压测拿到读接口回源链路的 P99 耗时再乘以 2 作为基准值然后加上主从复制的延迟观测值。举个具体的例子读接口回源 P99 是 40 毫秒主从延迟观测在 10 毫秒以内那延迟时间取 100 毫秒左右就够没必要上 500 毫秒甚至 1 秒。延迟双删的第二个删除动作建议放到异步线程池或者延迟队列里执行不要在主写链路里sleep。在主链路里睡等于把写接口的 RT 直接拉长了 100 毫秒高峰期线程池很快会被打满。用延迟队列比如基于 Redis 的 ZSet 做延时任务或者消息队列的延迟消息更稳妥。// 写链路示意先更新库再删缓存再异步延迟删一次 public void updateArticle(Article article) { // 1. 更新数据库事务内完成 articleMapper.updateById(article); // 2. 立即删除缓存 String key article:detail: article.getId(); redisTemplate.delete(key); // 3. 投递延迟删除任务100ms 后再删一次 delayQueue.offer(new DelayTask(key, 100, TimeUnit.MILLISECONDS)); }2.4 删除失败怎么办重试与兜底删除缓存这个动作是会失败的。Redis 抖动、网络超时、连接池打满都可能让删除落空。如果删除失败又没有兜底缓存里的旧值就会一直留到 TTL 到期对于 TTL 设得很长比如 24 小时的 key这就是一次长时间的数据不一致。我的处理方式是两层保险。第一层是有限次重试删除失败后立刻重试 2 到 3 次间隔 50 到 100 毫秒大部分抖动都能扛过去。第二层是消息队列补偿把删除失败的 key 和一个版本号投递到消息队列由消费者持续重试直到成功或者达到重试上限后告警。这里有个细节值得说补偿消息里最好带上数据版本号或者更新时间戳。消费者拿到消息后先读一下数据库里这条记录的当前版本如果版本比消息里的新说明已经有更新的写请求处理过了这条补偿消息可以直接丢弃避免用过期消息把新缓存删掉又引发一次回源。这个设计在多写并发下能省掉很多莫名其妙的“缓存被无故删除”问题。3. Read/Write Through把缓存当主存储来用Read/Write Through 的核心思想是业务代码只认缓存不认数据库。读的时候调缓存的读接口缓存未命中由缓存层自己去数据库加载写的时候调缓存的写接口缓存层自己负责同步写数据库并更新缓存。业务侧看起来缓存就是那个“唯一的数据源”。这个模式在业务代码层面确实干净不需要在每个 Service 里重复写“查缓存-回源-回填”这一套。代价是这个缓存层的实现要自己扛或者依赖成熟框架里提供的类似能力比如一些 ORM 框架的二级缓存就是类似的思路。自己写的话工作量不算小而且一旦缓存层出 bug排查起来比旁路模式难得多——因为业务代码里看不到数据库调用链路被藏起来了。3.1 读穿透未命中时由缓存层同步加载读穿透的流程是缓存层收到读请求命中直接返回未命中时缓存层调用配置好的加载器Loader去数据库查查到后写入缓存并返回。这里最关键的设计是Loader 的抽象。加载器需要知道“这个 key 对应哪张表、用什么条件查、查出来怎么序列化”。常见做法是按实体类型注册加载器key 的命名里带上实体标识比如user:{id}对应用户加载器。加载器内部还要做几件事空值处理查不到也要缓存空标记、并发控制同一个 key 的加载要合并避免缓存击穿、异常处理数据库异常时不能把异常当成空值缓存起来。加载器的并发合并特别值得说。如果同一个热点 key 失效瞬间几十个请求同时未命中每个都去查数据库这就是缓存击穿。Read/Write Through 模式下这个问题的处理责任在缓存层通常用一个 key 级别的互斥锁只有拿到锁的线程去加载其余线程短暂等待后重读缓存。def read_through(key, loader, ttl300): value redis.get(key) if value is not None: return deserialize(value) lock_key flock:{key} # 拿锁超时时间按回源 P99 的 2~3 倍设置 if redis.set(lock_key, 1, nxTrue, ex3): try: value loader() # 回源数据库 if value is None: redis.set(key, NULL_MARK, ex60) # 空值短 TTL else: redis.set(key, serialize(value), exttl) return value finally: redis.delete(lock_key) else: # 没拿到锁短暂等待后重读缓存 time.sleep(0.05) return read_through(key, loader, ttl)3.2 写穿透同步落库的代价在哪写穿透的流程是业务调缓存的写接口缓存层先或后写数据库再更新或失效缓存整个过程同步完成才返回。同步落库带来的直接后果是写接口的 RT 等于数据库写 RT 加缓存操作 RT没有任何折扣。这意味着写穿透在写密集场景下并不比直接写数据库快多少它的价值主要体现在“业务代码干净”和“一致性由统一入口保证”上而不是性能。那它适合什么场景我总结下来是两类。一类是读远多于写、且实体关系相对固定的系统比如配置中心、字典表、权限数据这类写操作很少绝大部分是读读穿透能省掉大量重复的回源代码。另一类是缓存层和存储层需要严格绑定的中间件场景比如自己封装的通用数据访问层所有业务都走它一致性策略统一在这一层实现比让每个业务团队各写一套要可靠得多。不适合的场景也很明确高并发写入的计数、订单状态流转这类写穿透的同步链路会成为瓶颈而且每次写都要同步碰数据库等于把缓存的吞吐优势抵消掉了。3.3 一个容易忽略的坑写穿透下的事务边界写穿透模式下缓存层内部同时操作了数据库和缓存这两者不在同一个事务里。如果数据库写成功、缓存更新失败缓存里就是旧值。如果数据库写失败、缓存却已经更新了缓存里就是一条数据库里不存在的数据这个更严重。解决办法是把缓存更新放在数据库事务提交之后并且缓存更新失败时回滚不了数据库只能标记为“脏 key”投递补偿。另外缓存层内部不要开跨库事务那是自找麻烦。我的做法是数据库事务内只做数据库的事事务提交成功后通过一个提交后回调afterCommit去更新或删除缓存失败就进补偿队列。这套逻辑在旁路模式里也是通用的只是旁路模式下这段代码由业务自己写写穿透下由缓存层统一写。注意不管哪种模式都不要在数据库事务未提交时去操作缓存。事务还没提交别的线程读缓存读到的可能是你马上要回滚的数据这种“脏读进缓存”的问题排查起来极其痛苦。4. Write Behind 写回吞吐量放大器与它的代价Write Behind 也叫 Write Back思路是写请求只写缓存立刻返回成功缓存层在后台按一定策略把变更批量刷进数据库。这一下就把写接口的 RT 从“数据库写耗时”压到了“内存写耗时”吞吐能有数量级的提升。代价也很直白数据在刷盘之前只存在于内存里Redis 挂了或者进程崩了这段数据就没了。另外如果同一条记录在短时间内被改了很多次只有最后一次会真正落库这其实是好事减少了写放大但如果业务需要保留每一次变更的历史写回模式就不合适。我在几个模拟项目里用过写回都是那种“丢一点无所谓”的场景文章的阅读数、点赞数、用户行为埋点、接口调用统计。这类数据即使丢了最近几百毫秒的量业务上也完全能接受而它们恰恰是写入量最大、最不值得为每条都去碰一次数据库的部分。4.1 写回的执行流程拆解完整的写回链路大概是这样写请求到达更新 Redis 里的对应字段比如HINCRBY article:stat:{id} read_count 1。同时把这次变更记录进一个待刷新的队列可以用 Redis 的 List 或者 ZSet按 key 去重。后台线程按固定间隔比如 100 毫秒或者按累积条数比如 200 条触发一次冲刷。冲刷时批量读取待刷新 key 的当前值组装成批量更新语句写进数据库。写库成功后再从待刷新队列里移除这些 key失败则保留下个周期重试。这里有个设计要点待刷新队列里存 key不存具体值。因为同一条记录可能被改了很多次队列里存值的话要么产生大量冗余要么需要复杂的合并逻辑。存 key 的话冲刷时直接读缓存里的最新值就行天然做到了“只落最终值”。4.2 数据丢失窗口有多大怎么算丢失窗口 冲刷间隔 重试等待时间。假设冲刷间隔 100 毫秒失败重试 3 次、每次间隔 200 毫秒那最坏情况下一条数据在内存里待了大约 700 毫秒还没落库这期间进程挂掉这条数据就丢了。这个窗口能不能接受取决于业务。对于阅读数这类700 毫秒的丢失量可能就几条无所谓。对于订单金额这类一毫秒都不能丢那就根本不该用写回。除了进程崩溃还有两个风险点要提前想好。一是Redis 内存打满触发淘汰待刷新的 key 被淘汰掉数据就静默丢失了这个比崩溃更隐蔽。所以写回场景下的 Redis 一定要设置noeviction或者把待刷新数据的 key 加上保护前缀、监控淘汰量。二是主从切换丢数据异步复制下主节点挂了最近的写入可能还没同步到从节点所以写回模式不要依赖主从切换来做高可用兜底。4.3 落地时必须补上的四件事第一件是幂等。冲刷失败重试时同一条更新可能被执行两次。如果用的是SET count 100这种覆盖式写法重复执行没问题如果用的是count count 1重试就会多加。所以冲刷语句要写成基于最终值的覆盖或者带上版本号做乐观锁。第二件是批量大小控制。一次冲刷的批量太大单条 SQL 会很长数据库解析压力大、锁持有时间长批量太小又起不到合并写入的效果。我的经验是单批 200 到 500 条单条 SQL 的大小控制在几百 KB 以内具体按数据库的max_allowed_packet反推。第三件是冲刷与业务读的一致性。如果业务读走的是数据库那在冲刷之前读到的是旧值如果业务读走缓存读到的是新值。同一个接口里两种读法混用就会出现“刚写完刷新页面列表里还是旧数字”的诡异现象。解决办法是同一个业务域内的读统一走缓存优先。第四件是优雅停机时的强制冲刷。进程收到停止信号时先把待刷新队列里的数据全部刷完再退出能挽回大部分丢失。这一步很多人会漏加上的成本很低收益却很实在。5. 实操把三种模式跑起来的完整流程与参数这一章把前面讲的东西落到具体操作上。我用一个虚构的“模拟项目”来说明一个内容详情服务文章主体数据读多写少用 Cache Aside文章的阅读数、点赞数写多读多且能容忍短暂不一致用 Write Behind后台的字典配置读极多写极少用 Read/Write Through 的思路封装。三种模式在同一个系统里并存各自解决不同的问题。5.1 数据模型与 Key 设计先把 key 规范定下来这是后面所有参数计算的基础。数据类型Key 格式结构TTL说明文章详情article:detail:{id}String(JSON)30 分钟 随机抖动Cache Aside空值标记article:detail:{id}String(__NULL__)60 秒防穿透回源互斥锁lock:article:{id}String3 秒防击穿阅读计数article:stat:{id}Hash不过期靠 LRU 淘汰Write Behind待刷新队列stat:pendingZSet不过期按 score 去重字典配置dict:{type}String(JSON)2 小时读穿透TTL 为什么加随机抖动因为如果一大批 key 在同一时刻被批量写入、TTL 又完全相同它们会在同一秒集体失效全量打到数据库这就是缓存雪崩。抖动的幅度取基础 TTL 的 5% 到 10%比如 30 分钟的基础 TTL抖动范围就是 0 到 180 秒。内存估算也得提前做。假设文章详情平均 2 KB热点文章 20 万篇那光详情缓存就是 400 MB 左右加上 key 本身和 Redis 内部开销通常按 value 大小的 1.2 到 1.5 倍估算预留 600 MB 比较稳妥。计数类数据假设 100 万篇文章、每篇一个 Hash 含 3 个字段每个 Hash 约 200 字节合计约 200 MB。加起来一个实例 1 GB 左右这个量级单实例完全扛得住不需要急着上集群。5.2 Cache Aside 的完整实现互斥锁加逻辑过期先看回源互斥锁。锁的超时时间怎么定按回源链路的 P99 耗时乘以 3 来设。比如回源 P99 是 800 毫秒包含数据库查询和 JSON 序列化那锁超时设 3 秒比较合适。设太短回源还没结束锁就自动释放了互斥失效设太长持锁线程一旦挂掉其他线程要等很久。3 秒这个值是基于“绝大多数回源都能在 1 秒内完成”的经验判断。public Article getArticle(Long id) { String key article:detail: id; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { if (__NULL__.equals(cached)) { return null; // 命中空值标记直接返回保护数据库 } return JSON.parseObject(cached, Article.class); } String lockKey lock:article: id; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (locked) { try { // 双重检查防止等锁期间已被其他线程回填 cached redisTemplate.opsForValue().get(key); if (cached ! null) { return __NULL__.equals(cached) ? null : JSON.parseObject(cached, Article.class); } Article article articleMapper.selectById(id); if (article null) { redisTemplate.opsForValue() .set(key, __NULL__, 60, TimeUnit.SECONDS); } else { long ttl 1800 ThreadLocalRandom.current().nextLong(180); redisTemplate.opsForValue() .set(key, JSON.toJSONString(article), ttl, TimeUnit.SECONDS); } return article; } finally { redisTemplate.delete(lockKey); } } else { // 没抢到锁短暂等待后重试读缓存避免所有请求都去回源 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getArticle(id); } }互斥锁方案在极热点场景下有个副作用锁竞争激烈时没抢到锁的线程都在sleep重试会占用大量工作线程。如果某个 key 的 QPS 特别高比如首页 banner建议改用逻辑过期方案缓存里存的是{data, expireAt}物理上不设 TTL 或者设一个很长的 TTL读到数据后判断expireAt是否已过过期则异步起一个线程去刷新当前请求先返回旧数据。这样所有请求都能立刻拿到数据只有刷新是异步的代价是刷新期间会读到旧值。5.3 穿透、击穿、雪崩的防护参数怎么算这三个词经常被混着用其实防护手段完全不同参数也不同。缓存穿透针对的是“查不存在的数据”。除了空值缓存还可以上布隆过滤器做前置拦截。布隆过滤器的误判率公式是p ≈ (1 - e^(-kn/m))^k其中 n 是预期元素数量、m 是位数组长度、k 是哈希函数个数。实际调参时不用自己算按“给 100 万条数据、要求误判率 1%”来估位数组大概 1 MB 出头3 到 7 个哈希函数都行。要注意布隆过滤器只能判断“一定不存在”和“可能存在”删除数据后它没法同步移除所以适合数据只增不改不删的场景。缓存击穿针对的是“单个热点 key 失效瞬间的大量并发”。防护参数就是互斥锁的超时时间和等待重试的间隔前者按 P99 乘 3 定后者一般 20 到 50 毫秒。缓存雪崩针对的是“大量 key 同时失效或 Redis 整体不可用”。前者靠 TTL 抖动解决抖动幅度取基础 TTL 的 5% 到 10%后者靠降级和限流Redis 不可用时业务回源数据库同时在数据库前面加一层并发限制比如用信号量把回源并发控制在 50 以内超出的请求短暂等待或直接返回兜底数据避免数据库被打穿。注意TTL 抖动只对“批量写入”有效。如果你的 key 是分散写入的它们的过期时间天然就分散无需额外抖动但如果你的缓存是用定时任务批量重建的那必须加抖动否则每天凌晨重建完第二天凌晨就是雪崩时刻。5.4 Write Behind 的批量冲刷参数冲刷的两个触发条件时间间隔和累积条数满足其一就触发。时间间隔的定法是从丢失窗口反推。假设业务能接受最长 1 秒的数据丢失那冲刷间隔最好不要超过 300 毫秒给重试留出余量。我一般设 100 到 200 毫秒。累积条数的定法是从批处理效率反推。数据库批量更新的性能拐点通常在单批 500 到 1000 条之间取决于单条数据宽度我一般设 200 条兼顾延迟和效率。def flush_pending_stats(batch_size200): # 从待刷新 ZSet 里取最早的一批 key keys redis.zrange(stat:pending, 0, batch_size - 1) if not keys: return updates [] for key in keys: article_id key.split(:)[-1] stat redis.hgetall(key) if not stat: redis.zrem(stat:pending, key) continue updates.append({ id: article_id, read_count: int(stat.get(read_count, 0)), like_count: int(stat.get(like_count, 0)), }) try: # 基于最终值的覆盖式更新天然幂等重试安全 db.batch_upsert_article_stat(updates) redis.zrem(stat:pending, *keys) except Exception as e: log.warn(flush failed, will retry next round: %s, e) # 不移除下个周期自动重试这里用 ZSet 而不是 List是为了让同一个 key 多次写入时能靠 score 去重。比如 score 用当前时间戳每次写入时先zadd更新 score同一个 member 只会保留一份天然实现了“合并重复写入”。冲刷时按 score 从小到大取先进先出避免某些 key 被饿死。还有一个细节更新语句必须是基于最终值覆盖不能用增量。因为冲刷读的是缓存里的当前绝对值如果写成SET read_count read_count ?重试的时候就会多算一次。用INSERT ... ON DUPLICATE KEY UPDATE read_count VALUES(read_count)这类写法重复执行结果一致。6. 常见问题与排查技巧实录缓存相关的问题有个共同特点现象很诡异日志里看不出啥但原因往往就那几类。下面这张表是我自己整理的速查表遇到问题先对号入座。6.1 高频问题速查表现象最可能的根因排查手段处理方式刚更新的数据查不到新值写后没删缓存或删了但被旧值回填查缓存内容和数据库对比看 TTL加延迟双删补偿队列带上版本号数据永久不一致重启才恢复删除缓存失败且无兜底TTL 又很长搜删除失败的日志看 TTL 设置加重试和补偿TTL 加上限某个 key 的 QPS 打满数据库热点 key 失效击穿看回源日志的 key 分布加互斥锁或改逻辑过期凌晨数据库负载突增批量 key 同时过期雪崩看缓存命中率曲线是否断崖TTL 加随机抖动错峰重建大量不存在 ID 的请求打到库缓存穿透看回源请求的 ID 命中率空值缓存加布隆过滤器计数类数据偶尔少一点写回模式丢了未刷新的数据查冲刷失败日志、Redis 淘汰量缩短冲刷间隔改 noeviction缓存内存涨得很快空值缓存或大 key 堆积MEMORY USAGE抽样看大 key限制空值数量拆分大 key主从切换后读到旧数据读请求打到延迟较大的从库对比主从数据版本关键读走主库或加版本校验6.2 几个只有踩过才知道的坑第一个坑以为删了缓存就万事大吉其实删错了 key。有次排查一个“用户改了昵称但页面没变”的问题查了半天代码逻辑都对最后发现缓存 key 里拼的是旧的用户 ID 格式删除时用的新格式两个 key 根本不是同一个。这类问题在 key 拼接逻辑散落在多个地方时特别容易发生。我的做法是把 key 的生成收敛到一个工具类里所有地方统一调用禁止手写字符串拼接。第二个坑序列化方式不一致导致读出来的数据是乱码或者反序列化失败。同一个 key写的时候用 JDK 序列化读的时候用 JSON或者升级了实体类字段但缓存里还是旧结构。缓存里存 JSON 时实体类加字段要保证向后兼容删字段要谨慎最稳的做法是给缓存结构带上版本号比如article:detail:v2:{id}结构变更时直接换版本号旧的自然过期。第三个坑在事务里删缓存事务回滚了缓存却已经被删。这个前面提过但值得再说一遍因为它太常见了。表现是“什么都没改成功缓存却被清了然后一堆请求去回源”。用afterCommit回调或者事务同步器能解决成本很低。第四个坑压测时 Redis 好好的上线后疯狂超时。十有八九是连接池配置的问题。默认连接池可能只有 8 个连接高并发下大量请求在等连接。经验值是最大连接数按“单实例 QPS × 平均 RT秒”来估比如单实例 2000 QPS、平均 RT 1 毫秒那理论上 2 个连接就够但考虑到突发和阻塞实际配 32 到 64 更稳。第五个坑用KEYS *排查问题直接把线上 Redis 卡住。KEYS是 O(N) 的阻塞命令几百万 key 的实例上执行一次能卡住好几秒期间所有请求超时。排查大 key 用SCAN配合MEMORY USAGE排查模式匹配用SCAN MATCH别用KEYS。这个坑几乎每个团队都踩过一次。第六个坑写回模式下读接口读缓存、列表接口读数据库两边数字对不上。用户刚点完赞详情页显示 101刷新一下列表页显示 100体验极差。解决办法是同一个业务域内的读统一走缓存或者列表页的数字也从缓存聚合别混着来。我个人在实际操作中的体会是缓存的复杂度从来不在 Redis 这一侧而在“业务代码怎么和它配合”这一侧。三种模式里Cache Aside 是最值得先吃透的因为它把一致性的责任明确摆在业务代码面前逼着你想清楚每个写操作的顺序和失败兜底Read/Write Through 和 Write Behind 更像是把这份责任收归到某一层用封装换取一致性策略的统一但前提是你真的有能力把那层封装写对。如果团队规模不大、业务还在快速迭代老老实实用 Cache Aside把 key 规范、TTL 抖动、空值缓存、删除补偿这四件事做扎实已经能覆盖九成以上的问题。剩下那一成等真的遇到了、并且能说清楚“不一致窗口有多大、业务能不能忍”再去考虑换模式也不迟。
阅读完成 · 觉得有帮助?