Redis 和 MySQL 如何保证数据一致性?先更新数据库还是先删缓存,延迟双删、MQ、Canal 一次讲透

先给结论:大多数读多写少的 Cache-Aside 业务,优先采用"提交数据库事务 → 删除缓存 → 下一次读取回源重建 ",同时必须有 TTL。删除失败不能只记日志,重要数据应进入持久重试;跨服务可升级到 Outbox + MQ;写入口很多可使用 binlog CDC/Canal。它们提供的是可度量的最终一致,不是跨 MySQL 与 Redis 的天然强一致。

这个问题最容易误导人的地方,是把"正常情况下顺序正确"当成"故障和并发下也正确"。真正要分析的是:慢读会不会把旧值写回来?数据库已经提交、缓存删除失败怎么办?读请求是否从延迟副库回填?消息重复或乱序时能否收敛?

先别讨论方案,先定义"不一致"能持续多久

缓存只是数据库数据的副本。两套独立系统没有共同本地事务时,双写一定存在部分失败窗口。工程上更有意义的目标不是"永远一致",而是:数据库是唯一事实源;缓存最多旧多少秒;删除事件多久必须完成;失败由 TTL、重试还是对账收敛;哪些业务完全不能用缓存做最终判定。

商品详情旧 3 秒也许可接受,用户刚改昵称希望自己立即可见,权限撤销和余额扣减则不应依赖缓存旧值。三者使用同一套"延迟双删 500ms"显然不合理。

Cache-Aside 为什么通常删除,而不是更新缓存?

读时先查 Redis,未命中才查 MySQL并带 TTL 回填;写时先提交 MySQL,再让缓存失效。删除的好处不是它绝对一致,而是它让数据库继续充当事实源,下一次读取按统一读逻辑重建。

同步更新缓存经常需要重复聚合逻辑:商品缓存可能来自多张表,写服务和读服务容易产生两套序列化规则。两个并发写请求还可能按 V2、V3 提交,却按 V3、V2 的顺序回写缓存。删除同一 Key 可重复执行,状态空间更简单。

Redis 官方文档把 Cache-Aside 描述为 TTL-bounded staleness,并在写入后显式 DEL。来源:Redis cache-aside

四种顺序逐个排除

先更新缓存,再更新数据库 :数据库失败时会暴露从未持久化的缓存值;并发写还可能乱序覆盖。

先更新数据库,再更新缓存 :没有解决回写乱序,而且写方必须准确重建读模型。除非系统明确采用 Write-Through 并封装了事实源与缓存写入,否则不应冒充普通 Cache-Aside

先删除缓存,再更新数据库:窗口很宽。删除后,并发读缓存未命中,从数据库读到 V1并回填;写请求随后才提交 V2,最终缓存长期保留 V1。

先更新数据库,再删除缓存:绝大多数情况下更稳,但极端顺序仍可能脏:缓存恰好缺失;慢读从数据库读 V1;写请求提交 V2并删除缓存;慢读最后回填 V1。更普遍的故障是删除超时或进程在提交后崩溃。

左侧是高概率窗口,右侧是苛刻但仍可能发生的慢读回填。原创教学图。

所以"先更新数据库再删缓存"是**基线**,不是"绝对不会错"的证明。

删除失败,才是生产系统真正要补的洞

数据库提交后可能出现:Redis 超时;DEL 已执行但响应丢失;应用进程在调用前崩溃;线程池拒绝任务;主从切换;网络分区。超时是模糊结果,客户端不知道服务端是否执行,但可以安全地再次删除同一 Key。

基础实现应把删除放在事务提交之后:

typescript 复制代码
@Transactional
public void updatePrice(long id, Money price) {
    repository.updatePriceAndIncrementVersion(id, price);
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                invalidateOrEnqueueRetry("cache:product:" + id);
            }
        }
    );
}

afterCommit 只保证数据库提交后才执行,不会把 Redis 加入 MySQL 事务。重试队列如果只是 JVM 内存结构,进程重启仍会丢。生产队列需要持久化、指数退避、死信和最老任务年龄监控。

延迟双删是不是标准答案?

第二次延迟删除用于清理并发慢读重新写入的旧值。它可以降低脏值寿命,却不是原子协议。

延迟 Δt 至少要覆盖数据库读取 P99、只读副库复制延迟、序列化和写缓存时间。固定 500ms 只是猜测:太短时旧值会在第二次删除之后才写入;太长时旧值窗口扩大。使用 Thread.sleep 会占用业务线程且不可恢复,应该用持久延迟任务。

延迟双删适合允许短旧读的历史系统,不适合余额、库存扣减与权限判定,也不能替代删除失败重试。

从一次 DEL 升级到可恢复链路

级别一:DB 后 DEL + TTL。 适合商品、文章等普通详情。TTL 是删除链路彻底失败后的上界兜底。

级别二:异步重试 删除失败后持久记录 eventId、Key、attempt、nextRetryAt;指数退避,最终进入死信并告警。

级别三:事务 Outbox + MQ。 更新业务行和插入 outbox 事件在同一 MySQL 本地事务中完成。Relay/CDC 发布消息,消费者幂等删除缓存。它解决"数据库提交后、事件发送前进程崩溃"的窗口。

rust 复制代码
sequenceDiagram
    participant API as 写请求
    participant DB as MySQL 事务
    participant O as outbox 表
    participant R as Relay / CDC
    participant MQ as 消息队列
    participant C as 失效消费者
    participant Redis as Redis
    API->>DB: 更新业务行并增加 version
    DB->>O: 同事务插入 CACHE_INVALIDATE 事件
    DB-->>API: COMMIT 成功
    R->>O: 轮询或订阅变更
    R->>MQ: 发布 entityId + version + eventId
    MQ->>C: 至少一次投递
    C->>Redis: 幂等 DEL cache:product:id
    C-->>MQ: ACK
    Note over O,C: 允许重复投递;消费者必须幂等,失败进入重试/死信

消息通常至少一次投递,因此重复是正常情况。不要因中间件宣传 exactly-once 就取消消费者幂等。

级别四:binlog CDC / Canal。 当后台脚本、多个服务都能写数据库时,CDC 从 binlog 捕获变化并统一产生失效事件。优点是覆盖所有入口,成本是延迟、积压、Schema 演进、乱序与一行到多个缓存 Key 的映射治理。

Debezium 说明 Outbox 用同一数据库事务避免内部状态与对外事件不一致。来源:Outbox Event Router

为什么 Pub/Sub 不能直接当可靠失效总线?

Redis 官方文档说明 Pub/Sub 是 at-most-once:订阅者断线或处理失败,已发送消息不会自动重投。允许丢失的刷新通知可以使用,关键缓存失效则应选择带持久化、ACK、重试和积压监控的队列或 Streams。

一个经常被漏掉的问题:副库延迟

主库提交 V2并删除缓存后,缓存未命中请求若从落后 2 秒的副库读取,会重新把 V1 写进 Redis。这不是 DEL 顺序问题,而是回源不新鲜。

可在写后短时间将实体读路由主库;携带最低版本令牌,副库未追上则回主库;关键读取直接使用一致读;复制延迟超过阈值时暂停回填。延迟双删可以概率缓解,却无法替代对复制状态的理解。

热 Key 删除后,怎样避免数据库被打穿?

一次正确失效可能让几千个请求同时 miss。用 single-flight/互斥重建,只允许一个请求回源;其他请求短暂等待或使用允许的旧副本。锁要有唯一 token,释放时比较 token,避免误删别人续上的锁。

逻辑过期可以先返回旧值再异步刷新,适合首页、榜单,不适合余额和权限。TTL 抖动用于分散批量到期,也不能修复主动删除失败。

版本号不是免费原子协议

数据库行增加 version 有利于事件去重、乱序检测和 Read-Your-Writes。但缓存为空时,慢读 V1仍可能被写入;只有 JSON 内版本并不能拒绝它。需要独立版本栅栏、回填前二次校验或锁,复杂度和额外读取都会上升。

版本号适合检测和排序,不等于跨 MySQL/Redis 的原子提交。

哪些数据根本不该依赖缓存判定?

余额、库存扣减、优惠券核销、权限撤销应由数据库事务、唯一约束、条件 UPDATE 或一致性系统决定。Redis 可以加速展示或做预检查,但最终提交要回事实源。

ini 复制代码
UPDATE sku_stock
SET available = available - 1,
    version = version + 1
WHERE sku_id = 10086 AND available > 0;

以影响行数判断扣减是否成功,比先读缓存库存再无条件写数据库可靠。

选择方案的决策矩阵

异步方案都存在消息或复制延迟;复杂度应匹配业务风险。原创教学图。

rust 复制代码
flowchart TD
    A["一次业务写入发生"] --> B{"该数据允许短暂旧读吗?"}
    B -- "不允许" --> C["关键判定直接读数据库或一致性存储"]
    C --> D["Redis 只做加速,不作为唯一真相"]
    B -- "允许" --> E["数据库事务提交"]
    E --> F["删除缓存 Key"]
    F --> G{"删除是否确认成功?"}
    G -- "是" --> H["下次读请求从数据库重建 + TTL"]
    G -- "否或超时" --> I{"可靠性等级需要多高?"}
    I -- "普通业务" --> J["异步重试 + 指数退避 + 死信告警"]
    I -- "跨服务重要数据" --> K["事务 Outbox + MQ + 幂等消费"]
    I -- "写入口很多" --> L["binlog CDC / Canal + 集中失效"]
    J --> M["定期对账与补偿"]
    K --> M
    L --> M
    H --> N["监控旧读窗口、重试积压与缓存命中率"]
    M --> N

上线后必须观察什么?

  • 数据库提交到缓存删除成功的延迟 P95/P99;
  • DEL 失败、超时和模糊结果比例;
  • 重试/Outbox/MQ/CDC 的最老事件年龄;
  • 死信数量、重复事件比例与消费失败率;
  • 抽样对账的不一致率和修复数量;
  • 热 Key 失效后的回源峰值与锁等待;
  • 用户写后读到旧 version 的业务埋点。

测试也要控制竞态顺序:让读请求拿到 V1 后暂停,写请求提交 V2并删除,再放行旧读回填。还应故障注入 Redis 超时、ACK 丢失、消费者宕机、副库延迟和重复消息。只跑正常路径不能证明可恢复。

一条失效消息应该包含哪些字段?

如果只发送 delete product:10086,出现重复、乱序或对账时很难定位。更实用的事件至少包含:全局 eventId、实体类型、实体 ID、数据库 version、事件类型、事务提交时间、来源服务和 traceId。

消费者收到事件后,先校验类型和 Key 命名空间,再执行幂等删除;成功后记录处理 version 和延迟。不要把完整用户隐私或大对象都塞进失效事件,失效只需要足够定位缓存。若同一实体对应列表、详情、聚合页多个 Key,应由明确的映射组件生成,而不是消费者到处拼字符串。

事件版本还可以帮助识别乱序。例如 version=43 已经处理后又收到 version=42,消费者可以记录为迟到事件。对纯 DEL 来说重复删除通常无害,但如果消费逻辑还会刷新缓存或触发其他副作用,版本检查就非常重要。

对账任务怎样设计才不会反向制造事故?

对账不应全量扫描数据库后逐个读取 Redis,这可能同时打满两个系统。可以按更新时间分片、按业务风险采样,或比较数据库 version 与缓存元数据。发现不一致后优先删除缓存,让正常读路径重建;不要让对账程序用缓存值覆盖数据库。

对账任务也要限速、可暂停、可续跑,并记录扫描游标。CDC 中断恢复、消费者大面积失败、Schema 变更之后,可以临时提高扫描比例。平时保持低成本抽样,形成"不一致率"的长期基线,只有偏离基线才告警。

怎样实现用户写后立即可见?

最终一致的全局缓存不代表用户必须在修改成功后立刻读到旧值。写接口可以直接返回事务提交后的新对象;前端用该响应更新当前页面。后续短时间读取可携带最低 version 或会话标记,服务发现缓存版本不足时回主库,而不是盲目返回旧缓存。

这种做法提供会话级 Read-Your-Writes,同时保留其他读请求的缓存收益。它比强迫整个系统所有节点同步刷新更经济,但仍要设置标记过期时间,避免所有用户长期绕过缓存。

最后的判断标准

普通业务从"DB 后 DEL + TTL"开始;重要删除加持久重试;跨服务用 Outbox + MQ;写入口很多时用 CDC/Canal;所有异步方案都要幂等、监控、死信和对账。

不要追求"让 Redis 与 MySQL 在任何瞬间都一样"这个昂贵且模糊的口号。应该追求:事实源明确、旧读窗口可度量、失败可以恢复、关键决策不依赖可能过期的缓存。

相关推荐
犹豫的果冻布丁2 小时前
从零给 DeepSeek Harness 写一个壁纸皮肤插件(已开源)
前端·后端
weixin_431600442 小时前
NestJS 入门(10):日志——为什么常用 Winston?
后端·学习·日志·nest.js·winston
步行cgn2 小时前
MyBatis <where> 标签详解:让你的动态 SQL 更优雅、更安全
后端
RainCity2 小时前
Java Swing 自定义组件库分享(十六)
java·笔记·后端
沙盘客2 小时前
AFSIM 14篇 C++ 插件开发:扩展你的仿真能力
c++·后端
桦说编程2 小时前
如何对待中断异常:一个被吞掉的 InterruptedException 引发的思考
后端
雪隐4 小时前
个人电脑玩AI-16让5060 Ti给你打工——5060Ti 16G 跑 MiniMax-Music-3:从下载到 60s 出歌的全流程
前端·人工智能·后端
报错小能手5 小时前
Go 语言结构 基础语法
开发语言·后端·golang
暗黑小白5 小时前
参数从哪来、何时来 —— 提取时机与平台化 Slot 管理
人工智能·后端·大模型·ai agent