业务系统运行久了,订单表、日志表里的数据越积越多,直接删除固然能瘦身,但审计和追溯时就会抓瞎。比较稳妥的做法是把要删除的数据先搬到一张归档表,再从主表里删掉。这件事如果靠应用层代码来做,每个删除入口都得改一遍,漏改一处就丢数据。而用SQL触发器来做,删除动作一旦发生,归档逻辑自动执行,应用层完全无感知,这是它最大的优势。下面从原理、实现到坑点,把这套方案完整讲一遍。

触发器归档的基本原理:AFTER DELETE为什么是首选
以SQL Server和MySQL为代表的主流数据库,触发器按执行时机分为两类:AFTER触发器和INSTEAD OF触发器。做数据归档时,AFTER DELETE触发器是最常见的选择,它在DELETE语句成功执行之后被激发,此时被删除的行已经进入逻辑表deleted中(MySQL里是OLD行集合),我们可以把这些行原样插入归档表。
需要理解的一点是,触发器和触发它的DML语句处于同一个事务中。也就是说,如果DELETE之后触发的归档插入失败了,整个DELETE也会一起回滚,主表数据保持原样。这个特性对数据一致性来说是好事:永远不会出现主表删了、归档表却没存到的情况。反过来也要注意,如果外层事务回滚,归档表里刚插入的数据同样会消失,所以归档表不是审计日志,不能指望它记录“未成功的删除”。
INSTEAD OF DELETE触发器则完全不同,它会拦截删除操作本身,主表的DELETE不会执行,你需要自己写删除语句。这种方式适合归档前需要做复杂校验或转换的场景,比如归档时改写主键、拆分字段,但日常归档用AFTER就够,逻辑更简单,出错概率更低。
动手实现:建归档表和DELETE触发器
先建一张和主表结构一致的归档表,并额外加几个审计字段。这里以订单表为例,用SQL Server语法演示,MySQL的写法在后面单独说明。
-- 主表
CREATE TABLE dbo.Orders (
OrderID INT PRIMARY KEY,
CustomerName NVARCHAR(100),
Amount DECIMAL(12,2),
CreatedAt DATETIME NOT NULL
);
-- 归档表,结构复制自主表,额外加归档信息
CREATE TABLE dbo.Orders_Archive (
OrderID INT NOT NULL,
CustomerName NVARCHAR(100),
Amount DECIMAL(12,2),
CreatedAt DATETIME NOT NULL,
ArchivedAt DATETIME NOT NULL DEFAULT GETDATE(),
ArchivedBy SYSNAME NOT NULL DEFAULT SUSER_SNAME()
);
-- 归档触发器
CREATE TRIGGER trg_Orders_Archive
ON dbo.Orders
AFTER DELETE
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.Orders_Archive (OrderID, CustomerName, Amount, CreatedAt)
SELECT OrderID, CustomerName, Amount, CreatedAt
FROM deleted;
END
核心就是最后那段INSERT ... SELECT FROM deleted,deleted是触发器内置的逻辑表,结构和主表完全相同,存放本次被删除的所有行。注意这里永远不要写成循环逐行插入,deleted本身就是结果集,一条INSERT SELECT就能批量搬完,性能差距是数量级的。
MySQL 8.0的写法类似,但语法细节不同,触发器里没有deleted表,改用OLD引用旧行,且MySQL的触发器是行级的,一条DELETE删N行就触发N次:
DELIMITER $$
CREATE TRIGGER trg_orders_archive
BEFORE DELETE ON orders
FOR EACH ROW
BEGIN
INSERT INTO orders_archive (order_id, customer_name, amount, created_at, archived_at)
VALUES (OLD.order_id, OLD.customer_name, OLD.amount, OLD.created_at, NOW());
END$$
DELIMITER ;
这里故意用了BEFORE DELETE而不是AFTER,原因是MySQL的AFTER DELETE触发器虽然也能读到OLD,但如果归档表有外键回指主表,AFTER阶段主表行已删,外键约束会直接报错。BEFORE阶段主表行还在,插入归档表不会冲突。此外别忘了MySQL有个log_bin_trust_function_creators或REQUIRE_ROW_FORMAT相关的复制限制,生产环境部署触发器前先在测试库验证主从复制行为。
级联归档和防递归:两个必须处理的问题
真实业务里很少只有一张表。订单删除时,订单明细也要跟着归档,否则归档表里只剩孤零零的订单头。如果主表外键设置了ON DELETE CASCADE,级联删除引发的子表删除同样会激发子表上的DELETE触发器,所以只需给每张子表也配上归档触发器即可,不必在主表触发器里手工处理子表。但要警惕SQL Server默认是递归触发器关闭的(recursive_triggers选项为OFF),而嵌套触发器默认开启,多层触发链路上的任何一环失败都会整体回滚,排查问题时要从最内层触发器看起。
另一个坑是归档表上的误触发。如果有人直接去归档表里DELETE数据(比如清理过期归档),而归档表恰好又被复制了主表触发器,就可能形成循环。规避办法很直接:触发器只建在主表上,归档表永远不建DELETE触发器;或者用TRIGGER_NESTLEVEL()(SQL Server)判断嵌套深度,超过一层就直接RETURN:
ALTER TRIGGER trg_Orders_Archive
ON dbo.Orders
AFTER DELETE
AS
BEGIN
SET NOCOUNT ON;
IF TRIGGER_NESTLEVEL() > 1 RETURN; -- 防止嵌套递归
INSERT INTO dbo.Orders_Archive (OrderID, CustomerName, Amount, CreatedAt)
SELECT OrderID, CustomerName, Amount, CreatedAt FROM deleted;
END
性能代价与适用边界:什么时候不该用触发器归档
触发器方案的代价必须说清楚。第一,每次DELETE都隐式多一次INSERT,大批量清理历史数据时,事务日志膨胀明显,SQL Server下一次删百万行可能把日志文件撑到几十GB。第二,触发器对应用是黑盒,DBA排查慢查询时经常忘了触发器的存在,执行计划里看到主表DELETE消耗异常,八成是归档表索引不合理导致的。第三,MySQL行级触发器在批量删除时逐行执行,性能劣化比SQL Server的语句级触发器严重得多。
因此触发器归档适合的场景是:删除操作零散、单次行数少、需要强一致性保证的业务表,比如用户注销、订单作废。如果是定期批量清理千万级历史数据,更合理的做法是用INSERT SELECT加DELETE的存储过程配合分区表,按分区直接切换归档,整秒完成且几乎不产生日志,这比任何触发器都快。两者也可以混用:日常零散删除靠触发器兜底,周期性大清理走批量脚本,但要在脚本里先禁用触发器避免重复归档。
最后提几个部署前的检查项:归档表不要照搬主表的主键约束,因为同一行可能被删除后重建再删除,主键重复会让归档失败;归档表上的索引只建查询必需的,写入越轻越好;给归档表单独规划文件组或表空间,避免和主表争抢IO。把这些细节考虑到位,触发器归档就能在生产环境长期稳定运行,为数据追溯提供一份可靠的底账。