适合放入before触发器的复合校验包括:订单总金额是否等于子项sum(price*qty)、用户状态是否为active、时间区间是否重叠(配合gist索引)、状态机流转是否合法;须单次查询、索引支撑、轻量无副作用。

不能直接在触发器里写复杂 JOIN 或调外部服务——校验逻辑必须轻量、快、无副作用,否则高并发下容易锁表或拖垮事务。
哪些复合校验适合放进 BEFORE 触发器
真正能放进去的,是那些单次查询就能完成、且有索引支撑的检查:
- 订单总金额是否等于子项 sum(price * qty):只要
order_items.order_id有索引,SELECT SUM()就够快 - 用户状态是否为 active:查
users(id, is_active),主键索引秒出结果 - 时间区间是否重叠:用
NOT EXISTS (SELECT 1 FROM events WHERE ... AND tsrange(start, end) && tsrange(NEW.start, NEW.end)),配合GiST索引 - 状态机流转是否合法:比如从
'draft'→'paid'允许,但'shipped'→'draft'不允许,用CASE WHEN TG_OP = 'UPDATE' THEN ... END判断
为什么不能在触发器里做跨表大查询或发 HTTP 请求
触发器运行在原始 DML 的同一事务中,所有操作共享锁和上下文:
- 如果触发器里
SELECT ... FROM huge_logs WHERE tenant_id = NEW.tenant_id没走索引,会全表扫描并持锁,阻塞其他租户写入 - 调用
curl或pg_notify外部通知是可行的,但http_get()(需http扩展)会同步等待响应,超时就卡住整个事务 - 写日志表本身没问题,但如果日志表没分区、没清理策略,
AFTER INSERT日志堆积会拖慢VACUUM - 最隐蔽的坑:触发器函数里用了游标或临时表,而这是
FOR EACH ROW触发器——1000 行插入就会执行 1000 次初始化,CPU 和内存直接翻倍
如何安全访问 NEW/OLD 字段避免 record "old" has no field "xxx"
这个错误不是语法问题,是运行时访问了不存在的记录变量。PostgreSQL 不自动兜底,必须显式判断事件类型:
-
INSERT:只有NEW可用,OLD是NULL;写OLD.status必报错 -
DELETE:只有OLD可用,NEW是NULL;写NEW.id同样报错 -
UPDATE:两者都可用,但字段可能未变(比如只改了updated_at),所以别依赖COALESCE(NEW.status, OLD.status),除非语义明确 - 正确写法是用
TG_OP包裹:IF TG_OP = 'UPDATE' THEN IF OLD.status = 'paid' AND NEW.status != 'shipped' THEN ... END IF; END IF;
租户 ID 校验必须用 IS DISTINCT FROM,不能用 !=
这是多租户系统里最容易翻车的一点:字段或会话值为 NULL 时,!= 返回 NULL(即 false),导致校验失效:
- 错误写法:
IF NEW.tenant_id != current_setting('app.tenant_id', true) THEN ...—— 当任一为NULL,条件不成立,跳过校验 - 正确写法:
IF NEW.tenant_id IS DISTINCT FROM current_setting('app.tenant_id', true) THEN ... - 注意:如果字段是
UUID类型,而current_setting()返回TEXT,得显式转换:NEW.tenant_id IS DISTINCT FROM current_setting('app.tenant_id', true)::UUID - DELETE 也一样要校验:
OLD.tenant_id IS DISTINCT FROM current_setting('app.tenant_id', true),否则攻击者可删其他租户数据
最常被忽略的是连接池配置——必须用会话池(session-level pooling),不能用事务池(transaction-level)。否则 SET app.tenant_id = 't123' 在事务结束就被重置,下个请求拿到的是空值或上个租户的残留值。










