MySQL 外键级联删除:受影响行数与审计触发器的边界
删除一张订单主表记录,明细跟着消失了三行,数据库客户端却显示只影响一行。这个结果不适合拿来判断“所有关联数据是否都删干净了”。受影响行数、级联动作和业务审计,本来就是三种不同口径。
在 MySQL 8.4 的 InnoDB 中,外键级联由存储引擎完成。应用显式执行的 DELETE 和它引出的级联删除不能简单相加,作为一条 SQL 的返回行数使用。更隐蔽的一点是:外键级联不会激活子表上的删除触发器。依靠子表触发器留删除审计,可能正好漏掉这条路径。
一个主记录、三条明细、一个审计触发器
在隔离实验库执行下面的结构。审计表没有再关联到被删的明细,否则审计记录自身也可能受到外键生命周期影响。
CREATE DATABASE IF NOT EXISTS cascade_count_lab;
USE cascade_count_lab;
CREATE TABLE invoice (
id BIGINT NOT NULL PRIMARY KEY,
invoice_no VARCHAR(30) NOT NULL,
UNIQUE KEY uk_invoice_no (invoice_no)
) ENGINE=InnoDB;
CREATE TABLE invoice_item (
id BIGINT NOT NULL PRIMARY KEY,
invoice_id BIGINT NOT NULL,
sku VARCHAR(30) NOT NULL,
quantity INT NOT NULL,
KEY idx_invoice (invoice_id),
CONSTRAINT fk_item_invoice FOREIGN KEY (invoice_id)
REFERENCES invoice(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE deleted_item_audit (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
item_id BIGINT NOT NULL,
deleted_at DATETIME(6) NOT NULL
) ENGINE=InnoDB;
CREATE TRIGGER trg_item_deleted
AFTER DELETE ON invoice_item
FOR EACH ROW
INSERT INTO deleted_item_audit(item_id, deleted_at)
VALUES (OLD.id, CURRENT_TIMESTAMP(6));
INSERT INTO invoice VALUES (1, 'INV-001');
INSERT INTO invoice_item VALUES
(11, 1, 'A', 1), (12, 1, 'B', 2), (13, 1, 'C', 1);
先用事务保留回退机会:
START TRANSACTION;
DELETE FROM invoice WHERE id = 1;
SELECT ROW_COUNT() AS direct_deleted;
SELECT COUNT(*) AS remaining_items FROM invoice_item;
SELECT COUNT(*) AS audit_rows FROM deleted_item_audit;
ROLLBACK;
这组操作的预期含义是:直接删除一张发票,明细不再存在,但子表触发器没有为级联生成三条审计。ROW_COUNT() 必须紧跟要观察的语句读取;中间插入其他查询,再读它就可能观察到另一条语句的状态。
如果程序通过驱动获取更新计数,也要核对驱动返回的是哪条语句的计数。批处理里多个语句的计数数组、单条 DELETE 的计数,以及应用自行统计的明细数量,不能统称一个“删除条数”。
换成显式删除,审计路径随之改变
上面的 ROLLBACK 恢复了父子记录。现在显式删除子表,再删除父表:
START TRANSACTION;
DELETE FROM invoice_item WHERE invoice_id = 1;
SELECT ROW_COUNT() AS explicit_item_deleted;
SELECT COUNT(*) AS audit_rows FROM deleted_item_audit;
DELETE FROM invoice WHERE id = 1;
ROLLBACK;
这次对子表直接执行 DELETE,删除触发器有机会按行执行。两个实验的数据关系相同,发起路径却不同。测试只覆盖“直接删除明细”,无法证明“删除主记录触发级联”的审计完整性。
也不要由此得出“所有删除都应该改成应用逐行删除”。显式多语句会增加调用、锁持有和代码路径,处理不当还会先删明细、后删父表失败。若采用这种方式,必须在同一事务里组织,并对失败回滚、重复请求和并发写明细作出明确约定。
计数若有业务含义,先定义计数的对象
一个后台按钮显示“删除成功,影响一条订单”,直接使用父表的删除结果可以很清楚。若显示“同时清理了三条明细”,则需要独立统计协议:统计的是尝试删除前的候选、事务内实际存在的明细,还是提交后最终消失的明细。
在事务外先查 COUNT,再发删除请求,两个数字之间可能夹入并发修改。即使放进事务,也要结合隔离级别和锁定范围判断是否允许新明细插入。不能为了显示一个看似精确的数字,额外引入一组不受约束的先查后删操作。
审计比展示计数要求更高。如果业务必须保留被删明细的内容,可以在明确锁定并核对对象后,将必要快照写入审计记录,再执行删除,并让这些写入共享事务提交边界。具体保存哪些字段、保存多久,应由审计目的决定,而不是将整条载荷无差别复制。
对于需要给下游发消息的删除,数据库提交与消息投递还有各自的失败窗口。外键级联本身不会替你构造完整的业务删除事件;把“父记录已删除”和“哪些子对象被删除”分别定义清楚,才能决定事件应携带主对象标识、明细快照还是一批可追溯的操作编号。
大级联删除还要观察锁和恢复成本
一条父表 DELETE 看起来很短,背后可能清理大量关联记录。客户端只返回一行,不代表数据库只做了一次轻量修改。级联链越长,事务日志、锁持有时间和回滚工作越可能成为真正的限制。
上线前可以在隔离副本中准备不同明细规模,核对执行耗时、锁等待和日志增长。这里不需要给出一个适用于所有数据库的“安全明细条数”;行宽、索引、并发写入和存储性能都会改变边界。真正要保留的是测试方法和结果对应的环境。
另外,逻辑删除只是 UPDATE 状态,ON DELETE CASCADE 不会因为 deleted=1 自动清理明细。若系统同时存在物理删除与逻辑删除,审计和展示计数都应标明路径,避免一个按钮在不同对象上得到完全不同的副作用。
实验结束可清理独立库:
DROP DATABASE cascade_count_lab;