before insert触发器中必须用select ... for update加行锁防止超卖,因并发读-改-写会导致库存重复扣减;for update需在if校验前执行且id须为主键或有索引;禁用after insert更新同表;signal须指定sqlstate如'45001';unique索引仍是防重第一道防线。

BEFORE INSERT 触发器里必须加 SELECT ... FOR UPDATE
库存重复扣减本质是并发读-改-写(read-modify-write)竞争,只靠 SELECT stock FROM products WHERE id = NEW.product_id 再 UPDATE 无法锁定行。两个事务查到同一库存值后都通过校验,接着同时执行扣减,结果超卖。
正确做法是在触发器开头就用行锁抢占资源:
BEGIN SELECT stock FROM products WHERE id = NEW.product_id FOR UPDATE; IF (SELECT stock FROM products WHERE id = NEW.product_id)
-
FOR UPDATE必须和后续UPDATE在同一个事务内——触发器天然属于主事务,这点 MySQL 保证了 - 不能把
SELECT ... FOR UPDATE放在IF判断之后,否则校验前已失去锁保护 - 若表无主键或索引,
FOR UPDATE会升级为表锁,吞吐骤降,务必确认id是主键或有索引
为什么不能用 AFTER INSERT + UPDATE 同一张表
常见错误是想“先插入订单,再更新库存”,于是在订单表上建 AFTER INSERT 触发器,里面写 UPDATE products SET stock = stock - ...。这直接违反 MySQL 触发器限制:禁止在触发器中修改被触发的同一张表。
执行时会报错:Can't update table 'products' in stored function/trigger because it is already used by statement which invoked this stored function/trigger。
- 哪怕你换名 alias、用子查询绕开,MySQL 8.0+ 仍会检测逻辑依赖并拒绝
- 想“先插后扣”,只能把扣减逻辑移到应用层或用事件调度器,但丧失原子性
-
BEFORE INSERT是唯一能安全联动库存扣减的时机,且不违反约束
SIGNAL 抛错要带明确 SQLSTATE,别只靠 MESSAGE_TEXT
用 SIGNAL SQLSTATE 'HY000' 或裸 SIGNAL 不够。客户端捕获异常时,仅靠错误消息文本难以做精细化处理(比如前端区分“库存不足”和“数据库连接失败”)。
必须使用自定义 SQLSTATE,如 '45000'(通用业务错误)或更细粒度的 '45001'(库存类)、'45002'(价格类):
SIGNAL SQLSTATE '45001'
SET MESSAGE_TEXT = CONCAT('Stock shortage for product_id=', NEW.product_id);
-
SQLSTATE前两位表示类别(45是用户定义异常),后三位可自定义,比字符串匹配稳定得多 - Go 的
database/sql可用err.(*mysql.MySQLError).SQLState提取;Python PyMySQL 用err.sqlstate - 漏写
SQLSTATE会导致错误归类到HY000,和底层系统错误混在一起,排查成本陡增
触发器不是防重银弹,UNIQUE 索引仍是第一道防线
有人试图用触发器拦截“同一用户秒内重复下单”,在 BEFORE INSERT 里查 orders 表最近记录。这看似可行,但高并发下极易死锁或性能崩塌。
真正可靠的方案是分层防御:
- 底层:用
UNIQUE INDEX强制唯一,例如CREATE UNIQUE INDEX uk_user_order_day ON orders (user_id, DATE(created_at))(MySQL 8.0+ 支持函数索引) - 中间:应用层捕获
1062 Duplicate entry错误,做指数退避重试 - 上层:触发器只干轻量活,比如写入
stock_reject_log表记录拒绝原因,不参与决策
把所有校验逻辑塞进触发器,等于让数据库承担本该由索引和应用分担的压力——锁住的不只是库存行,还有整个事务生命周期里的所有关联资源。











