Redis、MySQL 与缓存读写一致性

→ 返回 Redis

本章讨论三层异步系统叠加后的数据可见性:Redis Cluster 副本、MySQL 主从集群、Binlog → Canal → MQ → 缓存消费者。先明确结论:

MySQL 是事实源时,通用方案是“事务内只写 MySQL,提交后删除 Redis;删除失败由 Binlog/Outbox + MQ 重试兜底”。它通常实现的是有界或最终一致,不是跨 MySQL、MQ、Redis 的原子强一致。

写请求
  │
  ▼
MySQL Primary ──commit──► 返回业务成功
  │                         │
  │ binlog                  └─ 尽力删除 Redis(缩短不一致窗口)
  ▼
Canal / Debezium
  ▼
Kafka / RocketMQ
  ▼
缓存同步消费者 ──幂等删除/版本化更新──► Redis Cluster

先定义需要哪种一致性

目标含义常见实现
强一致读读必须看到已提交的最新值绕过缓存读 MySQL 主库;必要时加锁/事务
Read-your-writes用户写完后,自己的后续读能看到会话内读主、短时间粘主、版本/GTID token
单调读同一用户不能先看到新值又看到旧值固定副本、版本过滤、粘主/粘从
有界陈旧最多允许旧几秒TTL、复制 lag 阈值、CDC lag SLA
最终一致没有新写时最终收敛Cache Aside + CDC/MQ 重试 + 对账

余额、库存扣减、权限判定等不要只依赖异步缓存副本做最终裁决;缓存更适合读模型和性能加速。


Cache Aside 的正确顺序

读路径

GET cache
  ├─ hit  → 返回
  └─ miss → 读 DB → SET cache with TTL → 返回

写路径

BEGIN
UPDATE MySQL
COMMIT
DEL Redis key

推荐 先提交数据库,再删除缓存。不要把“先删缓存再写数据库”当作默认方案;写库期间并发读可能立刻把旧 DB 值重新写回缓存。

为什么通常删除而不是更新

  • DEL 是幂等操作,重复消费影响小;
  • 写请求不一定拥有构造完整缓存对象所需的全部字段;
  • 两次并发数据库更新可能按 DB: v1 → v2 提交,却按 Redis: v2 → v1 的顺序完成网络更新;
  • 删除后由下一次读按数据库当前值重建,减少双写逻辑。

删除会增加一次缓存 miss,但通常比直接覆盖旧值更容易保证收敛。


Cache Aside 仍有哪些竞争窗口

窗口一:DB 成功,删除缓存失败

UPDATE DB 成功 → Redis 超时/进程崩溃 → 旧缓存继续存在

这是最重要的工程问题。处理方式:

  1. 写请求提交后同步尝试 DEL,快速缩短窗口;
  2. 删除失败有限次重试,不能无限阻塞接口;
  3. Binlog/Outbox → MQ 再次投递失效事件;
  4. 所有缓存设置合理 TTL,作为最后收敛边界;
  5. 监控失败事件和 CDC/MQ lag,支持人工重放与全量重建。

窗口二:慢查询把旧值回填到删除之后

Reader: cache miss ──读取旧 DB 值──────────────► SET old
Writer:             UPDATE DB + COMMIT → DEL

如果 Reader 在 Writer 提交前读到旧值,却在 Writer 删除缓存后才执行 SET,旧值会重新进入缓存。常见缓解:

  • 缓存值携带 DB version / updated_at,只允许新版本覆盖旧版本;
  • 热点 key miss 时用互斥重建或 singleflight,缩小并发回填窗口;
  • CDC 消费者在提交后再次删除;
  • 使用较短 TTL,业务接受有界陈旧;
  • 真正强一致查询直接读主库,不走该缓存路径。

延迟双删的定位

DEL cache → UPDATE DB → sleep Δ → DEL cache

延迟双删可以覆盖部分旧值回填窗口,但不是严格一致性协议:延迟 Δ 很难准确覆盖慢查询、主从延迟与队列抖动;进程也可能在第二次删除前崩溃。更稳妥的主流程仍是提交 DB 后删除 + CDC/MQ 可靠补偿。


Redis Cluster 自身的读写一致性

Redis Cluster 的槽路由解决分片,不会自动让 MySQL 与 Redis 一致。单个槽内写入主节点,再异步复制给副本:

Redis Master 执行 SET 并返回
        │
        └────异步复制────► Replica
风险表现策略
读副本延迟写后立刻从 replica 读到旧值关键读走 Redis master;接受陈旧时才用 READONLY 副本读
failover 丢最后写入旧主已确认、尚未复制就宕机缓存可从 DB 重建;不能把 Redis 当唯一事实源
网络分区旧主写入切换后少量写无法保留min-replicas-*、客户端刷新拓扑、业务幂等
多 key 跨槽无法形成跨节点原子操作Hash Tag 同槽或把一致性落到 DB/业务事务

WAIT 可等待若干 Redis 副本确认复制 offset,降低切换丢写概率,但不是强一致提交,也不能解决 MySQL 与 Redis 双写原子性。

多数 Cluster 客户端默认对普通命令访问主节点;只有明确启用副本读时才需要承担副本陈旧。详见 Cluster、Redis 复制。


MySQL 集群的读写一致性

经典 MySQL 主从复制通常是:主库提交后生成/刷入 binlog,从库 I/O 线程接收 relay log,再由 applier 应用。收到日志不等于已经应用完成。

Primary commit
   ├─ client success
   └─ binlog → Replica relay log → apply → replica query visible
              ▲                  ▲
           已接收              已执行

即使使用半同步复制,常见语义也只是等待至少一个从库收到并确认日志,不必然等到该事务已在从库执行并对查询可见,所以写后读从仍可能旧。

读写分离策略

场景推荐路由
下单后立即查看、修改后立即校验当前会话短时间读主库
权限、余额、库存裁决主库或具备强一致能力的数据库读
商品详情、列表、统计可容忍延迟时读从库/缓存
明确携带 GTID 的因果读在目标从库等待 WAIT_FOR_EXECUTED_GTID_SET() 后读取,超时回主

“粘主 1 秒”实现简单,但固定时间只是经验值;高峰期复制延迟可能远超 1 秒。更精确的办法是写入后保存 GTID/一致性 token,在选中的副本确认已应用该事务后再读。

MySQL Group Replication

若使用 MySQL Group Replication/InnoDB Cluster,可通过一致性级别控制读写等待,例如 BEFORE、AFTER、BEFORE_AND_AFTER 等;一致性越强,网络分区或节点落后时等待和可用性成本越高。不要把“部署成集群”等同于“所有节点随时强一致可读”。

主从延迟、GTID 和并行回放详见 MySQL 主从复制。


Binlog、Canal 与 MQ 如何同步缓存

Binlog 为什么适合作为变更源

MySQL 提交事务时已经需要维护 binlog 用于复制和恢复。Canal 模拟 MySQL replica 订阅 binlog,从事务日志中提取行变更,避免业务代码再做一次不可靠的 DB + MQ 双写。

[mysqld]
log_bin=mysql-bin
binlog_format=ROW
binlog_row_image=FULL
sync_binlog=1
innodb_flush_log_at_trx_commit=1
  • ROW 记录具体行的前后镜像,更适合 CDC;
  • sync_binlog=1 与 InnoDB redo 刷盘策略共同缩小“DB 已提交但日志未持久”的崩溃窗口;
  • Canal 位点/GTID 和 binlog 保留期必须覆盖最长故障恢复时间;日志先被清理会迫使全量重建。

端到端的四个位置

T1 MySQL 事务提交
T2 Binlog 被 Canal 读取并确认位点
T3 事件进入 MQ 并获得持久化确认
T4 消费者修改 Redis 后提交 MQ offset/ACK

任意两个位置之间崩溃都可能产生重复或暂时缺失。因此默认按 at-least-once(至少一次)+ 幂等消费设计,而不是假设 exactly-once 能跨 MySQL、MQ 和 Redis 自动成立。


MQ 的顺序、重复和失败处理

同一实体必须有稳定分区键

message key = table + ':' + primaryKey

同一订单、商品或用户的事件进入同一 Kafka partition,才能利用分区内顺序。不同 partition 没有全局顺序保证;Canal 的 MQ 分区规则也应按业务主键配置,而不只是按表名。

消费者处理顺序

拉取事件
  → 校验 eventId / version
  → DEL cache(推荐)或版本化 UPSERT
  → 成功后提交 offset / ACK
  • 先 ACK 后处理:进程崩溃会永久漏消费;
  • 先处理后 ACK:ACK 前崩溃会重复消费,因此写入必须幂等;
  • DEL 天然幂等;直接更新缓存则应携带单调 version,拒绝旧事件覆盖新值;
  • 重试 Topic、并发消费者和 DLQ 回放可能打乱原顺序,版本检查不能省略;
  • 毒消息不能无限阻塞整个分区,应告警、隔离到 DLQ,并保留可修复和回放的信息。

事件建议字段

{
  "eventId": "mysql-bin.000123:456789:2",
  "database": "shop",
  "table": "product",
  "pk": "1001",
  "operation": "UPDATE",
  "version": 42,
  "commitTs": 1770000000000,
  "before": {},
  "after": {}
}

eventId 用于去重和排查,version 用于防乱序,binlog 文件/位置或 GTID 用于追踪和恢复。时间戳只能辅助观测,不宜单独作为严格顺序,因为机器时钟和同毫秒并发都可能冲突。


三种可靠同步方案

方案 A:提交后删缓存 + CDC 再删一次(推荐默认)

业务:UPDATE DB → COMMIT → DEL Redis
兜底:Binlog → Canal → MQ → DEL Redis

延迟低、删除幂等、耦合小。适合缓存可从数据库重新加载的绝大多数场景。

方案 B:Transactional Outbox

同一个 MySQL 本地事务:
  UPDATE business_table
  INSERT outbox(event_id, aggregate_id, version, payload)
 
Outbox CDC/任务 → MQ → Consumer

业务变更和“应该发出的事件”在同一数据库事务中提交,避免应用直接双写 DB 与 MQ。适合事件具有明确业务语义、下游不只缓存一个消费者的场景。

方案 C:版本化更新缓存

消费者用 version 做 compare-and-set,只允许更大版本写入。适合需要主动预热、不希望删除后回源的热点读模型,但实现复杂度高,Lua/事务必须保证“比较版本并更新”原子执行。


对账、重建与降级

异步链路必须设计控制面,而不只是正常消费代码:

  • 监控:MySQL replica lag、Canal 位点延迟、MQ consumer lag、DLQ 数量、Redis 删除失败率;
  • 对账:按 updated_at / 主键范围抽样比较 DB 与读模型版本;
  • 重放:保留足够长的 binlog/MQ 日志,消费者支持从位点重放;
  • 全量重建:先记录 CDC 起点,再全量导入,最后回放增量追平并切换;
  • 降级:lag 超过阈值时,关键请求绕过缓存读主库,普通请求接受旧值或限流;
  • 防回源风暴:批量删缓存或重建时限速、TTL 加抖动、热点 key 预热。

缓存穿透、击穿与雪崩

问题表现处理
穿透不存在 key 持续打 DB参数校验、空值短 TTL、布隆过滤器
击穿热 key 失效时大量并发回源singleflight/互斥重建、逻辑过期、热点预热
雪崩大量 key 同时过期或 Redis 故障TTL 抖动、限流降级、本地兜底、Cluster/Sentinel

多级缓存 Caffeine → Redis → MySQL 还需通过 MQ 广播 L1 失效;每增加一层缓存,就增加一层延迟和不一致窗口。


面试结论

MySQL 与 Redis 一致性通常采用 Cache Aside:读缓存,未命中读 DB 并回填;写先提交 DB,再删缓存。删除失败用 Binlog/Canal → MQ 可靠重试兜底,消费者按主键分区、成功后 ACK,并通过幂等删除或版本号处理重复和乱序。Redis replica 与 MySQL replica 都可能延迟,写后读要求读主、粘主或等待对应复制位点。整条链路默认是最终一致,强一致业务应直接以数据库事务结果为准。


相关