必须用AFTER行级触发器,因INSERT时OLD无效、DELETE时NEW无效,仅UPDATE两者俱全,而版本控制需完整捕获旧值与新值;TG_OP大小写敏感须全大写判断;历史表需valid_from/valid_to字段及唯一索引;禁止SELECT OLD.*插入;并发版本号冲突需应用层CAS解决。

必须用 AFTER 行级触发器,BEFORE 在 INSERT 时拿不到 OLD、DELETE 时拿不到 NEW,根本无法完成版本快照的完整捕获。
为什么只能用 AFTER FOR EACH ROW
行级版本控制的核心是“每次变更都存一份旧值 + 新值”,而 OLD 和 NEW 的可用性严格受限于操作类型:
-
INSERT:只有NEW有效,OLD是未分配状态,读取会报错record "old" is not assigned -
DELETE:只有OLD有效,NEW为空,强行引用直接崩溃 -
UPDATE:两者都可用——这正是版本衔接的关键:把OLD存为历史快照,再让NEW成为当前行
所以 BEFORE 触发器在 INSERT 和 DELETE 场景下天然缺失一端数据,无法构造完整版本链。只有 AFTER 能确保所有变更已落地,OLD/NEW 均可安全访问。
TG_OP 大小写敏感且必须显式判断
触发器函数里靠 TG_OP 区分操作类型,但它返回的是全大写字符串,任何大小写偏差都会导致逻辑跳过:
- ✅ 正确:
IF TG_OP = 'INSERT'、ELSIF TG_OP = 'UPDATE' - ❌ 错误:
IF TG_OP = 'insert'或IF TG_OP ILIKE 'update'(ILIKE不生效,TG_OP是text类型但比较不走模糊匹配) - 常见现象:历史表空空如也,查日志发现条件始终不满足——八成是写成了小写
建议统一用全大写 + 单引号包裹,并在函数开头加 RAISE NOTICE '%', TG_OP; 快速验证执行路径。
历史表结构和索引不能省
版本控制不是只把数据塞进去就完事,结构设计直接影响查询效率和数据一致性:
- 必须包含成对时间字段:
valid_from TIMESTAMP WITH TIME ZONE DEFAULT NOW()和valid_to TIMESTAMP WITH TIME ZONE;单时间戳无法表达“生效区间” - 必须建唯一索引:
CREATE UNIQUE INDEX idx_hist_id_valid_from ON user_history (id, valid_from),否则同一主键可能在相同时刻出现多条活跃记录 - 禁止用
*插入:INSERT INTO user_history SELECT OLD.*看似省事,但一旦主表加字段或历史表多出op_type字段就会失败;务必显式列出字段名 - 字段名冲突要提前规避:如果主表已有
version字段,历史表建议用plt_version,避免NEW.version := OLD.version + 1赋值时因同名字段覆盖出错
并发更新时版本号冲突必须由应用层兜底
触发器能自动递增版本号,但无法解决两个事务同时读到 OLD.version = 5 并都设 NEW.version = 6 的竞争问题:
- 数据库层面无法原子化“读旧值→算新值→写新值”三步,这是典型的 ABA 问题
- 真正可靠的方案是应用层用
UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?带条件更新,失败则重试 - 触发器只负责:插入历史快照、填充
valid_from、设置默认op_type;版本号生成逻辑应尽量移出触发器
最易被忽略的一点:触发器内写的每一条 INSERT INTO history 都在主事务中同步执行,批量更新百万行时,历史表写入会撑爆 WAL、拖慢主表响应——这不是触发器写得不对,而是它本就不适合替代异步归档。真要扛量,得切到逻辑复制或 CDC 工具。










