触发器不适合做库存校验,因其无法解决并发问题;正确做法是用原子update语句(如update products set stock = stock - 1 where id = ? and stock >= 1),配合row_count()判断结果,并确保id有唯一索引。

触发器里别做库存校验,它扛不住并发
触发器本身不解决并发问题。你在 BEFORE INSERT 里写 SELECT stock FROM products WHERE id = NEW.product_id 再判断是否够用,两个请求几乎同时执行,都会查到 stock = 10,然后一起扣减,最终变成 -1。这不是逻辑错,是数据库并发模型决定的——触发器没自动加锁。
常见错误现象:SIGNAL SQLSTATE '45000' 挡不住超卖;日志显示“库存充足但订单失败”;stock 字段出现负数却没报错。
- 根本原因:纯 SELECT 不会加锁,除非显式写
SELECT ... FOR UPDATE - 但触发器里用
FOR UPDATE风险高:锁住行后,如果事务后续操作慢(比如调外部接口、写大量日志),会拖垮整个表的 QPS - MySQL 默认隔离级别
REPEATABLE READ下,FOR UPDATE还会带间隙锁,容易引发死锁(尤其在秒杀场景下多个请求争抢同一商品)
高并发下该用 UPDATE WHERE 原子语句,不是触发器
真正扛得住并发的方案,是把“检查 + 扣减”压进一条 SQL:UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1。这条语句由 InnoDB 行锁保障原子性,不用触发器、不依赖事务包裹、也不需要额外 SELECT。
使用场景:下单、发券、预算扣减等所有“先查再改”的强一致场景。
-
WHERE stock >= 1是关键,缺了它,扣成负数也不会拦截 - 执行后必须检查
ROW_COUNT():返回 0 表示库存不足或已被抢完,不是 SQL 报错,应用层要主动判断 - 务必确保
id是主键或有唯一索引,否则可能升级为表锁 - 别用
UNSIGNED INT存 stock —— 超扣会溢出成极大正数,WHERE stock >= 1依然成立,彻底失控
触发器只适合兜底和审计,不是主流程
触发器真正的价值不在扣库存,而在事后补动作:比如订单插入成功后,自动记资金流水;库存更新后,写入 stock_reject_log 表记录拒绝原因;或者用户余额变更时,强制同步生成审计日志。
为什么不能当主流程用:
- 触发器无法控制事务边界 —— 它属于外层事务,一旦主 SQL 失败回滚,触发器逻辑也跟着回滚,没法单独提交日志
- 嵌套调用存储过程会放大延迟,且 MySQL 对触发器嵌套深度有限制(默认 15 层),容易触发
ER_TOO_MANY_DELAYED_THREADS - 调试困难:错误堆栈不包含触发器内具体哪一行出问题,
SIGNAL抛出的SQLSTATE如果漏写(比如只写SIGNAL SQLSTATE 'HY000'),客户端看到的就是模糊的 “Unknown error”
真要用触发器扣库存,必须配 SELECT FOR UPDATE + 显式事务
如果业务实在绕不开触发器(比如遗留系统强耦合),那唯一安全做法是在触发器开头就加锁:SELECT stock FROM products WHERE id = NEW.product_id FOR UPDATE,且整条触发器逻辑必须落在一个已开启的事务中。
注意点:
-
FOR UPDATE必须和后续的UPDATE在同一个事务里,否则锁立刻释放 - 事务粒度要极小:只锁商品行、不查无关表、不写文件日志、不调 RPC 接口
- 隔离级别至少设为
READ COMMITTED,避免REPEATABLE READ下的间隙锁放大死锁概率 - 别指望靠触发器实现“预占库存”——它没法返回扣减前的值,也没法配合 Redis 分布式锁做前置拦截
复杂点在于:你得确保所有走这个路径的请求,都从应用层显式开启事务并提交,否则锁行为不可控。而这点,在微服务或 ORM 场景下极易遗漏。










