必须配合显式字段比对、会话变量传递、JSONB结构化存储三步,否则审计日志漏变更、全记录或查不出;因AFTER UPDATE不判断真实变更,NULL比较需IS DISTINCT FROM,业务用户须通过SET app.user_id传递,审计表用JSONB存changed_fields并建关键组合索引。

直接上结论:不能只建触发器就完事,必须配合显式字段比对、会话变量传递、JSONB结构化存储这三步,否则审计日志要么漏变更、要么全记录、要么查不出来。
为什么AFTER UPDATE触发器默认不记录真实变更
PostgreSQL的AFTER UPDATE触发器确实能捕获行更新,但它只保证你拿到OLD和NEW整行数据,不判断字段值是否真变了。比如执行UPDATE users SET email = email WHERE id = 1,哪怕email根本没变,触发器照样把旧值和新值都塞进日志——结果就是日志膨胀、误报、查询卡顿。
关键点在于:!=无法安全比较NULL,必须用IS DISTINCT FROM逐字段判断。
- 错误写法:
IF OLD.email != NEW.email THEN ...(NULL != NULL 返回NULL,条件不成立) - 正确写法:
IF OLD.email IS DISTINCT FROM NEW.email THEN ...(NULL和NULL判为相等,非NULL差异才触发) - 别图省事用
row_to_json(OLD) != row_to_json(NEW)——JSON键序不确定,且性能差,大字段序列化开销高
如何安全获取业务用户而非数据库角色
current_user返回的是数据库登录角色,不是业务系统里的user_id;inet_client_addr()在pgbouncer后拿不到真实IP。硬编码会导致审计信息失真,合规过不了。
正确做法是应用层主动设置会话变量,触发器函数里读取:
- 应用连接池建立后立即执行:
SET app.user_id = '10086'; SET app.client_ip = '2001:db8::1'; - 触发器函数中用
current_setting('app.user_id', true)读取(第二个参数true允许返回NULL,避免报错) - 别用
session_user或USER()——前者不可控,后者在PL/pgSQL里语法非法
审计表结构怎么设计才扛得住查询压力
字段堆砌越多,INSERT越慢,索引越难建;字段太少又没法按“改了哪列”“谁改的”“什么时候改的”过滤。核心矛盾是写入轻量和查询灵活之间的平衡。
推荐结构(已在线上千万级日志验证):
- 必存字段:
audit_id(SERIAL),table_name(TEXT),row_pk(JSONB, 存主键如{"id": 123}或{"order_id": "O2026", "line_no": 1}) - 变更详情统一存
changed_fields(JSONB),键为字段名,值为{"old": "...", "new": "..."},避免宽表和空值污染 - 索引重点建三个:
(table_name, operation, created_at)(业务常用组合)、created_at(时间范围扫描)、row_pk(GIN,支持row_pk @> '{"id": 123}'快速定位) - 别把
changed_by设为TEXT DEFAULT current_user——这是静态绑定,拿不到业务用户,应从current_setting()动态取
触发器函数里最容易被忽略的性能陷阱
很多人写完逻辑就上线,结果高峰期事务延迟翻倍。问题往往出在触发器函数内部的隐式开销:
- 禁止在触发器里调用
SELECT查其他表——会加锁、阻塞、拖慢主事务;审计写入必须是单条INSERT,别搞子查询 - 避免
RAISE NOTICE或pg_sleep()调试残留——线上环境会卡住整个事务 - 大表+高频UPDATE慎用行级触发器,先用
pg_stat_statements看触发器函数平均耗时,超过5ms就得优化或切异步 - 别在
BEFORE触发器里修改NEW.*后再做复杂计算——它会干扰原SQL语义,尤其涉及约束或索引更新时
最常被跳过的一步:压测前没关掉log_statement = 'all',导致日志刷盘成为瓶颈,误以为是触发器本身慢。

















