必须用after行级触发器,因为before在insert时old为空、delete时new不存在,无法获取完整快照;after确保变更已落地,old/new值固化且一致,避免脏读与死锁。

必须用 AFTER 行级触发器,BEFORE 在 INSERT 时拿不到 OLD、在 DELETE 时拿不到 NEW,根本没法存完整快照。
为什么不能用 BEFORE 触发器存历史版本
很多人写 BEFORE UPDATE 想“先存旧值再更新”,但漏掉了关键限制:OLD 确实可用,可 NEW 在 INSERT 场景下为空,DELETE 场景下 NEW 根本不存在——你连操作类型都判不准。更麻烦的是,AFTER 才能确保主表变更已落地,审计记录和业务数据严格一致;BEFORE 里查原表(比如 SELECT * FROM main WHERE id = NEW.id)可能读到未提交的脏状态,甚至引发死锁。
-
INSERT:只有NEW.*有效,OLD为NULL -
UPDATE:OLD.*和NEW.*都可用,但必须在AFTER中才能保证值已固化 -
DELETE:只有OLD.*有效,NEW不可用
历史表结构怎么设计才不踩坑
字段不能图省事照搬主表,否则加个字段就得改所有触发器。核心是两对时间 + 明确操作标识:
-
valid_from(TIMESTAMP WITH TIME ZONE DEFAULT NOW()):记录该版本生效起点 -
valid_to(TIMESTAMP WITH TIME ZONE,允许NULL):为NULL表示当前活跃版本 -
op_type(CHAR(1)或ENUM('I','U','D')):别用数字编码,查日志时WHERE op_type = 'U'比= 2直观安全 - 必须建联合唯一索引:
CREATE UNIQUE INDEX idx_id_valid_from ON history_table (id, valid_from),防同一主键在相同时刻出现多条“当前有效”记录
别用 is_current BOOLEAN 字段——并发更新时容易状态竞争;也别用 created_at 代替 valid_from,它不反映逻辑生效时刻。
触发器函数里怎么写 INSERT 才安全
禁止用 * 或动态拼接列名,一加字段就崩。字段必须显式列出,尤其当历史表比主表多 op_type、valid_to 这些字段时:
INSERT INTO user_history (id, name, email, valid_from, valid_to, op_type) VALUES (OLD.id, OLD.name, OLD.email, OLD.valid_from, NOW(), 'U');
常见错误是写成 EXECUTE 'INSERT INTO hist_' || TG_TABLE_NAME || ' VALUES (' || NEW.id || ')'——这既易注入又难调试。真要跨表通用(比如分表场景),必须用 format('%I', table_name) 转义标识符,且只限极少数情况。
-
INSERT:只插NEW.*,op_type = 'I',valid_to = NULL -
UPDATE:先插OLD.*并设valid_to = NOW(),再插NEW.*且valid_to = NULL -
DELETE:插OLD.*,op_type = 'D',valid_to = NOW()
大事务下触发器性能怎么扛住
触发器代码运行在主 DML 事务内,批量更新百万行时,历史表写入会拖慢主事务、撑爆 WAL 日志、卡住 pg_stat_activity。这不是理论风险,是线上真实瓶颈。
- 历史表必须在
id和valid_from上建联合索引,否则SELECT查某条记录的历史链会全表扫 - 避免在触发器里做复杂计算、调用存储过程或跨库查询——每毫秒都算在主业务延迟里
- 单次
UPDATE影响上千行?触发器会逐行执行,此时应放弃触发器,改用应用层分批INSERT INTO history SELECT ... FROM main WHERE ...显式归档 - MySQL 中慎用
OLD.col != NEW.col判变更——遇NULL恒为UNKNOWN,要用NOT (OLD.col IS NOT DISTINCT FROM NEW.col)
真正需要强版本控制的业务(如合同、医疗记录),触发器只是兜底方案;主流程应该由应用层主动管理版本号或时间戳,数据库触发器只负责补漏和审计。











