行级触发器是版本管理的必要前提,因仅它能通过tg_op精准识别操作类型,并结合new/old获取单行变更细节;语句级触发器无法访问new/old,故不能实现逐行快照或版本号递增。

行级触发器 + TG_OP 是实现版本管理最直接、最可控的方式,但必须配合 NEW/OLD 使用,且不能在语句级触发器里读取 TG_OP 以外的行数据上下文。
为什么必须用行级触发器而不是语句级
版本管理本质是为每一行变更生成快照或标记版本号,操作粒度天然绑定到单行。语句级触发器无法访问 NEW 或 OLD,也就无法知道“哪一行变了、变成什么样”。哪怕一条 UPDATE 影响 10 万行,你也得对每行单独存档或打标。
- 语句级触发器中
TG_OP虽然可用(值为'INSERT'、'UPDATE'等),但NEW和OLD均为NULL,拿不到具体数据 - 行级触发器中
TG_OP值稳定可靠,且NEW(INSERT/UPDATE)、OLD(UPDATE/DELETE)自动绑定当前行上下文 - TRUNCATE 不支持行级触发器,所以版本表无法靠它自动归档——这点常被忽略
TG_OP 在行级触发器中的实际取值与行为
TG_OP 是只读字符串变量,由 PostgreSQL 自动注入触发器函数作用域,值取决于触发该次调用的 SQL 动作。它不反映“整条语句意图”,而精确对应“当前这一行正在经历什么”。
-
'INSERT':仅存在NEW,OLD为NULL -
'UPDATE':NEW和OLD都非空,可逐字段比对变化(如NEW.email != OLD.email) -
'DELETE':仅存在OLD,NEW为NULL -
'TRUNCATE':不会触发行级触发器,所以你在行级函数里永远收不到这个值
注意:TG_OP 大小写敏感,值恒为全大写字符串,不要写成 'insert' 或 'Insert'。
典型版本管理逻辑怎么写(带条件判断)
常见需求是:INSERT 新增版本号 1;UPDATE 时复制旧行并递增版本号;DELETE 时保留最后快照并标记为已删除。这些都依赖 TG_OP 分支 + NEW/OLD 操作。
CREATE OR REPLACE FUNCTION versioning_trigger()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.version := 1;
NEW.created_at := NOW();
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
-- 插入旧版本快照
INSERT INTO users_history SELECT OLD.*;
-- 更新当前行版本号
NEW.version := OLD.version + 1;
NEW.updated_at := NOW();
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
-- 存档并返回 NULL 阻止原 DELETE(若需软删)
INSERT INTO users_history SELECT OLD.*, 'deleted'::text AS status;
RETURN NULL;
END IF;
RETURN NULL; -- 安全兜底,尤其用于 BEFORE 触发器
END;
$$ LANGUAGE plpgsql;
- 必须用
BEFORE行级触发器才能修改NEW字段(如version)并影响最终插入/更新结果 - 若用
AFTER,NEW已写入,只能做日志或异步动作,无法改当前行 -
RETURN NULL在BEFORE中会取消本次行操作(适合软删),在AFTER中会被忽略
容易踩的坑:TG_OP 不是万能开关
TG_OP 只告诉你“发生了什么动作”,不告诉你“动作是否真正生效”。比如一个 UPDATE 语句 WHERE 条件没命中任何行,行级触发器根本不会执行——TG_OP 连露面机会都没有。
- 别假设
TG_OP = 'UPDATE'就一定有数据变动:可能NEW和OLD完全一致,纯属无效更新 - 想跳过无意义更新?得手动比较字段,例如
IF NEW.name IS DISTINCT FROM OLD.name THEN ... - 多个触发器共存时,
TG_OP值不变,但前一个BEFORE触发器若返回NULL,后续同级触发器和实际 DML 都不会执行 - 视图上的
INSTEAD OF触发器也提供TG_OP,但它的NEW/OLD含义取决于视图定义,不是底层表原始值
真正难的从来不是判断操作类型,而是决定在哪一层(BEFORE/AFTER)、以什么方式(复制/覆盖/阻断)去响应那一行的真实变化。把 TG_OP 当开关用很简单,让它精准驱动版本逻辑,才需要反复验证边界。











