触发器(下):事务与锁、binlog 一致性与替代方案

触发器(下):事务与锁、binlog 一致性与替代方案

MySQL 进阶系列 · 第 6 篇(触发器 · 2/undefined)

本篇是《触发器》的第 2/undefined 部分。前一部分见第 5 篇《触发器(上):语法精讲与审计日志实战》,建议先读完再来看本篇。

本篇覆盖:五、深入底层:触发器的执行时机、事务与锁、六、深入底层:触发器与 binlog、主从复制、七、触发器的合理使用场景、八、把审计日志做对:三种升级方案、九、触发器的调试与排查、十、本篇小结与面试速答。


目录


五、深入底层:触发器的执行时机、事务与锁

前面讲的都是「怎么用」,从这一章开始讲「为什么生产环境要慎用」。

5.1 执行时序:触发器到底插在哪一步

text 复制代码
   客户端                          MySQL 服务端(InnoDB,同一个事务内)
     │                                        │
     │   UPDATE student SET age=20            │
     │   WHERE id=13;                         │
     ├───────────────────────────────────────►│
     │                                        │  ① 优化器生成执行计划
     │                                        │     ⚠️ EXPLAIN 里【看不到】触发器
     │                                        │  ② 定位目标行,加 X 行锁
     │                                        │  ③ 写 undo log(旧值 age=28)
     │                                        │     └─► OLD 的数据来源
     │                                        │
     │                                        │  ┌────────────────────────────────┐
     │                                        │  │ ④ BEFORE UPDATE 触发器          │
     │                                        │  │    · 可读 OLD / NEW             │
     │                                        │  │    · 可 SET NEW.col = value     │
     │                                        │  │    · SIGNAL 抛错 → 本行不执行    │
     │                                        │  └────────────────────────────────┘
     │                                        │
     │                                        │  ⑤ 修改 Buffer Pool 中的数据页
     │                                        │     该页变为「脏页」
     │                                        │  ⑥ 写 redo log(prepare 状态)
     │                                        │     └─► NEW 的数据来源
     │                                        │
     │                                        │  ┌────────────────────────────────┐
     │                                        │  │ ⑦ AFTER UPDATE 触发器           │
     │                                        │  │    · OLD / NEW 均只读           │
     │                                        │  │    · 里面那条 INSERT 会走完整的  │
     │                                        │  │      ②~⑥ 流程(对日志表)        │
     │                                        │  │    · 失败 → 【整个语句回滚】     │
     │                                        │  └────────────────────────────────┘
     │                                        │
     │                                        │  ⑧ 若还有下一行 → 回到 ②(行级!)
     │                                        │  ⑨ 语句结束
     │                                        │  ⑩ 事务提交:redo commit + 写 binlog
     │   Query OK, 1 row affected             │
     │◄───────────────────────────────────────┤

三个关键观察:

  1. 触发器不是一个独立的执行单元 ,它是被内联 进触发语句的执行流程里的。第 ⑦ 步里那条 INSERT INTO student_log 会完整地再走一遍 ②~⑥(对 student_log 表加锁、写 undo、改页、写 redo)。
  2. 第 ⑧ 步的循环就是「行级触发器」的物理实现。影响 N 行,②~⑦ 就跑 N 遍。
  3. binlog 在第 ⑩ 步才写 ,也就是事务提交时。触发器产生的所有变更,都在同一个事务里,一起提交,一起进 binlog。这是第六章的基础。

5.2 触发器和触发语句在同一个事务里

这是整章最重要的一句话

触发器没有独立的事务。它运行在触发它的那条语句所属的事务上下文里,与触发语句同生共死。

官方文档的原文表述是:

"An error during either a BEFORE or an AFTER trigger results in failure of the entire statement that caused trigger invocation."

"If a BEFORE trigger fails, the operation on the corresponding row is not performed."

拆开看:

情况 后果
BEFORE 触发器失败 (比如 SIGNAL 抛错) 对应行的主操作根本不执行。行没被改,undo 没被写
AFTER 触发器失败(比如日志表插入违反唯一约束) 主操作的行已经改了,但整个触发语句回滚------InnoDB 下改回去,就像什么都没发生
表是 InnoDB(事务型) 触发语句的所有变更(含触发器造成的)整体回滚,原子性有保障
表是 MyISAM(非事务型) ⚠️ 没有回滚能力 。主表可能已经改了一半,日志表也可能写了一半,留下永久不一致的脏数据
autocommit = 1(默认) 一条语句就是一个事务,语句结束即提交。触发器仍然在这个隐式事务里
autocommit = 0 + 显式 BEGIN 触发器的变更和触发语句的变更一起,等到你 COMMIT 才生效

动手复现「AFTER 触发器失败导致主操作回滚」:

sql 复制代码
-- 给日志表加一个唯一约束(模拟一个不合理的约束)
ALTER TABLE student_log ADD UNIQUE KEY uk_op (operation_id, operation_type);

-- 现在同一个 id 的同一种操作只能记一次日志
UPDATE student SET age = 21 WHERE id = 13;
text 复制代码
ERROR 1062 (23000): Duplicate entry '13-update' for key 'student_log.uk_op'

关键验证------主表到底改了没有?

sql 复制代码
SELECT id, name, age, class_id FROM student WHERE id = 13;
text 复制代码
+----+------+-----+----------+
| id | name | age | class_id |
+----+------+-----+----------+
| 13 | 曹操 |  20 |        3 |
+----+------+-----+----------+
1 row in set (0.00 sec)

age 还是 20 ,不是 21。主表的更新被一起回滚了。 报错信息说的是 student_log 的唯一键冲突,但受害的是 student 表的业务更新。

划重点给一张表加触发器,就等于给这张表的所有 DML 增加了一个新的失败点。

从此以后,UPDATE student 能不能成功,不只取决于 student 表的约束,还取决于 student_log 表的一切:唯一键、非空约束、字段长度、外键、磁盘空间、表是否被锁、DEFINER 是否还存在......

而且报错信息指向的是日志表 ,排查的人会一头雾水:「我改的是 student,你跟我扯 student_log 干什么?」这就是「逻辑隐身」的第一个具体代价。

5.3 死锁风险:触发器如何放大锁范围

先说一个好消息:MySQL 有一道天然护栏。

3.4 节提到的 ERROR 1442 规则------「触发器不能修改调用语句正在使用的表」------意味着:

text 复制代码
mysql> CREATE TRIGGER trg_loop AFTER UPDATE ON student FOR EACH ROW
    ->     UPDATE student SET age = age + 1 WHERE id = NEW.id;
ERROR 1442 (HY000): Can't update table 'student' in stored function/trigger
because it is already used by statement which invoked this stored function/trigger.

同样,「A 表触发器改 B 表,B 表触发器改 A 表」这种环状触发链 也会在执行到第二跳时撞上 1442。所以 MySQL 里几乎不可能出现「触发器定义本身构成的循环死锁」 。这一点比 SQL Server 安全------SQL Server 需要 RECURSIVE_TRIGGERSnested triggers 两个配置项专门防这个。

但护栏挡不住另一种形态:锁获取顺序冲突(AB-BA)。

来看一个真实可构造的死锁:

sql 复制代码
-- 班级统计表
CREATE TABLE class_stat (
    class_id  BIGINT PRIMARY KEY,
    stu_cnt   INT NOT NULL DEFAULT 0
) ENGINE = InnoDB;

INSERT INTO class_stat VALUES (1, 4), (2, 4), (3, 0);

-- student 上的 AFTER UPDATE 触发器:写日志 + 维护班级人数统计
DELIMITER //

CREATE TRIGGER trg_student_update2
AFTER UPDATE ON student FOR EACH ROW
BEGIN
    INSERT INTO student_log (operation_type, operation_time, operation_id, operation_data)
    VALUES ('update', NOW(), NEW.id, CONCAT(OLD.class_id, '|', NEW.class_id));

    -- ↓ 就是这一句,让锁范围从 student 扩展到了 class_stat
    UPDATE class_stat SET stu_cnt = stu_cnt + 1 WHERE class_id = NEW.class_id;
END //

DELIMITER ;

现在开两个会话:

text 复制代码
时刻   会话 1(业务接口)                        会话 2(运维 / 对账脚本)
────   ────────────────────────────────────     ────────────────────────────────────
 t1    BEGIN;
 t2    UPDATE student
         SET class_id = 3
         WHERE id = 13;
       → 拿到 student.13 的 X 行锁   ✅
       → 触发器执行
       → INSERT student_log          ✅
       → UPDATE class_stat
           WHERE class_id = 3
       → 【等待】class_stat.3 的 X 锁
                                                 BEGIN;
                                                 UPDATE class_stat
                                                   SET stu_cnt = 0
                                                   WHERE class_id = 3;
                                                 → 拿到 class_stat.3 的 X 锁 ✅
 t3                                              UPDATE student
                                                   SET age = 22
                                                   WHERE id = 13;
                                                 → 【等待】student.13 的 X 锁

       ══════════════════  环路形成  ══════════════════
       会话 1  持有 student.13    ,等待 class_stat.3
       会话 2  持有 class_stat.3  ,等待 student.13

InnoDB 的死锁检测机制介入:

text 复制代码
              wait-for graph(等待图)

      ┌───────────────────┐   waits for    ┌───────────────────┐
      │  事务 T1           │──────────────►│  事务 T2           │
      │  holds student.13  │               │ holds class_stat.3│
      └───────────────────┘                └───────────────────┘
                 ▲                                    │
                 │            waits for               │
                 └────────────────────────────────────┘

                        ══► 检测到环路!

InnoDB 在每次有事务进入锁等待时,都会做一次等待图(wait-for graph)的环检测。一旦发现环:

  1. 计算环里每个事务的「代价」------大致是已修改的行数 / undo log 量
  2. 回滚代价最小的那个事务 (不一定是最新的那个),让它报 ERROR 1213
  3. 另一个事务继续执行。
text 复制代码
mysql> UPDATE class_stat SET stu_cnt = 0 WHERE class_id = 3;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

相关参数与排查手段:

参数 / 命令 默认值 作用
innodb_deadlock_detect ON 是否开启主动死锁检测。8.0.18+ 可动态调整
innodb_lock_wait_timeout 50(秒) 关掉检测后,事务等锁多久放弃,报 ERROR 1205 (HY000): Lock wait timeout exceeded
SHOW ENGINE INNODB STATUS\G --- LATEST DETECTED DEADLOCK 段:记录了最近一次死锁的两个事务各自持有和等待的锁、各自的 SQL、以及谁被回滚了
performance_schema.data_locks performance_schema.data_lock_waits 8.0 实时查看当前的锁和等待关系(5.7 用 information_schema.INNODB_LOCKS / INNODB_LOCK_WAITS
sys.innodb_lock_waits --- 8.0 的 sys schema 封装,直接告诉你「谁阻塞了谁」,还会给出可执行的 kill 语句

SHOW ENGINE INNODB STATUS 的死锁段长这样:

text 复制代码
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-09-19 12:03:41 0x7f8b2c0a1700
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 12, OS thread handle 140234..., query id 88 localhost root updating
UPDATE class_stat SET stu_cnt = 0 WHERE class_id = 3
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `youju_demo`.`class_stat`
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 57 page no 3 n bits 80 index PRIMARY of table `youju_demo`.`student`
*** (2) TRANSACTION:
TRANSACTION 12344, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 13, OS thread handle 140235..., query id 90 localhost root updating
UPDATE class_stat SET stu_cnt = stu_cnt + 1 WHERE class_id = 3
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 57 page no 3 n bits 80 index PRIMARY of table `youju_demo`.`student`
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `youju_demo`.`class_stat`
*** WE ROLL BACK TRANSACTION (2)

读懂它的方法:找 (1) HOLDS / (1) WAITING(2) HOLDS / (2) WAITING,看这两个集合是否交叉------交叉就是 AB-BA 。最后一行告诉你谁被牺牲了。注意 (2) 那条 SQL 是 UPDATE class_stat SET stu_cnt = stu_cnt + 1它不是业务写的,是触发器写的------这就是「触发器隐身」在死锁排查现场的体现:日志里出现的 SQL,业务代码里搜不到。

💡 实战建议:在触发器里写「更新另一张表」的语句,是死锁的高危信号。 因为它悄悄改变了业务 SQL 的锁获取顺序,而写业务 SQL 的人对此毫不知情,也不可能在自己的代码里做相应的顺序约束。

如果一定要在触发器里维护冗余计数(比如 class_stat.stu_cnt),至少要做到:

  • 让所有会话以固定的顺序访问这几张表(约定「先主表后统计表」,并写进规范文档)
  • 应用层对 ERROR 1213自动重试 (死锁是可重试错误,ERROR 1205 锁等待超时同样)
  • 高并发热点场景下,把计数改成 Redis 原子自增 + 定时回写,彻底从数据库里拿走这个热点行

补充一个反直觉的点:死锁检测本身是有成本的。 检测是 O(等待事务数) 的图遍历,在热点行上(比如秒杀的库存行)几百个事务同时等待时,检测开销会显著吃掉 CPU。这种场景下有些团队会选择 SET GLOBAL innodb_deadlock_detect = OFF,退化为纯超时(并把 innodb_lock_wait_timeout 调小到 5~10 秒)。这是针对特定热点场景的权衡,不是通用优化,普通业务库不要动这个参数。

5.4 锁范围放大的完整账单

回到 4.2 节那个最简单的 INSERT INTO student。有无触发器的对比:

资源 无触发器 有 AFTER INSERT 触发器写 student_log
行锁 student 新行的隐式 X 锁 + student_log插入意向锁(Insert Intention Lock)
间隙锁 一般无(顺序自增插入) student_log 上有唯一索引,可能升级为 next-key lock
自增锁 student 的 AUTO-INC + student_log 的 AUTO-INC
索引维护 student 聚簇索引 + 全部二级索引 + student_log 聚簇索引 + 全部二级索引(4.1 节加了 3 个!)
undo log 1 条 2 条
redo log 1 行变更 2 行变更
binlog(ROW) 1 个 Write_rows_event 2 个 Write_rows_event
Buffer Pool 页 student 的页 + student_log 的页(互相挤占缓存
失败点 主键 / 唯一键 / 非空 / 外键 / 长度 以上全部 × 2 张表

注意「Buffer Pool 互相挤占」这一项。 审计日志表通常是只写不读 的冷数据,但它会持续地把热数据页从 Buffer Pool 里挤出去(除非配了 innodb_old_blocks_time / innodb_old_blocks_pct 这类冷热分离策略)。结果是你的业务查询缓存命中率下降、QPS 掉,而你在慢查询日志里根本看不到原因。

5.5 自增锁的三种模式与触发器

student_log 是高频插入的表,它的 AUTO_INCREMENT 是一个潜在的串行化点。InnoDB 用 innodb_autoinc_lock_mode 控制这个锁的粒度:

名称 行为 默认版本
0 traditional(传统) 每条 INSERT 语句持有表级 AUTO-INC 锁直到语句结束 MySQL 5.1 之前
1 consecutive(连续) 行数已知 的「简单插入」(INSERT ... VALUES)用轻量互斥量(mutex) ,插入完立即释放;行数未知 的「批量插入」(INSERT ... SELECTLOAD DATAREPLACE ... SELECT)仍持有表级 AUTO-INC 锁到语句结束 5.7 及之前默认
2 interleaved(交错) 一律用轻量互斥量,绝不加表级锁 。并发最好,但自增值可能不连续;在 STATEMENT binlog 格式下不安全 8.0 默认

和触发器的交互:

场景 mode=1(5.7 默认) mode=2(8.0 默认)
触发器体是 INSERT INTO student_log VALUES (...)(单行、行数已知) 轻量 mutex,影响小 轻量 mutex,影响小
触发器体是 INSERT INTO student_log SELECT ... FROM xxx(行数未知) ⚠️ 持有 student_log 的表级 AUTO-INC 锁直到语句结束 → 所有并发写日志表的会话全部串行化 无表级锁,但要求 binlog_format=ROW
批量 UPDATE student 影响 100 万行 触发器执行 100 万次,每次争抢一次 mutex → 自增互斥量成为热点 同样争抢,但没有表级锁的额外放大

⚠️ 注意:innodb_autoinc_lock_modeMySQL 8.0 之前是只读参数,必须重启才能改 ;8.0 里可以用 SET GLOBAL 动态调整(但对已有连接的实际影响建议实测)。从 5.7 升级到 8.0 时,这个默认值从 1 静默变成了 2------如果你的应用依赖「自增 ID 严格连续」(比如用它做业务对账或分页游标),升级前必须评估。

5.6 触发器不能被禁用,也不能被手动调用

这是 MySQL 触发器和 SQL Server / Oracle 的一个重要差距:

能力 MySQL SQL Server Oracle
手动执行触发器 ❌ 没有 CALL trigger
临时禁用触发器 没有 DISABLE TRIGGER DISABLE TRIGGER trg ON tbl ALTER TRIGGER trg DISABLE
重新启用 ENABLE TRIGGER ALTER TRIGGER trg ENABLE
修改触发器 ❌ 只能 DROP + CREATE ALTER TRIGGER CREATE OR REPLACE

MySQL 想临时关掉触发器,唯一的办法就是 DROP TRIGGER

这在数据迁移和批量导入时是个大麻烦。常见的错误认知要先澄清:

你以为能关掉触发器的做法 实际效果
SET sql_log_bin = 0; 完全不管用。它只影响「是否写 binlog」,不影响触发器执行。触发器照跑,日志表照写
SET foreign_key_checks = 0; ❌ 只管外键,不管触发器。而且触发器体内禁止 SET FOREIGN_KEY_CHECKS
SET unique_checks = 0; ❌ 只管唯一索引检查
SET autocommit = 0; ❌ 只影响事务提交时机
ALTER TABLE student DISABLE KEYS; ❌ 只影响 MyISAM 的非唯一二级索引维护,对 InnoDB 和触发器都无效

正确做法有三档:

① 最干净:迁移前 DROP,迁移后 CREATE

sql 复制代码
-- 迁移前:把触发器 DDL 导出保存
SHOW CREATE TRIGGER trg_student_insert\G
SHOW CREATE TRIGGER trg_student_update\G
SHOW CREATE TRIGGER trg_student_delete\G

DROP TRIGGER IF EXISTS trg_student_insert;
DROP TRIGGER IF EXISTS trg_student_update;
DROP TRIGGER IF EXISTS trg_student_delete;
sql 复制代码
-- ... 执行批量导入 / 数据迁移 ...

-- 迁移后:重建(把上面导出的 DDL 存成文件)
SOURCE /path/to/triggers.sql;

💡 实战建议:mysqldump 默认带 --triggers 选项 ,也就是说你 dump 出来的 SQL 里含 CREATE TRIGGER,恢复时触发器会被自动重建。这在大批量恢复时是个性能陷阱------恢复 1 亿行业务数据的同时,触发器会同步往日志表里写 1 亿条毫无意义的审计记录。

正确姿势是分两步

bash 复制代码
# 第一步:只导数据,不带触发器
mysqldump --skip-triggers --single-transaction youju_demo > data.sql

# 第二步:单独导触发器定义
mysqldump --no-data --no-create-info --skip-routines --triggers youju_demo > triggers.sql

# 恢复:先灌数据,再挂触发器
mysql youju_demo < data.sql
mysql youju_demo < triggers.sql

② 折中:民间约定的「开关变量」

sql 复制代码
DELIMITER //

CREATE TRIGGER trg_student_insert
AFTER INSERT ON student FOR EACH ROW
BEGIN
    -- 会话级开关:@DISABLE_TRIGGERS = 1 时跳过审计写入
    IF @DISABLE_TRIGGERS IS NULL OR @DISABLE_TRIGGERS <> 1 THEN
        INSERT INTO student_log (operation_type, operation_time, operation_id, operation_data)
        VALUES ('insert', NOW(), NEW.id, CONCAT_WS(',', NEW.id, NEW.name, NEW.sno));
    END IF;
END //

DELIMITER ;
sql 复制代码
-- 批量导入前打开开关
SET @DISABLE_TRIGGERS = 1;
LOAD DATA INFILE '/data/student.csv' INTO TABLE student
    FIELDS TERMINATED BY ',' (id, name, sno, age, gender, enroll_date, class_id);
SET @DISABLE_TRIGGERS = NULL;

这个方案的代价:

问题 说明
只作用于当前会话 @var 是会话变量。多连接并行导入时,每一个连接都要单独设,漏一个就写一堆垃圾日志
连接池会串味 连接归还池后 @DISABLE_TRIGGERS 还留着,下一个借到这条连接的正常业务请求会带着开关跑,导致审计静默丢失
是约定不是机制 没有任何强制力。新人不知道有这个约定,或者迁移脚本忘了设 / 忘了清,就出事
每行多一次判断 行级触发器下这个 IF 也要跑 N 次(虽然开销很小)

③ 最省事:根本不用触发器

这正是第六、七章要讲的:用 CDC 从 binlog 里拿变更,主库上什么钩子都不用挂,迁移时也不需要「关触发器」这个动作。

5.7 哪些操作不会触发触发器

这是一组必须记住的例外,否则你的审计会出现盲区

操作 会触发吗 说明
INSERT / UPDATE / DELETE 正常触发
LOAD DATA INFILE 逐行触发 INSERT 触发器。这是批量导入慢的常见原因之一
INSERT ... ON DUPLICATE KEY UPDATE 走插入分支触发 INSERT 触发器,走更新分支触发 UPDATE 触发器
REPLACE INTO 触发 INSERT 触发器;若因唯一键冲突走「先删后插」路径,DELETE 触发器是否激活建议实测确认
TRUNCATE TABLE 它是 DDL ,内部实现是 drop + recreate,根本不走 DELETE 路径。审计彻底丢失
DROP TABLE 表都没了,触发器也被一并删除
ALTER TABLE DDL。即使 ALGORITHM=COPY 内部重建了整张表的数据,也不触发
外键级联动作ON DELETE CASCADE / ON UPDATE CASCADE 官方明确:级联外键动作不激活触发器。 这是个非常隐蔽的审计盲区------父表删一行,子表被级联删了 1 万行,日志表里一条记录都没有
复制 SQL 线程(ROW 格式) 见第六章
复制 SQL 线程(STATEMENT 格式) 见第六章

⚠️ 注意:TRUNCATE 和外键级联这两个盲区,是「用触发器做审计」这个方案在正确性上就已经不成立 的直接证据。一个号称「全量审计」的方案,被 TRUNCATE TABLE 一句话就绕过去了------这在合规审计(等保、SOX、GDPR)场景下是不可接受的。


六、深入底层:触发器与 binlog、主从复制

这一章回答开篇那个核心矛盾的后半段,也是**「为什么生产环境慎用触发器」最硬核的理由**。

第 12 篇会系统讲 MySQL 的日志体系(redo log / undo log / binlog / relay log),第 16 篇会讲主从复制的完整原理。这里只讲和触发器直接相关的部分。

6.1 第一个事实:触发器本身不会单独进 binlog

很多人以为「主库执行了触发器,binlog 里就会记一条触发器的 SQL」。错。

binlog 里记录的只有两样东西:

binlog 格式 记录什么 触发器的痕迹在哪
STATEMENT 触发语句本身的 SQL 原文 ❌ 完全没有。触发器体的语句不会被单独记录
ROW 事务造成的所有行变更(前后镜像) ✅ 触发器造成的行变更,和主操作的行变更混在一起,作为同一事务的 row event 被记录
MIXED 由 MySQL 自动在上面两种之间切换 取决于每条语句被判为哪种

binlog_format 的默认值演进(这个时间线很重要):

MySQL 版本 binlog_format 默认值
5.1 ~ 5.7.6 STATEMENT
5.7.7 及以后 ROW
8.0 ROW

查看当前配置:

sql 复制代码
SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'binlog_row_image';    -- ROW 格式下记录整行还是最小列集

binlog_row_image 的取值:

取值 含义
FULL(默认) 记录整行的前后镜像------所有列,不管有没有变
MINIMAL 只记录主键(或第一个非空唯一键)+ 真正变化的列
NOBLOB 同 FULL,但不记录未发生变化的 BLOB / TEXT 列

6.2 ROW 格式下的行为

MySQL 官方文档对「Replication and Triggers」的说明非常明确:

"With row-based replication, triggers executed on the source do not execute on the replica. Instead, the row changes on the source resulting from trigger execution are replicated and applied on the replica."

翻译:行级复制下,主库执行的触发器不会在从库上再执行一次;主库上因触发器执行而产生的行变更,会被复制并在从库上直接应用。

text 复制代码
      主库                                     binlog                        从库
      ────                                     ──────                        ────
   UPDATE student SET age=20 WHERE id=13
      │
      ├─ student.13 行变更 ─────►  Update_rows_event(student)   ────►  应用到 student
      │                            (before: age=28, after: age=20)         ⚠️ 从库的触发器
      │                                                                       【不】激活
      │
      └─ AFTER UPDATE 触发器执行
           └─ INSERT student_log
                └─ student_log 行变更 ─►  Write_rows_event(student_log) ─► 应用到 student_log
                                          (整行新数据)                       ⚠️ 同样不激活触发器

   结果:从库 student_log 里恰好 1 条新记录   ✅  主从一致

ROW 格式是「安全」的那种组合:主库触发器干了什么,从库就照单全收,不会重复也不会遗漏。

但 ROW 格式有它自己的账要算:

问题 说明
binlog 体积放大 触发器写多少行,binlog 就多多少行。审计触发器在热点表上会让 binlog 直接翻倍甚至更多 。这直接影响:主从之间的网络带宽、从库磁盘、binlog 保留策略(binlog_expire_logs_seconds)、备份体积、CDC 组件的解析吞吐
binlog_row_image=FULL 的双重浪费 默认 FULL 意味着每个 row event 都带整行 前后镜像。审计日志表里存了一份整行快照,binlog 里又存了一份------同一份数据存了两遍
从库的触发器形同虚设 如果你指望在从库上建一个触发器做点从库本地的事(比如从库专属的统计、报表预聚合),它不会被复制流触发。这个坑很多人踩过
组复制 / InnoDB Cluster 强制 ROW MySQL Group Replication 要求 binlog_format=ROW。所以在 MGR 集群里,从节点的触发器永远不会因复制而被激活

6.3 STATEMENT 格式下的行为

同一份官方文档:

"With statement-based replication, triggers executed on the source also execute on the replica."

翻译:语句级复制下,主库执行的触发器也会在从库上执行。

text 复制代码
      主库                                     binlog                        从库
      ────                                     ──────                        ────
   UPDATE student SET age=20 WHERE id=13
      │
      ├─ student.13 行变更(不入 binlog)
      │
      └─ AFTER UPDATE 触发器执行
           └─ INSERT student_log
                └─ student_log 行变更(【不】单独入 binlog)

   整条 SQL 原文 ────────────────►  Query_event ──────────►  从库 SQL 线程执行
                                   (带 timestamp、                            同一条 SQL
                                    thread_id、                                          │
                                    自增值等上下文)                                        ├─ student.13 被更新
                                                                                         │
                                                                                         └─ 从库本地的
                                                                                            trg_student_update
                                                                                            【被激活】✅
                                                                                              └─ INSERT student_log

   结果:从库 student_log 里也是 1 条新记录(由从库自己的触发器写入)

只要两边触发器定义完全一致、触发器体是确定性的,主从最终是一致的。但这里的「只要」藏着四个真实的坑:

具体形态
① 触发器定义必须两边逐字节一致 任何一次「只在主库执行了 CREATE / DROP TRIGGER」的操作(手工修数据、灰度发布、迁移脚本漏跑、DBA 临时加了个触发器忘了同步)都会造成静默的数据分歧 。这种分歧不会报错,只有跑 pt-table-checksum 才能发现
② 触发器体内的不确定性 NOW() / CURRENT_TIMESTAMP 在 SBR 下是安全的 (Query_event 带 timestamp,从库用同一时间基准)。但 UUID()RAND()SYSDATE()(注意 SYSDATE()NOW(),前者每次调用返回真实系统时间,不可复制 ),以及在触发器里 SELECT ... INTO 读取另一张表(从库上那张表的内容可能因其他原因不同),都会导致主从产生不同的值。MySQL 会对部分场景给出「unsafe statement」告警(warning 1592),但不是全部
③ 自增值分歧 student_log.id 在主从两边各自独立生成 。即使内容完全一致,主库这条日志的 id 是 6、从库可能是 8(因为从库上还跑过别的写入)。任何依赖 student_log.id 做主从对比或对账的逻辑都会失效
④ 从库上触发器报错 = 复制中断 从库 SQL 线程执行触发器时如果报错(比如日志表字段超长、DEFINER 不存在、唯一键冲突),复制直接停摆SHOW REPLICA STATUSReplica_SQL_Running: No(5.7 及之前是 SHOW SLAVE STATUS / Slave_SQL_Running)。排查时你看到的是「从库复制中断」,报错指向的却是日志表------很难联想到根因是触发器

6.4 两种格式的对照总结

维度 ROW 格式 STATEMENT 格式
从库触发器是否激活 ❌ 不激活 ✅ 激活
触发器造成的行变更如何到从库 作为 row event 直接复制过去 由从库自己的触发器重新产生
主从一致性 结构性保证(复制的就是变更结果) 依赖两边触发器定义一致 + 触发器体确定性
binlog 体积 大(触发器的行变更全量入 binlog) 小(只有原始 SQL)
从库的触发器能否用于「从库本地逻辑」 ❌ 永远不触发 ✅ 会触发
触发器体内用 UUID() / RAND() / SYSDATE() ✅ 安全(复制的是结果) ❌ 主从分歧
MySQL 默认(5.7.7+ / 8.0) ✅ 就是这个 ---
组复制 / InnoDB Cluster 强制 不支持

⚠️ 必须谨慎对待的部分 :上面的行为描述来自 MySQL 8.0 官方文档对 Replication and Triggers 的说明。触发器与复制的交互是版本敏感的 ------复制实现(5.6 的 SHOW SLAVE STATUS → 8.0.22 的 SHOW REPLICA STATUS)、binlog 默认格式(5.7.7 从 STATEMENT 改成 ROW)、组复制的强制约束,这些年都变过。在做任何生产决策之前,请在你的目标版本上实测验证以下几项

  1. 主库建触发器、从库故意不建,在 ROW 和 STATEMENT 两种格式下各自的表现
  2. INSERT ... ON DUPLICATE KEY UPDATEREPLACE INTO 这类复合语义语句,在两种格式下触发器的激活情况是否一致
  3. 级联触发器(触发器体内再触发另一张表的触发器)在 ROW 格式下产生多少个 row event
  4. binlog_row_image = MINIMAL 时,触发器写入的日志行在 binlog 里是否完整
  5. 从库上触发器报错时,复制中断的错误信息与主库直接报错时的差异
    一句话结论触发器 + binlog + 主从复制这三者的交互,行为取决于 binlog 格式和 MySQL 版本,而任何一种组合都有它专属的坑。

这不是「配好参数就没问题」的事,而是一个结构性问题 :审计逻辑被藏进了存储引擎的执行流,它对代码仓库隐身、对 Code Review 隐身、对 EXPLAIN 隐身、对慢查询日志隐身,而它的一致性又和复制链路死死绑在一起。这是生产环境避免用触发器做审计的首要原因------性能只是第二位。

6.5 替代方案:CDC(Change Data Capture)

如果触发器做审计这么麻烦,那正确的做法是什么?

答案藏在 6.1 节那句话里:ROW 格式的 binlog 里,本来就带着每一行的完整前后镜像。

也就是说------你需要的审计数据,MySQL 已经免费帮你生成好了,就放在 binlog 里。 你不需要在业务表上挂任何钩子,只需要去读 binlog。这就是 CDC。

CDC 的原理:伪装成一个从库。

text 复制代码
  ┌────────────────────────────────────────────────────────────────┐
  │                        MySQL 主库                               │
  │   ┌───────────┐                                                │
  │   │ 业务事务   │─► redo log ─► 提交 ─► binlog(ROW 格式)        │
  │   └───────────┘                             │                  │
  └─────────────────────────────────────────────┼──────────────────┘
                                                │
             ① 建立连接,发送 COM_REGISTER_SLAVE │
             ② 发送 COM_BINLOG_DUMP              │  ③ 主库把 binlog event
                (指定 binlog 文件名 + 位点)       │     持续推送过来
                                                ▼
  ┌────────────────────────────────────────────────────────────────┐
  │       CDC 组件(Canal / Debezium / Maxwell / Flink CDC)         │
  │                                                                │
  │   ④ 解析 event:                                                │
  │      · Query_event        → DDL 变更                           │
  │      · Table_map_event    → 表结构映射(列名、类型)             │
  │      · Write_rows_event   → INSERT,含整行新值                  │
  │      · Update_rows_event  → UPDATE,含【before + after】镜像     │  ◄── 审计要的
  │      · Delete_rows_event  → DELETE,含整行旧值                  │       就是它
  │      · Xid_event          → 事务提交边界                        │
  │   ⑤ 维护消费位点(保证不丢不重)                                  │
  └──────────────────────────┬─────────────────────────────────────┘
                             │
         ┌───────────────────┼───────────────────┬──────────────────┐
         ▼                   ▼                   ▼                  ▼
    ┌──────────┐       ┌──────────┐       ┌──────────┐      ┌────────────┐
    │ 审计库    │       │ Kafka /  │       │ ES 搜索   │      │ 数仓 /      │
    │(ES/CH)   │       │ RocketMQ │       │ 索引重建  │      │ 实时大屏    │
    └──────────┘       └──────────┘       └──────────┘      └────────────┘

关键点:CDC 组件在协议层就是一个从库。 它发的 COM_BINLOG_DUMP 和真正的从库 IO 线程发的是同一个命令 。所以它拿到的数据,和主从复制用的数据是同一份 ------这意味着它天然不会和主从不一致

CDC vs 触发器,逐项对比:

维度 数据库触发器 CDC(binlog 解析)
对主库的侵入 改变每条 DML 的执行路径 零侵入,只读 binlog
主库性能损耗 每行多执行一次触发器体 + 多一套锁 + 多一份 undo / redo 仅多一个 binlog dump 线程的网络发送开销
额外的锁 有(日志表行锁、插入意向锁、自增锁、可能的间隙锁)
额外的失败点 有(日志表的任何约束都会让业务 DML 失败) (完全异步)
主从一致性风险 ⚠️ 高,行为随 binlog 格式和版本变 (读的就是复制用的那份 binlog)
审计信息完整度 取决于你在触发器里手写多少字段 binlog_row_image=FULL自带整行前后镜像
能否覆盖 TRUNCATE / 外键级联 ❌ 盲区(5.7 节) ✅ TRUNCATE 作为 DDL 在 binlog 里有记录;外键级联造成的行变更在 ROW 格式下也会被记录
下游消费者数量 只能写进一个库表 一份 binlog 喂任意多个消费者(审计 + 缓存失效 + 搜索索引 + 数仓)
事务边界 在业务事务内,失败会回滚业务 完全异步,失败不影响业务
实时性 同步,毫秒级 准实时,通常 100ms ~ 1s
拿得到操作人吗 CURRENT_USER() 是 DEFINER(见 8.5) ❌ row event 里也不含用户(可开 binlog_rows_query_log_events 拿到原始 SQL 文本,但仍不含用户)
部署运维成本 极低(就是一段 DDL) 高(独立组件 + 位点管理 + 高可用 + 监控)
技术栈门槛 会写 SQL 就行 需要 Java / Go + 消息队列 + 运维能力

唯一一项 CDC 输给触发器的,是「部署成本」。 这也是触发器在小项目里依然有价值的原因。

💡 实战建议:常见的 CDC 组件选型------

组件 语言 特点
Canal Java 阿里开源,国内生态最成熟,「伪装从库」的经典实现,输出到 Kafka / RocketMQ
Debezium Java Red Hat 开源,基于 Kafka Connect,支持 MySQL / PostgreSQL / MongoDB / SQL Server,国际化项目首选
Maxwell Java 轻量,直接把 binlog 转成 JSON 打到 Kafka / Redis / 文件,上手最快
Flink CDC Java 把 CDC 内嵌进 Flink,支持「全量 + 增量」一体化读取,适合实时数仓

前置条件:binlog_format = ROWbinlog_row_image = FULL (要完整前后镜像)、账号需要 REPLICATION SLAVE + REPLICATION CLIENT 权限、server_id 全局唯一。

binlog 的三种格式、事件结构、位点管理在第 13 篇 详解;主从复制的完整链路(IO 线程 / SQL 线程 / relay log / 半同步 / GTID)在第 16 篇 详解。这一篇你只需要记住一个结论:审计这件事,binlog 已经替你做完了,别在业务表上挂触发器。


七、触发器的合理使用场景

前面两章把触发器「劝退」得很彻底,但工程上不能一刀切。触发器不是坏技术,它是被用错了地方。 这一节我们把边界划清楚:什么时候用它是对的,什么时候用它是自找麻烦。这也是课件面试题 14 的完整答案。

7.1 六个真正适合用触发器的场景

场景一:BEFORE 触发器做数据校验与规范化

这是触发器最不可替代 的场景,因为它有一个别人没有的能力:在数据落盘之前修改它

sql 复制代码
CREATE TABLE user_info (
    id     BIGINT       NOT NULL AUTO_INCREMENT COMMENT '用户ID',
    name   VARCHAR(20)  NOT NULL                COMMENT '姓名',
    phone  VARCHAR(20)  NOT NULL                COMMENT '手机号',
    email  VARCHAR(64)           DEFAULT NULL   COMMENT '邮箱',
    PRIMARY KEY (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表';

DELIMITER $$

CREATE TRIGGER trg_user_before_insert
BEFORE INSERT ON user_info
FOR EACH ROW
BEGIN
    -- ① 规范化:去掉姓名首尾空格、去掉手机号里的所有分隔符、邮箱统一小写
    SET NEW.name  = TRIM(NEW.name);
    SET NEW.phone = REPLACE(REPLACE(REPLACE(NEW.phone, ' ', ''), '-', ''), '+86', '');
    SET NEW.email = LOWER(TRIM(NEW.email));

    -- ② 校验:手机号必须是 11 位纯数字且以 1 开头
    IF NEW.phone NOT REGEXP '^1[0-9]{10}$' THEN
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = '手机号格式非法,必须是 11 位数字';
    END IF;

    -- ③ 校验:年龄/金额类字段禁止为负(这里用邮箱后缀演示业务规则)
    IF NEW.email IS NOT NULL AND NEW.email NOT LIKE '%@%' THEN
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = '邮箱格式非法';
    END IF;
END$$

DELIMITER ;

测试:

sql 复制代码
-- 故意写入「脏」数据
INSERT INTO user_info (name, phone, email)
VALUES (' 张三 ', ' 138-1234-5678 ', 'ZhangSan@Example.COM ');

SELECT * FROM user_info;
text 复制代码
+----+------+-------------+----------------------+
| id | name | phone       | email                |
+----+------+-------------+----------------------+
|  1 | 张三 | 13812345678 | zhangsan@example.com |
+----+------+-------------+----------------------+
1 row in set (0.00 sec)

再测试非法数据:

sql 复制代码
INSERT INTO user_info (name, phone) VALUES ('李四', '12345');
text 复制代码
ERROR 1644 (45000): 手机号格式非法,必须是 11 位数字

💡 实战建议:这个场景触发器的价值在于「兜底 」------不管数据从哪条路进来(Java 服务、Python 脚本、DBA 手工敲 SQL、数据同步工具),规则都生效。应用层校验拦不住 DBA 的一条 UPDATE,但 BEFORE 触发器能。

⚠️ 注意:SIGNAL 抛出的错误会让整条 INSERT 语句失败并回滚 。这是你想要的行为(脏数据不该进来),但一定要在错误信息里写清楚是哪个字段不合法,否则调用方拿到 ERROR 1644 会一头雾水。

场景二:BEFORE DELETE 阻止非法删除

业务规则:「已支付的订单不允许删除」「核心配置表不允许删行」。这类规则用触发器实现最省事,因为它连 DELETE FROM t(不带 WHERE 的全表删除)都能拦住。

sql 复制代码
DELIMITER $$

CREATE TRIGGER trg_order_before_delete
BEFORE DELETE ON `order`
FOR EACH ROW
BEGIN
    -- 已支付的订单禁止物理删除,只能改状态为「已取消」
    IF OLD.pay_time IS NOT NULL THEN
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = '已支付订单禁止删除,请使用逻辑删除(更新 status)';
    END IF;

    -- 非取消状态的订单也禁止删除
    IF OLD.status <> 'CANCELLED' THEN
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = '只有已取消的订单才能删除';
    END IF;
END$$

DELIMITER ;

划重点 :BEFORE DELETE 触发器里的 OLD 就是即将被删除的那一行。在被删除之前 做判断,是唯一能拦住它的时机------AFTER DELETE 触发时行已经没了,SIGNAL 虽然也能让整个语句回滚(InnoDB),但语义上是「先删再撤销」,不如 BEFORE 干净。

更进一步:很多团队的做法是根本不做物理删除 ,而是加一列 is_deleted TINYINT DEFAULT 0 做逻辑删除。这时候 BEFORE DELETE 触发器可以直接 SIGNAL 一句「本表禁止物理删除」,把规范固化到数据库里。

场景三:AFTER 触发器维护冗余计数列(附反面教材)

第 1 篇讲反范式时提过:为了性能,我们常常故意违反第三范式,把「统计值」冗余成一列。比如文章表存 comment_count,避免每次列表页都 COUNT(*)

sql 复制代码
CREATE TABLE article (
    id            BIGINT       NOT NULL AUTO_INCREMENT,
    title         VARCHAR(100) NOT NULL,
    comment_count INT          NOT NULL DEFAULT 0 COMMENT '冗余的评论数',
    PRIMARY KEY (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE comment (
    id         BIGINT NOT NULL AUTO_INCREMENT,
    article_id BIGINT NOT NULL,
    content    VARCHAR(500) NOT NULL,
    PRIMARY KEY (id),
    KEY idx_article (article_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

DELIMITER $$

CREATE TRIGGER trg_comment_after_insert
AFTER INSERT ON comment
FOR EACH ROW
BEGIN
    UPDATE article
       SET comment_count = comment_count + 1
     WHERE id = NEW.article_id;
END$$

CREATE TRIGGER trg_comment_after_delete
AFTER DELETE ON comment
FOR EACH ROW
BEGIN
    UPDATE article
       SET comment_count = GREATEST(comment_count - 1, 0)
     WHERE id = OLD.article_id;
END$$

DELIMITER ;

测试:

sql 复制代码
INSERT INTO article (title) VALUES ('MySQL 索引底层原理'), ('事务隔离级别详解');
INSERT INTO comment (article_id, content) VALUES (1, '写得好'), (1, '受教了'), (1, '第三段有疑问');
SELECT id, title, comment_count FROM article;
text 复制代码
+----+--------------------+---------------+
| id | title              | comment_count |
+----+--------------------+---------------+
|  1 | MySQL 索引底层原理 |             3 |
|  2 | 事务隔离级别详解   |             0 |
+----+--------------------+---------------+
2 rows in set (0.00 sec)

看起来很美。但这恰恰是最容易出事的用法,请务必看完下面这段。

⚠️ 注意:热点行的排他锁会把并发彻底串行化。

UPDATE article SET comment_count = comment_count + 1 WHERE id = 1 需要在 article 表 id=1 这一行上加 X 行锁 ,锁一直持有到事务提交 。如果一篇文章突然爆了(上热搜、被大 V 转发),每秒 5000 条评论涌进来,这 5000 个事务全部在同一行上排队,一个提交了下一个才能拿到锁。

排队本身不可怕,可怕的是第五章讲的锁范围放大 :每个事务持有 comment 新行的锁 + article 热点行的锁,事务时长由「排队时间」决定,而排队时间又由「别人的事务时长」决定------这是一个自我放大的正反馈。压测时你会看到 TPS 从 3000 掉到 200,innodb_row_lock_time_avg 飙升,最后触发 innodb_lock_wait_timeout(默认 50 秒)大面积报 ERROR 1205

更糟的是:如果计数更新失败了,整个评论写入事务都会回滚(第 5.2 节的同事务原则)。用户发一条评论,因为计数列的死锁失败了,评论也没了。这是典型的「次要功能拖垮主要功能」。

正确的三级演进方案:

阶段 方案 说明
小规模(评论 < 万级) COUNT(*) + 覆盖索引 SELECT COUNT(*) FROM comment WHERE article_id = 1,走 idx_article 覆盖索引,不回表,其实很快。先别急着冗余
中规模 Redis INCR + 定时回写 计数以 Redis 为权威,业务读 Redis;每 5 分钟把 Redis 值刷回 MySQL 作为持久化快照。热点行的锁竞争被彻底移走
大规模 异步消息聚合 评论写入发消息,消费端按 article_id 攒批 (比如 1 秒内的 +37 合并成一次 +37),把 5000 次行锁变成 1 次

💡 实战建议:如果你在面试里被问到「触发器能做什么」,把场景三主动讲成反面教材,比讲成优点更能拿分------它证明你知道行锁和热点竞争,而不只是背了语法。

场景四:自动填充审计字段(但有更好的原生方案)
sql 复制代码
DELIMITER $$

CREATE TRIGGER trg_student_before_insert_time
BEFORE INSERT ON student
FOR EACH ROW
BEGIN
    SET NEW.create_time = NOW();
    SET NEW.update_time = NOW();
END$$

CREATE TRIGGER trg_student_before_update_time
BEFORE UPDATE ON student
FOR EACH ROW
BEGIN
    SET NEW.update_time = NOW();
END$$

DELIMITER ;

但 MySQL 早就内置了这个能力,用列属性一行就搞定:

sql 复制代码
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP                 COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
                                       ON UPDATE CURRENT_TIMESTAMP        COMMENT '更新时间'
对比项 列属性 ON UPDATE CURRENT_TIMESTAMP BEFORE UPDATE 触发器
实现复杂度 建表时一列,零维护 一个触发器对象,需要 DDL 变更流程
性能 存储引擎层直接写入,几乎零成本 多一次触发器体调用 + SQL 层解析执行
能否被绕过 显式 SET update_time = xxx 时会被覆盖(这是特性) 同样会被显式赋值覆盖
主从一致性风险 有(见第六章)
版本要求 MySQL 5.6.5+ 支持 DATETIME 类型 全版本

划重点这个场景没有任何理由用触发器。 我在实际项目里见过不止一次「触发器维护 update_time」的历史遗留代码,删掉换成列属性,一行 DDL 的事。把它列在这里,是为了让你知道「哪些看起来合理的用法其实是过度设计」。

场景五:简单的冗余表 / 汇总表同步

和场景三类似,但目标表不是热点单行,而是分散的统计表:

sql 复制代码
DELIMITER $$

CREATE TRIGGER trg_student_after_update_class
AFTER UPDATE ON student
FOR EACH ROW
BEGIN
    -- 只在班级真正发生变化时才动汇总表,避免无谓的行锁
    IF NOT (OLD.class_id <=> NEW.class_id) THEN
        UPDATE class_stat SET stu_cnt = stu_cnt - 1 WHERE class_id = OLD.class_id;
        UPDATE class_stat SET stu_cnt = stu_cnt + 1 WHERE class_id = NEW.class_id;
    END IF;
END$$

DELIMITER ;

两个细节值得注意:

  1. IF NOT (OLD.class_id <=> NEW.class_id)<=> 是 MySQL 的 NULL 安全等于 运算符,NULL <=> NULL 返回 1,NULL <=> 3 返回 0。用普通 = 时,只要有一边是 NULL,整个表达式就是 NULL,IF NULL 走不进 THEN 分支------数据就漏了。所有涉及可空字段的比较,一律用 <=>
  2. 这个触发器仍然是第 5.3 节那个 AB-BA 死锁的元凶 。班级数量少、class_stat 是典型热点小表,多会话并发转班时死锁概率不低。用它,就要接受这个代价,并写好重试逻辑。
场景六:教学、单机小项目、临时数据修复护栏
  • 教学与实验:触发器是理解「数据库也有执行流程」的最佳教具,第 5 篇第四章的实战就是这个用途。
  • 单机小项目 / 内部工具 / 无主从的运维库 :QPS 个位数、没有从库、没有分库分表,触发器带来的运维复杂度低于应用层改造成本,用它是对的
  • 临时护栏 :数据修复期间,怕别人误操作,临时建一个 BEFORE UPDATE 触发器 SIGNAL 拦住,修完 DROP 掉。这是一种「土办法但有效」的临时管控------比在群里喊「别动那张表」靠谱得多。

💡 实战建议:临时护栏用完必须 DROP ,并且最好在触发器名里带日期,比如 trg_student_tmp_guard_20240919。我见过建了忘删的临时触发器,半年后新同事改数据一直报 ERROR 1644,查了半天。

7.2 六个明确不适合的场景

不适合的场景 为什么不适合 应该用什么
高并发核心表的审计日志 每行 DML 都插一条日志,行锁 + 自增锁放大(第 5.4 节);主从一致性风险(第六章);表膨胀拖慢 Buffer Pool CDC(Canal / Debezium)+ 独立审计库;或应用层 AOP
跨库 / 跨服务的业务编排 触发器没有自己的事务边界,不能 COMMIT/ROLLBACK,不能发 HTTP 请求、不能发消息 消息队列(本地消息表 / 事务消息)+ 异步消费
复杂业务流程 触发器体不能单元测试、不能代码评审、不能灰度、不能回滚版本,改一次要走 DDL 变更 应用层 Service,可测试、可版本管理、可监控
需要独立事务边界的场景 「主操作成功,附加操作失败也不能影响主操作」------触发器做不到,它俩同一个事务 异步消息 / 定时任务补偿
「失败了要能重试」的旁路逻辑 触发器失败 = 主语句失败,没有重试队列 MQ + 消费重试 + 死信队列
分库分表 / 中间件环境 触发器建在每个物理分片上,定义同步是噩梦;中间件(ShardingSphere 等)大多不支持透传触发器 应用层统一处理

7.3 四种方案选型对照表

这张表是本篇最需要记住的一张。「怎么实现变更后的附加逻辑」在工程上有四个答案,选错了会疼很久。

对比维度 数据库触发器 应用层 AOP / 拦截器 / MyBatis 插件 CDC(Canal / Debezium) 定时任务扫描
侵入性 对业务代码零侵入 侵入应用代码,但集中在框架层 对应用和数据库双零侵入 零侵入
性能影响 ❌ 同步执行,放大锁范围,拖慢主语句 ⚠️ 同步执行,但不加额外数据库锁 ✅ 异步,主库几乎零开销 ✅ 异步,但扫描本身消耗 IO
一致性保证 ✅ 强一致(同事务,要么都成功要么都回滚) ⚠️ 同事务内强一致,跨库需自己保证 ⚠️ 最终一致,有秒级延迟 ❌ 最终一致,延迟由调度周期决定
能否被绕过 不能,任何入口的 DML 都拦得住 ❌ 能,DBA 手工 SQL / 其他服务直连就绕过了 ✅ 不能,binlog 记录一切 ✅ 不能
可测试性 ❌ 差,需要真实数据库环境 ✅ 好,可 Mock、可单元测试 ✅ 好,可回放 binlog ✅ 好
可维护性 ❌ 差,无 ALTER TRIGGER、无版本管理、藏在库里容易失传 ✅ 好,在 Git 里,能评审能回滚 ✅ 好,独立组件独立部署 ✅ 好
拿得到操作人吗 ❌ 只有 DEFINER(见 8.6) ,在请求上下文里有登录用户 ❌ binlog 里没有业务用户
主从复制风险 ❌ 有(第六章) ✅ 无 ✅ 无 ✅ 无
部署成本 ✅ 最低,一条 SQL ⚠️ 需要改代码发版 ❌ 最高,要引入 Kafka / 独立服务 ⚠️ 中等
典型场景 单机小项目、数据校验兜底、教学 业务日志、权限校验、通用字段填充 生产环境审计、缓存同步、异构数据复制、实时数仓 对账、超时关单、状态补偿

一句话理解选型要「拦得住任何入口」选触发器或 CDC,要「拿得到操作人」选应用层 AOP,要「不影响性能又能审计」选 CDC。 生产环境的最优组合通常是 应用层 AOP(记业务语义 + 操作人)+ CDC(记数据变更 + 兜底),两者互补,都不碰主库性能。


八、把审计日志做对:三种升级方案

课件面试题 15 的原始答案是第四章那个 CONCAT 方案。它教学上很好------一句话说清了 OLD/NEW 怎么用。但拿到生产环境是不够的。这一章我们把它升级三次,每一次都解决一个真实痛点。

8.1 先回顾:CONCAT 方案错在哪

第 4.6 节列了五个缺陷,这里浓缩成一句话:它把结构化数据压成了一个非结构化字符串,从此失去了所有查询能力,还引入了 NULL 和截断两个隐形炸弹。

具体后果:

sql 复制代码
-- 你想查「class_id 从 2 改成 3 的所有记录」
SELECT * FROM student_log WHERE operation_data LIKE '%,2|%,3';

这条 SQL 会误伤age 从 12 改成 13、id 是 23 的、名字里带逗号的......全都匹配上了。审计日志一旦不能精确查询,它就退化成了「出事之后人肉翻」的存档,价值大打折扣。

8.2 方案 A:字段级变更日志(推荐用于中小项目)

核心思路:一个字段变化 = 一行日志。 结构完全打平,任意维度可查、可索引、可聚合。

sql 复制代码
CREATE TABLE student_column_log (
    id          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '日志ID',
    table_name  VARCHAR(64)  NOT NULL                COMMENT '表名(便于多表复用同一张日志表)',
    record_id   BIGINT       NOT NULL                COMMENT '业务主键值',
    column_name VARCHAR(64)  NOT NULL                COMMENT '发生变化的列名',
    old_value   VARCHAR(500)          DEFAULT NULL   COMMENT '变更前的值(统一转字符串存储)',
    new_value   VARCHAR(500)          DEFAULT NULL   COMMENT '变更后的值',
    changed_at  DATETIME(3)  NOT NULL                COMMENT '变更时间,精确到毫秒',
    changed_by  VARCHAR(64)           DEFAULT NULL   COMMENT '操作人,由应用层传入',
    PRIMARY KEY (id),
    KEY idx_record (table_name, record_id, changed_at),  -- 查某条记录的完整变更历史
    KEY idx_column (table_name, column_name, changed_at),-- 查某个字段被谁改过
    KEY idx_time   (changed_at)                          -- 按时间范围归档 / 清理
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学生表字段级变更日志';

触发器:

sql 复制代码
DELIMITER $$

CREATE TRIGGER trg_student_update_collog
AFTER UPDATE ON student
FOR EACH ROW
BEGIN
    DECLARE v_user VARCHAR(64) DEFAULT NULL;

    -- 操作人由应用层通过会话变量传入(见 8.6)
    SET v_user = @current_user;

    -- 逐列比较:只有真正变化的列才记一行
    -- 必须用 <=> (NULL 安全等于),否则可空字段的变更会被漏掉
    IF NOT (OLD.name <=> NEW.name) THEN
        INSERT INTO student_column_log
            (table_name, record_id, column_name, old_value, new_value, changed_at, changed_by)
        VALUES
            ('student', NEW.id, 'name', OLD.name, NEW.name, NOW(3), v_user);
    END IF;

    IF NOT (OLD.sno <=> NEW.sno) THEN
        INSERT INTO student_column_log
            (table_name, record_id, column_name, old_value, new_value, changed_at, changed_by)
        VALUES
            ('student', NEW.id, 'sno', OLD.sno, NEW.sno, NOW(3), v_user);
    END IF;

    IF NOT (OLD.age <=> NEW.age) THEN
        INSERT INTO student_column_log
            (table_name, record_id, column_name, old_value, new_value, changed_at, changed_by)
        VALUES
            ('student', NEW.id, 'age', OLD.age, NEW.age, NOW(3), v_user);
    END IF;

    IF NOT (OLD.gender <=> NEW.gender) THEN
        INSERT INTO student_column_log
            (table_name, record_id, column_name, old_value, new_value, changed_at, changed_by)
        VALUES
            ('student', NEW.id, 'gender', OLD.gender, NEW.gender, NOW(3), v_user);
    END IF;

    IF NOT (OLD.enroll_date <=> NEW.enroll_date) THEN
        INSERT INTO student_column_log
            (table_name, record_id, column_name, old_value, new_value, changed_at, changed_by)
        VALUES
            ('student', NEW.id, 'enroll_date', OLD.enroll_date, NEW.enroll_date, NOW(3), v_user);
    END IF;

    IF NOT (OLD.class_id <=> NEW.class_id) THEN
        INSERT INTO student_column_log
            (table_name, record_id, column_name, old_value, new_value, changed_at, changed_by)
        VALUES
            ('student', NEW.id, 'class_id', OLD.class_id, NEW.class_id, NOW(3), v_user);
    END IF;
END$$

DELIMITER ;

测试(复用第四章的两次更新):

sql 复制代码
UPDATE student SET age = 20, class_id = 2 WHERE id = 13;

SELECT id, record_id, column_name, old_value, new_value, changed_at
  FROM student_column_log
 WHERE table_name = 'student' AND record_id = 13
 ORDER BY id;
text 复制代码
+----+-----------+-------------+-----------+-----------+---------------------+
| id | record_id | column_name | old_value | new_value | changed_at          |
+----+-----------+-------------+-----------+-----------+---------------------+
|  1 |        13 | age         | 28        | 20        | 2024-09-19 11:47:21 |
|  2 |        13 | class_id    | 3         | 2         | 2024-09-19 11:47:21 |
+----+-----------+-------------+-----------+-----------+---------------------+
2 rows in set (0.00 sec)

现在,查询能力彻底打开了:

sql 复制代码
-- ① 某条记录的完整变更历史(时间线)
SELECT column_name, old_value, new_value, changed_at, changed_by
  FROM student_column_log
 WHERE table_name = 'student' AND record_id = 13
 ORDER BY changed_at, id;

-- ② 最近 7 天,哪个字段被改得最频繁(定位异常改动的来源)
SELECT column_name, COUNT(*) AS cnt
  FROM student_column_log
 WHERE table_name = 'student'
   AND changed_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
 GROUP BY column_name
 ORDER BY cnt DESC;

-- ③ 所有「把 class_id 改成 3」的操作(精确匹配,不会误伤)
SELECT record_id, old_value, new_value, changed_at
  FROM student_column_log
 WHERE table_name = 'student'
   AND column_name = 'class_id'
   AND new_value   = '3'
 ORDER BY changed_at;

第 ③ 条的结果:

text 复制代码
+-----------+-----------+-----------+---------------------+
| record_id | old_value | new_value | changed_at          |
+-----------+-----------+-----------+---------------------+
|         7 | 2         | 3         | 2024-09-19 11:50:36 |
|         8 | 2         | 3         | 2024-09-19 11:50:36 |
|        13 | 2         | 3         | 2024-09-19 11:50:36 |
+-----------+-----------+-----------+---------------------+
3 rows in set (0.00 sec)

对比一下第 4.6 节:同样是这个需求,CONCAT 方案只能写 LIKE '%,2|%,3',会把 age 从 12 改成 13、id 是 23 的记录全部误伤;方案 A 直接走 idx_column (table_name, column_name, changed_at) 索引,精确、可索引、可聚合。这就是「结构化」三个字的全部价值。

⚠️ 注意:方案 A 有一个必须知道的固有缺陷 ------old_value / new_value 统一用 VARCHAR 存储,所以它们只能做等值比较 ,不能做数值分析

  • WHERE new_value > '100' 走的是字符串序,'9' > '100' 成立,结果完全错。
  • ORDER BY new_value 排出来的是 '10', '100', '9'
  • SUM(new_value) 会触发隐式类型转换,放弃索引 并可能报 Warning 1292 Truncated incorrect value

想做数值分析,要么 CAST(new_value AS DECIMAL(20,4))(会全表扫),要么改用方案 B 的 JSON ------JSON_OBJECT('age', OLD.age) 保留的是真实的整型,CAST(new_data ->> '$.age' AS SIGNED) 才是语义正确的比较。这也是 8.4 对照表里「精确查询能力」那一栏给方案 B 打 ✅、给方案 A 打 ⚠️ 的原因。

为什么触发器体这么啰嗦? 因为第 3.4 节讲过------触发器体内禁止动态 SQLPREPARE / EXECUTE 都被禁),你没办法遍历 information_schema.COLUMNS 动态拼 INSERT。每一列都得手写一个 IF

💡 实战建议:这段代码不要手写。用 SQL 生成 SQL :查 information_schema.COLUMNS 拼出整个触发器体,再复制出来执行。表结构变更后重跑一次生成脚本即可。

sql 复制代码
SELECT CONCAT(
           'IF NOT (OLD.`', COLUMN_NAME, '` <=> NEW.`', COLUMN_NAME, '`) THEN ',
           'INSERT INTO student_column_log ',
           '(table_name, record_id, column_name, old_value, new_value, changed_at, changed_by) ',
           'VALUES (''student'', NEW.id, ''', COLUMN_NAME, ''', ',
           'OLD.`', COLUMN_NAME, '`, NEW.`', COLUMN_NAME, '`, NOW(3), @current_user); END IF;'
       ) AS trigger_line
  FROM information_schema.COLUMNS
 WHERE TABLE_SCHEMA = 'youju_demo'
   AND TABLE_NAME   = 'student'
   AND COLUMN_NAME NOT IN ('id', 'create_time', 'update_time')  -- 排除不需要审计的列
   AND EXTRA NOT LIKE '%GENERATED%'                            -- 生成列没有 OLD/NEW
 ORDER BY ORDINAL_POSITION;

8.3 方案 B:JSON 快照(MySQL 5.7+ 推荐)

方案 A 的痛点是「列多了触发器体爆炸」和「数值比较失真」。方案 B 换个思路:JSON 类型存完整的前后镜像,一次 INSERT 搞定所有列。

sql 复制代码
CREATE TABLE student_snapshot_log (
    id         BIGINT      NOT NULL AUTO_INCREMENT COMMENT '日志ID',
    op_type    VARCHAR(10) NOT NULL                COMMENT 'insert / update / delete',
    record_id  BIGINT      NOT NULL                COMMENT '业务主键值',
    old_data   JSON                 DEFAULT NULL   COMMENT '变更前的完整快照',
    new_data   JSON                 DEFAULT NULL   COMMENT '变更后的完整快照',
    changed_at DATETIME(3) NOT NULL                COMMENT '变更时间',
    changed_by VARCHAR(64)          DEFAULT NULL   COMMENT '操作人',
    PRIMARY KEY (id),
    KEY idx_record (record_id, changed_at),
    KEY idx_type_time (op_type, changed_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学生表 JSON 快照变更日志';

DELIMITER $$

CREATE TRIGGER trg_student_update_json
AFTER UPDATE ON student
FOR EACH ROW
BEGIN
    INSERT INTO student_snapshot_log
        (op_type, record_id, old_data, new_data, changed_at, changed_by)
    VALUES (
        'update',
        NEW.id,
        JSON_OBJECT(
            'id', OLD.id, 'name', OLD.name, 'sno', OLD.sno,
            'age', OLD.age, 'gender', OLD.gender,
            'enroll_date', OLD.enroll_date, 'class_id', OLD.class_id
        ),
        JSON_OBJECT(
            'id', NEW.id, 'name', NEW.name, 'sno', NEW.sno,
            'age', NEW.age, 'gender', NEW.gender,
            'enroll_date', NEW.enroll_date, 'class_id', NEW.class_id
        ),
        NOW(3),
        @current_user
    );
END$$

DELIMITER ;

测试:

sql 复制代码
UPDATE student SET age = 20 WHERE id = 13;

SELECT id, op_type, record_id, old_data, new_data
  FROM student_snapshot_log
 WHERE record_id = 13
 ORDER BY id DESC LIMIT 1;
text 复制代码
+----+---------+-----------+--------------------------------------------------------+--------------------------------------------------------+
| id | op_type | record_id | old_data                                               | new_data                                               |
+----+---------+-----------+--------------------------------------------------------+--------------------------------------------------------+
|  1 | update  |        13 | {"id": 13, "age": 28, "name": "曹操", "sno": "300001"} | {"id": 13, "age": 20, "name": "曹操", "sno": "300001"} |
+----+---------+-----------+--------------------------------------------------------+--------------------------------------------------------+
1 row in set (0.00 sec)

JSON 最大的好处:类型信息保留住了,能直接做数值查询。

sql 复制代码
-- ① 提取指定字段(->> 等价于 JSON_UNQUOTE(JSON_EXTRACT(...)),返回字符串)
SELECT record_id,
       old_data ->> '$.age' AS old_age,
       new_data ->> '$.age' AS new_age,
       changed_at
  FROM student_snapshot_log
 WHERE record_id = 13;
text 复制代码
+-----------+---------+---------+---------------------+
| record_id | old_age | new_age | changed_at          |
+-----------+---------+---------+---------------------+
|        13 | 28      | 20      | 2024-09-19 11:47:21 |
+-----------+---------+---------+---------------------+
1 row in set (0.00 sec)
sql 复制代码
-- ② 真正的数值比较:找出所有「年龄被调小了」的记录(方案 A 做不到)
SELECT record_id,
       CAST(old_data ->> '$.age' AS UNSIGNED) AS old_age,
       CAST(new_data ->> '$.age' AS UNSIGNED) AS new_age
  FROM student_snapshot_log
 WHERE op_type = 'update'
   AND CAST(new_data ->> '$.age' AS SIGNED) < CAST(old_data ->> '$.age' AS SIGNED);

-- ③ 判断某个字段是否发生了变化(NULL 安全)
SELECT record_id, changed_at
  FROM student_snapshot_log
 WHERE op_type = 'update'
   AND NOT ( (old_data ->> '$.class_id') <=> (new_data ->> '$.class_id') );

-- ④ MySQL 8.0.4+:用 JSON_TABLE 把 JSON 展开成关系行,做聚合分析
SELECT jt.record_id, jt.age
  FROM student_snapshot_log AS l,
       JSON_TABLE(l.new_data, '$' COLUMNS (
           record_id BIGINT PATH '$.id',
           age       INT    PATH '$.age'
       )) AS jt
 WHERE l.op_type = 'update';

⚠️ 注意几个 JSON 的坑:

  1. 不要依赖 JSON 的输出顺序。 MySQL 会对 JSON 对象的键做规范化存储,SELECT 出来的键顺序不保证 和你 JSON_OBJECT() 里写的顺序一致,也不保证跨版本一致。要取值一律用 JSON_EXTRACT / ->>不要SUBSTRING_INDEX 之类的字符串切割。
  2. JSON_OBJECT('age', OLD.age) 里 NULL 会变成 JSON 的 null ,而 ->> '$.age' 取出来是 SQL 的字符串 'null' 还是 SQL NULL,取决于路径是否存在。判断时用 JSON_CONTAINS_PATH(old_data, 'one', '$.age') 更稳妥,或者干脆在生成时统一 IFNULL(OLD.age, -1)需实测确认你的版本行为)。
  3. JSON 列不能直接建普通索引。 MySQL 8.0.13+ 支持函数索引ALTER TABLE ... ADD INDEX idx_age ((CAST(new_data ->> '$.age' AS UNSIGNED)));5.7 只能用生成列 + 索引的间接方式。如果审计查询很频繁,这一点会直接影响方案选型。
  4. 存储开销比方案 A 大得多。 方案 A 是「只记变化的列」,方案 B 是「每行都存两份完整快照」。一张 30 列的宽表,改一个字段也要写两个 30 键的 JSON。高频更新的表上,日志表体积可能是业务表的十几倍。

8.4 三种方案对照

对比项 课件 CONCAT 方案 方案 A:字段级 方案 B:JSON 快照
精确查询能力 ❌ 只能 LIKE '%..%',会误伤 ⚠️ 等值查询完美,但数值比较/排序失真(VARCHAR 存储) ✅ 支持数值比较、路径提取、JSON_TABLE
索引能力 ❌ 无 ✅ 三个维度都能建索引 ⚠️ 需函数索引(8.0.13+)或生成列
存储开销 ✅ 最小(一行一字符串) ⚠️ 中等(改 N 列 = N 行) ❌ 最大(每行两份完整快照)
截断风险 VARCHAR(500) 装不下宽表,严格模式下报 ERROR 1406 直接回滚业务语句 ✅ 低,每列独立 500 字符 ✅ 无(JSON 上限 4GB,受 max_allowed_packet 约束)
NULL 陷阱 致命CONCAT 任一参数为 NULL 则整体为 NULL ✅ 用 <=> 规避 ✅ JSON 原生表达 null
分隔符冲突 ❌ 有(姓名带逗号就崩) ✅ 无 ✅ 无
表结构变更影响 ❌ 位置耦合,加一列全盘错乱 ⚠️ 需要重新生成触发器体 ⚠️ 需要在 JSON_OBJECT 里加一个键
触发器体复杂度 ✅ 一行 INSERT ❌ 每列一个 IF,很啰嗦(可用 SQL 生成) ✅ 一行 INSERT
版本要求 全版本 全版本 MySQL 5.7+
推荐场景 教学演示 中小项目、字段数少、以「谁改了什么」为主 字段多、需要数值分析、5.7+ 环境

💡 实战建议:如果只能选一个,中小项目选方案 A,5.7+ 且字段多选方案 B 。但请记住,这两个方案都还在数据库里挂触发器,第五章(锁)和第六章(主从)的所有代价一分不少。真正上规模,走方案 C。

8.5 方案 C:CDC + 独立审计存储(生产环境首选)

第 6.5 节已经把架构讲透了,这里只补一句「审计存储怎么选」:

审计存储 适合场景 注意
独立的 MySQL 审计库(异实例) 中小规模,团队熟悉 SQL 用方案 A/B 的表结构;和业务库物理隔离,避免日志表膨胀影响业务
Elasticsearch 需要全文检索、多维度聚合、时间范围扫描 按时间滚动索引 + ILM 自动过期,冷热分层
ClickHouse / Doris 海量变更、需要 OLAP 分析 压缩比极高,适合长期留存合规审计数据
Kafka + 对象存储(S3/OSS) 超大规模、成本敏感 Kafka 做缓冲,Parquet 落对象存储,用 Athena / MaxCompute 按需查询

这个方案里,业务库一个触发器都不用建。 主库零性能损耗、零锁放大、零主从不一致风险,审计数据的留存周期、检索能力、合规导出都比数据库内的日志表强得多。

8.6 CURRENT_USER() 的坑:触发器拿不到「真正的操作人」

审计日志最核心的字段是「谁改的」。而这里有一个几乎所有人第一次都会踩的坑。

sql 复制代码
DELIMITER $$

CREATE TRIGGER trg_student_bad_user
AFTER UPDATE ON student
FOR EACH ROW
BEGIN
    INSERT INTO student_column_log
        (table_name, record_id, column_name, old_value, new_value, changed_at, changed_by)
    VALUES
        ('student', NEW.id, 'age', OLD.age, NEW.age, NOW(3), CURRENT_USER());
END$$

DELIMITER ;

问题来了: 假设触发器的 DEFINERroot@localhost,而实际执行 UPDATE 的业务账号是 app_rw@10.0.0.%。第 3.7 节讲过------触发器永远以 DEFINER 的身份执行。所以:

  • CURRENT_USER() 返回的是 root@localhost (DEFINER),不是 app_rw
  • USER() 返回的是当前连接的认证账号 ,在这个场景下恰好app_rw,但它拿到的是 MySQL 账号 ,不是业务用户(张三 / 李四)
  • 而审计真正想记录的是「业务用户张三

MySQL 账号和业务用户之间没有任何映射关系,触发器在数据库内部无论如何都拿不到业务用户。 这是触发器做审计的一个结构性缺陷,不是技巧能绕过的。

三种补救办法:

方案 做法 优点 缺点
① 会话变量 应用层在拿到连接后立刻 SET @current_user = '张三',触发器体里读 @current_user 实现简单,全版本可用 ⚠️ 连接池污染 :连接归还后再被别的请求借走,@current_user 还是上一个人的值。必须在每次借用连接时无条件重设,或在归还时清空
② 真实字段(推荐) 表里加 updated_by VARCHAR(64) 列,由应用层写入,触发器直接记 NEW.updated_by 最可靠,无状态,无连接池问题,字段本身也有业务价值 需要改表结构 + 改应用代码
performance_schema MySQL 8.0:PS_CURRENT_THREAD_ID() 拿到线程 ID,再关联 performance_schema.threads.PROCESSLIST_USER 不需要应用层配合 拿到的还是 MySQL 账号 不是业务用户;每行都查一次 performance_schema 在批量更新时开销明显(需实测 );依赖 performance_schema 开启

方案 ① 的完整代码:

sql 复制代码
-- 应用层:从连接池拿到连接后,第一件事(无条件执行)
SET @current_user = '张三';
SET @current_user_id = 10086;

-- 业务 SQL
UPDATE student SET age = 20 WHERE id = 13;

-- 应用层:归还连接前(可选,但强烈建议)
SET @current_user = NULL;

MyBatis 里通常用一个 Interceptor 或者干脆在 DruidDataSource / HikariCPconnectionInitSqls 里做------但 connectionInitSqls 只在连接创建时执行一次 ,解决不了污染问题,必须做成每次借出连接都执行的钩子。

⚠️ 注意:会话变量方案在连接池环境下是定时炸弹。 我曾经排查过一个诡异的问题:审计日志里连续 200 多条记录的 changed_by 全是同一个已经离职的账号,时间跨度三天。根因就是某个长连接(一个定时任务的连接)设置了 @current_user 之后一直没归还,被反复复用。

这也是第 7.3 节那张表里「应用层 AOP 能拿到操作人,触发器和 CDC 拿不到」的具体含义。 如果你的审计需求里「谁改的」是硬性合规项(等保、SOX、金融监管),那么触发器方案从一开始就不成立,请直接选应用层 AOP 或「AOP + CDC」组合。


九、触发器的调试与排查

触发器难调试,是它被诟病的原因之一:它不能返回结果集,不能打断点,不能 PRINT,出错信息还常常指向业务 SQL 而不是触发器本身。 这一节给你一套能落地的排查手法。

9.1 第一步永远是 SHOW CREATE TRIGGER

90% 的「触发器不生效」问题,答案都在这条命令的输出里。

sql 复制代码
SHOW CREATE TRIGGER youju_demo.trg_student_update\G
text 复制代码
*************************** 1. row ***************************
             Trigger: trg_student_update
            sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,
                      NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
  SQL Original Statement: CREATE DEFINER=`root`@`localhost` TRIGGER
                          `youju_demo`.`trg_student_update` AFTER UPDATE ON `student`
                          FOR EACH ROW BEGIN ... END
character_set_client: utf8mb4
collation_connection: utf8mb4_general_ci
  Database Collation: utf8mb4_general_ci
             Created: 2024-09-19 11:45:02.31
1 row in set (0.00 sec)

四个必查点:

检查项 看什么 常见问题
SQL Original Statement 触发器体的真实内容 上线的和你以为的不是同一份;改过但忘了 DROP + CREATE(没有 ALTER TRIGGER
sql_mode 创建时的 sql_mode 创建时是宽松模式,现在实例改成了 STRICT_TRANS_TABLES,行为完全不同(第 3.5 节讲过)
DEFINER(在 Statement 里) 执行身份 用户被删了 → ERROR 1449;权限不够 → 触发器体内 SQL 报错
character_set_client / collation_connection 创建时的字符集环境 中文比较、LIKE 匹配行为异常

9.2 全库触发器清单盘点

接手一个陌生项目,第一件事是把库里所有触发器摸清楚

sql 复制代码
-- 全库触发器总览:哪个库、哪张表、什么事件、什么时机、谁定义的、什么时候建的
SELECT
    TRIGGER_SCHEMA                              AS `库`,
    EVENT_OBJECT_TABLE                          AS `表`,
    TRIGGER_NAME                                AS `触发器名`,
    CONCAT(ACTION_TIMING, ' ', EVENT_MANIPULATION) AS `类型`,
    DEFINER                                     AS `执行身份`,
    CREATED                                     AS `创建时间`,
    LEFT(REPLACE(REPLACE(ACTION_STATEMENT, '\n', ' '), '  ', ' '), 80) AS `逻辑摘要`
FROM information_schema.TRIGGERS
ORDER BY TRIGGER_SCHEMA, EVENT_OBJECT_TABLE,
         FIELD(ACTION_TIMING, 'BEFORE', 'AFTER'),   -- BEFORE 排在前面,符合执行顺序
         FIELD(EVENT_MANIPULATION, 'INSERT', 'UPDATE', 'DELETE');
sql 复制代码
-- 找出「谁在往哪些表里写数据」(审计溯源神器)
SELECT DISTINCT TRIGGER_SCHEMA, EVENT_OBJECT_TABLE AS `被写的表`,
       TRIGGER_NAME, ACTION_TIMING, EVENT_MANIPULATION
FROM information_schema.TRIGGERS
WHERE ACTION_STATEMENT REGEXP 'INSERT[[:space:]]+(INTO[[:space:]]+)?`?(student_log|audit_log|.*_log)`?'
ORDER BY 2;
sql 复制代码
-- 找出 DEFINER 已经不存在的「孤儿触发器」(这些随时会抛 ERROR 1449)
SELECT t.TRIGGER_SCHEMA, t.TRIGGER_NAME, t.DEFINER
FROM information_schema.TRIGGERS t
LEFT JOIN mysql.user u
       ON CONCAT(u.User, '@', u.Host) = t.DEFINER
WHERE u.User IS NULL;

💡 实战建议:把第一条 SQL 存成团队的巡检脚本,每次上线前跑一遍并 diff。触发器没有版本管理,这是唯一能发现「谁偷偷改了触发器」的办法。 更进一步,把 information_schema.TRIGGERS 的快照定时落到一张监控表里,做变更告警。

9.3 触发器体不能返回结果集,怎么调试?

第 3.4 节讲过,触发器体里的 SELECT(不带 INTO)会直接报 ERROR 1415。所以你不能像在应用代码里那样「打个日志看看」。两个实战手法:

手法一:写调试表(稳,但要注意清理)
sql 复制代码
CREATE TABLE trg_debug_log (
    id       BIGINT      NOT NULL AUTO_INCREMENT,
    tag      VARCHAR(50) NOT NULL COMMENT '标记是哪个触发器的哪一步',
    msg      VARCHAR(500)         DEFAULT NULL,
    logged_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '微秒级时间戳',
    PRIMARY KEY (id),
    KEY idx_tag (tag, logged_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '触发器调试日志(临时表,用完删)';

DELIMITER $$

CREATE TRIGGER trg_student_update_debug
BEFORE UPDATE ON student
FOR EACH ROW
BEGIN
    INSERT INTO trg_debug_log (tag, msg)
    VALUES ('student_update:before',
            CONCAT('id=', NEW.id,
                   ' OLD.class_id=', IFNULL(OLD.class_id, '<NULL>'),
                   ' NEW.class_id=', IFNULL(NEW.class_id, '<NULL>'),
                   ' @current_user=', IFNULL(@current_user, '<NULL>')));

    -- 真正的业务逻辑...
END$$

DELIMITER ;

-- 执行你的 UPDATE,然后:
SELECT logged_at, tag, msg FROM trg_debug_log ORDER BY id DESC LIMIT 20;

⚠️ 注意:调试完必须把这些 INSERT 删掉(DROP + CREATE 触发器)。 原因有三:① 每次 DML 都多一次 INSERT,性能损耗实打实;② 这些调试数据也会进 binlog ,会同步到从库、会被 CDC 消费;③ 调试表本身会无限膨胀。并且调试表和被审计表在同一个事务里,如果 UPDATE 回滚了,调试日志也一起消失------这是它和「应用层日志」的本质区别,排查回滚类问题时反而看不到东西。

手法二:用 SIGNAL 把变量「喊」出来(快,适合定位单点)

这是一个非常好用的实战技巧:既然不能返回结果集,那就把值塞进错误消息里。

关键点:SIGNAL ... SET MESSAGE_TEXT 后面可以跟一个字符串变量或表达式 ,所以只要先把想看的值 CONCAT 进一个变量,再抛出去,错误消息就变成了你的「调试输出窗口」。

sql 复制代码
DELIMITER $$

CREATE TRIGGER trg_student_update_probe
BEFORE UPDATE ON student
FOR EACH ROW
BEGIN
    DECLARE v_msg VARCHAR(128);

    SET v_msg = CONCAT('debug: OLD.class_id=', IFNULL(OLD.class_id, 'NULL'),
                       ' NEW.class_id=', IFNULL(NEW.class_id, 'NULL'),
                       ' @current_user=', IFNULL(@current_user, 'NULL'));

    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = v_msg;
END$$

DELIMITER ;
sql 复制代码
UPDATE student SET class_id = 5 WHERE id = 13;
text 复制代码
ERROR 1644 (45000): debug: OLD.class_id=3 NEW.class_id=5 @current_user=zhangsan

一次就看到了三个值。 比建调试表快得多。

⚠️ 注意三个限制:

  1. MESSAGE_TEXT 超过 128 个字符会被截断(这是 MySQL 的硬限制),所以想看的变量要挑重点,或者分几次改触发器逐段看。
  2. SIGNAL 会让整条 UPDATE 失败并回滚 (第 5.2 节),所以这个手法只能在测试环境用,绝对不能留在生产触发器里。
  3. SQLSTATE'45000' 是「用户自定义异常」的约定值,不要乱用别的(比如 '23000' 会被当成完整性约束冲突,可能触发调用方的特殊处理逻辑)。

调试完记得 DROP:DROP TRIGGER IF EXISTS youju_demo.trg_student_update_probe;

9.4 SHOW WARNINGS:被忽略的信息宝库

触发器体里的很多「静默失败」都会变成 warning,而不是 error。执行完 DML 立刻查 SHOW WARNINGS

sql 复制代码
UPDATE student SET age = 20 WHERE id = 13;
SHOW WARNINGS;
text 复制代码
+---------+------+----------------------------------------------------------+
| Level   | Code | Message                                                  |
+---------+------+----------------------------------------------------------+
| Warning | 1265 | Data truncated for column 'operation_data' at row 1      |
+---------+------+----------------------------------------------------------+
1 row in set (0.00 sec)

常见 warning 及其含义:

Code 含义 在触发器场景下通常意味着
1265 Data truncated CONCAT 结果超出 VARCHAR(500);非严格模式下静默截断,数据已经丢了
1264 Out of range value 数值溢出(比如 comment_count 减到负数用了 UNSIGNED)
1292 Truncated incorrect value 字符串和数字比较时的隐式转换(方案 A 的 new_value = '3' 就会遇到)
1366 Incorrect string value 字符集不匹配,比如触发器创建时的 character_set_client 是 latin1,写入 emoji 失败
1592 Unsafe statement for binlog STATEMENT 格式下,触发器里的语句被判定为不确定(第 6.3 节)
sql 复制代码
-- 查看当前会话累积了多少 warning
SELECT @@warning_count;

💡 实战建议:SHOW WARNINGS 只对紧接着的上一条语句 有效,一旦执行别的语句就被清空。所以在脚本里排查时,要写成 UPDATE ...; SHOW WARNINGS; 紧挨着的两行。用 JDBC 时,warning 通过 SQLWarning 链拿(stmt.getWarnings()),很多框架默认吞掉了------这是一个隐蔽的数据丢失来源

9.5 性能排查:怎么确认「慢」是触发器造成的

这是最难的一类问题。现象通常是:「一条 UPDATE ... WHERE id = 13 的单行更新,慢日志里显示耗时 3 秒。」

关键认知一:EXPLAIN 看不到触发器
sql 复制代码
EXPLAIN UPDATE student SET age = 20 WHERE id = 13;

EXPLAIN 只分析你写的这条语句 的执行计划,触发器完全不出现在里面。所以「执行计划看着没问题啊」这句话在触发器场景下毫无意义。第 5.1 节的时序图里,优化器在第 ① 步,触发器在第 ④⑦ 步------两者根本不在一个阶段。

关键认知二:慢日志把触发器的耗时算在业务 SQL 头上

慢查询日志里的 Query_timeLock_time整条语句 的耗时,包含了触发器体执行的全部时间。所以:

text 复制代码
# Query_time: 3.142  Lock_time: 2.870  Rows_sent: 0  Rows_examined: 1
SET timestamp=1726716000;
UPDATE student SET age = 20 WHERE id = 13;

Rows_examined: 1 但耗时 3 秒、其中 2.87 秒在等锁------这个组合就是触发器 + 热点行锁的典型指纹 。正常单行主键更新应该是 Query_time: 0.000x。看到这种「扫描行数极少但耗时极长」的语句,第一反应就该是:

sql 复制代码
SELECT TRIGGER_NAME, ACTION_TIMING, EVENT_MANIPULATION
  FROM information_schema.TRIGGERS
 WHERE TRIGGER_SCHEMA = DATABASE() AND EVENT_OBJECT_TABLE = 'student';
关键认知三:最直接的办法是「对照实验」

不要迷信工具,DROP 掉触发器再跑一遍同样的 SQL,对比耗时,这是最快最确定的定位方法:

sql 复制代码
-- ① 记录当前耗时(跑 3 次取平均)
UPDATE student SET age = age + 1 WHERE id BETWEEN 1 AND 10000;

-- ② 备份触发器定义(务必先备份!)
SELECT ACTION_STATEMENT FROM information_schema.TRIGGERS
 WHERE TRIGGER_NAME = 'trg_student_update';

-- ③ 临时删除
DROP TRIGGER youju_demo.trg_student_update;

-- ④ 再跑一次同样的 SQL,对比耗时
UPDATE student SET age = age + 1 WHERE id BETWEEN 1 AND 10000;

-- ⑤ 恢复触发器
-- CREATE TRIGGER ... (用第 ② 步备份的定义重建)

⚠️ 注意:这个实验只能在测试环境或从库上做。生产库上 DROP 触发器意味着这段时间的审计数据全部丢失(第 3.6 节讲的「变更窗口盲区」)。

关键认知四:performance_schema 能看到触发器级别的耗时

MySQL 5.7+ 的 performance_schema 有专门的存储程序统计表:

sql 复制代码
SELECT OBJECT_SCHEMA, OBJECT_NAME, EVENT_NAME,
       COUNT_EXECUTE                       AS `执行次数`,
       SUM_TIMER_WAIT / 1e12               AS `总耗时(秒)`,
       AVG_TIMER_WAIT / 1e9                AS `平均耗时(毫秒)`,
       SUM_ERRORS, SUM_WARNINGS
FROM performance_schema.events_statements_summary_by_program
WHERE OBJECT_TYPE = 'TRIGGER'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
text 复制代码
+---------------+--------------------+------------+---------------+---------------------+
| OBJECT_SCHEMA | OBJECT_NAME        | EVENT_NAME | COUNT_EXECUTE | SUM_TIMER_WAIT/1e12 |
+---------------+--------------------+------------+---------------+---------------------+
| youju_demo      | trg_student_update | UPDATE     |             1 |              0.0031 |
+---------------+--------------------+------------+---------------+---------------------+
1 row in set (0.01 sec)

这条 SQL 能直接回答「哪个触发器最耗时、执行了多少次 」------批量更新时 COUNT_EXECUTE 会等于受影响行数,一眼就能验证第 2.7 节讲的「1M 行 = 1M 次执行」。

⚠️ 注意:

  1. 这个统计默认可能没开 。需要确认 performance_schema = ON,并且 setup_instrumentsstatement/sql/* 相关的 instrument 是 ENABLED='YES'具体的 instrument 名称和默认值随版本变化,请在你的目标版本上实测确认。
  2. sys 库里没有 专门针对触发器的封装视图(不像它有 sys.statements_with_errors_or_warnings 这类)。你能用的是 sys.format_statement 等通用函数配合上面的原始表。
  3. performance_schema 本身有开销(虽然官方说在 5% 以内),高负载生产库上开启前建议先在预发环境压测对比。
  4. events_statements_summary_by_program 的统计是累积值 ,要做「本次变更前后对比」,先用 TRUNCATE TABLE performance_schema.events_statements_summary_by_program; 清零(需要相应权限)。
排查流程小结
text 复制代码
慢 SQL 出现
    │
    ├─ ① EXPLAIN 看执行计划  ──────► 计划正常但依然慢?继续
    │
    ├─ ② 查 information_schema.TRIGGERS
    │      这张表上有触发器吗?
    │      ├─ 没有 ──► 不是触发器问题,转向索引/锁/硬件排查(第 7 篇、第 14 篇)
    │      └─ 有   ──► 继续
    │
    ├─ ③ 查 performance_schema.events_statements_summary_by_program
    │      WHERE OBJECT_TYPE='TRIGGER'
    │      ├─ COUNT_EXECUTE 远大于业务预期行数 ──► 批量更新放大了触发次数(第 2.7 节)
    │      └─ AVG_TIMER_WAIT 单次就很慢 ──► 触发器体里有慢 SQL,看它写了哪张表
    │
    ├─ ④ SHOW ENGINE INNODB STATUS / sys.innodb_lock_waits
    │      锁等待里出现了「意料之外的表」 ──► 触发器把锁范围扩大了(第 5.3、5.4 节)
    │
    └─ ⑤ 测试环境做对照实验:DROP TRIGGER 后重跑
           确认根因,再决定是优化触发器体、还是换成 CDC / 应用层方案

💡 实战建议:给每一张挂了触发器的表建一份「触发器台账」,记录:谁建的、为什么建、影响哪些下游表、能否临时禁用、性能基线是多少。没有台账的触发器,就是一颗不知道什么时候炸的雷------因为它不在代码仓库里,不在 CI 流程里,不在任何人的 review 视野里。


十、本篇小结与面试速答

10.1 知识地图

text 复制代码
触发器(Trigger)
│
├── 一、本质定位
│    ├── 数据库内部的「事件监听器」:声明式注册,引擎在固定时机回调
│    ├── 与 addEventListener / @EventListener 的对应关系
│    └── 核心矛盾:优雅 vs 生产环境大量团队禁用(阿里 Java 开发手册明令禁止)
│
├── 二、基本概念
│    ├── 三个要素:trigger_time(BEFORE/AFTER)× trigger_event(INSERT/UPDATE/DELETE)× 绑定的表
│    ├── 6 种类型(2 × 3),强绑定单表,不能跨表触发
│    ├── 每表数量演进:
│    │     ├─ MySQL < 5.7.2:同一 (时机, 事件) 组合只能有 1 个,每表最多 6 个
│    │     └─ MySQL ≥ 5.7.2:可以多个,用 FOLLOWS / PRECEDES 控制顺序
│    ├── OLD / NEW 规则:
│    │     ├─ INSERT → 只有 NEW;DELETE → 只有 OLD;UPDATE → 两者都有
│    │     ├─ OLD 永远只读
│    │     ├─ NEW 仅在 BEFORE 里可写(SET NEW.col = xxx),且需要该列的 UPDATE 权限
│    │     ├─ BEFORE INSERT 里 NEW.id(AUTO_INCREMENT)为 0
│    │     └─ 生成列(GENERATED)没有 OLD/NEW
│    └── 行级 vs 语句级:
│          ├─ 行级:每影响一行执行一次触发器体(FOR EACH ROW)
│          ├─ 语句级:每条语句执行一次(Oracle/PostgreSQL 支持,SQL Server 只有语句级)
│          └─ **MySQL 只支持行级** ⇒ 1M 行的 UPDATE = 触发器体执行 1M 次
│
├── 三、语法与限制
│    ├── CREATE [DEFINER=...] TRIGGER name {BEFORE|AFTER} {INSERT|UPDATE|DELETE}
│    │      ON tbl FOR EACH ROW [FOLLOWS|PRECEDES other] trigger_body
│    ├── DELIMITER 是**客户端指令**,不是服务端语法;JDBC/PyMySQL 不认
│    ├── FOR EACH ROW 在 MySQL 里**强制**(Oracle 里可省略 = 语句级)
│    ├── 触发器体禁区:
│    │     ├─ 返回结果集的 SELECT / CALL(ERROR 1415)
│    │     ├─ PREPARE / EXECUTE / DEALLOCATE PREPARE(禁动态 SQL)
│    │     ├─ START TRANSACTION / COMMIT / ROLLBACK(无独立事务边界)
│    │     ├─ LOCK TABLES / UNLOCK TABLES
│    │     ├─ LOAD DATA / LOAD XML
│    │     ├─ DDL(隐式提交)
│    │     └─ 修改「触发语句正在操作的那张表」(ERROR 1442)⇒ 天然防递归
│    ├── 查看:SHOW TRIGGERS [FROM db] [LIKE '表名模式'](LIKE 匹配的是**表名**不是触发器名)
│    │        SHOW CREATE TRIGGER  /  information_schema.TRIGGERS
│    ├── 删除:DROP TRIGGER [IF EXISTS] [schema.]name
│    │        **没有 ALTER TRIGGER**,改 = DROP + CREATE(存在变更盲区)
│    │        DROP TABLE 会连带删触发器;TRUNCATE 不会(也不触发)
│    └── DEFINER 安全坑:
│          ├─ 触发器**没有 SQL SECURITY 子句**,永远以 DEFINER 身份执行
│          ├─ ⇒ 权限提升风险;DEFINER 用户被删 ⇒ ERROR 1449
│          └─ 创建时的 sql_mode / character_set_client 被固化进定义
│
├── 四、事务与锁(深入底层)
│    ├── 执行时序:优化器 → X 行锁 → undo log → BEFORE → 改 Buffer Pool 脏页
│    │              → redo prepare → AFTER → (逐行循环)→ 语句结束 → 提交写 binlog
│    ├── 同一个事务:触发器失败 ⇒ 整条触发语句回滚(InnoDB)
│    │     ├─ BEFORE 失败 ⇒ 该行操作根本没发生
│    │     ├─ AFTER 失败 ⇒ 主操作已做但被回滚
│    │     └─ MyISAM 不支持回滚 ⇒ 数据永久不一致
│    ├── ⇒ 加一个触发器 = 给这张表的所有 DML 加了一个新的失败点
│    ├── 死锁:触发器体碰其他表 ⇒ 持有额外锁 ⇒ AB-BA 环形等待
│    │     ├─ InnoDB wait-for graph 环检测,回滚代价小的事务,报 ERROR 1213
│    │     ├─ innodb_deadlock_detect / innodb_lock_wait_timeout(默认 50s,ERROR 1205)
│    │     └─ SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 里,
│    │        「案发现场的 SQL」常常是触发器体内的那条,看起来毫无来由
│    ├── 锁范围放大:业务行锁 + 日志表行锁 + 插入意向锁 + 自增锁 + 索引维护 + undo/redo
│    │     └─ 日志表数据会污染 Buffer Pool(innodb_old_blocks_time / _pct 缓解)
│    ├── innodb_autoinc_lock_mode:0 传统 / 1 连续(5.7 及以前默认)/ 2 交错(8.0 默认)
│    │     └─ 触发器驱动的批量插入在模式 0/1 下会持有表级 AUTO-INC 锁,并发受限
│    ├── **不能手动 CALL,也不能 DISABLE**(SQL Server 有 DISABLE TRIGGER,MySQL 没有)
│    │     └─ 想临时关掉只有 DROP;sql_log_bin=0 / foreign_key_checks=0 统统无效
│    └── 不触发触发器的操作:TRUNCATE、DROP TABLE、ALTER TABLE、外键级联、
│          以及(建议实测确认)REPLACE INTO 的删除分支
│
├── 五、binlog 与主从复制(深入底层)
│    ├── 触发器本身不单独进 binlog,记录的是「触发语句」或「合并后的行变更」
│    ├── ROW 格式(官方文档):主库触发器的执行结果被复制,**从库触发器不再执行**
│    │     └─ 结构上一致,但 binlog 膨胀、从库触发器形同虚设、MGR 强制 ROW
│    ├── STATEMENT 格式(官方文档):从库 SQL 线程重放语句,**从库触发器会被执行**
│    │     └─ 风险:定义漂移、不确定函数、自增值分叉、从库触发器报错导致复制中断
│    ├── 核心结论:触发器 + binlog + 主从复制三者交互存在数据一致性风险,
│    │            行为随 binlog 格式和 MySQL 版本而变,
│    │            **这是生产环境避免用触发器做审计的首要原因**
│    └── 替代方案 CDC(Canal / Debezium / Maxwell / Flink CDC):
│          伪装成从库 → COM_REGISTER_SLAVE + COM_BINLOG_DUMP → 解析 binlog 事件
│          ⇒ 主库零侵入、零性能损耗、零额外锁、天然多消费者、无主从不一致
│
├── 六、使用场景
│    ├── 适合:BEFORE 数据校验/规范化、BEFORE DELETE 防误删、
│    │        冗余计数维护(谨慎,热点行锁)、审计字段填充(有原生方案,别用)、
│    │        简单冗余表同步、教学 / 单机小项目 / 临时护栏
│    ├── 不适合:高并发核心表审计、跨库跨服务编排、复杂业务流程、
│    │           需要独立事务边界、失败不能影响主流程、分库分表环境
│    └── 四方案选型:触发器 vs 应用层 AOP vs CDC vs 定时任务
│          (侵入性 / 性能 / 一致性 / 能否绕过 / 可测试 / 可维护 / 拿不拿得到操作人)
│
├── 七、审计日志的三种升级方案
│    ├── 课件 CONCAT 方案:一行一字符串,不可查、有 NULL 陷阱、有截断风险
│    ├── 方案 A 字段级:一列变化一行,等值查询完美,触发器体啰嗦(用 SQL 生成 SQL)
│    ├── 方案 B JSON 快照(5.7+):JSON_OBJECT 存前后镜像,支持数值比较与 JSON_TABLE
│    ├── 方案 C CDC + 独立审计存储(生产首选)
│    └── CURRENT_USER() 返回的是 DEFINER;触发器**拿不到业务操作人**
│          ⇒ 会话变量 @current_user(连接池污染风险)/ 真实字段 updated_by(推荐)
│
└── 八、调试与排查
     ├── SHOW CREATE TRIGGER(看 sql_mode / DEFINER / 字符集)
     ├── information_schema.TRIGGERS 全库盘点 + 孤儿 DEFINER 检查
     ├── 调试手法:写调试表(会进 binlog、会随事务回滚)
     │            SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = CONCAT('debug: ', ...)
     │            (MESSAGE_TEXT 上限 128 字符,只能测试环境用)
     ├── SHOW WARNINGS(1265 截断 / 1366 字符集 / 1592 binlog 不安全)
     └── 性能排查:EXPLAIN 看不到触发器;慢日志把耗时算在业务 SQL 上;
                   performance_schema.events_statements_summary_by_program
                   WHERE OBJECT_TYPE='TRIGGER';最可靠的是 DROP 后对照实验

10.2 面试速答卡

💡 用法:先自己答一遍,再看参考答案。参考答案刻意写得比课件的一句话答案长------面试官要的不是名词,是「你为什么这么认为」


Q12:MySQL 中触发器分为几种类型?

按「触发时机 × 触发事件」两个维度组合,一共 6 种

触发时机有 2 种:BEFORE(在触发语句对行做实际修改之前 执行)和 AFTER(在修改之后、语句结束之前执行)。

触发事件有 3 种:INSERTUPDATEDELETE

组合起来就是:BEFORE INSERT、AFTER INSERT、BEFORE UPDATE、AFTER UPDATE、BEFORE DELETE、AFTER DELETE。

补充一个加分点 :在 MySQL 5.7.2 之前,同一张表的同一个(时机, 事件)组合只能有一个触发器 ,所以一张表最多 6 个;从 5.7.2 开始放开了这个限制 ,同一组合可以有多个触发器,并且可以用 FOLLOWS / PRECEDES 子句指定它们的执行顺序。另外 MySQL 8.0.29 增加了 CREATE TRIGGER IF NOT EXISTS

再补一个 :触发器是强绑定到单张表 的,不能像 Oracle 那样建 AFTER LOGON ON DATABASE 这种库级/实例级触发器;也不能建在 mysqlinformation_schemaperformance_schema 这些系统库的表上。


Q13:行级触发器与语句级触发器的区别是什么?

区别在于触发器体执行的次数

行级触发器 (Row-Level):触发语句每影响一行,触发器体就执行一次。语法标志是 FOR EACH ROW。在触发器体内可以通过 OLD / NEW 访问当前这一行变更前后的值。

语句级触发器 (Statement-Level):无论这条语句影响了 0 行还是 100 万行,触发器体只执行一次 。它拿不到具体某一行的数据(Oracle 里要用 :OLD/:NEW 之外的复合触发器机制,SQL Server 里要自己去查 inserted / deleted 这两张伪表)。

关键结论:MySQL 只支持行级触发器。 FOR EACH ROW 在 MySQL 里是强制子句 ,不写会直接报 ERROR 1064 语法错误;而 Oracle 里它是可选的,不写就是语句级。information_schema.TRIGGERS.ACTION_ORIENTATION 这一列在 MySQL 里永远是 'ROW'

由此推导出性能结论 (这是面试官最想听的):UPDATE t SET x = 1 WHERE id > 0 如果影响 100 万行,那么触发器体就要执行 100 万 次,而不是 1 次。每一次执行都意味着一次完整的 SQL 层解析执行、一次对目标日志表的 INSERT、一次行锁和自增锁的申请。所以批量操作 + 触发器是一个需要特别警惕的组合,压测时必须用真实数据量测。

课件 §6.5 里 UPDATE student SET class_id = 3 WHERE id >= 7 匹配到 3 行、日志表里就出现了 3 条记录,这就是行级触发器的微缩演示。


Q14:说一下你了解的触发器使用场景都有哪些?

我先说真正适合的

  1. 数据校验与规范化 ------用 BEFORE 触发器 + SET NEW.col = ...,在数据落盘前清洗。比如去掉手机号里的空格和横线、邮箱统一转小写、金额字段禁止负数(SIGNAL SQLSTATE '45000' 直接拒绝)。它的独特价值是「兜底」:不管数据从 Java 服务、Python 脚本还是 DBA 手工 SQL 进来,规则都生效,这是应用层校验做不到的。
  2. 防止非法删除 ------BEFORE DELETE 里判断 OLD.status,已支付的订单、核心配置行直接 SIGNAL 拦住。连不带 WHERE 的全表 DELETE 都能拦。
  3. 维护冗余的派生列 ------第 1 篇讲反范式时提到的场景,比如评论表变更时同步 article.comment_count但这个要谨慎:热点文章的那一行会成为排他锁竞争点,把并发彻底串行化,评论写入还会因为计数失败而整体回滚。规模上来之后应该换成 Redis INCR + 定时回写,或者消息攒批合并更新。
  4. 自动填充审计字段 ------create_time / update_time但 MySQL 有原生的列属性 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(5.6.5+ 支持 DATETIME),性能更好、零维护,这个场景没必要用触发器。
  5. 简单的冗余表同步 ------比如学生转班时同步 class_stat 的人数。注意用 <=> 做 NULL 安全比较,并且要意识到这会引入跨表死锁风险。
  6. 教学、单机小项目、内部工具、临时数据修复护栏------这些场景 QPS 低、没有从库、没有分库分表,触发器的运维复杂度低于应用层改造成本。

再说明确不适合的

  • 高并发核心表的审计日志:性能代价(锁放大)+ 主从一致性风险,应该用 CDC 或应用层 AOP。
  • 跨库/跨服务编排:触发器没有独立事务边界,不能 COMMIT/ROLLBACK,不能发消息,应该用 MQ(本地消息表 / 事务消息)。
  • 复杂业务流程:触发器体不能单元测试、不能代码评审、不能灰度回滚,应该放应用层 Service。
  • 「失败也不能影响主流程」的旁路逻辑:触发器失败 = 主语句失败,做不到解耦,应该用异步消息 + 定时补偿。
  • 分库分表环境:触发器要在每个物理分片上建,定义同步是噩梦,中间件大多也不透传。

最后一句总结 :触发器不是坏技术,它是被用错了地方。判断标准是------这段逻辑需不需要「任何入口都拦得住」,以及它失败时能不能接受「主业务一起失败」。两个都是「是」,才考虑触发器。


Q15:如果对一张表中的数据进行更新,要在日志表中记录该条记录更新前与更新后的值,如何实现?

AFTER UPDATE 行级触发器 + OLD / NEW 伪记录。我先给课件的标准做法,再说生产环境怎么升级。

第一步,建日志表:

sql 复制代码
CREATE TABLE student_log (
    id             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '日志ID',
    operation_type VARCHAR(10)  NOT NULL                COMMENT '操作类型:insert/update/delete',
    operation_time DATETIME     NOT NULL                COMMENT '操作时间',
    operation_id   BIGINT       NOT NULL                COMMENT '被操作记录的主键',
    operation_data VARCHAR(500)          DEFAULT NULL   COMMENT '变更数据',
    PRIMARY KEY (id),
    KEY idx_opid (operation_id),
    KEY idx_time (operation_time)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学生表变更日志';

第二步,建触发器:

sql 复制代码
DELIMITER $$
CREATE TRIGGER trg_student_update
AFTER UPDATE ON student
FOR EACH ROW
BEGIN
    INSERT INTO student_log (operation_type, operation_time, operation_id, operation_data)
    VALUES ('update', NOW(), NEW.id,
            CONCAT(OLD.id, ',', OLD.name, ',', OLD.sno, ',', OLD.age, ',',
                   OLD.gender, ',', OLD.enroll_date, ',', OLD.class_id, '|',
                   NEW.id, ',', NEW.name, ',', NEW.sno, ',', NEW.age, ',',
                   NEW.gender, ',', NEW.enroll_date, ',', NEW.class_id));
END$$
DELIMITER ;

第三步,测试: UPDATE student SET age = 20, class_id = 2 WHERE id = 13;,日志表里会出现一行 operation_data 形如 13,曹操,300001,28,1,2024-09-01,3|13,曹操,300001,20,1,2024-09-01,2,用 | 分隔「变更前」和「变更后」。

关键点 :① 选 AFTER 而不是 BEFORE,因为要记录「已经真实发生的变更」;② UPDATE 事件下 OLDNEW 都可用;③ 行级触发器意味着批量更新会产生多条日志------WHERE id >= 7 匹配 3 行就产生 3 条,且它们的 NOW() 相同(NOW() 返回语句开始时间,不是每行的实际执行时间)。

然后我要主动指出这个方案的五个问题(这是拉开差距的地方):

  1. NULL 陷阱CONCAT 只要有一个参数是 NULL,整个结果就是 NULL。enroll_date 为空的学生,日志会记成 NULL。要用 CONCAT_WS + IFNULL(col, '<NULL>')
  2. 不可查询 :想查「class_id 从 2 改成 3 的记录」只能 LIKE '%,2|%,3',会误伤其他字段。
  3. 分隔符冲突 :姓名里带逗号(比如 张,三)就彻底错乱。
  4. 位置耦合:表加一列、调一列顺序,所有历史日志的解析规则全废。
  5. 截断风险VARCHAR(500) 装不下宽表;严格模式下报 ERROR 1406 导致业务 UPDATE 一起回滚,非严格模式下静默截断丢数据。而且 utf8mb4 下 500 是字符数不是字节数,估算容量容易错。

生产环境的升级方案方案 A 字段级日志(table_name / record_id / column_name / old_value / new_value / changed_at / changed_by,一个字段变化一行,可索引可聚合,用 <=> 做 NULL 安全比较);方案 BJSON 类型存前后完整快照(5.7+,支持 ->> '$.age' 提取和数值比较、JSON_TABLE 展开);方案 C (真正上规模)干脆不在数据库里做------用 CDC (Canal / Debezium)解析 ROW 格式 binlog,Update_rows_event 里天然就带着完整的前后镜像,主库零侵入、零锁放大、无主从不一致。

最后补一句致命缺陷CURRENT_USER() 在触发器里返回的是 DEFINER 而不是实际操作者,触发器在数据库内部拿不到业务操作人 。如果「谁改的」是硬性合规要求,触发器方案从一开始就不成立,必须走应用层 AOP,或者由应用层通过会话变量 / 真实的 updated_by 字段把操作人传进来。


Q16:如何查看数据库中创建的触发器?

三种方式,从粗到细。

SHOW TRIGGERS ------ 看当前库(或指定库)的所有触发器:

sql 复制代码
SHOW TRIGGERS;                        -- 当前库全部
SHOW TRIGGERS FROM youju_demo;          -- 指定库
SHOW TRIGGERS FROM youju_demo LIKE 'student%';   -- ⚠️ LIKE 匹配的是**表名**,不是触发器名
SHOW TRIGGERS WHERE `Trigger` = 'trg_student_update';  -- 按触发器名过滤要用 WHERE

输出列包括 Trigger(名称)、Event(INSERT/UPDATE/DELETE)、Table(绑定的表)、Statement(触发器体)、Timing(BEFORE/AFTER)、Createdsql_modeDefinercharacter_set_clientcollation_connectionDatabase Collation

SHOW CREATE TRIGGER ------ 看单个触发器的完整可重建定义,排查问题第一步就是它:

sql 复制代码
SHOW CREATE TRIGGER youju_demo.trg_student_update\G

这里能直接看到 DEFINER、创建时固化的 sql_mode 和字符集环境------这两项是「触发器行为诡异」的高频根因。

information_schema.TRIGGERS ------ 结构化查询,能做全库盘点和复杂筛选:

sql 复制代码
SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION,
       DEFINER, CREATED, SQL_MODE, ACTION_STATEMENT
  FROM information_schema.TRIGGERS
 WHERE TRIGGER_SCHEMA = 'youju_demo'
 ORDER BY EVENT_OBJECT_TABLE,
          FIELD(ACTION_TIMING, 'BEFORE', 'AFTER'),
          FIELD(EVENT_MANIPULATION, 'INSERT', 'UPDATE', 'DELETE');

几个常用列的含义:TRIGGER_NAME 触发器名、EVENT_MANIPULATION 触发事件、EVENT_OBJECT_TABLE 绑定的表、ACTION_TIMING 时机、ACTION_STATEMENT 触发器体、ACTION_ORIENTATION 在 MySQL 里恒为 'ROW' (因为只支持行级)、ACTION_ORDER 同一组合内多个触发器的执行顺序(5.7.2+ 才有意义)、CREATED 创建时间、SQL_MODE 创建时的 sql_mode、DEFINER 执行身份。

一个实用的巡检 SQL ------找出 DEFINER 已被删除的「孤儿触发器」(它们随时会抛 ERROR 1449):

sql 复制代码
SELECT t.TRIGGER_SCHEMA, t.TRIGGER_NAME, t.DEFINER
  FROM information_schema.TRIGGERS t
  LEFT JOIN mysql.user u ON CONCAT(u.User, '@', u.Host) = t.DEFINER
 WHERE u.User IS NULL;

高频追问

追问 1:一张表最多能建几个触发器?多个触发器的执行顺序怎么控制?

MySQL 5.7.2 之前 :同一张表的同一个(时机, 事件)组合只能有一个触发器,所以最多 6 个(2 时机 × 3 事件)。再建会直接报错。

MySQL 5.7.2 及之后 :限制放开,同一组合可以有多个,数量上没有硬上限。执行顺序用 FOLLOWS / PRECEDES 指定:

sql 复制代码
CREATE TRIGGER ins_transaction
AFTER INSERT ON account FOR EACH ROW
FOLLOWS ins_sum            -- 在 ins_sum 之后执行
INSERT INTO transaction_log VALUES (NEW.id, NOW());

顺序信息记录在 information_schema.TRIGGERS.ACTION_ORDER 里。默认顺序按创建时间。

但我要补一句工程判断如果你的业务逻辑依赖多个触发器的执行顺序,这本身就是设计坏味道。 顺序依赖意味着任何人 DROP + CREATE 其中一个都可能悄悄破坏整体行为,而且 ACTION_ORDER 在跨环境迁移(mysqldump 到另一台机器)时很容易错乱。正确做法是把逻辑合并进一个触发器体,或者干脆挪到应用层。


追问 2:BEFORE 和 AFTER 怎么选?

一句话原则:要「改」数据用 BEFORE,要「记」数据用 AFTER。

你的目的 选哪个 原因
修改即将写入的值(规范化、默认值、自动填充) BEFORE 只有 BEFORE 里 NEW 是可写的;AFTER 里 NEW 只读,改了也不生效
拒绝这次操作(校验失败、禁止删除) BEFORE BEFORE 失败 ⇒ 这一行的操作根本没发生,语义最干净,代价最小
记录审计日志(变更前后对比) AFTER 要记录「真实发生的变更」;BEFORE 阶段变更还没落地,如果后续因为别的原因失败,日志就成了假记录
同步更新其他表(汇总表、冗余表) AFTER 逻辑上应该「主操作成功了才联动」;虽然两者失败都会回滚主语句,但 AFTER 的语义更清晰
DELETE 场景读取被删数据 两者都行 OLD 在 BEFORE DELETE 和 AFTER DELETE 里都可用且只读

一个容易忽略的细节BEFORE INSERT 里读 NEW.id(AUTO_INCREMENT 列)得到的是 0 ,因为自增值是存储引擎在真正插入时才分配的。想在日志里记自增 ID,只能用 AFTER INSERT


追问 3:能在触发器里写 COMMIT 吗?为什么?

不能。 START TRANSACTIONCOMMITROLLBACK 在触发器体里都是被明确禁止的,写了会在创建时就报语法错误。

原因是触发器没有独立的事务边界。 触发器不是一个可以独立调用的程序单元,它是嵌套在触发语句的执行流程内部 的一段代码------第 5.1 节的时序图里,它是第 ④ 步和第 ⑦ 步,夹在「加行锁」和「写 redo/binlog」之间。触发语句自己就是一个事务(或者事务里的一条语句),触发器体的所有操作天然属于这同一个事务

如果允许触发器 COMMIT,就意味着一条 UPDATE 执行到一半把事务提交了------这会破坏原子性,会让触发语句所在的显式事务被意外结束,会让 binlog 与 InnoDB 的提交时序(redo prepare → binlog → redo commit 的两阶段提交)错乱。所以 MySQL 从设计上直接禁掉。

有一个例外ROLLBACK TO SAVEPOINT 是允许的,因为它不结束事务,只回滚到事务内部的一个点。但这个能力在实际中几乎用不上。

工程含义任何需要「独立成功/失败」的旁路逻辑,触发器都做不到。 「主操作成功,日志写失败也不能影响主操作」------这个需求用触发器永远实现不了,只能走异步消息或者 CDC。这也是第 7.2 节把「需要独立事务边界」列为不适合场景的根本原因。


追问 4:触发器执行失败了会发生什么?

分三种情况,都需要说清楚:

情况 结果
BEFORE 触发器失败 这一行的操作根本没有执行。InnoDB 下整条触发语句回滚,其他已经处理过的行也一起撤销
AFTER 触发器失败 主操作已经在 Buffer Pool 里做完了,但因为在同一个事务里 ,整条语句全部回滚,包括之前所有行的变更
存储引擎是 MyISAM MyISAM 不支持事务、不支持回滚 。主操作已经写进数据文件了,触发器失败也撤不回来 ⇒ 数据永久不一致。这是「审计表和被审计表必须用同一个事务型引擎」的原因

两个实战要点

  1. 加一个触发器 = 给这张表的所有 DML 增加了一个新的失败点。 原本一条稳稳当当的 UPDATE,现在可能因为日志表的唯一键冲突、字段超长(ERROR 1406)、日志表磁盘满、DEFINER 用户被删(ERROR 1449)而整体失败。业务功能被审计功能拖垮,这是我见过最憋屈的线上事故类型。
  2. 报错信息会误导你。 用户看到的是「更新学生信息失败:Duplicate entry '13-update' for key 'uk_op'」,他会以为是学生表出了问题,但真正的原因藏在日志表上。所以触发器的错误必须在应用层做翻译和兜底,否则排查会绕很远。

顺便说一个「反向保护」:MySQL 不允许触发器修改触发语句正在操作的那张表 ,会报 ERROR 1442 (HY000): Can't update table 'student' in stored function/trigger because it is already used by statement which invoked this stored function/trigger。这个限制的好处是------MySQL 天然不存在触发器递归和环状触发链 ,而 SQL Server 需要专门用 RECURSIVE_TRIGGERS 数据库选项来控制这件事。


追问 5:《阿里巴巴 Java 开发手册》明令禁止使用存储过程、触发器、外键,你怎么看?

手册的原文大意是:这些特性「难以调试和扩展,更没有移植性」,属于「数据库的滥用行为」。我认同这条规范,但我想说清楚它背后的技术理由,而不是简单地「因为手册这么说」。

禁止触发器的四条硬理由:

  1. 不可测试、不可版本管理 :触发器体不在 Git 里,不进 CI,不能 code review,不能灰度,不能一键回滚。而没有 ALTER TRIGGER,改一次要走 DROP + CREATE,中间有一个「审计盲区」窗口。
  2. 性能不透明且会放大锁范围EXPLAIN 看不到触发器,慢日志把耗时算在业务 SQL 头上。行级触发器让 100 万行的 UPDATE 变成 100 万次触发器体执行,还额外锁住日志表的行和自增锁,甚至引发跨表 AB-BA 死锁------而 SHOW ENGINE INNODB STATUS 里「案发现场的 SQL」是触发器体内那条,看起来毫无来由。
  3. 主从复制一致性风险这是最硬的一条 ):触发器 + binlog + 主从复制三者交互的行为随 binlog 格式和 MySQL 版本而变 。ROW 格式下从库触发器不执行(靠复制主库的结果保持一致),STATEMENT 格式下从库会重新执行触发器(依赖两边定义完全一致、语句完全确定)。任何一种情况出问题都是主从数据分叉,而分叉往往是静默的,要靠 pt-table-checksum 才能发现。
  4. 隐藏的业务逻辑:新人看 Java 代码看不出「为什么这条 UPDATE 会失败」,因为答案在数据库里。这种「逻辑藏在另一个技术栈里」的架构,维护成本随人员流动指数级上升。

禁止外键的理由也类似:外键约束在每次写入时都要做父表的存在性检查并对父表记录加共享锁,高并发下是明确的性能瓶颈;分库分表之后外键根本无法跨库生效;数据迁移和归档时外键会成为障碍。

但我不认为应该「一刀切地全面禁止」。 我的判断标准是:

  • 如果这段逻辑需要「任何入口都拦得住 」(包括 DBA 手工 SQL),且「失败时接受主业务一起失败 」,触发器是唯一 能在数据库层做到的方案------比如「已支付订单禁止物理删除」这类数据完整性护栏
  • 如果是审计日志 ,2026 年的正确答案是 CDC(Canal / Debezium 解析 ROW binlog),它零侵入、零性能损耗、无主从风险,触发器在这个场景上已经被彻底淘汰。
  • 如果是业务逻辑,一律放应用层。

所以我的实践是:遵守这条规范(默认不用),但保留对「数据库层完整性护栏」这一个例外的判断力。规范的价值在于默认值,不在于绝对禁令。


追问 6:怎么用触发器阻止某一列被修改?

BEFORE UPDATE 触发器 + SIGNAL,比较 OLDNEW。关键是必须用 <=>(NULL 安全等于)而不是 =

sql 复制代码
DELIMITER $$

CREATE TRIGGER trg_student_protect_sno
BEFORE UPDATE ON student
FOR EACH ROW
BEGIN
    -- 学号一经录入不允许修改
    IF NOT (OLD.sno <=> NEW.sno) THEN
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = '学号(sno)不允许修改,如需变更请联系 DBA 走数据订正流程';
    END IF;
END$$

DELIMITER ;
sql 复制代码
UPDATE student SET sno = '300002' WHERE id = 13;
-- ERROR 1644 (45000): 学号(sno)不允许修改,如需变更请联系 DBA 走数据订正流程

UPDATE student SET age = 21 WHERE id = 13;   -- 正常,因为 sno 没变

三个必须讲清楚的细节:

  1. 为什么用 <=> 不用 = :如果 sno 可空,OLD.sno = NEW.sno 在任意一边为 NULL 时结果是 NULL,IF NULL 不会进 THEN 分支------保护直接失效<=> 是 NULL 安全等于,NULL <=> NULL 返回 1,能正确判断「两边都没变」。
  2. 另一种等价写法SET NEW.sno = OLD.sno;------不报错,而是静默地把这一列「冻结」回原值 。适合「不想让业务报错,只想让改动无效」的场景。但我不推荐 ,因为调用方会以为改成功了(Rows matched: 1),这是隐式失败,比显式报错更危险。
  3. 这个保护能被绕过ALTER TABLE ... MODIFY COLUMN 重建列、TRUNCATE TABLE 之后重新导入、DROP TABLE + 重建、以及 LOAD DATA 覆盖(LOAD DATA 会触发触发器,建议实测确认 ),都能突破。触发器是「防误操作」,不是「防恶意」。 真正的强制约束需要配合数据库账号权限(收回 UPDATE 权限、只开放给特定账号)和应用层校验。

追问 7(补充):触发器和外键级联、CHECK 约束有什么区别?

三者都能做「数据一致性」,但层次完全不同:

机制 生效层 能否自定义逻辑 性能 版本要求
CHECK 约束 存储引擎/SQL 层,单行单表 ❌ 只能是布尔表达式 ✅ 最好 MySQL 8.0.16+ 才真正强制执行,之前只是解析后忽略
外键 + ON DELETE/UPDATE CASCADE InnoDB 存储引擎 ❌ 只有 4 种固定动作 ✅ 好 全版本,仅 InnoDB
触发器 SQL 层,可跨表 ✅ 任意 SQL 逻辑 ❌ 最差(行级放大 + 锁 + 主从风险) 全版本

两个重要的坑

  1. 外键级联操作不会激活触发器。 也就是说,ON DELETE CASCADE 删掉子表的行时,子表上的 BEFORE DELETE / AFTER DELETE 触发器不会执行 。如果你的审计依赖触发器,级联删除的数据就是审计盲区。这在等保、SOX、GDPR 这类合规场景下是硬伤。
  2. MySQL 8.0.16 之前,CHECK 约束是「假的」 ------语法能解析、能建表,但不做任何校验 。很多老项目里那些 CHECK (age > 0) 完全是装饰品。8.0.16 之后才真正生效,而且升级后原本违反约束的存量数据会让写入开始报错,这是一个需要评估的升级风险点。

选型建议:能用 CHECK 用 CHECK(8.0.16+),能用外键用外键(单机、无分库分表、并发不高),实在需要跨表复杂逻辑才用触发器,而生产环境的审计需求直接上 CDC。



下一篇:第 7 篇《索引底层原理与 EXPLAIN 十二字段》


本文参考了公开的 MySQL 进阶教学资料整理撰写,并在其基础上补充了触发器与事务锁、binlog 与主从复制一致性、CDC 方案等底层分析。

相关推荐
浅念-3 小时前
Redis基础详解:单线程模型、String与Hash数据结构
服务器·数据库·redis·sql·mysql·nosql数据库·nosql
databook8 小时前
使用 DuckDB 分析 Parquet 文件
sql·数据分析·nosql
旺仔不是程序员9 小时前
相关子查询与性能优化:PostgreSQL 逐行执行的代价与 JOIN 重写
数据库·后端·sql
绘梨衣54712 小时前
慢SQL排查手册
python·sql
知识的搬运工旺仔1 天前
CREATE INDEX CONCURRENTLY:线上建索引不阻塞 DML 的代价与坑
数据库·后端·sql
天天喝旺仔1 天前
MySQL索引优化实战:B+Tree原理、索引失效与覆盖索引调优
数据库·sql·mysql·性能优化
迷枫7121 天前
MySQL 迁移到达梦过程中遇到的一些问题记录
sql
Lucifer三思而后行2 天前
Oracle SQL 优化实战:明明走了索引,SQL 还是慢?
sql·oracle