必须将触发器移出事务边界,因其与主SQL共享事务上下文,导致锁持有时间延长、延迟激增、链式阻塞及死锁;应改用消息表等异步机制,仅保留毫秒级无IO操作在触发器内。
高并发下触发器卡住,本质是把本该异步做的事硬塞进同步事务里。直接改用消息队列或异步处理,不是“绕开问题”,而是回归数据库职责边界——它只管数据原子性,不负责通知、统计、缓存更新这些副作用。
为什么触发器必须移出事务边界
触发器在
或
中执行时,和主 SQL 共享同一个事务上下文。只要它里面有一次
、一次没索引的点查、甚至只是调用一个含
的存储函数,整个事务就得等它完成才能提交。500 QPS 下,平均延迟从 0.8ms 涨到 12ms 就是因为这个等待链。
锁持有时间 = 主SQL执行时间 + 触发器内所有SQL执行时间 + 函数解析/权限检查开销
一旦触发器写入日志表、调用外部API、或更新另一张带触发器的表,就可能引发链式阻塞或死锁
MySQL 不支持
这类嵌套深度检测,递归触发只能靠业务层加标记字段硬防
用轻量消息表替代直接写队列服务
不是所有系统都已接入 Kafka 或 RabbitMQ。更务实的做法,是建一张极简的
表,用 MySQL 自身机制模拟异步:在触发器里只做一件事——
。
这张表必须只有几个字段,引擎用
,主键为自增
,其他字段加
避免在触发器里对
做
或
,否则又引入新锁
消费者任务(如每 100ms 跑一次的定时脚本)用
拉取并标记处理中,再异步执行后续逻辑
别在触发器里写
多行进
,批量操作会触发 N 次插入,N 行就写 N 条队列记录
哪些逻辑必须异步,哪些还能留在触发器里
能留在触发器里的,仅限于毫秒级、无IO、不查表、不调函数的操作:比如自动设置
、
、或简单
赋值。其余全部拆出。
要发短信/邮件 → 写入消息表,由独立服务消费后调 HTTP 接口
要更新 Redis 缓存 → 写入消息表,消费者执行
,别在触发器里连 Redis 客户端
要聚合统计订单金额 → 改用物化视图增量刷新,或定时任务跑
要校验用户余额是否充足 → 必须前置到应用层或存储过程预检,
里查余额表就是典型自锁陷阱
异步后怎么保证最终一致性不丢数据
消息表方案本身不解决可靠性,得靠三件事兜底:消费者幂等、消息表定期归档、失败重试机制。
消费者处理前先
判断是否已成功,避免重复执行
表按月分区(
),旧分区定期
防爆
消费者每次处理完一批记录后,用
删除,但必须确保删除与业务处理在同一事务中(即先更新业务表,再删队列)
如果消费者崩溃,未处理的记录仍在表中,下次拉取继续处理;不要依赖定时任务“每分钟扫一次”,而要用长轮询或监听
(如 Canal)降低延迟
最易被忽略的是:异步不是把触发器代码原样搬进消费者里就完事。你要重新评估每一步的锁范围、索引依赖、NULL 值处理——比如触发器里用
查用户列表,异步任务里得换成
,否则遇到 NULL 仍会漏数据。
AFTER INSERTBEFORE UPDATESELECT ... FOR UPDATESELECTpg_trigger_depth()trigger_queueINSERT INTO trigger_queue (table_name, row_id, event_type, created_at) VALUES ('orders', NEW.id, 'insert', NOW())InnoDBidINDEX(event_type, created_at)trigger_queueUPDATEDELETESELECT ... FOR UPDATE LIMIT 100INSERT ... SELECTtrigger_queueNEW.updated_at = NOW()NEW.version = OLD.version + 1CASE WHENSET user:123 '{"name":"a"}'INSERT INTO daily_stats SELECT ... GROUP BY DATE(created_at)BEFORE INSERTSELECT id FROM processed_log WHERE queue_id = ?trigger_queuePARTITION BY RANGE (TO_DAYS(created_at))DROPDELETE FROM trigger_queue WHERE id IN (...)binlogNOT INEXISTS