前几天项目里又有人来问我:MySQL触发器(TRIGGER)到底该怎么写,什么时候该用,为什么网上教程一抄就报错,答案又众说纷纭。我索性把这些年实际写触发器踩过的坑、拆过的案例、总结过的排查思路整理成一篇,聊清楚它到底能干什么、怎么落地、以及哪些场景千万别碰。MySQL触发器本质上是一种特殊的数据库对象,它绑定在某张表上,当这张表发生INSERT、UPDATE、DELETE操作时,数据库引擎会自动执行你预先定义好的一段SQL逻辑,整个过程不需要应用层主动调用。它很适合处理“数据写入后必须跟着做点什么”的一类固定动作,比如扣库存、记日志、做校验、同步汇总。这篇内容适合所有正在用MySQL的数据开发、后端工程师,也包括准备面试时想系统理解触发器原理的同学,我会尽量用实际可运行的案例把关键细节讲透。
1. 触发器到底是什么:理解它的运行机制和工作场景
1.1 触发器的定义与核心机制
MySQL官方文档里对触发器的定义很简洁:触发器是与表关联的、在某个事件发生时自动激活的命名数据库对象。这里的“事件”指的就是针对某张表的DML操作,INSERT、UPDATE、DELETE三种事件,再加上执行时机BEFORE和AFTER两种,组合出了六种触发器类型。
用生活里的例子来理解最方便。你可以把触发器想象成厨房里的烟雾报警器:你不用手动按它,也不用在炒菜前“调用”它,只要厨房里出现烟雾浓度超标这个事件,报警器就会自动响应。数据库里的触发器也类似,它默默挂在某张表上,只要符合条件的SQL语句经过这张表,引擎就会自动执行你写好的那一小段逻辑。
有一个关键点你刚接触时容易忽略:触发器不是在应用里通过代码显式调用的,而是由MySQL引擎在语句执行阶段自动判断并激活。应用端可能同时有几十个服务连到同一个库,它们都只是在执行普通的DML语句,根本不知道自己会触发什么额外的动作。这就带来一个明显的优点:被强制固化在数据库层,不会因为应用漏写一段逻辑而导致数据不一致。但它也是缺点来源,因为它像隐形逻辑一样,排查问题时如果不主动查看触发器列表,很容易像见鬼一样找不到数据变化的原因。
1.2 什么时候该用触发器:典型业务场景
以我实际做过的项目为参照,下面这些场景用触发器非常合适。
第一,库存扣减和订单状态联动。订单表插入一条新记录后,必须同步扣减商品表的库存。这类动作业务上几乎百分之百要发生,而且一旦漏掉,就可能导致超卖。放在应用里做也行,但需要每个写订单的服务都记得调库存服务。用AFTER INSERT触发器把扣库存动作挂在订单表上,至少能保证“订单插入成功但库存没扣”这件事不会发生。
第二,审计日志。核心账号表、余额表发生变化时,希望自动记录“旧值是什么、新值是什么、什么时候变的”。这类审计要求非常强调完整性,用AFTER UPDATE或AFTER DELETE触发器把新旧值捞出来写进日志表,比应用层逐个字段比对要靠谱得多。
第三,数据校验。有些格式约束用普通CHECK约束不好写,或者希望报错信息更加友好。在BEFORE INSERT或BEFORE UPDATE触发器里做校验,一旦不满足条件就通过SIGNAL语句主动抛错,阻止整条SQL正常执行。比如手机号格式校验、金额必须大于0、删除受保护记录之前先拦截。
第四,数据汇总与冗余字段维护。比如用户表插入新用户后,自动往统计表里增加用户总数;或者更新订单总金额时,自动同步到客户表的“累计消费”字段。这种场景适合低频、小数据量的同步,如果在高频大表上做,就得慎重,后文我会展开讲性能问题。
1.3 触发器的六种类型与各自用途
MySQL规定触发器只能针对INSERT、UPDATE、DELETE三种DML事件,再搭配BEFORE和AFTER两个时机,总共六种组合。下面这张表是我根据实践整理的对照:
| 触发器类型 | 事件时机 | 典型用途 |
|---|---|---|
| BEFORE INSERT | 插入前 | 数据校验、字段默认值处理、主键生成前干预 |
| AFTER INSERT | 插入后 | 扣减库存、写日志、同步汇总统计 |
| BEFORE UPDATE | 更新前 | 校验新值是否合法、统一补上updated_at时间戳 |
| AFTER UPDATE | 更新后 | 记录变更日志、同步其他表的冗余字段 |
| BEFORE DELETE | 删除前 | 删除保护、把被删数据归档到回收站表 |
| AFTER DELETE | 删除后 | 清理关联表、扣减统计计数、记录删除日志 |
这里有一个版本相关的细节值得注意。在MySQL 5.7.2之前,同一张表同一个事件时机最多只能创建一个触发器。比如orders表上只能有一个AFTER INSERT触发器,你再想建第二个直接报错。从5.7.2开始这个限制取消了,同一个事件时机可以创建多个触发器,并且可以显式指定它们的执行次序。这个改动在实际项目里非常实用,你可以把“扣库存”“记日志”“更新统计”拆成三个独立触发器,而不是把一大堆逻辑堆进一个巨型触发器里,维护和理解都舒服得多。
2. 创建触发器的完整语法与实操案例
2.1 CREATE TRIGGER语法逐行拆解
创建触发器的标准语法长这样:
CREATE TRIGGER trigger_name {BEFORE | AFTER} {INSERT | UPDATE | DELETE} ON table_name FOR EACH ROW trigger_body;逐项拆开来看。trigger_name是触发器名称,最好带环境标识和业务前缀,比如trg_orders_after_insert,一眼能看出它是哪张表、在什么时机触发、干什么用的。接着是触发时机和事件,BEFORE表示在SQL真正执行前激活,AFTER表示在SQL执行完成后激活。ON table_name表明这个触发器挂在哪张表上,它只能建在基表上,不能建在临时表或视图上。FOR EACH ROW表示这是行级触发器,这是MySQL唯一支持的粒度,它会对当前语句影响的每一行执行trigger_body。
重点说trigger_body。如果逻辑只有一句话,可以直接写;但大多数真实场景都是一段复合语句,这时候必须用BEGIN...END包裹。由于trigger_body里允许出现分号,而我们平时用的MySQL命令行客户端自己就用分号作为语句结束符,为了让MySQL正确识别触发器定义的边界,创建触发器之前通常要修改默认的语句定界符,也就是大家常说的delimiter。例子如下:
DELIMITER $$ CREATE TRIGGER trg_orders_after_insert AFTER INSERT ON orders FOR EACH ROW BEGIN UPDATE products SET stock = stock - NEW.quantity WHERE product_id = NEW.product_id; END$$ DELIMITER ;如果你漏了DELIMITER操作,在命令行工具里执行会频繁遇到语法错误提示,因为客户端在分号处就把CREATE TRIGGER语句截断了。用Navicat、DataGrip这类图形工具时,工具通常会自动处理定界符,但在命令行操作时必须养成写DELIMITER的习惯。
2.2 案例1:订单表写入后自动扣减库存
先创建订单表和商品表,作为案例基础:
CREATE TABLE products ( product_id INT PRIMARY KEY, stock INT NOT NULL ); CREATE TABLE orders ( order_id INT AUTO_INCREMENT PRIMARY KEY, product_id INT NOT NULL, quantity INT NOT NULL );现在业务要求:每次orders表成功插入一条订单,products表对应商品的库存就必须扣掉对应的quantity。这个动作用AFTER INSERT触发器做最直接。为什么用AFTER而不是BEFORE?因为订单还在插入过程中,你并不需要提前操作库存;等订单真正写入成功后再扣库存,可以避免订单插入失败时库存被白白扣掉的情况。如果库存扣减失败,整个事务会回滚,订单插入也会失败,数据一致性由事务保证。
完整写法如下:
DELIMITER $$ CREATE TRIGGER trg_orders_after_insert AFTER INSERT ON orders FOR EACH ROW BEGIN UPDATE products SET stock = stock - NEW.quantity WHERE product_id = NEW.product_id; END$$ DELIMITER ;这里最有意思的是NEW这个关键字。NEW代表当前正在插入的那行数据,NEW.quantity就是刚才插入的新订单数量。对INSERT触发器来说,NEW是只读还是可写?在BEFORE INSERT里,你可以通过SET NEW.xxx = 值来修改将要写入的数据;但在AFTER INSERT里,数据已经写入刚完成,你再修改NEW已经没有意义了,所以实践中AFTER触发器里我不会尝试去改NEW字段。
这个案例看起来很简单,但实际生产环境有个并发隐患。上面直接通过UPDATE products SET stock = stock - NEW.quantity来扣库存,在高并发下存在一定的超卖风险,除非你有适当的锁机制兜底。如果你们项目对库存准确性要求很高,在触发器里可以通过SELECT ... FOR UPDATE锁定商品行,并且先检查库存是否充足。但更稳妥的做法是,把扣库存作为一个条件更新来看待:
UPDATE products SET stock = stock - NEW.quantity WHERE product_id = NEW.product_id AND stock >= NEW.quantity;如果影响行数为0,说明库存不足,此时触发器要主动抛错,阻止订单插入,比如:
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足,无法下单';2.3 案例2:更新余额时自动记录变更日志
审计类需求天然适合触发器。假设account表如下:
CREATE TABLE accounts ( id INT PRIMARY KEY, name VARCHAR(50), balance DECIMAL(10, 2) );希望每次balance发生变化时,都往日志表里写入一条记录,包含账号id、旧余额、新余额和变更时间:
CREATE TABLE account_balance_log ( id INT AUTO_INCREMENT PRIMARY KEY, account_id INT NOT NULL, old_balance DECIMAL(10, 2), new_balance DECIMAL(10, 2), change_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这个场景应该用AFTER UPDATE触发器,因为日志要记录的是“已经发生”的变更,而不是“即将发生”的变更,AFTER时机能拿到最新的真实结果。触发器写法如下:
DELIMITER $$ CREATE TRIGGER trg_account_after_update AFTER UPDATE ON accounts FOR EACH ROW BEGIN INSERT INTO account_balance_log(account_id, old_balance, new_balance) VALUES (OLD.id, OLD.balance, NEW.balance); END$$ DELIMITER ;注意这里OLD和NEW同时出现。OLD取的是更新前的旧行,NEW取的是更新后的新行。哪怕一条UPDATE语句同时更新了users表里的一千行,这个触发器也会执行一千次,每次OLD和NEW都指向各不相同的那一行数据,这就是FOR EACH ROW的含义。
实际测试时我发现一个容易忽略的点:如果应用层每次都把整行所有字段都UPDATE一遍,即使balance值没有变化,AFTER UPDATE触发器依然会被执行,日志里照样会出现一堆没意义的“变更”。如果你只关心余额真正变化时的记录,需要在触发器里加一个判断:
IF OLD.balance <> NEW.balance THEN INSERT INTO account_balance_log(account_id, old_balance, new_balance) VALUES (OLD.id, OLD.balance, NEW.balance); END IF;这种“值没变也触发”的行为对很多新手来说是第一个隐形坑。
2.4 案例3:删除主表数据前做删除保护与归档
再来看一个BEFORE DELETE触发器的实战用法。我们先给users表加一个is_protected字段,用于标记受保护账号:
CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(50), is_protected TINYINT DEFAULT 0 );如果直接执行DELETE删除受保护用户,业务风险很高。用BEFORE DELETE触发器做拦截是一个常见的兜底方案:
DELIMITER $$ CREATE TRIGGER trg_users_before_delete BEFORE DELETE ON users FOR EACH ROW BEGIN IF OLD.is_protected = 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '受保护用户不允许删除'; END IF; END$$ DELIMITER ;SIGNAL的作用是主动抛出异常,SQLSTATE '45000'是用户自定义异常的标准状态码,后面的MESSAGE_TEXT会作为异常信息返回给客户端。系统自动抛错时,整条DELETE语句会被回滚,一张数据都不会删掉。
另一种典型场景是“删除即归档”。有些业务希望删除用户不是物理删除,而是先把整行数据迁到archive表里,再执行真正的DELETE。这个操作可以放在BEFORE DELETE里完成:
DELIMITER $$ CREATE TRIGGER trg_users_before_delete_archive BEFORE DELETE ON users FOR EACH ROW BEGIN INSERT INTO users_archive(id, name, is_protected, deleted_at) VALUES (OLD.id, OLD.name, OLD.is_protected, NOW()); END$$ DELIMITER ;这样用户被删除后,原表数据消失了,但归档表里留了一份完整快照。归档操作放在BEFORE时机有一个好处:万一归档插入失败,删除本身也会中止,避免数据真的消失后归档却没跟上。
2.5 案例4:用BEFORE UPDATE统一维护更新时间字段
很多设计里,users表需要维护一个updated_at字段。常见做法是建表时用DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,这也是一种隐式处理机制。但如果你还需要在更新前做其他业务判断,用触发器写会更加直观:
DELIMITER $$ CREATE TRIGGER trg_users_before_update BEFORE UPDATE ON users FOR EACH ROW BEGIN SET NEW.updated_at = NOW(); END$$ DELIMITER ;在BEFORE UPDATE触发器里,NEW是可以修改的,你通过SET NEW.updated_at = NOW()把所有“即将写入”的数据行都补上当前时间。这个改动会真实生效在最终写入的数据上。掌握这个特性之后,你完全可以在BEFORE触发器里做字段级别的新值修正,比如把空字符串统一置为NULL,把金额统一向上取整等等。
3. 触发器的运行机制与NEW/OLD深入理解
3.1 NEW与OLD伪行的取值规则
触发器操作的数据不是直接用变量名访问的,而是通过NEW和OLD这两个“伪行”来获取。它们不是真正的表,而是代表当前操作涉及的那一行数据的前后快照。
具体规则如下:
- INSERT触发器:只有NEW可用,代表即将插入或刚插入的新行。
- DELETE触发器:只有OLD可用,代表即将删除或刚删除的旧行。
- UPDATE触发器:NEW和OLD都可用,OLD是更新前的旧值,NEW是更新后的新值。
我再补充一个容易忽略的细节:在BEFORE INSERT和BEFORE UPDATE中,你可以修改NEW的字段值,通过SET NEW.column = value来干预最终写入的数据,这本质上是给了数据库逻辑一个“二次加工”的入口。但在AFTER触发器中,数据已经落库,任何对NEW的赋值都不会影响真实表数据,虽然某些版本下语法上不一定报错,但它办不到任何事,所以不要写这种没有意义的代码。OLD字段永远是只读的,因为已经发生过的历史值不能被触发器改变。
需要注意,NEW和OLD只能读取当前触发表的字段,你没法从中拿到另一张表的列。如果想拿商品表的库存数量,必须用SELECT查询。
3.2 触发器的执行顺序与事务边界
一条DML语句在MySQL内部大致经历这样的阶段:检查数据是否合法、执行BEFORE触发器、执行真正的DML操作、执行AFTER触发器。如果过程中出现错误,整个语句及对应的触发动作都会被回滚。
这里有一个容易绕晕的点:多个同类型同一个事件时机的触发器如何排序。5.7.2之后同一时刻允许多个触发器存在,它们的默认执行顺序是按创建时间先后执行的。如果你需要精确控制先后关系,可以在创建新触发器时通过FOLLOWS和PRECEDES指定它跟在哪个触发器之后执行,或者在某一个触发器之前执行。比如:
CREATE TRIGGER trg_orders_after_insert_2 AFTER INSERT ON orders FOLLOWS trg_orders_after_insert FOR EACH ROW BEGIN -- 第二个触发器的逻辑 END;这意味着trigger的执行顺序可以被管理,而不是永远靠“感觉”在猜。
关于事务边界,触发器和它的触发语句共享同一个事务。比如你执行一行UPDATE accounts语句,该语句如果没有显式开启事务,默认也是自动提交,但这不影响触发器在同一个原子单元里运行:任何触发器内的异常都会让整个DML语句回滚。尤其是AFTER触发器,虽然表数据已经写入,但SIGNAL一抛错,已写入的操作照样会被撤销。所以有人以为AFTER触发器里抛错就来不及了,其实是误解。
另外要注意,MySQL触发器中不允许出现显式事务控制语句,比如START TRANSACTION、COMMIT、ROLLBACK。因为触发器本身就是一个事务流程的一部分,再嵌套事务控制没有意义也容易出问题。
3.3 触发器与存储过程、事件调度的对比
很多人分不清触发器、存储过程、事件调度器(EVENT)这三种东西,我列个表简单对照一下:
| 功能项 | 触发器 TRIGGER | 存储过程 PROCEDURE | 事件调度器 EVENT |
|---|---|---|---|
| 触发方式 | 表的DML事件自动触发 | 显式CALL调用 | 按时间计划自动执行 |
| 能复用吗 | 一般不显式调用 | 可以被多处调用 | 不关注复用 |
| 适合场景 | 数据变更后的固定动作 | 复杂业务逻辑封装 | 定时统计数据、定期清理 |
| 事务控制 | 不能显式控制事务 | 可以控制事务 | 可以控制事务 |
| 参数支持 | 不支持参数,使用NEW/OLD | 支持输入输出参数 | 不支持参数 |
触发器最大的价值在于“自动”二字,它不依赖外部调用和定时器;但也正因为如此,它的逻辑对开发者来说是隐性的。系统运行时间久了,新人接手时根本不知道某张表数据为什么变化,这是触发器被很多人排斥的原因。存储过程则更透明一点,至少你能通过应用代码里显式的CALL语句看到调用点,但它的缺点是需要主动调用,做不到“自动”。事件调度器则是数据库层面的定时任务,比如每天凌晨清理过期数据,它和表上的DML事件没关系。
所以选型上我自己的习惯是:跟行级数据变更强绑定的、必须保证一致性的动作,优先考虑触发器;复杂的多步骤业务处理,写成存储过程并由应用显式调用;纯定时统计类的任务,用事件调度器。
4. 触发器管理:查看、删除、修改与权限控制
4.1 查看触发器列表与定义
项目跑了一段时间后,你想知道某张表上到底挂了多少触发器,最简单的命令是:
SHOW TRIGGERS;这条命令会把当前数据库里的全部触发器列出来,内容包括触发器名称、事件、关联表、SQL语句片段等。如果只想看某张表相关的,使用LIKE匹配触发器名或直接模糊匹配表名:
SHOW TRIGGERS LIKE 'trg_orders%';如果需要更完整的元数据,包括定义者的权限信息、创建时间等,可以直接查系统表information_schema.TRIGGERS:
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, ACTION_TIMING, ACTION_STATEMENT FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'your_db_name';用这个方式排查“某张表的数据为啥突然变了”很管用,先查有没有触发器,再查ACTION_STATEMENT具体做了什么,大部分数据诡异变化的问题都能一眼找到元凶。
4.2 删除与重建触发器的正确姿势
删除触发器比建表删表简单:
DROP TRIGGER IF EXISTS trg_orders_after_insert;删的时候要特别注意,如果你执行DROP TABLE删除某张表,该表上所有的触发器也会一并被删除,而且不会给你任何提示。如果你用mysqldump只备份表结构,默认情况下触发器是否包含在主备份中,取决于你导出时的选项,后面我会专门讲。
MySQL没有像MariaDB那样提供一个直接禁用触发器的开关。你需要临时停用某个触发器时,只能先DROP,用完之后再CREATE回来。如果这种临时停用操作很频繁,建议把每个触发器的DDL语句单独存成SQL脚本文件,统一放在数据库变更目录里,需要重建时直接执行脚本。我自己管理时,还会在触发器名称里带上版本号或业务标识,这样DROP之后重建时不容易混淆。
一个进阶的“软禁用”技巧是使用会话变量或用户变量做开关。比如在触发器内部先判断:
IF @disable_trigger IS NULL THEN -- 执行正常逻辑 END IF;需要禁用时,业务端先执行SET @disable_trigger = 1,触发器就会自动跳过核心逻辑。对临时性的维护操作,这个方案可以省去频繁删建触发器的麻烦。但要注意,这个变量只在当前会话内有效,其他连接仍然会触发完整逻辑,所以它不能作为永久的启用/禁用开关。
4.3 触发器与数据库备份、迁移的细节
备份迁移时,触发器是很容易被忽略的一块。mysqldump导出单库时,如果指定了--triggers选项,并且版本支持,它会连同触发器一起导出;默认情况下对于触发器的处理逻辑有时候会因参数变化而改变。稳妥的做法是,备份完成后专门用下面这条命令单独导出触发器,避免遗漏:
mysqldump -u用户名 -p 库名 --triggers --no-create-info --no-data > triggers_backup.sql更严格的团队流程,会把触发器脚本作为数据库迁移版本的一部分,由专人统一管理,而不是完全依赖mysqldump。
恢复触发器时还要留意DEFINER权限问题。如果触发器里的DEFINER指定的用户在新环境不存在,或者权限不足,导入时会报错,恢复后也可能出现触发器执行失败。此时要么提前创建同名用户,要么在导入前用sed等工具把DEFINER替换成新环境的数据库账号。主从复制环境更要多做一步验证:在从库上手工创建一张测试表,建一个测试触发器,确认从库复制链路不会因为触发器产生异常。因为有些场景下主库的触发器动作在从库重放时行为并不一致,尤其是使用了存储函数或依赖特定参数设置时,容易出现复制中断。
5. 触发器实战中的常见问题与排查技巧
5.1 触发器不生效的几类原因
有段时间我排查过一个“触发器好像没生效”的问题,最后发现是另一个同类型的触发器先执行,并且提前SIGNAL抛错,导致后续逻辑根本走不到。所以遇到不生效,我建议按顺序排查以下原因:
- 触发器是否真的存在?用
SHOW TRIGGERS查看,别光靠记忆。 - 是不是同表同事件有多个触发器,执行顺序和后建的那个预期不一致?
- 检查事件表和触发器定义中的库名是否匹配?有时候你在demo库建了触发器,却在test库执行DML,那当然不触发。
- 触发器逻辑里是不是有IF条件被绕过?比如余额日志触发器只在
OLD.balance <> NEW.balance时写入,但你更新时两次值相同,日志就是空的,这属于设计符合预期。 - 有没有调用SIGNAL导致整个触发器在早期位置退出?这是隐蔽原因。
另外,DML语句对触发器的影响也有区别。使用TRUNCATE TABLE清空数据时,触发器不会触发,因为TRUNCATE被MySQL归类为DDL操作,不是DELETE。这也是一个经典的“为什么触发器没跑”的原因。
5.2 递归触发与死锁:如何避免
触发器里最常见的“事故”就是递归。最直接的禁止项:MySQL不允许触发器去修改自己所在的表。比如你在orders表的AFTER INSERT触发器里再执行一条INSERT INTO orders,MySQL会直接报错:Can't update table 'orders' in stored function/trigger because it is already used by statement which invoked this stored function/trigger。
但更隐蔽的是不同表之间的交叉触发。比如orders表的AFTER INSERT触发器里UPDATE了products表,而products表又有AFTER UPDATE触发器来UPDATE orders表,两个触发器就可能形成循环调用。MySQL会检测部分递归并报错,但一些复杂链路在死锁超时前不一定能及时暴露。
我处理这种问题时有一条经验:不要用触发器去维护另一张“最终都会被业务大量更新”的核心表之间的交叉逻辑。如果A表触发器要更新B表,B表上不能再建触发器反过头来更新A表或C表,除非有严格的层级划分。尽量让触发器只做“单向、叶子节点”的更新,比如A表的新增动作更新一张统计表C,统计表C不再有触发器去更新A或B。如果实在需要防止重复触发,可以给表加一个标志字段或利用@user_variable做引用标记,但这属于比较绕的方案,能不用尽量不用。
5.3 触发器影响性能的真相和优化建议
触发器对性能的影响经常被人低估。FOR EACH ROW是行级触发,一条UPDATE语句更新一万行,触发器就会被执行一万次。如果触发器内部还包含客户余额表的历史日志、汇总统计等操作,那么单条SQL的实际开销会翻好多倍。
我第一次踩到性能坑是给一张订单表加了AFTER INSERT触发器,每次插入都更新商品表并写入日志表。单条插入数据时很快,但一次批量导入十几万条订单时,导入时间从原来的几分钟直接拉长到一个多小时,而且因为触发器执行过程中持有行锁,并发导入时大量出现了锁等待和超时,监控图上锁等待曲线触目惊心。
从那以后,我对触发器性能优化有了几条固定的检查项:
- 触发器内部涉及的每张表,WHERE条件列务必有合适索引。比如
UPDATE products WHERE product_id = NEW.product_id,如果product_id没索引,每次触发器执行都是全表扫描,批量导入场景下就是灾难。 - 触发器只做必要且轻量级的操作。日志表要避免在触发器内部做复杂聚合查询,更不要在触发器里调用外部存储过程去处理大量数据。
- 大批量数据变更时,先评估是否真的需要逐行触发。如果只是同步汇总数据,完全可以用存储过程在批处理结束后统一执行一次UPDATE,替代逐行触发器。
- 触发器内不要使用动态SQL、不要执行DDL,这类操作会严重拉长锁持有时长,增加死锁风险。
另外,在MySQL默认的RR隔离级别下,触发器执行过程中的读操作会使用一致性读,但写操作会加行锁直到事务结束。如果触发器内部执行了一个慢查询,那么这条DML语句会一直持锁,其他事务对同一行数据的更新都会排队。热词里提到的“锁表”“锁等待”问题,很多都是触发器里藏着慢SQL造成的,排查时可以优先看看当前运行的事务和锁等待事件。
5.4 触发器踩坑实录:三个值得说的真实案例
第一个案例是误把AFTER时机用成了“校验”。我在一个用户表上建了AFTER INSERT触发器,想在插入后校验某个字段是否符合业务规则,不符合就SIGNAL报错。虽然AFTER里报错确实会让整个事务回滚,表面上看似可行,但有一个问题:AFTER触发器是在数据落库之后才执行的,如果表比较大,回滚成本和造成锁等待的时间都要更长。所以涉及校验、拦截、阻止写入的逻辑,优先用BEFORE触发器,让错误在数据写入之前暴露。
第二个案例是触发器里做审计日志时忘记考虑批量更新。某次运营人员执行了一条不带条件的UPDATE语句,意图是更新一个字段,结果把全表数据都改了。虽然AFTER UPDATE触发器把每一行的OLD和NEW都记录到了日志表,但日志表瞬间多了几十万行,而且因为触发器需要逐行读取和写入,那条UPDATE语句跑了很久,期间其他业务全部卡在锁等待上。后来我们给所有账号更新类操作都增加了WHERE条件规范要求,同时也把批量更新操作改成“先SELECT主键,再分批UPDATE”的方式。
第三个案例是主从环境下的触发器问题。某次我们在主库建了一个触发器,内部逻辑依赖一个自定义函数,直接调用后发现从库复制中断。排查原因后发现,主库执行DML时,触发器的动作在主库执行,但部分版本的binlog设置会导致从库重放时对存储函数或触发器的执行要求更高,需要提前设置log_bin_trust_function_creators参数,否则创建函数或复制过程中就会报错。这个案例给我的教训是:涉及触发器、函数和主从复制的组合场景,必须在搭建测试环境时完整演练一遍,不要等到生产复制中断才去补救。
5.5 什么时候不要用触发器,应用层和数据库层怎么划分
触发器很好用,但它不是万能的。我建议在下面几类场景里避免使用触发器:
- 核心交易链路中,对响应时间极其敏感的操作。触发器给每条DML增加额外耗时,即使只有几毫秒,高频场景下累积影响也很大。
- 需要与外部系统交互的场景。比如订单插入后需要调HTTP接口通知配送系统,这种动作不应该放在触发器里,因为数据库事务期间的网络调用既不可控也不安全。
- 复杂业务规则判断。规则判断中间依赖大量历史数据、缓存、其他服务的结果,把这种逻辑塞进触发器,会让数据库变得臃肿且极难调试。
- 临时的、实验性质的逻辑。如果只是跑一次数据修复,用完就删的逻辑,不要用触发器固化下来,尽量用一次性的SQL脚本。
那么触发器适合放在哪一层?我的判断标准是:它是单纯的数据一致性保障动作,不依赖网络IO,不依赖不可控的外部服务,而且执行时间很短。满足这些条件,用触发器是合适的。比如扣库存、记审计日志、补时间戳、做归档,这些都属于典型的“数据层内部固定动作”,放在数据库里自动执行,反而是最省心的方式。
我个人的一点体会是,触发器最怕的不是触发器本身,而是团队对它缺乏管理。你只要把触发器脚本纳入版本管理、命名规范清晰、每次变更都经过review,并且定期用SHOW TRIGGERS清理掉没用的触发器,它完全可以成为数据一致性体系里一个可靠的守门员。尤其是那种“业务上无论如何都不能漏做”的固定动作,交给触发器以后,我睡起觉来都踏实不少。如果你把触发器当成一个隐藏的定时任务来用,在设计时没有想清楚它的触发边界和性能影响,那大概率会在某次大促、某个深夜跑批的时候收到报警电话。用好它、管好它、控制它的作用范围,这个工具会让你的数据库省掉大量琐碎的手工维护工作。