存储过程和触发器在高可用集群中不自动幂等,主备切换时可能重复执行;真实重复发生在binlog已重放但事务未提交的异常场景;唯一索引+on duplicate key update是核心防重方案。

高可用集群里,存储过程和触发器本身不自动具备幂等性;它们的执行是否重复、是否被重放,取决于主备切换时 binlog 的重放行为、事务是否完整提交、以及你有没有在逻辑层做防重设计。
主备切换后,存储过程和触发器会不会被重复执行?
会,但只在特定条件下:
- 如果存储过程或触发器内的操作写入了 binlog(
log_bin = ON且未被sql_log_bin = 0临时关闭),那么它在主库执行后,会随 binlog 被备库重放一次——这是正常复制行为,不是“重复”,而是“必须一致” - 但如果主库崩溃前事务已写 binlog 但未提交,而备库已拉取并执行了该 binlog(即半同步未生效或配置为异步),随后又发生主备切换,新主库可能再次执行同一逻辑——这时才构成真实重复
- 触发器尤其危险:比如一个
BEFORE INSERT触发器对NEW.amount做了修正,若该 INSERT 在主库未提交就宕机,备库却已执行过该触发器逻辑,切换后新主库再执行一遍,就会 double-correct
唯一索引 + INSERT ON DUPLICATE KEY UPDATE 是最稳的兜底方案
别指望靠集群配置“自动防重”,得在 SQL 层加固:
- 所有对外暴露的幂等入口(如支付回调、消息去重、日志落库),必须有业务唯一键(如
request_id)并建UNIQUE索引 - 用
INSERT INTO t (...) VALUES (...) ON DUPLICATE KEY UPDATE updated_at = NOW(), is_repeated = 1替代先SELECT再INSERT的两段式逻辑 - 禁止在
ON DUPLICATE KEY UPDATE子句里写非幂等表达式,例如counter = counter + 1或status = 'processed'(除非你确认 status 只能从 A → B 单向流转) - 注意
NULL值不参与唯一索引校验,request_id字段必须设NOT NULL,否则两个NULL会被视为不同值,绕过约束
触发器内部不能依赖 SELECT 查询做状态判断
高可用环境下,间隙锁、主备延迟、binlog 格式(STATEMENT vs ROW)都会让触发器里的 SELECT 行为不可靠:
- 想查“当前是否有未完成订单”,不要在触发器里写
SELECT COUNT(*) FROM orders WHERE user_id = NEW.user_id AND status = 'pending'—— 这个查询在备库重放时可能看到过期快照 - 若必须查,改用
SELECT ... FOR UPDATE,但前提是该SELECT必须命中主键或唯一索引,且整个操作包裹在显式事务中(START TRANSACTION开头,COMMIT结尾) - 更推荐把状态判断提到存储过程里做,触发器只负责纯副作用(如写日志、更新统计字段),且这些字段本身带唯一约束或版本号
- 避免触发器修改同表数据,否则在主备切换后容易触发
Can't update table 'xxx' in stored function/trigger错误
测试阶段必须模拟主备延迟与切换场景
本地单实例测通 ≠ 集群安全:
- 用
SET GLOBAL rpl_semi_sync_master_timeout = 1000模拟半同步超时降级为异步,观察 binlog 重放是否导致双写 - 手动 kill 主库 mysqld 进程,在备库
STOP SLAVE; START SLAVE;后立刻插入相同request_id,验证ON DUPLICATE KEY UPDATE是否仍生效 - 检查
SHOW BINLOG EVENTS IN 'mysql-bin.000001',确认存储过程调用(CALL)和触发器产生的 DML 是否都以 ROW 格式记录——STATEMENT 格式下,触发器行为在备库可能不一致 - 所有涉及时间判断的逻辑(如“30 分钟内不允许重复提交”),避免用
NOW(),改用客户端传入的submit_time参数,防止主备系统时间偏差放大问题
真正麻烦的不是语法怎么写,而是你得清楚哪条语句会在主库执行、哪条会在备库重放、哪条根本不会进 binlog——这三者混在一起时,只有唯一约束+原子语句+确定性参数才能守住底线。











