在业务系统中,有些规则必须与写入动作同时成立:订单状态变化时记录变更历史,敏感字段更新时保存审计信息,库存扣减时阻止负数,或者在删除主数据前清理关联记录。把这些逻辑全部放在应用层,容易出现多个服务实现不一致、批处理绕过校验、异常重试产生重复记录等问题。
数据库触发器提供了另一种边界:当指定表发生 INSERT、UPDATE 或 DELETE 时,由数据库在同一事务中自动执行一段函数。它不能替代领域服务,但可以把“无论谁写入数据库都必须满足”的规则固定在数据层。
本文使用 PostgreSQL 语法示例。其他数据库通常也支持触发器,但函数语法、行级变量和事务行为存在差异,不能直接照搬。
运行原理
触发器由两部分组成:触发器函数和触发器定义。
触发器函数描述发生事件后执行什么逻辑;触发器定义则描述在哪张表、哪些操作、什么时机触发它。常见时机有两种:
BEFORE:在行真正写入前执行,适合修正字段或拒绝非法数据。AFTER:在行写入成功后执行,适合记录审计或发送同事务内的后续变更。
行级触发器使用 FOR EACH ROW,函数可以访问 NEW 和 OLD。对于插入,通常只有 NEW 有效;对于删除,通常只有 OLD 有效;更新操作同时拥有两者。
触发器执行失败时,当前 SQL 通常失败,所在事务也会受到影响。这个特性保证了数据与审计记录可以保持一致,但也意味着触发器中的慢查询、锁等待和异常会直接增加业务写入的风险。
第一步:建立示例表
先创建一张业务表和一张审计表。审计表只保存必要字段,避免把完整业务对象无约束地复制一份。
CREATE TABLE account_profile (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
risk_level SMALLINT NOT NULL DEFAULT 0 CHECK (risk_level BETWEEN 0 AND 3),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE account_audit_log (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
old_risk SMALLINT,
new_risk SMALLINT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
changed_by TEXT
);
CHECK 约束负责最基础的数据合法性。不要把所有校验都塞进触发器,能够用声明式约束表达的规则,应优先使用约束,因为约束更容易被工具识别,也更便于维护。
第二步:维护更新时间
更新时间属于行本身的派生字段,可以使用 BEFORE UPDATE 触发器统一维护。函数只修改 NEW,最后返回它。
CREATE OR REPLACE FUNCTION set_account_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS {mathJaxContainer[0]};
CREATE TRIGGER trg_account_updated_at
BEFORE UPDATE ON account_profile
FOR EACH ROW
EXECUTE FUNCTION set_account_updated_at();
这里使用 clock_timestamp() 表示函数执行时的实际时间。如果系统要求同一事务内所有时间保持一致,可以改用 CURRENT_TIMESTAMP。两者语义不同,选择应写进项目约定,不要只凭习惯决定。
第三步:记录重要变更
审计触发器不应记录每一个无意义的更新。下面的实现只在风险等级发生变化时写入一条更新日志,同时记录插入和删除事件。
CREATE OR REPLACE FUNCTION audit_account_change()
RETURNS trigger
LANGUAGE plpgsql
AS {mathJaxContainer[1]};
CREATE TRIGGER trg_account_audit
AFTER INSERT OR UPDATE OR DELETE ON account_profile
FOR EACH ROW
EXECUTE FUNCTION audit_account_change();
IS DISTINCT FROM 能正确处理 NULL,比使用 <> 更适合比较可能为空的字段。示例中的操作者名称由数据库会话参数传入,应用在开启事务后可以执行:
SELECT set_config('app.operator', 'user-1001', true);
第三个参数为 true,表示该设置只在当前事务有效,适合连接池环境。应用必须在每次事务中重新设置,不能依赖连接上一次残留的值。
第四步:验证事务边界
先执行一组正常操作:
BEGIN;
SELECT set_config('app.operator', 'user-1001', true);
INSERT INTO account_profile (account_id, display_name, risk_level)
VALUES (9001, '示例账户', 1);
UPDATE account_profile
SET display_name = '名称变化'
WHERE account_id = 9001;
UPDATE account_profile
SET risk_level = 2
WHERE account_id = 9001;
COMMIT;
查询审计记录:
SELECT account_id, action, old_risk, new_risk, changed_by, changed_at
FROM account_audit_log
WHERE account_id = 9001
ORDER BY id;
预期是插入产生一条记录,单独修改名称不会产生风险变更记录,修改风险等级再产生一条记录。这里的“预期”是基于上述函数逻辑推导出的结果,并不代表所有数据库或配置环境都具有相同表现。
再验证回滚:
BEGIN;
SELECT set_config('app.operator', 'user-1002', true);
UPDATE account_profile SET risk_level = 3 WHERE account_id = 9001;
ROLLBACK;
回滚后,业务表的风险等级和本次触发器写入的审计记录都不应保留,因为它们属于同一个事务。若审计要求即使业务事务回滚也必须留痕,普通同事务触发器无法满足这一要求,需要使用独立日志通道,并重新评估可靠性、顺序和隐私风险。
迁移与上线建议
触发器属于数据库结构,应通过版本化迁移管理,而不是手工在生产库执行。推荐把表、函数、触发器拆成有顺序的迁移文件,并为删除和重建操作提供明确脚本。
上线前至少检查以下内容:
- 函数是否具备最小必要权限,避免使用不必要的高权限定义方式。
- 触发器是否覆盖了批量写入、后台任务和人工维护脚本。
- 审计表是否有按业务主键和时间查询所需的索引。
- 触发器函数是否执行外部网络请求、复杂聚合或不可控的递归操作。
- 失败时应用是否能识别数据库异常,并正确回滚或重试。
- 是否制定审计表的保留周期、归档策略和访问权限。
对于高写入量表,审计记录会放大写入量。可以按时间分区、异步归档,或只记录法规和运维真正需要的字段。优化前应先通过日志和监控确认瓶颈,不能仅凭表面感觉调整触发器。
常见问题
触发器会不会导致递归?
会。触发器函数如果更新了自身所在的表,可能再次触发同一规则。可以通过改变数据模型、把派生字段放到 BEFORE 阶段,或使用明确的递归保护降低风险。保护逻辑必须经过并发测试,不能把全局开关当作通用解决方案。
为什么应用更新成功,但审计没有记录?
先确认触发器是否存在且启用,再检查条件判断是否过滤了该更新。还要确认查询是否连接到了正确的数据库和 schema。若审计写入失败,通常会使原业务语句失败;如果业务成功而审计缺失,可能说明审计并非由该触发器完成,或查询使用了错误的时间、主键和连接。
触发器能代替应用层校验吗?
不能完全代替。数据库适合保证数据完整性和跨入口一致性;复杂流程、权限决策、调用外部系统和用户提示仍应由应用层负责。两层都需要校验时,应让数据库承担不可绕过的底线规则,避免两处规则长期分叉。
什么时候不该使用触发器?
当逻辑涉及长耗时计算、跨系统调用、难以解释的隐式状态变化,或者团队没有成熟的数据库迁移和观测机制时,应谨慎使用。此时可以采用显式领域服务、事务消息或变更数据捕获,并保留清晰的执行记录。
总结
触发器的价值不在于减少几行应用代码,而在于把数据一致性规则放到所有写入入口都能经过的事务边界上。工程化使用时,应优先采用声明式约束,其次使用短小、可解释的触发器函数,并通过版本化迁移、审计字段、事务回滚测试和性能观测控制风险。
一个可维护的设计通常具备三个特征:触发条件明确,副作用范围有限,失败行为可预测。只要能回答“谁触发、写了什么、失败后怎么办、如何查询和归档”,数据库自动化规则才真正具备运维价值。