核心诉求是用after update触发器捕获update前的旧值并写入历史表,需配合after insert和after delete触发器完整覆盖增删改;历史表须显式存档字段、记录updated_at与operation,并避免触发器递归与重逻辑。

用触发器自动捕获 UPDATE 时的旧值
历史变更记录表的核心诉求,是「某行被改了,就立刻记下改之前的样子」。最直接可靠的方案是 AFTER UPDATE 触发器,它在更新完成、事务尚未提交前执行,能稳定读取 OLD.* 中的原始字段值。
注意:不能用 BEFORE UPDATE,因为此时新值已覆盖旧值,OLD.* 虽存在但语义上仍是“即将被覆盖的值”,而你真正需要的是“已被覆盖的上一版”——这只有 AFTER 阶段才能安全拿到(尤其涉及多列并发更新时)。
- 触发器必须定义在目标业务表上,例如
orders表的变更要记录到orders_history - 显式列出需存档的字段,避免用
SELECT * FROM OLD—— 字段增减会导致触发器失效或插入错位 - 务必在历史表中增加
updated_at(用NOW()或CURRENT_TIMESTAMP)和operation(固定写'UPDATE')字段,否则无法区分变更时间与类型 - 若业务表有自增主键
id,历史表应保留该值(作为外键线索),但不要设为主键或唯一约束——同一行可能被多次更新
INSERT 和 DELETE 也得覆盖,但逻辑不同
只处理 UPDATE 是不完整的。用户删了一条订单,你也得知道它原来长什么样;新插入一条记录,有时也需要标记“首次创建”作为历史起点。这时要补上两个触发器:
-
AFTER INSERT:从NEW.*取值,operation = 'INSERT',updated_at记插入时间 -
AFTER DELETE:从OLD.*取值,operation = 'DELETE',updated_at记删除时间
关键区别在于:INSERT 没有“旧值”,DELETE 没有“新值”,所以不能混用 OLD/NEW。另外,DELETE 触发器中禁止访问被删行的关联数据(比如 JOIN 原表),因为行已物理移出,只能依赖 OLD.* 快照。
避免触发器死循环和性能陷阱
触发器本身会引发新 SQL 执行,若历史表也建了相同触发器,就会无限递归。MySQL 默认禁用递归触发器(max_sp_recursion_depth=0),但 PostgreSQL 需手动关:SET session_replication_role = 'replica'; 再插入历史记录——否则可能触发嵌套调用。
- 历史表必须去掉所有触发器,且不参与任何级联操作(如
ON DELETE CASCADE) - 批量更新(
UPDATE ... WHERE id IN (1,2,3))会为每一行单独触发一次,不是一次触发全量。高并发下易成瓶颈,建议对高频更新表加索引:在历史表的business_id+updated_at上建联合索引 - 不要在触发器里调用存储过程或外部 HTTP 请求——超时或失败会导致主事务回滚,业务不可控
替代方案:应用层写双写 vs 触发器,怎么选?
如果团队已用 ORM(如 Django ORM、MyBatis),有人倾向在代码里 save() 前手动插入历史记录。这看似可控,但实际风险更高:
- 绕过触发器的事务隔离:应用写历史表失败,业务表却成功提交,数据必然不一致
- 漏写场景多:直接执行
UPDATE原生 SQL、DBA 手动修复、其他服务接入时都容易跳过双写逻辑 - 时间戳不统一:应用层取系统时间 vs 数据库
NOW()可能差几毫秒,审计时难以对齐
真正需要双写的,仅限于「历史记录需附加应用上下文」的场景,比如记录修改人 user_id、操作来源 source_ip——这些字段数据库触发器拿不到,就得在应用层补充写入,但基础字段变更仍应由触发器兜底。
触发器不是银弹,但它把「谁改了什么、什么时候改的、改之前什么样」这个事实,牢牢锁死在数据库原子性里。只要别往里面塞重逻辑,它就是最省心的历史记录守门人。










