必须用after行级触发器,因before在insert时old为空、delete时new为空;需在函数中按操作类型分别处理:insert记new、update记old(设valid_to)+ new(valid_to为null)、delete记old并标op_type='d',历史表须含valid_from/valid_to成对字段并建唯一索引。

触发器函数必须用 AFTER INSERT OR UPDATE OR DELETE
行级历史留存的核心是捕获变更瞬间的旧值/新值,所以不能用 BEFORE 触发器——它拿不到 OLD 在 INSERT 时为空、NEW 在 DELETE 时为空的问题。必须用 AFTER,并在函数里显式判断操作类型:INSERT 记新值,UPDATE 记旧值(历史)+ 新值(当前),DELETE 记旧值。
-
INSERT:只写入NEW.*到历史表,同时加op_type = 'I'和valid_to = NULL -
UPDATE:先将OLD.*插入历史表并设valid_to = CURRENT_TIMESTAMP,再更新主表(或由业务逻辑保证主表已是新值) -
DELETE:插入OLD.*并设op_type = 'D',valid_to = CURRENT_TIMESTAMP
历史表结构要包含 valid_from 和 valid_to 时间字段
只靠单个时间戳无法表达“某条记录在什么时间段内有效”,必须成对使用。典型设计是:valid_from 为 TIMESTAMP WITH TIME ZONE DEFAULT NOW(),valid_to 为 TIMESTAMP WITH TIME ZONE 允许为空。每次更新主表时,触发器把原记录的 valid_to 设为当前时间,再插入一条新记录且 valid_to = NULL。
- 不要用
created_at替代valid_from—— 它不反映逻辑生效时间 - 避免用
BOOLEAN is_current字段,会导致并发更新时状态竞争 - 给
(id, valid_from)加唯一索引,防止同一主键在相同时刻出现多条活跃记录
触发器函数里慎用 EXECUTE 动态 SQL
动态拼接表名或列名容易引入 SQL 注入,也难调试。PostgreSQL 触发器函数中,OLD 和 NEW 是已知结构的记录,应直接引用字段,比如 NEW.id、OLD.name。只有当确实需要跨多个相似结构的历史表(如分表场景)才考虑 EXECUTE,此时必须用 format() + %I 占位符转义标识符。
- 错误写法:
EXECUTE 'INSERT INTO hist_' || TG_TABLE_NAME || ' VALUES (' || NEW.id || ')' - 正确写法:
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'); - 如果历史表字段和主表不完全一致(比如多了
op_type),就老老实实列全字段,别图省事用*
注意事务边界与 pg_stat_activity 中的长时间运行风险
触发器代码在主 DML 事务内执行,一旦历史表写入量大(比如批量 UPDATE 百万行),会拖慢主事务、撑高 WAL 日志、甚至阻塞 vacuum。生产环境务必限制单次操作影响行数,或改用异步方案(如 LISTEN/NOTIFY + 后台 worker)。
- 测试阶段可用
EXPLAIN ANALYZE看触发器内 INSERT 的执行计划,确认是否命中索引 - 监控
pg_stat_all_tables中历史表的n_tup_ins增长速率,突增可能意味着误触发 - 不要在触发器里调用外部 HTTP 请求或写文件 —— 事务回滚时这些副作用无法撤销
UPDATE t SET a = ? WHERE id = ?)时,如何让历史记录只体现被改的字段?PostgreSQL 触发器拿不到“哪些列被 SET”,只能看到完整 OLD 和 NEW。这意味着你得自己比对字段值,或者接受全量快照——后者更简单也更常见。











