异地多活架构中最大的谎言就是“流量 100% 精准路由”。近期排查了一起单元化(Cell-Based)架构中的严重脏写故障,根因是网关本地缓存失效导致流量跨机房逃逸,在 DTS 同步延迟窗口内引发了 MySQL 双机房并发更新同一行数据。核心结论:切忌纯靠网关层控制流量隔离,必须在 DAL(数据访问层)引入基于 Sharding Key 的强制写校验(防逃逸),配合底层双向同步组件的冲突解决策略,才能实现真正的兜底防御。
故障现场:静默的脏写与主键冲突
某次核心链路巡检中,监控大盘发出警告,负责机房 A 与机房 B 之间双向数据同步的 Canal 节点出现 canal_instance_traffic_delay 指标异常飙升(> 10000ms)。
登录同步组件节点查看日志,满屏的 MySQL 1062 报错,复制链路已彻底假死:
[destination = cell_sync_ab , address = /10.20.3.45:3306] ERROR c.a.o.c.p.inbound.mysql.rds.RdsBinlogEventParserProxy -
dump address /10.20.3.45:3306 has an error, retrying. cause:
com.alibaba.otter.canal.parse.exception.CanalParseException: column size is not match for table: `trade_db`.`t_order`,
Error 1062 (23000): Duplicate entry 'ORD-209938475' for key 't_order.PRIMARY'
业务表现上,机房 A 和机房 B 的 API 接口均响应正常,没有 P99 抖动,没有 5xx 报错。但实际上,同一笔订单 ORD-209938475 在两个机房被写入了不同的状态。
排查链路:
-
排查自增主键步长: 怀疑是双活基础配置遗漏,检查两端 MySQL 8.0.32 的
auto_increment_increment和auto_increment_offset,确认为奇偶交替配置,不存在自身生成的 ID 冲突。 -
追溯 Binlog 现场: 提取两端 DB 的 Row 格式 Binlog,发现
ORD-209938475这行记录在机房 A 的写入时间戳是10:05:12.100,在机房 B 的写入时间戳是10:05:12.350。 -
定位业务流量: 按照单元化规则,该订单的
user_id尾号为4,本应 100% 路由到机房 B。为什么机房 A 会在 250ms 前出现针对该订单的写入请求?
真相浮出水面:流量逃逸(Traffic Escape)。
为什么网关层的流量调度无法保证绝对的故障域隔离?
在经典的基于 Envoy 或 Nginx/OpenResty 的网关层多活路由中,网关需要依赖控制面(如 etcd 或 Istio Pilot)来下发路由规则。这里存在一个无解的分布式系统 CAP 矛盾。
当跨机房专线出现瞬间抖动时,控制面与数据面的心跳超时。此时网关有两种选择:
-
阻断请求(CP 取向): 无法确认路由规则,直接返回 503。这会导致系统可用性大幅下降,违反了“多活”为了提高可用性的初衷。
-
降级缓存(AP 取向): 使用本地内存中的 Stale Cache,或者走降级 Default 路由。
排查发现,当时跨城专线发生了 500ms 的微小抖动,网关触发降级,将原本属于机房 B 的请求“就近”错误路由到了机房 A。机房 A 的服务依然按部就班地执行业务逻辑并写入本地 DB。由于 Canal/DTS 存在百毫秒级的同步延迟,机房 A 的数据还没同步到机房 B,用户又刷新了页面,重试请求正确落入机房 B 并再次触发写入。最终,双向同步组件在回放 Binlog 时遭遇 Duplicate entry 报错,同步线程挂起。
防御性架构实战:DAL 层防逃逸与底层兜底
不要把系统的命脉全挂在网关的可靠性上。高可用多活必须遵循“多层拦截,底层兜底”的设计原则。
1. DAL 层拦截:强校验 Cell 归属
在微服务的 DAL(数据访问层,如 Go 的 GORM 拦截器或 Java 的 MyBatis Plugin),必须再做一次本地化的 Sharding Key 校验。这里以 Go 1.19 为例,演示拦截器核心逻辑:
package dal
import (
"context"
"fmt"
"hash/crc32"
"gorm.io/gorm"
)
// 全局配置:当前应用所在的物理机房 Cell ID
var currentCellID = "CELL_A"
func MultiActiveProtectionPlugin(db *gorm.DB) {
db.Callback().Create().Before("gorm:create").Register("multi_active_check", checkCellRule)
db.Callback().Update().Before("gorm:update").Register("multi_active_check", checkCellRule)
}
func checkCellRule(db *gorm.DB) {
if db.Statement.Schema == nil {
return
}
// 从上下文中提取 Sharding Key(比如 UserID)
// 实际工程中可通过 ThreadLocal/Context 传递,或解析 AST 提取 SQL 字段
uid, ok := db.Statement.Context.Value("sharding_uid").(int64)
if !ok {
// 降级策略:如果没有带 sharding key,可能需要告警并放行,视业务严格度而定
return
}
// 核心路由规则计算,比如按 uid hash 模 100
bucket := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%d", uid))) % 100
targetCell := calculateTargetCell(bucket)
// 如果本应去 B 机房的请求来到了 A 机房,直接掐断写入,拒绝产生脏数据
if targetCell != currentCellID {
db.Error = fmt.Errorf("FATAL: Cell routing escape detected. uid %d maps to %s, but arrived at %s", uid, targetCell, currentCellID)
return
}
}
func calculateTargetCell(bucket uint32) string {
// 简化的单元化路由表查找逻辑
if bucket < 50 {
return "CELL_A"
}
return "CELL_B"
}
原理解析: DAL 层拦截是保护 DB 的最后一道防线。读请求可以适当放宽(容忍百毫秒级别的最终一致性脏读),但写请求必须严格拦截。哪怕对上层返回报错,也比产生两边机房数据冲突要好处理得多。
2. 底层兜底:Canal/Otter 冲突解决策略配置
即使有了代码层拦截,某些 DBA 运维操作或后门脚本依然可能引发双写。双向同步组件必须具备冲突自动解决能力,防止因为单行报错导致整个 DB 同步被 Block。
在 Canal/Otter (v1.1.6+) 架构中,针对特定业务表,需要配置基于时间戳(或数据版本号)的 LWW(Last Write Wins,最后写入胜出)策略:
# canal-adapter 或 otter 节点级配置
# 开启数据冲突覆盖机制 (伪代码及配置项示意)
canal.sync.conflict.resolution.enable = true
# 针对主键冲突 (Error 1062),转为 UPDATE 覆盖
canal.sync.conflict.on_duplicate_key = UPDATE_OVERWRITE
# 记录冲突日志到特定的防御性监控表中,而非直接抛出异常导致同步线程假死
canal.sync.conflict.log_table = `trade_db`.`sync_conflict_audit`
若底层采用 MySQL 自身的 Group Replication (MGR) 多主模式,其内置的 Paxos 协议会通过 Certifier 机制直接阻断并发写冲突(后提交的事务会被 Rollback)。但在传统的异步双向同步链路中,必须由同步组件实现行级版本校验(通常要求表中强制带有 gmt_modified 字段及毫秒级精度)。
常见问题
Q:多活场景下,进行 DDL 变更(如加字段)如何避免打破双向同步?
使用 gh-ost 或 pt-osc 进行无锁 DDL 时,会产生大量针对影子表(Ghost Tables)的 Binlog,这些事件如果被双向同步回放,极易引发无限循环或元数据错乱。
最佳实践: 必须在同步组件(如 Canal)的过滤规则中,正则屏蔽 DDL 工具产生的临时表(例如 .*_gho$, .*_ghc$, .*_del$)。同时,DDL 变更应严格按机房顺序执行,先在备机房执行,同步中断后再在主机房执行,最后重置同步位点。
Q:什么时候应该选择“同城双活”而不是真正的“异地多活”? 取决于业务对数据一致性与 RTT(往返延迟)的容忍度。如果业务强依赖数据库级别的分布式事务(XA)或对 Read-After-Write 延迟要求在 2ms 以内,异地(跨城通常 30ms+ 延迟)的物理限制会导致同步写入吞吐断崖式下跌。此时只能做“同城双活”(低延迟光纤直连,当作一个大局域网),而异地只做冷备或异步灾备。
Q:在主动进行机房级流量切换时,如何处理 DTS 同步延迟导致的数据不一致? 流量切换绝对不能瞬间完成,必须引入“禁写期”(Read-only Window)。 标准切换步骤:
-
拦截层对即将切走的 Sharding 规则下发“禁写”指令(返回特定报错,前端展示友好提示)。
-
持续监控 DTS / Canal 的延迟指标,直到
canal_instance_traffic_delay为 0(且校验双端 GTID 一致)。 -
在目标机房放开对应的 Sharding 规则写权限。 整个过程通常在 3-5 秒内通过自动化控制面(如 Apollo/Nacos 配合网关)完成。