在订单系统中,重复提交是让人头疼的问题——同一笔订单号被插入了两次,后续对账、发货全乱套。很多团队的第一反应是加唯一索引,但业务上往往需要更人性化的报错提示,或者更复杂的去重规则(比如“同一客户24小时内不能重复”)。这时候,触发器就成了一个灵活的补充方案。不过,用触发器防重复,有不少门道和坑,绕不过去。

触发器里怎么判断订单号是否已存在
核心思路是用 BEFORE INSERT 触发器在插入前拦截,而不是等数据写进去再回滚——后者不仅浪费性能,还容易留下中间状态。常见的翻车写法是直接在触发器里 SELECT COUNT(*) 查一下表,这在低并发下看着没问题,但一上高并发,幻读就来了:两个事务同时查到不存在,然后都插进去了。唯一索引当然能兜底,但触发器的查重逻辑就失效了。
正确的做法是加锁读。不同数据库的写法略有差异:
- MySQL 8.0+ 可以用
SELECT ... FOR SHARE(在可重复读隔离级别下足够),注意别用FOR UPDATE,否则会锁住整行甚至间隙,影响写入性能。 - PostgreSQL 必须用
SELECT ... FOR NO KEY UPDATE,防止其他事务插入相同值,同时避免锁升级。 - SQL Server 推荐
IF EXISTS (SELECT 1 FROM orders WITH (UPDLOCK, HOLDLOCK) WHERE order_no = @order_no),其中的HOLDLOCK相当于SERIALIZABLE隔离级别,能有效防止幻读。
注意,这里加锁不是为了阻塞,而是为了确保“我查到的结果在提交前不会被其他事务改变”。
为什么不能只靠唯一索引而要用触发器
唯一索引确实是防重复的底线,但它抛出的数据库级错误(比如 MySQL 的 1062 Duplicate entry)太笼统了。业务层接住这个异常时,往往只能判断“哦,唯一键冲突了”,但到底冲突的是订单号还是其他唯一字段?很难区分。触发器可以抛出自定义错误信息,让业务层直接拿到明确的提示,比如 PostgreSQL 的 RAISE EXCEPTION '订单号 % 已存在', NEW.order_no,或者 MySQL 的 SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '重复订单号'。这样前端的错误提示就能直接显示“订单号重复”,而不是让用户看到一个莫名其妙的500错误。
另外,有些业务规则是唯一索引搞不定的。比如“同一客户24小时内不能提交相同订单号”——你没法在索引上写时间条件。这时候触发器里写一个带时间范围的查询,就能灵活处理。
触发器里抛错后事务怎么回滚
这一点容易误解:触发器本身不开启事务,它运行在当前 INSERT 语句的事务上下文中。只要触发器抛出异常(SIGNAL / RAISE / THROW),整个 INSERT 语句就会失败,自动回滚——前提是客户端没有手动关闭自动提交或提前 COMMIT。
- MySQL 中
SIGNAL是原子性的,不需要额外写ROLLBACK。 - PostgreSQL 中
RAISE EXCEPTION会立即终止当前函数并回滚当前语句。 - SQL Server 中
THROW同样中断执行,但要注意外层是否包裹了TRY...CATCH——如果错误被吞掉,应用层可能误以为插入成功。
一个常见的踩坑场景:在存储过程中调用插入语句,又没有检查返回状态,导致触发器报错了,但应用层以为成功了,后续逻辑全跑偏。
性能和并发下的实际限制
触发器本质是行级锁加上额外的一次查询,每插入一条记录就多一次查表操作。当订单表超过千万行、QPS 上千时,BEFORE INSERT 触发器会成为明显的瓶颈。这时候应该优先考虑:
- 把
order_no字段设为UNIQUE索引(这是底线,不能省) - 在应用层做幂等控制,比如用 Redis 记录
order_no:expire=300s,先快速拦截大部分重复请求 - 触发器只作为兜底手段,而不是主防重复的校验
另一个隐形问题:触发器无法优雅地处理批量插入(INSERT ... SELECT)中的部分重复。MySQL 5.7+ 默认行为是整批失败,而 PostgreSQL 可能只报第一个冲突——具体行为要看版本和配置,开发时务必测试清楚。
