深入异地多活陷阱排查:GSLB 探针脑裂引发的流量震荡与 MySQL 双写冲突实战

异地多活的核心不是简单部署两套集群,而是严格的故障域隔离与流量染色。某次处理跨区双活故障时,边缘 GSLB 探针因专线网络抖动发生“脑裂”,导致单租户流量被同时打入双机房。配合底层 MySQL 异步双向同步的秒级延迟,直接引发大面积主键冲突与脏写。结论:多活架构必须在网关层实现强单元化路由,且 DB 层需引入基于 DC 标识的防冲突发号器,绝不能仅依赖全局 DNS 调度。

故障现场:P99 飙升与主键雪崩

排查期间,监控大盘突然出现剧烈波动:

  1. API 网关指标:核心业务写接口 P99 延迟从 45ms 飙升至 5s 以上,5xx 错误率瞬间击穿 15% 的 SLA 阈值。

  2. DB 负载:双机房的 MySQL (8.0.28) 实例 Load Average 均出现异常冲高,CPU Sys 态占比激增。

  3. 同步链路:基于 Canal/Otter 搭建的异地双向同步链路大面积告警,同步延迟从 500ms 扩大至 NaN(同步中断)。

登录双边机房的数据库机器,直接查看 MySQL 错误日志,满屏的 1062 报错触目惊心:

[ERROR] [MY-010584] [Repl] Slave SQL for channel 'dc2_to_dc1': Worker 1 failed executing transaction 'b9f8a3c2-11e9-11ed-a8b3-00163e0020a1:1593452'; Error 'Duplicate entry '89341093841' for key 'orders.PRIMARY'' on query. Default database: 'trade_db'. Query: 'INSERT INTO orders (order_id, user_id, status) VALUES (89341093841, 10045, 1)'

从日志看,DC1 和 DC2 几乎在同一时间对同一张表插入了相同 order_id 的记录。当 DC2 的 Binlog 异步同步到 DC1 时,由于主键已存在,导致 SQL 线程直接挂起,复制中断。

为什么 GSLB 会在物理机房之间产生流量“抽搐”?

业务原本的设计是:基于 GSLB(全局负载均衡)做就近路由,华南用户解析到 DC1,华北用户解析到 DC2。当某机房故障时,GSLB 将域名切到可用机房。

但仔细分析 Nginx Access Log 发现,同一个客户端 IP,在短短 3 秒内,请求分别落在了 DC1 和 DC2 的网关上。这是典型的流量“抽搐”,而非正常的机房切换。

深入排查 GSLB 探针的运行日志与配置:

# GTM Health Check Probe Config
health_check:
  protocol: tcp
  port: 443
  timeout: 2s
  interval: 3s
  healthy_threshold: 2
  unhealthy_threshold: 3

底层原理与诱因剖析: GSLB 的健康检查依赖分布在全国的多个边缘探针节点。事发时,DC1 所在机房的 BGP 出口发生了路由收敛抖动(持续约十几秒)。

  1. 探针脑裂:部分电信探针节点在 3s * 3 = 9s 内无法建连 DC1,判定 DC1 宕机,向全局调度中心上报故障,DNS 解析开始剔除 DC1,将该区域流量指向 DC2。

  2. 联通/移动探针正常:而联通/移动的探针由于走不同的骨干网路由,依然判定 DC1 存活。

  3. LocalDNS 缓存混战:由于各省 LocalDNS 的 TTL 缓存失效时间不一致(部分运营商甚至无视我们设置的 60s TTL,强行缓存 10 分钟),导致用户的连续请求在重试时,通过不同的 DNS 缓存拿到了不同机房的 VIP。

结果就是:同一个用户的写请求,前一秒打到了 DC1,由于前端超时发起重试,后一秒重试请求通过另一个 LocalDNS 解析到了 DC2。这就是灾难的开始。

灾难放大:MySQL 双向同步的“黑暗时刻”

如果只是流量调度混乱,顶多是请求变慢。但多活架构的致命弱点在于数据一致性的取舍

我们在底层使用了双向异步复制通道。当流量同时打向 DC1 和 DC2 时:

  1. 请求 A 落在 DC1,应用层生成了订单,执行 INSERT

  2. 重试请求 B 落在 DC2,由于应用层的订单号生成逻辑依赖时间戳+用户ID的简单哈希(并未包含机房标识),生成了与请求 A 一模一样的 order_id,并在 DC2 执行 INSERT

  3. MySQL 8.0 默认的隔离级别(RR)和本地事务只能保证单机房内的唯一性。DC1 和 DC2 各自本地提交成功,向前端返回 200 OK。

  4. 毫秒级之后,双向同步组件(Otter)开始拉取 Binlog。DC1 的记录同步到 DC2 报错 1062;DC2 的记录同步到 DC1 同样报错 1062。

  5. 双边复制链路同时中断,两边的数据开始分叉(Split-Brain),形成事实上的“脏写”。

此时,如果强行设置 slave_exec_mode = IDEMPOTENT(幂等模式)跳过 1062 错误,虽然同步链路能恢复,但这会导致双边数据静默不一致,后续的业务逻辑将彻底失控。

架构重塑:防御性多活设计与一致性取舍

针对这种“基础设施不可控”引发的流量逃逸,必须在应用层和网关层建立深度的防御性编程机制。单纯依赖 GSLB 做多活路由是极度脆弱的。

1. 网关层:基于 UID 的强单元化染色拦截

不能信任 DNS 解析的结果。流量到达任意机房的 API Gateway(如 OpenResty/Nginx)后,必须进行二次校验。 我们引入了基于用户 ID 的强路由规则。在 OpenResty 中使用 Lua 脚本拦截:

-- OpenResty Lua 流量染色与校验 (版本 1.21.4.1)
local core_route = require "resty.core_route"
local headers = ngx.req.get_headers()
local uid = headers["X-User-Id"]

if uid then
    -- 简单的 Sharding 逻辑:奇数去 DC1,偶数去 DC2
    local expected_dc = (tonumber(uid) % 2 == 0) and "DC2" or "DC1"
    local current_dc = ngx.var.current_dc -- 环境变量注入本物理机房标识

    if expected_dc ~= current_dc then
        -- 发现流量逃逸!当前机房不该处理该用户的写请求
        -- 方案A:直接阻断,返回特定错误码让客户端重新获取路由
        -- ngx.status = 421 
        -- ngx.say('{"err": "Misdirected Request"}')

        -- 方案B(平滑过渡):在网关层通过内网专线反向 Proxy 到正确机房
        ngx.var.upstream_target = "internal-api." .. string.lower(expected_dc) .. ".svc"
    end
end

通过上述逻辑,即使 DNS 将流量导错了机房,网关层也能将其纠正或拒绝,将“多活乱序写”拦截在 DB 之前。

2. DB 层:底层发号器的机房隔离(防脏写底线)

即便网关层做了拦截,为了兜底极端情况下的应用层 Bug,必须彻底消灭跨机房主键冲突的可能性。 全面废除简单的时间戳哈希,引入包含 Data Center ID (机房标识) 的全局发号器(如改进版 Snowflake 算法):

  • 1位符号位 + 41位时间戳 + 4位机房ID + 8位机器/Pod ID + 10位序列号。

  • DC1 写入的数据,高位永远带有 0001 标识;DC2 带有 0010 标识。

从根本上保证:即便出现流量双写,两边生成的 order_id 也是不同的。同步时退化为两次独立的 INSERT(虽有业务重复的瑕疵,但 DB 链路不会中断,后期可通过离线脚本对冲重复数据)。

常见问题

Q: MySQL 双向同步出现 1062 主键冲突,除了手工跳过,有什么自动修复策略? A: 生产环境中禁止盲目 sql_slave_skip_counter=1。标准做法是:引入冲突解决表(Conflict Resolution Tables)。在同步中间件(如 Canal/Otter)配置冲突降级策略:一旦捕获到目标端抛出 1062 异常,提取 Binlog 中的前后像数据写入专门的 sync_conflict_log 表,并丢弃当前 event 以维持链路运转,随后由自动巡检脚本比对时间戳(Last Write Wins)或通过人工后台介入仲裁。

Q: 异地多活场景下,如何根本解决 DNS TTL 缓存导致的切流延迟问题? A: 无法彻底解决。公网 LocalDNS 的缓存污染是不可控的。最佳实践是:

  1. 客户端接入 HTTPDNS:绕过 LocalDNS,直接通过 HTTP 接口向云厂商获取当前准确的机房 IP 列表。

  2. 长连接探活与下发:App 端与后端维持 WebSocket/TCP 长连,当机房准备切换时,通过长连主动下发新的接入点 IP,强制客户端断线重连至新机房。

Q: 单元化架构中,由于业务关联导致跨单元的写请求(如 A 机房用户给 B 机房用户转账),如何处理? A: 严禁跨机房直接读写对方的数据库!这会引入极高的网络延迟和分布式事务灾难。 标准解法是服务层 RPC 转发:A 机房的业务代码通过内部 RPC/MQ 将转账请求发送给 B 机房的转账服务微服务,由 B 机房的服务在 B 机房本地完成数据库写入。即:让流量在服务网关层走专线跨机房,而不是让 DB 跨机房。