mysql主从复制本身不提供双写一致性,也不保证零数据丢失;真正落地的方案是“单点写入+变更捕获(如canal)+异步投递”,并辅以幂等、版本控制、持久化位点与死信队列等机制兜底。

MySQL 主从复制本身不提供双写一致性,也不保证零数据丢失——这是必须先划清的边界。你没法靠开几个配置项就让主从自动解决应用层写 Redis + 写 MySQL 的顺序、失败重试、幂等或回滚问题。真正能落地的方案,是把「双写」拆成「单点写入 + 变更捕获 + 异步投递」,再用机制兜底。
为什么直接双写 MySQL + Redis 必然不一致
典型错误是应用代码里先 UPDATE mysql,再 SET redis,或者反过来。这会暴露在三个致命断点上:
- MySQL 写成功但 Redis 网络超时/宕机 → 缓存缺失,后续读可能穿透到 DB,但数据已脏
- Redis 写成功但 MySQL 事务回滚 → 缓存有,DB 没,强一致性彻底破坏
- 主从延迟导致 Canal 或 binlog 解析拿到的是“旧值”,投递给 Redis 时覆盖了新值
这些不是小概率事件,而是网络分区、主库切主、大事务堆积后的常态。别信“加个 try-catch 就行”。
canal 是目前最可控的变更捕获入口
它不依赖应用改代码,也不要求业务层感知复制链路,只做一件事:伪装成 MySQL slave,拉取 binlog 并解析为结构化事件。但要注意几个硬约束:
- MySQL 必须开启
binlog-format=ROW,否则无法精确捕获字段级变更 -
server_id不能和真实从库冲突,否则主库 dump thread 会拒绝连接 - Canal 实例的
slaveId要全局唯一,重启后不能变,否则可能跳过部分 binlog - Canal 订阅的
binlog position或GTID必须可持久化,断连重连时才能续传,不能丢位点
示例关键配置片段(canal.properties):
canal.destinations = example canal.instance.mysql.slaveId = 1234 canal.instance.master.address = 192.168.1.10:3306 canal.instance.dbUsername = canal canal.instance.dbPassword = canal canal.instance.filter.regex = mydb\..*
Redis 更新必须带幂等与版本控制
Canal 解析出的 UPDATE 事件,不能直接 SET 到 Redis。你要把 binlog event 的 commit_ts 或 gtid 作为逻辑版本号,附在缓存 value 中:
- 写入时用
HSET user:1001 name "Alice" version "e7a5b2",而不是纯字符串 - 读取时先取整个 hash,比对 version 是否更新,旧则忽略,避免“后发先至”覆盖
- 删除缓存不用
DEL,改用EXPIRE+ 延迟双删,给 Canal 消费留出窗口
没有版本号的缓存更新,在主从延迟 > 1s 的场景下,100% 出现反向覆盖。
零数据丢失靠的是 binlog + WAL + 持久化消费位点三重锚定
所谓“零丢”,是指任意节点宕机重启后,能从上次成功提交的位置继续,不跳过、不重复。这需要:
- MySQL 开启
sync_binlog=1和innodb_flush_log_at_trx_commit=1,确保事务提交即落盘 - Canal server 使用 ZooKeeper 或本地文件持久化
position,不能只存在内存 - Redis 写入失败时,不能丢消息,要进死信队列(如 Kafka),人工干预或自动重试带最大重试次数
- 禁止在 Canal client 层做异步批量写 Redis —— 批量意味着失败时整批回退不可控
最后提醒一句:如果你的业务要求严格意义上的“实时强一致”,比如银行转账余额,那这条路本身就走错了。MySQL + Redis 架构天然适合最终一致性,想零延迟零丢失,该上分布式事务框架(如 Seata AT 模式)或统一存储层(如 TiDB),而不是在复制链路上打补丁。











