MySQL 9.7 的 enable_cascade_triggers:级联外键会触发子表触发器,复制路径不对称
MySQL 9.7.0 发布说明里有一条 WL #17024:SQL 层处理外键时执行的 CASCADE 和 SET NULL 动作,现在可以激活受影响子行上的 BEFORE / AFTER 触发器。开关是新系统变量 enable_cascade_triggers,默认 OFF,按会话设置。
Foreign key CASCADE and SET NULL actions performed by SQL-layer foreign key handling can now activate BEFORE and AFTER triggers on affected child rows. Enable this behavior for a session by setting the new enable_cascade_triggers system variable to ON. The default is OFF. Cascade-induced triggers execute as part of the initiating statement. Trigger errors cause the statement and its cascaded changes to be rolled back. Row-based replication records the changes made by the triggers without reexecuting them on replicas; statement-based replication uses the source's enable_cascade_triggers setting. (WL #17024)
行为:触发器和级联变更属于同一条语句
enable_cascade_triggers 是会话变量,打开后当前会话内的级联动作才会带动子表触发器:
SET SESSION enable_cascade_triggers = ON;
SELECT @@session.enable_cascade_triggers; -- ON
触发器的类型取决于级联动作:ON DELETE CASCADE 激活子表的 BEFORE DELETE / AFTER DELETE;ON UPDATE CASCADE 激活 BEFORE UPDATE / AFTER UPDATE;SET NULL 会把子表行改成 NULL,因此激活的是子表的 UPDATE 触发器。
CREATE TABLE parent (
id INT PRIMARY KEY
) ENGINE=InnoDB;
CREATE TABLE child (
id INT PRIMARY KEY,
parent_id INT,
note VARCHAR(64),
CONSTRAINT fk_child_parent FOREIGN KEY (parent_id)
REFERENCES parent(id) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB;
CREATE TABLE audit_child (
child_id INT,
parent_id INT,
op VARCHAR(32)
) ENGINE=InnoDB;
CREATE TRIGGER trg_child_after_delete
AFTER DELETE ON child
FOR EACH ROW
INSERT INTO audit_child VALUES (OLD.id, OLD.parent_id, 'cascade_delete');
打开变量后执行父表删除,子表每一行的删除都会走到上面的触发器:
SET SESSION enable_cascade_triggers = ON;
DELETE FROM parent WHERE id = 1;
-- child 中 parent_id = 1 的行被级联删除,
-- 每行都触发 trg_child_after_delete,写入 audit_child
把外键换成 SET NULL 时,子表行不会被删除,而是被更新:
ALTER TABLE child
DROP FOREIGN KEY fk_child_parent,
ADD CONSTRAINT fk_child_parent FOREIGN KEY (parent_id)
REFERENCES parent(id) ON DELETE SET NULL ON UPDATE CASCADE;
DELETE FROM parent WHERE id = 1;
-- child.parent_id 被置 NULL,触发 BEFORE UPDATE / AFTER UPDATE
关键点是执行边界:由级联动作引发的触发器属于发起语句的一部分,不是独立语句。这也决定了下面两条语义。
默认 OFF 是为了不改旧行为
在 9.7 之前,级联动作不会激活子表触发器。业务侧如果已经在子表上放了审计、计数、状态流转之类的触发器,升级后行为会直接变:原来只删父行、连带删子行,现在每一行子行都多出一次触发器写入,还可能命中触发器里的约束或 SIGNAL,让原本成功的 DELETE 失败。
默认 OFF 保证升级瞬间语义不变,需要新行为的会话显式打开。它是会话级变量,可以按连接、按任务灰度,而不是一升级全实例生效。
打开之前要评估的点
- 子表触发器写入放大:一条父表
DELETE可能级联几十上百行子行,每行都跑一次BEFORE和AFTER。触发器里再写审计表、更新统计表,写入次数按级联行数成倍增长。 - 审计日志翻倍:应用层审计、触发器审计、binlog 会同时记录同一次级联,日志量和存储按级联规模放大。row-based 下触发器造成的结果也会进 binlog。
- 锁范围与死锁概率:触发器在级联过程中对其它表加锁,和外层事务已有的锁顺序交叉,死锁概率上升。触发器里再访问父表或其它子表,会拉长级联期间的锁持有时间。
- 错误传播:触发器里的
SIGNAL、唯一键冲突、死锁都会让发起语句失败,级联变更一并撤销。原本只是“删一行父数据”的操作,现在可能因为子表触发器报错而不可用。
复制:ROW 与 STATEMENT 不对称
同一条级联操作,两种 binlog_format 下副本的行为不同:
- row-based:binlog 记录触发器造成的结果,副本不重跑触发器。副本上的触发器定义与源不一致,不影响这份数据;但副本本地依赖触发器的副作用(本地审计表、本地计数)不会发生。
- statement-based:语句被复制,副本执行时按源的
enable_cascade_triggers设置走,而不是副本自己的变量值。副本上必须有与源一致的触发器定义,否则触发器产生的写入会缺失或报错。
混用 binlog_format 时问题最明显:源是 MIXED,同一类 DELETE 可能这次走 row、下次走 statement,触发器在副本上就时执行时不执行;拓扑里一部分副本从 row 源复制、另一部分从 statement 源复制,两边的审计表会逐渐分叉。切换 binlog_format 或重搭复制前后,也要把这段差异算进去。
核对项:
SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'enable_cascade_triggers';
SHOW TRIGGERS FROM app LIKE 'child';
源和副本的 binlog_format、触发器定义、MySQL 版本都要对齐;低版本副本不识别这个变量,statement-based 路径下更容易出现行为差异。
回滚语义是语句级
触发器报错时,回滚范围是发起语句及其级联变更,不是整个事务。事务在语句失败后仍然存在,由应用决定继续还是回滚。
CREATE TRIGGER trg_child_before_delete
BEFORE DELETE ON child
FOR EACH ROW
BEGIN
IF OLD.parent_id = 42 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'cascade blocked';
END IF;
END;
START TRANSACTION;
DELETE FROM parent WHERE id = 42;
-- 触发器 SIGNAL,父行删除和子行的级联删除一起回滚
SELECT COUNT(*) FROM child WHERE parent_id = 42; -- 行还在
ROLLBACK;
注意这里撤销的不只是触发器里的写入,还包括已经执行的级联删除本身。应用如果习惯“父表删除失败就重试”,要意识到重试会把整段级联再跑一遍。
9.7.0 同批的其它变更
UNIQUE NOT NULL键在CREATE TABLE/ALTER TABLE时被视为 Primary Key 等价,配合sql_generate_invisible_primary_key使用。- 审计日志支持
audit_log.rotate_on_time。 replica_allow_higher_version_source允许低版本副本连接高版本源。