触发器不能直接用ON DELETE CASCADE实现软删除,必须用BEFORE UPDATE触发器手动级联更新子表deleted_at字段,并加OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL判断避免误触发,同时所有查询须显式过滤deleted_at IS NULL。

触发器里不能直接用 DELETE CASCADE
SQL 标准的 ON DELETE CASCADE 是硬删除,会物理移除子记录,不满足软删除需求。想实现「父记录标记为已删除,同时自动把所有关联子记录也标记为已删除」,必须用触发器手动控制逻辑,且要避开外键约束冲突。
常见错误是:在父表 UPDATE 触发器里执行子表 UPDATE,但子表若定义了外键引用父表主键(且未设 ON UPDATE CASCADE),可能触发约束失败;更隐蔽的问题是触发器递归——比如子表更新又触发另一个触发器,再反向更新父表,导致死循环。
- 确保父表和子表都有统一的软删除字段,如
is_deleted BOOLEAN DEFAULT FALSE或deleted_at TIMESTAMP NULL - 触发器类型选
BEFORE UPDATE(不是AFTER),便于在事务内拦截并统一修改子表状态 - 避免在触发器里调用存储过程做跨表更新,除非该过程显式禁用递归检查(如 PostgreSQL 的
pg_trigger_depth()判断)
PostgreSQL 中用 FOR EACH ROW BEFORE UPDATE 实现级联标记
以 PostgreSQL 为例,假设父表为 orders,子表为 order_items,两者都含 deleted_at 字段。目标是:当 orders.deleted_at 从 NULL 设为非空值时,自动将对应 order_items.deleted_at 也设为相同时间戳。
关键点在于触发器函数中必须检查「是否真发生了软删除动作」,而不是每次 UPDATE 都触发级联:
CREATE OR REPLACE FUNCTION cascade_soft_delete_order()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
UPDATE order_items
SET deleted_at = NEW.deleted_at
WHERE order_id = OLD.id AND deleted_at IS NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-
OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL这个判断必不可少,否则更新其他字段(如status)也会误触发子表更新 - 子表
UPDATE加上AND deleted_at IS NULL条件,防止重复标记或覆盖已有软删除时间 - 不要在触发器里加
COMMIT或事务控制语句——它运行在父语句的同一事务中
MySQL 8.0+ 的限制与绕过方式
MySQL 不允许触发器内直接修改触发它的表的关联表(报错 ERROR 1442 (HY000)),更不支持对同一 schema 内其他表执行 UPDATE。所以无法像 PostgreSQL 那样在 BEFORE UPDATE 里写子表更新语句。
可行方案只剩一个:改用应用层协调,或退而求其次,在父表更新后由定时任务/消息队列异步补全子表状态。但如果坚持用纯 SQL,只能靠 AFTER UPDATE + 存储过程 + 手动禁用唯一约束或外键检查(不推荐):
- 临时关闭外键检查:
SET FOREIGN_KEY_CHECKS = 0;—— 仅限开发环境调试,生产禁用 - 用
INSERT ... ON DUPLICATE KEY UPDATE模拟条件更新,但需子表有唯一索引支撑,且逻辑变重 - 实际项目中,更稳妥的做法是把级联逻辑提到 ORM 层(如 Django 的
select_related+ 批量update())或用数据库事件调度器(EVENT)延迟执行
WHERE 条件漏判 is_deleted 导致查询结果错乱
设计完触发器只是第一步。几乎所有软删除场景都会翻车在查询端:如果业务 SQL 忘记加 WHERE deleted_at IS NULL,就会查出已被“删除”的数据。触发器不会帮你自动过滤。
这不是触发器的问题,但和它强相关——因为触发器让数据看起来“还在”,只是状态变了。所以必须同步建立规范:
- 所有涉及父表或子表的
SELECT,必须显式排除已删除记录,例如:SELECT * FROM order_items WHERE order_id = 123 AND deleted_at IS NULL - 考虑用视图封装常用查询逻辑,如
CREATE VIEW active_orders AS SELECT * FROM orders WHERE deleted_at IS NULL - 某些 ORM(如 Laravel Eloquent)支持全局作用域(global scope),可自动注入软删除条件;但原生 SQL 没这功能,漏写就是线上事故
真正难的从来不是怎么写触发器,而是让整个系统——包括所有旧 SQL、新接口、报表脚本、DBA 工具——都始终记得那条 WHERE 子句。










