业务规则只放在应用层,会留下一个明显缺口:只要有人绕过服务代码,用数据库客户端、迁移脚本或定时任务直接写入数据,金额可能变成负数,状态可能出现非法值。SQL的Before Insert触发器可以在INSERT写入表之前自动拦截每一行数据,检查字段值、关联表状态,发现问题就抛错并终止插入。它相当于给数据库加了一道兜底校验,下面从执行时机、典型场景、多数据库语法差异和局限性几个方面展开。

一、Before Insert触发器的执行时机和语法骨架
在行级触发器里,Before Insert会在INSERT语句真正把数据写入数据页之前触发,并且对语句影响的每一行都执行一次。此时NEW代表即将插入的那一行数据,可以读取其中字段,也可以在写入前修改字段值。如果触发器内部抛出错误,整条INSERT语句会回滚,不会留下半截数据。不同数据库对行引用的写法略有差异:MySQL和PostgreSQL使用NEW,Oracle使用:NEW,SQL Server则没有Before Insert,需要改用INSTEAD OF INSERT实现类似效果。
以MySQL为例,创建一个订单表,并挂一个Before Insert触发器做基础校验。金额不能为空或为负数,状态必须是预设的枚举值之一。这里使用SIGNAL SQLSTATE '45000'主动抛出用户自定义错误,切断本次插入操作。
DELIMITER $$
CREATE TRIGGER trg_order_bi
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
IF NEW.amount IS NULL OR NEW.amount < 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '订单金额不能为空或负数';
END IF;
IF NEW.status NOT IN ('PENDING', 'CONFIRMED', 'CANCELLED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '非法的订单状态';
END IF;
END$$
DELIMITER ;
上面代码里的DELIMITER只是MySQL客户端工具为了区分语句结束符而设置的,并不是数据库服务端语法。实际保存到数据库时,触发器体内部可以包含多条判断语句,也可以调用存储过程,适合承载略复杂的校验逻辑。不同数据库抛错方式不同:PostgreSQL在触发器函数里使用RAISE EXCEPTION,并且必须返回NEW;如果返回NULL,则表示跳过插入。下面是PostgreSQL的等价写法,可以看出结构类似,但语法有差异。
CREATE OR REPLACE FUNCTION trg_order_bi_fn()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.amount IS NULL OR NEW.amount < 0 THEN
RAISE EXCEPTION '订单金额不能为空或负数';
END IF;
IF NEW.status NOT IN ('PENDING', 'CONFIRMED', 'CANCELLED') THEN
RAISE EXCEPTION '非法的订单状态: %', NEW.status;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_order_bi
BEFORE INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION trg_order_bi_fn();
二、用触发器实现三类典型业务校验
第一类是字段值域校验。除了金额非负、状态枚举之外,还常见于折扣率、日期范围、评分范围等场景。比如优惠券的折扣率必须落在0到1之间,结束时间必须晚于开始时间。应用层当然会做校验,但数据库侧再拦一次可以防止绕过应用的写入路径带来的脏数据。下面这个触发器同时检查两个字段,任何一项不满足就报错。
DELIMITER $$
CREATE TRIGGER trg_coupon_bi
BEFORE INSERT ON coupon
FOR EACH ROW
BEGIN
IF NEW.discount < 0 OR NEW.discount > 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '折扣率必须在0到1之间';
END IF;
IF NEW.end_time IS NOT NULL AND NEW.start_time IS NOT NULL
AND NEW.end_time <= NEW.start_time THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '结束时间必须晚于开始时间';
END IF;
END$$
DELIMITER ;
第二类是跨表校验。典型需求是插入订单明细时,先确认商品是否存在,再确认库存是否充足。触发器里可以查询其他表,也可以使用FOR UPDATE锁住商品行,降低并发超卖风险。不过需要注意,如果多个触发器或事务同时锁定多张表,可能出现锁等待甚至死锁,因此跨表查询要尽量简单,只做必要检查。
DELIMITER $$
CREATE TRIGGER trg_order_item_bi
BEFORE INSERT ON order_item
FOR EACH ROW
BEGIN
DECLARE v_stock INT DEFAULT 0;
SELECT stock INTO v_stock
FROM product
WHERE id = NEW.product_id
FOR UPDATE;
IF v_stock IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '商品不存在';
END IF;
IF v_stock < NEW.quantity THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足';
END IF;
END$$
DELIMITER ;
第三类是状态流转约束。比如新插入的订单不能直接是已取消状态,或者某些业务对象只能以草稿状态创建。Before Insert触发器适合校验插入行的属性和关联数据,但如果要判断旧状态到新状态的流转是否合法,比如已取消订单不能再次激活,则通常需要Before Update触发器,因为插入时并不存在旧行版本。所以设计触发器时要先清楚变更类型,避免把更新场景误写成插入校验。
三、多数据库语法差异与选择策略
SQL Server比较特殊,它没有标准的Before Insert触发器,只有INSTEAD OF INSERT触发器。这种触发器会完全替换原始的插入动作,也就是说数据不会自动进入目标表,必须在触发器内部手动执行INSERT。这给校验带来了灵活性,但也要求开发人员自己处理所有字段映射、默认值、自增列等细节。下面是一个用INSTEAD OF INSERT模拟Before Insert校验的示例。
CREATE TRIGGER trg_order_bi
ON orders
INSTEAD OF INSERT
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (SELECT 1 FROM inserted WHERE amount IS NULL OR amount < 0)
BEGIN
RAISERROR('订单金额不能为空或负数', 16, 1);
ROLLBACK TRANSACTION;
RETURN;
END
INSERT INTO orders (customer_id, amount, status, created_at)
SELECT customer_id, amount, status, created_at FROM inserted;
END;
GO
Oracle则是原生支持Before Insert,行引用写作:NEW,抛错使用RAISE_APPLICATION_ERROR,错误号一般使用-20000到-20999之间的自定义范围。Oracle触发器里的判断逻辑和MySQL、PostgreSQL类似,但要注意:NEW前的冒号不能省略。
CREATE OR REPLACE TRIGGER trg_order_bi
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
IF :NEW.amount IS NULL OR :NEW.amount < 0 THEN
RAISE_APPLICATION_ERROR(-20001, '订单金额不能为空或负数');
END IF;
END;
/
不同数据库的差异可以汇总如下,方便选型时快速对照。
| 数据库 | 是否支持Before Insert | 行引用 | 抛错方式 |
|---|---|---|---|
| MySQL | 支持 | NEW | SIGNAL SQLSTATE |
| PostgreSQL | 支持 | NEW | RAISE EXCEPTION |
| Oracle | 支持 | :NEW | RAISE_APPLICATION_ERROR |
| SQL Server | 不支持,用INSTEAD OF | inserted伪表 | RAISERROR或THROW |
选择策略上,如果团队主要使用MySQL或PostgreSQL,Before Insert触发器是直接可用的方案;如果是SQL Server,则要考虑INSTEAD OF触发器对原始插入的完全接管是否会影响现有逻辑;如果是Oracle,要注意自定义错误号不要与系统预定义错误冲突。跨数据库迁移时,触发器语法通常需要重写,不适合直接照搬。
四、触发器校验的局限、维护与替代思路
触发器最大的问题是隐蔽性。它藏在数据库对象里,排查插入失败时,很多开发人员第一反应是检查应用日志,而不是查看表上的触发器。大量触发器也会增加写入路径上的开销,尤其是触发器内部再查询其他表、加锁时,可能拖慢并发写入性能。调试时可以在触发器里统一使用错误码前缀,或者把校验失败信息写入专门的日志表,方便定位。
并非所有业务规则都适合下沉到数据库。涉及外部接口调用、异步消息、缓存更新等逻辑,放在触发器里会导致事务时间变长,甚至出现分布式事务问题。更合理的做法是:应用层负责友好提示和复杂流程编排,数据库触发器只做最后一道硬校验。也就是说,应用层校验给用户看,数据库校验保护数据底线。
测试触发器时,不能只通过正常业务接口验证,还要直接用数据库客户端执行非法INSERT,确认会被拦截。可以准备一批非法数据脚本,比如负数金额、超范围折扣、不存在的商品ID,逐条执行后检查错误信息和表中是否有残留数据。对于规则特别多的系统,建议把校验逻辑封装成存储过程或函数,让触发器只负责调用,这样能减少重复代码,也便于单独维护和测试。

