资讯动态

MySQL与Redis缓存一致性:脏数据场景、延迟双删与日志订阅兜底

发布时间:2026/10/26 13:22:52 来源:尧图企业网站定制
1. 先把“不一致”这件事定义清楚MySQL 和 Redis 数据一致性这个话题几乎每个做过缓存的团队都会撞上。我在面试里问过不下五十个人绝大多数人第一反应就是“先更新数据库再删除缓存”再往下追问“为什么不是先删缓存”“删缓存失败了怎么办”“主从延迟的时候读请求会不会把旧值写回缓存”能答完整的不超过五个。这说明一件事这个问题的难点从来不在代码怎么写而在于你有没有把“不一致”这件事拆到足够的细度。缓存一致性说的不是数据库和缓存里永远一模一样那是不可能做到的除非你放弃缓存带来的全部性能收益它真正要解决的是——在哪些时间窗口里用户有可能读到旧数据这个窗口有多长能不能接受出现了怎么兜住。写过线上业务的人都知道脏数据往往不是均匀分布的。它集中出现在几个特定的瞬间事务刚提交但缓存还没删、删了缓存但读请求正好落在主从延迟区间里、缓存删除请求因为网络抖动丢了、两个并发写请求的删除动作顺序颠倒。这些窗口的持续时间可能只有几十毫秒但落在热点商品、热点账户上就是成百上千次错误曝光。这篇内容我会按我自己真实的落地顺序来写先把场景定义清楚再讲方案取舍的底层逻辑然后给一套能直接抄的代码和时间参数计算过程最后是巡检、灰度和踩坑记录。适合已经在用缓存、被脏数据折腾过一轮的中高级开发看如果你刚接触前两章把概念补齐了也能跟上。2. 四种典型脏数据场景先对号入座在动手改代码之前我习惯先拿一张纸把可能出问题的路径画出来。缓存和数据库双写的一致性坑朴素地讲就四大类认清它们后面的方案选择会变得非常清晰。2.1 双写顺序带来的“旧值回填”最经典的是“先删缓存、再更新数据库”这条路。线程 A 收到写请求先把缓存里的 key 删掉然后去更新数据库与此同时线程 B 收到读请求缓存未命中于是回源读数据库读到了 A 还没提交的旧值顺手把它写回缓存接着 A 的事务提交数据库变成新值。结果就是数据库新、缓存旧而且如果这个 key 没有过期时间脏数据会一直挂在那里。这个窗口为什么这么容易命中因为读操作通常比写操作快一个数量级。写请求涉及事务、锁、磁盘刷盘一次可能要几十毫秒而“读缓存未命中 → 查库 → 写缓存”这条链路在库表数据量小的时候几毫秒就跑完了。时间差越大被插进去的概率越高。所以“先删缓存再更新数据库”这个顺序除非你配上延迟双删或者加锁否则在并发写场景下基本等于把脏数据往上送。反过来“先更新数据库、再删除缓存”这条路出问题的窗口要小得多。要产生脏数据必须让线程 B 的“读库 → 写缓存”整个过程完整地跨在线程 A 的“更新库 → 删缓存”之间而读库本身比写库快交叉成功的概率被压得很低。这不是靠数学证明是经验概率也是为什么业界主流推荐这个顺序的真正原因。2.2 缓存删除失败与“沉默的脏数据”第二种场景更隐蔽数据库更新成功了删缓存也发出去了但 Redis 那边因为网络丢包、主从切换、连接池耗尽、超时被熔断删除最终没生效。业务代码里通常是一个 try-catch 把异常吞掉打一行 warn 日志接口照样返回成功。用户看到的是“修改成功”刷新页面还是旧数据。这类问题的麻烦在于它不报错、不告警、不影响成功率的监控曲线只有等到用户投诉或者对账发现差异才暴露。我见过一个案例某个配置类数据缓存没过期时间一次批量删除时 Redis 连接池打满导致部分 key 删除失败脏数据挂了三天。所以任何删除缓存的调用都必须配一个可靠的兜底通道这一点后面我会给具体实现。第三种是并发写导致的删除顺序错乱。两个请求同时更新同一条记录A 先更新库值变成 1B 后更新库值变成 2但 B 的删除缓存请求先到A 的删除请求后到。虽然最终数据库是 2缓存被删掉后下次读会回填成 2其实没问题。真正有问题的是“更新缓存”而不是“删除缓存”的方案A 把缓存写成 1B 把缓存写成 2如果 B 的写入先执行、A 的后执行缓存就永久变成 1 了。这就是我不推荐“写缓存”的核心原因留到下一章展开。2.3 主从延迟制造的“合法旧值”第四种是我认为最容易被忽略的一类。很多团队为了扛读流量数据库做了读写分离。写请求走主库读请求走从库。当你“更新主库 → 删除缓存”之后读请求进来缓存未命中回源读从库而从库还没同步到最新值于是读到旧值并回填进缓存。这条路径可怕的地方在于每一个环节单独看都是正确的删除缓存成功了读请求正常回源了从库也只是延迟了一点点。但组合起来就产出了脏数据。它的触发条件是“缓存未命中 主从延迟未收敛”热点 key 在删除后必然有一次未命中所以只要主从延迟存在这个概率就不低。同机房的主从延迟通常在几毫秒到几十毫秒跨可用区可能上百毫秒遇到大事务、批量更新、DDL、从库压力大时延迟会突然飙到秒级。这类问题最典型的特征是“偶发、刷新一下就好了”很多人以为是浏览器的锅其实是缓存里被塞了一个短暂存在的旧值。还有一类不算脏数据但同样致命的缓存击穿。热点 key 刚被删除瞬间大量请求同时未命中全部压到数据库上。如果不做互斥或者逻辑过期删除缓存本身就可能变成一次小型的流量冲击。这个问题和一致性是两个维度但经常一起出现方案设计时要一并考虑。2.4 什么程度的“一致”才是可接受的把场景摆清楚之后下一个问题就是定标准。我的经验是把业务分成三档分别对应不同的投入成本一致性档位典型业务可接受的不一致窗口推荐方案强一致账户余额、库存扣减、权限校验接近 0不允许读到旧值不用缓存读或缓存只做降级走数据库 分布式锁准强一致订单状态、商品价格100ms ~ 1s 内收敛更新库 删缓存 延迟双删 日志订阅兜底最终一致商品详情、列表页、统计类数据秒级到分钟级更新库 删缓存 合理 TTL靠过期兜底大多数业务其实落在第二档和第三档但很多团队要么用第三档的方案去做第一档的业务要么为了第一档的目标把整个链路拖垮。我见过最极端的反例是一个商品详情页为了保证“绝对一致”在读取时加了分布式锁结果单机 QPS 从八千掉到八百。判断标准应该是如果用户读到旧数据最坏后果是什么会不会造成资金损失会不会引发客诉把这两个问题回答完档位自然就定了。3. 方案选型的底层逻辑为什么“删缓存”比“写缓存”更稳这一章讲取舍。市面上的方案就那么几种但每种方案背后的假设和代价差异极大选错了不是性能问题是数据正确性问题。3.1 四种双写顺序的失败窗口对比把“更新数据库”和“处理缓存”两个动作排列组合一共四种先删缓存后写库、先写库后删缓存、先写缓存后写库、先写库后写缓存。后两种属于“更新缓存”路线前两种属于“删除缓存”路线。删除缓存路线的核心优势是懒加载。写完数据库只负责把缓存标记为失效真正的数据在下次读请求到来时才生成而那次生成一定读的是当前数据库的值。这意味着缓存里的值永远来自数据库的某一次真实快照而不是应用侧拼装出来的东西。应用侧拼装的风险在于如果缓存值是多个表 join 出来的聚合结果那么“更新缓存”就需要在每次写入时重算整套聚合逻辑一旦某张表更新没触发到缓存就长期错了。更新缓存路线还有一个更致命的并发问题写入无版本后写覆盖先写。两个线程几乎同时更新同一条记录先完成数据库更新的一方先写缓存后完成的一方后写缓存如果因为网络原因顺序倒过来缓存里留下的就是旧值而且不会再有任何机制去修正它。要解决必须引入版本号比较那复杂度已经超过删除方案了。所以我的结论很明确除非是写多读极少、且缓存值计算成本极高的场景否则一律用删除缓存不要更新缓存。写多读少的场景比如某些计数器确实可以考虑更新缓存但那时应该用 Redis 的原子自增或者 Lua 脚本保证操作顺序而不是“读出来改再写回去”。那么在“先删缓存”和“先写库后删缓存”之间怎么选直觉上先删缓存更“保守”实际恰好相反。先删缓存的问题是删完之后数据库还没更新这中间的任何读请求都会读到旧值并回填窗口取决于事务执行时长写事务越重越危险。先写库后删缓存的问题窗口是“读库 写缓存”的耗时通常更短。所以我默认选后者只有在写事务极轻、读链路极重且明确能靠延迟双删兜住的场景才会考虑前者。3.2 延迟双删、日志订阅、分布式锁三条路线选定“先写库后删缓存”作为基础之后还要解决“删了之后又被回填旧值”和“删失败”两个问题。业内有三条主要的兜底路线。路线一延迟双删。写完数据库后删一次缓存然后延迟一段时间再删一次。第二次删除的目的是把第一轮可能被回填进去的旧值清掉。它的假设是所有脏回填动作都会在延迟时间内完成第二次删除之后就不会再有旧值出现。这个假设要成立延迟时间必须大于主从延迟加上读库耗时。优点是不引入任何外部组件实现简单缺点是延迟时间只能估、无法精确保证而且延迟任务在应用重启时会丢失需要配合持久化调度。路线二增量日志订阅。订阅数据库的变更日志把每一行变更翻译成“删除对应缓存 key”的消息投递到一个可靠队列由消费者执行删除。这条路的优势是解耦且可靠删除动作不再依赖业务代码即使业务代码忘了删、删失败、或者有别的系统直接改库缓存都能被正确失效。它天然支持乱序以外的一切场景只要消费者按主键分区保证同一 key 的消息有序最终一定收敛。代价是需要维护一套日志订阅和消费链路运维成本不低消息有延迟通常几十到几百毫秒属于典型的最终一致。路线三分布式锁 串行化。写请求加锁删缓存读请求发现缓存未命中也去抢同一把锁抢到之后回源并回填。这把锁保证了“删除”和“回填”不会交叉。它的一致性是三条路里最强的实时性好但并发能力被锁住热点 key 上读请求全部排队吞吐直接掉一个数量级。所以我只在库存、余额这类必须强一致的小流量场景用它而且考虑用分段锁或者本地锁 Redis 锁的双层结构减少开销。实际落地中我通常是组合使用主链路用“先写库 删缓存 延迟双删”延迟双删负责覆盖读回填窗口同时接一条日志订阅作为兜底负责修复删失败和旁路改库的情况。两者不冲突日志订阅相当于一层不可绕过的保险。3.3 一张选型表和我的决策顺序把三种路线的关键指标摊开对比选型会直观很多维度延迟双删增量日志订阅分布式锁一致性强度中依赖延迟估计中高最终一致通常百毫秒级高对业务代码侵入高每处写都要加低集中在一处高额外组件依赖无需延迟调度需要日志订阅与消息队列需要 Redis 锁热点 key 性能影响无无严重旁路改库能否覆盖不能能不能实现与运维成本低高中我的决策顺序是这样的先问业务能不能接受最终一致不能就走锁或者干脆不缓存能接受的话看团队有没有现成的日志订阅和消息队列基础设施有就直接上路线二为主、路线一为辅没有就先落地路线一把延迟双删和失败重试做扎实等基础设施补齐再演进。千万不要一上来就追求“完美方案”很多团队卡在方案设计阶段三个月线上脏数据一天没停过。4. 实操落地一套能跑的最终一致方案这一章是全文最干的部分我按真实项目里的实现顺序来写Key 怎么设计、写链路怎么写、延迟时间怎么算、失败怎么补偿。代码用 Java 举例逻辑换成任何语言都一样。4.1 数据结构与 Key 设计的前置约定动手写代码之前先定三件事这三件事定错了后面全是坑。第一Key 的命名必须能从数据行直接推导出来。格式建议业务域:实体:主键例如order:detail:10086。千万别在 key 里混入查询条件、分页参数、用户维度否则日志订阅那条兜底链路根本不知道该删哪个 key。如果一个实体有多种维度的缓存详情、列表、计数每个维度一个独立 key各自能被独立失效。第二必须给 key 设过期时间且带随机抖动。过期时间是最后一道保险任何方案失效时它都能兜住。抖动是为了防止大批 key 在同一秒集中过期把数据库打穿。我的做法是基础 TTL 加一个 0 到 20% 的随机偏移private long ttlWithJitter(long baseSeconds) { long jitter (long) (baseSeconds * 0.2 * ThreadLocalRandom.current().nextDouble()); return baseSeconds jitter; }基础 TTL 按数据的热度和变更频率定商品详情这类给 30 到 60 分钟配置类给 10 分钟几乎不变的字典数据给 2 小时但必须保证有主动失效通道。热点 key 不设永久过期因为永久 key 一旦脏了就是永久脏。第三缓存值里必须带版本号。版本号可以是数据库里的自增版本字段、更新时间戳毫秒数任何单调递增的量。它的作用是在回填时做一次比较防止旧值覆盖新值这是整个方案里成本最低、收益最高的一处加固。同时空值也要缓存用一个特殊标记比如__NULL__TTL 给 60 秒防止缓存穿透把库打穿。4.2 写链路的代码实现与三处关键加固写链路的标准流程是数据库更新带乐观锁→ 删缓存 → 投递延迟删除任务 → 失败进重试队列。看代码public void updateOrder(Order order) { // 1. 数据库更新带版本号乐观锁防止并发写互相覆盖 int rows orderMapper.updateWithVersion(order); if (rows 0) { throw new ConcurrentModifyException(订单被并发修改请重试); } String key order:detail: order.getId(); // 2. 首次删除失败不抛异常转投重试 try { redis.del(key); } catch (Exception e) { log.warn(cache delete failed, key{}, key, e); deleteRetryQueue.offer(key); // 本地有界队列由后台线程消费 } // 3. 延迟二次删除覆盖读回填窗口 delayScheduler.schedule(() - { try { redis.del(key); } catch (Exception e) { deleteRetryQueue.offer(key); } }, DELAY_MILLIS, TimeUnit.MILLISECONDS); }这里有三处加固每一处都是踩过坑才加的。乐观锁是前提不是可选项。没有版本号两个并发写会在数据库层互相覆盖缓存层再怎么处理都没意义。版本号字段建议直接用更新时间戳省一个字段但在同一毫秒内的两次更新会失效极端情况下要加自增版本。删除异常必须捕获并转投不能向上抛。有人觉得删缓存失败应该让整个请求失败回滚听起来很“正确”实际上很危险数据库已经提交了你抛异常回滚不了只是把一次缓存删除失败升级成一次用户可见的失败。正确做法是吞掉异常、投递重试、打点告警。重试队列用本地有界队列加个数上限避免 Redis 长时间不可用时把内存撑爆队列满了就只打点等 TTL 兜底。延迟任务要能持久化。基于内存的ScheduledExecutorService在应用重启时会丢掉所有未执行的延迟任务发布期间正好有写请求脏数据就可能留下。生产环境我一般用带持久化的延迟队列或者把延迟任务写入数据库表让定时任务扫描量大的话用消息队列的延迟消息能力。这一点很多人忽略发布窗口恰恰是脏数据的高发期。4.3 延迟时间到底该设多少含计算过程延迟双删最常被问的就是“延迟多久”。随便填个 500 毫秒也能跑但我想把推导过程写清楚这样你在自己的环境下能算出合适的值。延迟时间的物理含义是从数据库提交完成到所有可能读到旧值的读请求完成回填这个时间窗的最长长度。它由三段组成主从同步延迟。第一个删除动作发生时从库可能还没同步到新值。读请求打到从库读到旧值的最大时间窗等于主从延迟的 P99 值。同机房通常 20 到 80 毫秒跨可用区按实际监控值取一定要用 P99 而不是平均值。单次读库加回填的耗时。这是读请求从开始查库到写缓存完成的时间包括 SQL 执行、序列化、Redis 写入。P99 按业务量级估简单主键查询 20 到 50 毫秒带 join 的复杂查询可能 200 毫秒。缓冲余量。留给 GC 停顿、线程调度、网络抖动。我一般给 150 到 200 毫秒。代入一个真实量级主从延迟 P99 取 80 毫秒读库耗时 P99 取 50 毫秒缓冲 200 毫秒合计 330 毫秒。往上取整到 500 毫秒留足冗余。公式可以写成delay masterSlaveDelayP99 readAndFillP99 buffer(150~200ms)两个边界要注意。别设太小小于主从延迟的 P99 就等于没做第二次删除打在旧值回填之前白删。也别设太大每笔写都挂一个延迟任务延迟拉到 5 秒队列积压会很明显而且这段时间内如果发生第二次写两个延迟任务叠加删除次数翻倍。我的经验区间是 300 毫秒到 1 秒超过 1 秒说明主从延迟已经严重到该先解决基础设施问题而不是继续调参数。还有一个容易被忽略的细节延迟任务里删除的 key如果在这段时间内已经被别的写请求删掉并回填了新值第二次删除会把新值也删掉。这不是数据错误只是多了一次缓存未命中下一次读会重新回填。所以延迟删除是幂等且安全的宁可多删不能少删。4.4 读取端的版本号比较把回填旧值的路彻底堵死延迟双删是概率性覆盖真正能把“旧值覆盖新值”这件事从机制上消除的是读取端的版本号比较。思路很简单缓存里存的值带上 version回填之前先比较只有新值的 version 严格大于缓存里的 version 才允许写入。用 Lua 脚本保证原子性避免“比较通过但写入前被改”的竞态-- KEYS[1] 缓存key -- ARGV[1] 待写入值的version -- ARGV[2] 待写入的完整JSON -- ARGV[3] 过期毫秒数 local cur redis.call(GET, KEYS[1]) if cur then local ok, obj pcall(cjson.decode, cur) if ok and obj[version] and tonumber(obj[version]) tonumber(ARGV[1]) then return 0 end end redis.call(SET, KEYS[1], ARGV[2], PX, ARGV[3]) return 1对应的读取逻辑public Order getOrder(long id) { String key order:detail: id; String cached redis.get(key); if (cached ! null) { return __NULL__.equals(cached) ? null : JSON.parseObject(cached, Order.class); } Order order orderMapper.selectById(id); // 关键这里走主库 if (order null) { redis.set(key, __NULL__, PX, 60_000); return null; } String json JSON.toJSONString(order); redis.eval(FILL_SCRIPT, singletonList(key), asList(String.valueOf(order.getVersion()), json, String.valueOf(ttlWithJitter(1800) * 1000))); return order; }两个点值得强调。第一回源读必须走主库。上面 4.2 节讲的主从延迟问题最直接的解法就是让缓存回源不走从库。代价是缓存未命中的请求全部压到主库所以只对写了之后的那一小段时间有意义常见的做法是“写后 N 秒内的读强制主库”用一个短期的标记位比如本地缓存记一下最近写过哪些 key来控制成本很低。如果实在不想改造那至少要把延迟双删的延迟时间按主从延迟来算。第二Lua 脚本里的判断要小心。用大于等于意味着同版本不覆盖这在“数据库不会产生同版本不同内容”的前提下是安全的如果你的 version 是毫秒时间戳且存在同毫秒更新就要改成用自增序列。4.5 日志订阅兜底与删除失败的重试设计前面那套方案有一个共同的前提业务代码记得删缓存且删除动作能到达 Redis。现实中这两个前提都会破有人写了新接口忘了删、有运营后台直接连库改数据、Redis 短暂不可用导致删除全丢。日志订阅就是给这些情况兜底的。实现上分三步。第一步订阅数据库变更日志只关注需要失效的表。第二步把每条变更转换成表名 主键的形式按主键哈希投递到消息队列的固定分区这样同一行数据的变更天然有序。第三步消费者执行DEL key这个操作是幂等的重复消费没有副作用。public void onBinlogEvent(BinlogEvent event) { if (!CACHE_TABLES.contains(event.table())) { return; } String key keyBuilder.build(event.table(), event.primaryKey()); try { redis.del(key); } catch (Exception e) { // 消费失败抛出让队列重投靠重试次数和死信队列兜底 throw new RetryableException(e); } }几个必须注意的点。消费端要能容忍乱序到达的历史消息比如一条延迟很久的消息删掉了一个刚回填的 key结果只是一次未命中不影响正确性所以要接受“多删”而不能要求“精确删”。要有消费延迟监控日志订阅的端到端延迟超过 1 秒就要告警积压说明消费能力不足。死信队列必须有反复失败的消息进死信人工介入绝不能让消息无限重投把消费端拖死。至于业务侧的删除失败重试我一般设计成三级本地队列立即重试最多 3 次指数退避→ 写入重试表由定时任务扫描 → 依赖日志订阅兜底。三级都失败的极端情况靠 TTL 过期收敛。这套组合下来我负责的系统里缓存脏数据的平均存活时间能压到秒级以内。5. 巡检、灰度与线上验证方案写完只是开始真正决定它能不能长期稳定的是上线后的验证手段。没有巡检的方案你永远不知道自己的一致性到底做到了什么程度。5.1 一致性巡检任务怎么写巡检的本质是抽样比对从缓存里取一批 key回查数据库比较关键字段是否一致。写起来不难难在别把巡检本身变成线上故障。我的做法是分批 限速 低峰执行。每批从缓存里随机扫 500 个 key用SCAN命令而不是KEYS因为KEYS在大 key 空间下会阻塞 Redis 主线程这是纯粹的自伤。拿到 key 之后拼接主键批量查库一次查 100 条控制 SQL 的 in 列表长度。整批扫完 sleep 一小会儿把巡检任务对数据库的 QPS 增量控制在峰值的 2% 以内。比对逻辑不能只看“是否相等”因为比对的那一刻数据可能正在被更新这属于正常的瞬时不一致。我的判断标准是版本号方向如果缓存里的 version 小于数据库的 version且这个差异持续超过两个巡检周期才算真正的脏数据。这样可以过滤掉绝大部分瞬时噪声误报率能降一个数量级。巡检结果分两个指标看脏数据率和脏数据存活时长。前者反映方案的整体质量后者反映兜底通道的效率。我的经验阈值是脏数据率低于十万分之一存活时长中位数低于 3 秒。超过阈值的告警要带样本 key方便直接定位是哪个写入口漏了删除。5.2 灰度节奏与回滚预案上线新的一致性方案最怕的做法是直接全量切。我通常分三步走。第一步双读比对不影响返回。读取链路同时读缓存和数据库但只返回缓存的值把两者的差异打点上报。这样能在不影响用户的前提下量化出现有链路的脏数据基线。这一步至少跑三天把基线摸清楚后面才有对比依据。第二步开关控制写链路按流量比例放量。把写链路的删除逻辑挂到配置开关上先放 1% 的写流量观察脏数据率和接口耗时的变化。删除缓存会带来额外的 Redis 调用接口 P99 会有几毫秒的上涨这个要提前和上下游对齐别让监控误告警。放量节奏 1% → 10% → 50% → 100%每一档至少观察一天覆盖完整的业务波峰波谷。第三步保留物理回滚能力。所有变更都要能用配置开关一键关闭回到旧逻辑。同时新写的缓存值格式要保证旧代码能解析做法是只加字段不改字段、不改结构。这一条经常被忘结果回滚之后旧代码反序列化新格式直接报错回滚变成了二次故障。灰度期间还要特别盯两个指标缓存命中率和数据库 QPS。删除策略变激进会导致命中率下降进而抬高数据库压力。如果命中率掉得太多说明删除频率超过了必要程度要回头检查是不是延迟双删的任务重复投递了。6. 常见问题排查实录这一章是我这些年积累的问题清单按出现频率排序。遇到脏数据先来这里对号入座能省掉大量排查时间。6.1 高频问题速查表现象大概率原因排查动作处理方式修改后刷新页面还是旧值过一会儿自己好了主从延迟 读回填看缓存 key 的 TTL 是否还很新比对写入时间和缓存生成时间回源读主库或加大延迟双删间隔脏数据一直不消失重启缓存才恢复key 没有过期时间且删除失败检查 key 的 TTL 是否为 -1补齐 TTL接入删除重试与日志订阅发布后短时间内脏数据集中出现内存中的延迟任务丢失对比脏数据产生时间与发布时间延迟任务持久化改造缓存命中率突降数据库 QPS 飙升删除过于频繁或 key 设计过细统计单位时间内的 DEL 次数合并 key 粒度延迟任务去重个别 key 每次读都未命中空值缓存没做或版本号比较逻辑写反打日志看回填脚本返回值修正 Lua 比较条件补空值缓存并发写后偶发回退到旧值没有乐观锁写覆盖查数据库是否存在并发更新加版本字段写路径校验影响行数6.2 几个用血换来的经验版本号一定要用同一个来源。我踩过一次坑数据库的 version 是自增字段缓存里存的是更新时间毫秒两者混用比较逻辑看起来对但语义完全错乱结果新值被旧值挡住写不进去缓存一直停留在首次回填的状态。定下来用哪个就全链路统一别在不同接口里各写各的。不要在事务里删缓存。事务未提交时删缓存其他事务读到的还是旧值又会回填一次等于白删而且事务回滚的话缓存已经被删了多一次无谓的未命中。正确位置是事务提交之后用TransactionSynchronization类的钩子挂在 afterCommit 阶段或者干脆把删除动作发到事务提交后的事件里。批量更新要合并删除动作。一次批量更新一万条记录如果逐条删缓存Redis 那边是一万次往返接口耗时直接爆炸。做法是收集所有受影响的 key去重之后用管道批量删或者只删掉包含这些记录的聚合缓存。我见过一个批量导入接口因为逐条删缓存超时率从 0.1% 涨到 15%。监控要盯“删除成功率”而不是“接口成功率”。删除缓存的调用通常被 try-catch 包着接口成功率高不代表缓存删成功了。单独打一个删除成功/失败的计数器失败率超过千分之一就告警这个指标比任何业务指标都更早发现问题。跨库跨服务的缓存靠日志订阅而不是靠业务代码。只要有第二个系统能改这张表业务代码里的删除就一定有漏网之鱼。这种情况别犹豫直接上日志订阅把删除动作收敛到一个地方谁改的库都能被捕获。最后分享一个我在实际项目里的取舍这套方案跑了一年多脏数据率稳定在百万分之一以下代价是写链路的 P99 多了大约 4 毫秒多了一个消息队列和一套巡检任务。如果让我重新做一次我还是会这么选但我可能会更早地把巡检和监控建起来——因为方案对不对只有数据说了算。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑