很多人排查触发器锁冲突时,第一反应是“能不能在触发器里单独开事务,把更新包起来”。问题恰好出在这里:MySQL 和 SQL Server 的 DML 触发器本身就运行在父事务里,锁的持有时间、回滚点和提交时机都由主 SQL 决定。
这类问题真正要看的,不是语法能不能写出来,而是锁的顺序、范围和持续时间是否可控。下面按几个最容易踩坑的点展开:先明确触发器为什么不能显式开事务,再看主事务里怎样预加锁,最后说明哪些触发器逻辑应该改成异步或上移到应用层。
触发器里为什么不能显式开启事务
MySQL 和 SQL Server 的 DML 触发器都在父事务上下文内执行。也就是说,你在触发器里写 BEGIN TRANSACTION,并不会得到一个新的独立事务;触发器中的 UPDATE、INSERT 仍然复用主事务的事务 ID、锁生命周期和回滚点,并随主事务一起提交或回滚。

这也是很多“语法看起来没错,但线上还是出问题”的根源。触发器里试图再开事务、提交事务,通常只会带来报错或无效行为:
- 在触发器里写
START TRANSACTION:MySQL 可能报Can't execute the given command because you ha ve active locked tables,也可能直接被忽略 - 在触发器里执行
COMMIT:MySQL 8.0+ 可能报Cannot execute statement in a READ ONLY transaction;SQL Server 可能报The COMMIT TRANSACTION request has no corresponding BEGIN TRANSACTION
因此,触发器中的事务控制不是“写法问题”,而是机制上就不成立。把锁管理寄希望于触发器内部,方向本身就是错的。
为什么要在主事务开头先做 SELECT ... FOR UPDATE
如果主 SQL 在操作 orders,而触发器又要去更新日志表、余额表或别的关联表,最危险的不是“会不会加锁”,而是“先锁哪张表、走哪条索引路径”不一致。一旦并发事务的锁顺序不同,就很容易死锁。

正确做法是:不要等触发器自动执行时再去碰这些行,而是在主事务一开始,就把触发器后续一定会访问的目标行提前锁住。
例如,主 SQL 是:
INSERT INTO orders (...) VALUES (...)而触发器中会执行:
UPDATE log_table SET status = 'done' WHERE order_id = NEW.order_id这时应优先采用下面的写法:
- 正确:在
START TRANSACTION后第一句执行SELECT id FROM log_table WHERE order_id = FOR UPDATE - 错误:等触发器被动执行时再
UPDATE log_table,把锁顺序交给优化器决定
原因在于,后者可能走非唯一索引,甚至触发更大的锁范围,最终出现 gap lock 或锁等待放大。
FOR UPDATE 使用时要注意什么
SELECT ... FOR UPDATE 只有在事务内部执行才有意义,而且目标条件必须足够精确。若查询条件没有命中主键或唯一索引,数据库可能锁住一个范围,而不是单行。
FOR UPDATE必须在事务中执行,不能脱离事务单独使用- 优先锁定主键或唯一索引列,避免把锁范围扩大成区间
- 预加锁的目标,应与触发器后续真正访问的行保持一致,否则提前锁也起不到稳定锁序的作用
触发器里的 DML 为什么必须走唯一索引
触发器中最常见的死锁来源,不是更新本身,而是更新条件太“宽”。在 InnoDB 里,UPDATE 或 DELETE 一旦使用范围条件,比如按时间、状态或普通索引过滤,就可能引入 gap lock,锁定的也不再是你以为的那几行。
例如下面这种写法就应避免:
UPDATE audit_log SET processed = 1 WHERE created_at < NOW() - INTERVAL 1 HOUR更稳妥的方式,是把它改成基于主键或唯一键的精确命中:
UPDATE audit_log SET processed = 1 WHERE id = 或者:
UPDATE audit_log SET processed = 1 WHERE order_id = AND tenant_id = 其中 order_id + tenant_id 应对应复合唯一索引。这样主事务才能预判锁范围,触发器执行时也更不容易出现额外的间隙锁。
执行计划要检查到什么程度
不要只看 SQL 语义“像是单行更新”,还要实际检查执行计划。对触发器中的关键语句执行 EXPLAIN,确认它们命中的是明确的点查路径。
- 优先看到
type=const或type=eq_ref - 尽量避免
range或index扫描 - 如果执行计划依赖普通索引或范围过滤,说明锁范围仍然不可控,需要继续改写 SQL 或补齐唯一索引
跨表副作用为什么更适合移出触发器事务
触发器一旦开始写多个非核心表,问题就不只是死锁概率上升,而是整条主事务的耗时和锁窗口都变得不可预测。比如统计表、通知表、外部映射表,任何一张表响应慢、索引不稳定或遇到锁冲突,都会把主业务 SQL 一起拖住。

对强一致性要求高的场景,触发器更适合只保留轻量逻辑,把重操作移出事务边界:
- 资金类、库存类场景:触发器只做轻量校验,例如接近
CHECK约束级别的逻辑;日志写入、消息发送移出事务 - 审计类需求:可先写缓冲表,例如
INSERT IGNORE INTO log_buffer (order_id, action) VALUES (, 'created'),再由定时任务批量 flush 到正式表 - 若必须同步完成多表写入,更适合在应用层显式组织单事务,例如 ORM 中的
select_for_update+save(),而不是依赖数据库触发器隐式串联
从可维护性看,触发器最棘手的地方并不是语法,而是它把锁的“时间窗口”和“作用域”都藏在数据库内部。表面上你只执行了一条 INSERT,实际背后可能已经涉及三张表、四个索引,以及数秒的锁等待。
一组可直接落地的实践规则
如果你需要给现有系统做一次快速排查,可以先按下面几条检查:
- 不要在触发器里写
BEGIN TRANSACTION、START TRANSACTION或COMMIT - 在主事务开头,用
SELECT ... FOR UPDATE提前锁住触发器会访问的目标行 - 让触发器中的
UPDATE、DELETE全部走主键或唯一索引 - 对关键语句跑
EXPLAIN,确认不是range、index扫描 - 把日志、通知、统计、外部映射这类非核心副作用尽量移出触发器事务
当你把锁顺序固定、锁范围收窄、跨表副作用外移之后,触发器相关的死锁问题通常会明显下降。这也是判断一套方案是否可靠的核心标准:锁的获取顺序是否明确,锁住的对象是否可预期,事务时间窗口是否足够短。







