线上突然接到业务告警,订单状态批量更新失败率超过 50%。登录 MySQL 看了半天,Group Replication(MGR)三节点全部 ONLINE,CPU、内存、磁盘全部正常,复制距离也没异常,但业务日志里到处都是死锁、锁等待超时,客户端持续报更新失败。如果你也遇到过这种“集群看着没毛病、业务却写不进去”的诡异故障,那这篇故障复盘应该能帮你省下不少排查时间。
我这次要聊的是一个基于 MySQL 8.0 搭建的三节点 MGR 多主模式集群,承载订单状态流转和库存扣减场景。整个故障窗口大概持续四十分钟,最终定位到的是多主模式下非常典型的写写冲突与认证失败问题。文章会按“现象还原 -> 排查路径 -> 根因机制 -> 应急与根治 -> 监控预防”的顺序展开,既有 SQL 也有参数,尽量给到可以直接参考的排查思路。
1. 故障现象:三节点 ONLINE,业务却写不进去
1.1 集群拓扑与现场信息
先说下背景。这套环境是某公司核心交易库的一部分,一共三个节点,跑的是 MySQL 8.0,MGR 采用多主模式,也就是每个节点都可以接受写入。应用侧通过中间件做了读写分离,正常情况下写请求会被分发到不同节点,比如订单创建走 node-a,订单状态更新走 node-b,库存扣减走 node-c,这样做的初衷是分散写入压力,提高吞吐。
故障发生在某天下午的业务高峰期,持续约四十分钟。业务方反馈两个核心接口开始出现大量报错:一个是订单状态更新接口,报错内容是“Lock wait timeout exceeded”;另一个是库存扣减接口,报错内容直接是“Deadlock found when trying to get lock; try restarting transaction”。两个接口都集中在 UPDATE 和 DELETE 操作上,INSERT 反而很少有问题。
当时我第一反应是数据库负载被打满了,或者某个节点出现了磁盘/内存瓶颈。但从监控平台看三个节点的 CPU、内存、磁盘 IO、网络流量都处于正常水位,InnoDB 的 buffer pool 命中率也稳定在 99% 左右。这就有点反常了——如果是资源瓶颈,监控多少会露出点迹象,但这次表面完全是“风平浪静”。
1.2 客户端实际看到的报错是什么样
为了还原问题,我让业务方把客户端日志打出来,发现报错并不是单一类型,主要集中在下面几种:
| 报错信息 | 错误码 | 出现频率 |
|---|---|---|
| Lock wait timeout exceeded; try restarting transaction | ERROR 1205 | 高 |
| Deadlock found when trying to get lock; try restarting transaction | ERROR 1213 | 中 |
| The transaction has been rolled back because it conflicted with another transaction | ERROR 3098 | 低 |
| Can't execute the given command because you have active locked tables or transactions | ERROR 1123 | 偶发 |
这几种报错混合出现,很多 DBA 看到 1205 和 1213 会先往 InnoDB 锁竞争上排查,比如长事务、大事务、行锁太多。这也是这次故障迷惑性的来源:你往锁方向查,能查到一些行锁等待记录,但根因根本不是普通锁竞争,而是 MGR 多主认证冲突把事务直接终止了,客户端感知到的“锁”其实是冲突检测机制的副作用。
1.3 为什么先排除硬资源问题
排查初期我做了几件比较常规的事,先确认不是基础设施问题:
- 看系统负载:三个节点
uptime的负载都远低于 CPU 核数,没有拍到打满的迹象。 - 看磁盘与 IO:
iostat里 %util 没有长期超过 60%,redo log 写入没有明显排队。 - 看网络:节点之间的 RPC 延迟和丢包率正常,没有出现网络分区。
- 看慢日志:确实出现了一批慢 SQL,但单条 SQL 执行时间不超过两秒,和“大面积超时”对不上。
这些检查的价值在于快速划定边界。如果网络有抖动或者磁盘有延迟,MGR 的选主和同步机制也会表现异常,但表现方式会和这次完全不同——通常会出现成员状态异常、节点退出集群等。而这次所有成员都稳定 ONLINE,说明基础层和 MGR 通信层大概率没有硬故障。那问题就只能出现在事务执行或认证层面。
2. 初步排查:MGR 监控指标里藏着答案
2.1 成员状态全部 ONLINE,不代表集群健康
很多人排查 MGR 第一步是看performance_schema.replication_group_members,看到三个节点都是 ONLINE 就觉得集群没问题。这个认知需要修正一下。replication_group_members反映的只是组成员和通信状态,ONLINE 只能说明这个节点还活着、还在参与组通信,但完全不能反映这个节点的复制线程是否健康、认证是否在失败、流控是否在生效。
我这次也是先查了这个视图:
SELECT MEMBER_ID, MEMBER_HOST, MEMBER_STATE, MEMBER_ROLE FROM performance_schema.replication_group_members;结果三个节点全部 ONLINE,两个 PRIMARY,一个 SECONDARY(多主模式下角色其实都是 PRIMARY,这里显示 PRIMARY 表示可写)。单看这个结果,很多人就会得出结论“数据库没问题,是应用的问题”。但业务真实报错摆在面前,不能就这样交差。
2.2 真正的线索在 replication_group_member_stats
接着我查了另一个容易被忽略的表:performance_schema.replication_group_member_stats。这张表记录了每个成员的事务统计信息,是定位 MGR 写冲突的核心视图。
SELECT MEMBER_ID, COUNT_TRANSACTIONS_IN_QUEUE, COUNT_TRANSACTIONS_CONFLICTS, COUNT_TRANSACTIONS_LOCAL_PROPOSED, COUNT_TRANSACTIONS_LOCAL_ROLLBACK FROM performance_schema.replication_group_member_stats;查询结果让我一下子锁定了方向:
| 节点 | 事务队列 | 认证冲突数 | 本地提议事务数 | 本地回滚事务数 |
|---|---|---|---|---|
| node-a | 0 | 132 | 15800 | 130 |
| node-b | 0 | 118 | 14900 | 116 |
| node-c | 0 | 145 | 17100 | 143 |
正常健康的多主集群,COUNT_TRANSACTIONS_CONFLICTS应该长期保持在一个极低水平,可能一天也就几十个。而现在一个小时内积累了上百个冲突,且每个本地回滚事务数都对应着一次冲突。这说明集群内部正在频繁发生事务互相“打架”的情况。
这里要说明一下,COUNT_TRANSACTIONS_CONFLICTS是累计值,所以排查时不能只看当前大小,要连续采样两次,计算差值,看单位时间内的增长速率。我当时隔了 30 秒查了两次,确认冲突数仍在持续快速上涨,这就确定了问题还在进行中,而不是历史遗留的统计值。
2.3 错误日志和 InnoDB 状态的双重佐证
接下来我去翻了三个节点的 MySQL error log,找到了不少组复制层面的报错,类型大致是:
[ERROR] [MY-011712] Plugin group_replication reported: 'Transaction ' ... ' failed certification with conflict'这类日志直接证实了认证失败的存在。然后我又在其中一个节点执行了SHOW ENGINE INNODB STATUS\G,看了 LATEST DETECTED DEADLOCK 部分,发现两个事务确实是围绕同一行订单记录互相持锁等待,看起来像是典型的行锁死锁,但结合组复制日志,其实背后的逻辑是:事务 A 在 node-a 提交并广播写集合,事务 B 在 node-b 提交并广播写集合,两个事务修改了同一行数据,MGR 的认证器判定冲突,后提交的那个事务被回滚。
也就是说,InnoDB 层看到的“死锁”是表现,组复制层的“认证冲突”才是根因。如果只盯着 InnoDB 死锁信息去优化索引、拆分事务,问题大概率还会二进宫。
3. 根因分析:多主模式的写写冲突与认证机制
3.1 MGR 认证机制到底做了什么
要理解这次故障,必须先把 MGR 的认证机制讲透。MGR 和传统主从复制最大的区别在于,事务不仅要写本地,还要在组内达成共识。具体流程大致是:
- 应用在某个节点上执行事务,事务先在本地 InnoDB 中写入。
- 事务提交时,节点会把这个事务的二进制日志内容连同“写集合”一起广播到组内。
- 组内所有节点对该事务执行认证(certification),判断它和其他已经提交或正在提交的事务是否修改了同一行数据。
- 如果认证通过,事务可以提交;如果认证失败,事务直接被回滚。
这里的“写集合”是 MGR 用来判断冲突的核心。它通过主键把事务涉及的行映射成一组哈希值。两个事务只要写集合有交集,就判定为冲突。这也是为什么 MGR 强制要求表必须有主键——没有主键就没法稳定计算行哈希,认证阶段直接无法工作。
用个生活化的类比:多主模式就像三个人同时编辑同一份在线文档,你改了第 3 段的文字,另一个人也改了第 3 段,后保存的那个人会收到提示“文档已被修改”,他的更改会被丢弃。MGR 的认证器就是那个文档协作系统,“你不能更新了”本质上是系统在保护数据一致性,防止两个节点对同一行数据做出互相覆盖的更改。
3.2 哪些场景最容易触发认证冲突
多主模式不是所有操作都会冲突,实际上 INSERT 的冲突率远低于 UPDATE 和 DELETE。容易触发认证冲突的场景往往具有以下特征:
- 热点行并发更新:比如同一张订单记录被多个节点同时修改,库存表同一行被多个服务扣减,这种是最高频的冲突源。
- 业务逻辑是“先查再改”:应用先 SELECT 出订单状态,然后在业务代码里判断状态,再执行 UPDATE。两次操作之间如果另一个节点也改了同一行,认证冲突概率极高。
- 更新条件不是主键:如果 UPDATE 语句的 WHERE 条件走的是普通二级索引或者没索引,MGR 认证时需要扫描更多行来判断写集合交集,冲突检测的粒度更粗,容易被误伤。
- 长事务和大事务:事务持续时间越长,和别的事务产生写集合交集的机会就越大。批量 UPDATE 扫过大量行,也会放大写集合范围。
- 表没有主键:这会直接导致认证失败或复制报错,严格说是“不能进入认证环节”而不是冲突问题,但表现同样是数据不能更新。
3.3 本次故障的具体触发链路
回到这次案例。最终定位到的根因有三层,逐层递进:
第一层,业务侧存在“读改写”的并发模式。两个应用实例通过中间件把同一批订单的更新请求分发到了不同 MGR 节点。比如订单号 10001 的状态更新请求,一部分发到 node-a,一部分发到 node-b。两个节点同时读取订单状态、判断是否允许流转、然后执行 UPDATE,天然形成了写集合交集。
第二层,某些表的 WHERE 条件走的是非主键索引。订单状态更新用了订单状态扫描条件,一条 UPDATE 可能触碰几十行,写集合被放大。原本只改一行可能还不至于频繁冲突,一改几十行,冲突概率呈几何级增长。
第三层,也是最隐蔽的一层,集群里有一张历史遗留的明细表没有主键,只有二级索引。这张表平时写入量不大,但 MGR 对无主键表的处理非常严格,一旦有事务包含对这张表的修改,认证器就无法生成稳定的行哈希,事务在组内广播时直接被拒绝。业务更新订单时联表操作会顺带修改这张明细表,导致整个事务不可用。
三层原因叠加,最终表现就是“多主数据不能更新”。第一层和第二层是诱因,第三层是放大器,把偶发冲突变成了大面积故障。
4. 处理过程与最终修复
4.1 应急处理:先把业务恢复起来
故障发生时首先要做的不是分析根因,而是恢复业务可用性。我当时的应急动作按顺序执行:
- 通知应用团队开启事务重试机制,死锁和 1205 错误属于可重试错误,应用层设置最多重试三次,间隔 200 毫秒。这一步立竿见影,很多偶发写入失败被自动消化掉了。
- 将批量订单更新任务从高峰期摘除,改到业务低峰期执行。批量任务是大写集合的主要生产者,摘掉后冲突数立刻下降。
- 对那几张热点表临时启用“同一行数据路由到固定节点”的策略。中间件层面配置写路由规则,比如订单状态更新统一只发到 node-a,库存扣减统一只发到 node-b,错开写集合交集。
这里要特别提醒一个容易做错的动作:不要因为害怕冲突就随便重启节点。有些 DBA 遇到 MGR 异常会本能地执行STOP GROUP_REPLICATION再START GROUP_REPLICATION,希望通过重新加入集群来触发状态重置。但在冲突密集的窗口内重启节点,反而可能因为恢复同步时积压大量事务而加剧流控,甚至导致该节点一直卡在 RECOVERING 状态。重启操作只适用于节点完全卡死或成员状态异常的场合,不要作为常规应急手段。
加急操作大约十五分钟后,业务报错量明显回落,从 50% 降到 5% 以下。业务侧算是缓过来了,接下来才是治本。
4.2 业务代码层改造:消除热点行冲突
应急只是把问题压下去了,不改造业务模式,冲突迟早还会爆发。业务侧的根治措施我总结成三条:
- 引入乐观锁。在订单表增加版本号字段,UPDATE 语句改为
UPDATE orders SET status = '已完成', version = version + 1 WHERE id = ? AND version = ?。这样即使两个节点同时尝试更新同一行,只有一个事务的版本号能匹配成功,另一个直接更新 0 行,不会进入认证冲突。 - 拆分“读改写”事务。尽量把查询状态和更新状态拆成两步独立操作,缩短事务持有写集合的时间。如果必须保持原子性,把事务体量压缩到最小,不在一个事务里同时更新订单主表和明细表。
- 按业务做写路由。多主模式下,不同业务模块的写入尽量只在固定节点执行,比如订单模块写入只走 node-a,库存模块写入只走 node-b。这不会影响 MGR 的多主能力,但能大幅降低跨节点写同一行的概率。
这次踩坑后,我把“多主模式必须有冲突防控设计”固化成了团队开发规范。多主不是问题,问题是用多主的方式去写单行热点数据,这在架构上就是拧巴的。
4.3 参数与配置层调整
代码改造完成后,我又对数据库参数做了一轮针对性调整,主要围绕三个方面:
- 调整流控参数。MGR 自带流控机制(flow control),当某个节点的事务应用队列积压时,会反过来限制所有成员的事务写入,避免落后节点积压过大。但流控生效时,写操作会出现延迟甚至超时,容易被误判为“不能更新”。我这次把
group_replication_flow_control_mode从默认的 QUOTA 调整成了 DISABLED,先消除了流控带来的额外延迟。这里必须说明,生产环境长跑不建议一直 DISABLED,它会让落后节点失去保护。正确做法是保持流控开启,但把group_replication_flow_control_min_quota和group_replication_flow_control_max_quota调大,给瞬时写入高峰留出缓冲空间。 - 调整锁等待超时。
innodb_lock_wait_timeout默认是 50 秒,在 MGR 冲突场景下,锁等待超时很容易被当成普通锁问题。我把这个参数调低到 10 秒,让应用更快地感知到事务失败并重试,而不是长时间挂起占住连接。 - 调整事务大小限制。
group_replication_transaction_size_limit是 MGR 对单个事务大小的硬限制,超过限制的事务会直接失败。我检查后发现默认配置能覆盖日常业务,暂时不用调整,但确认了批量任务拆分到事务内的行数,避免单事务过大。
参数调整不是万能的,它更多是给业务改造争取时间。真正解决问题的永远是业务侧对写集合的控制。
4.4 给历史表补主键:这次故障的根源修复
这次故障的最终引爆点是那张无主键的明细表。处理完业务代码后,我专门花了一个周末把集群里所有无主键表全部梳理出来,逐一补主键。
补主键本身不复杂,但在线执行有坑。MySQL 8.0 支持ALTER TABLE ... ADD PRIMARY KEY,用的算法是 INPLACE,但大表执行时依然会产生大量 redo 和内部临时文件。我当时的操作步骤是:
- 先在备节点上执行
ALTER TABLE,观察执行时间和产生的临时文件大小。 - 确认无异常后,选择业务低峰期在主节点执行,并在执行前调整
lock_wait_timeout,避免 DDL 语句被事务阻塞。 - 优先选用业务自然键或自增列作为主键,如果表本身有逻辑唯一字段就直接用;没有业务主键的,加一个自增列并用
ALTER TABLE ... AUTO_INCREMENT=初始值避免后续主键冲突。 - 执行完成后检查
performance_schema.replication_group_member_stats,确认认证冲突数不再上涨。
这里也提醒一句:MGR 环境里给大表加主键前,最好先确认group_replication_consistency的配置,必要时在低峰期临时调整为EVENTUAL,减少 DDL 对组内其他节点的影响。我这次是在维护窗口内操作的,没有遇到异常,但提前做足检查总是对的。
5. 监控与预防:别等报错再上线排查
5.1 上线前必须过一遍的检查项
经历过这次故障后,我把 MGR 多主集群的上线前检查清单重新整理了一遍,每一条都是拿教训换来的:
- 全库表必须都有主键或非空的唯一键,这是硬性要求,没有加主键的表不允许接入 MGR。
- 写集合冲突压测要做真实并发,只测单节点写入看不出问题,至少模拟两个节点同时写同一批热点行的场景。
- 业务规范里明确写清“读改写”事务的边界,禁止在同一个事务里做无关行的批量更新。
- 确认
performance_schema开启,replication_group_member_stats和replication_group_members这些视图才能正常采集,监控数据才不会缺失。 - 提前把
group_replication_flow_control_mode、group_replication_transaction_size_limit、innodb_lock_wait_timeout这些参数按业务峰值压测结果固化到配置模板里。
这套检查清单现在直接挂在我们部门的数据库上线流程里,新业务接入 MGR 前必须逐项确认,确认不了就用单主模式。
5.2 监控指标与告警设置建议
MGR 的监控不能只看节点状态和主从延迟,更要看认证冲突和流控指标。我目前保留的监控项和阈值如下:
| 监控项 | 数据来源 | 告警阈值 |
|---|---|---|
| 成员状态 | replication_group_members | 非 ONLINE 立即告警 |
| 认证冲突数增长率 | replication_group_member_stats | 30 秒内增长超过 20 次告警 |
| 事务队列积压数 | replication_group_member_stats | 持续超过 30 秒大于 0 告警 |
| 节点应用线程异常 | replication_applier_status | 线程停止即告警 |
| 流控触发次数 | 错误日志关键字 | 5 分钟内触发 3 次以上告警 |
特别提一下认证冲突数这个指标,日常多主集群也可能会有一两次冲突,但 30 秒增长 20 次已经是明显的异常信号,足以触发人工介入。设置这个告警之前,很多 MGR 写冲突类问题都是业务先发现,DBA 才被动介入,有了告警后我们基本能在业务感知前就收到通知。
5.3 多主模式的适用性反思
这次故障之后我一直在想,MGR 多主模式到底适合什么场景,后来得出的结论很明确:多主模式更适合“多写少冲”的业务模型,比如不同业务模块写不同的库表,或者同一个业务的数据天然按维度拆分,比如按租户、按地域分片。如果业务存在明显的单行热点写入,比如订单状态频繁更新、库存扣减、账户余额变动,那么多主模式会频繁触发认证冲突,不仅没享受到多写带来的吞吐提升,反而额外背负冲突回滚和重试的开销。
这种情况下,单主模式加读写分离反而更合适。MGR 同样支持单主模式,写入全部走主节点,不存在跨节点写单行的问题,冲突数为零,架构还更简单。当初这套系统选多主,是因为业务团队希望充分利用三台服务器,多主似乎更“高级”,但实际运行下来多主带来的额外复杂度远大于收益。
当然,如果业务层已经做好了写路由,不同数据域固定走不同节点,多主模式也能跑得很稳定。关键是架构选型必须匹配业务特征,而不是为了技术而技术。
最后再分享一个小技巧:MGR 排查写冲突类故障时,别急着看业务代码,先查replication_group_member_stats里的COUNT_TRANSACTIONS_CONFLICTS增长速率,再结合 error log 里的 certification 相关日志,基本五分钟内就能确定是不是认证冲突。方向对了,剩下的就是业务改造和参数调优,方向错了,半夜起来查锁等待只会越查越乱。这套排查路径我验证过多次,越用越顺手。