必须用BEFORE UPDATE校验NEW.balance是否小于0,因AFTER UPDATE已写入数据无法拦截负数;MySQL 8.0.16+推荐优先使用CHECK(balance>=0),但5.7及更早版本会静默忽略。

必须用 BEFORE UPDATE,且只校验 NEW.balance 是否小于 0——其他写法都拦不住负数落地。
为什么 AFTER UPDATE 触发器完全无效
AFTER UPDATE 已经把数据写进表了。哪怕你里面写 SIGNAL SQLSTATE '45000' 抛错,NEW.balance 也早就存进去了。InnoDB 下事务可能回滚,但 MyISAM 完全不支持回滚;更麻烦的是并发场景:两个请求同时读到余额 100,都判断“扣 80 没问题”,结果先后执行,余额变成 -60。
- BEFORE UPDATE 才是唯一能卡在写入前的时机
- 触发器里不能依赖
OLD.balance - delta这种计算,因为 delta 来源不确定(可能是字段、参数、表达式),容易漏判或硬编码 - 真正要拦的,是最终要落盘的值:
NEW.balance
正确写法:只检查 NEW.balance,不碰 OLD 或计算逻辑
触发器不是用来重算余额的,那是应用层的事。它只负责“绝不让负数进库”这一条底线。
- ✅ 正确:
IF NEW.balance < 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余额不能为负'; END IF; - ❌ 错误:
IF OLD.balance - @amount < 0 THEN ...(@amount 可能未定义,且并发下 OLD 已过期) - ❌ 错误:
SET NEW.balance = OLD.balance - NEW.amount;(这会覆盖应用层传入的 NEW.balance,破坏业务语义)
MySQL 8.0+ 推荐优先用 CHECK 约束
如果只是防负数,CHECK (balance >= 0) 比触发器更轻量、性能更好、语法更直白。
- ✅ MySQL 8.0.16+ 原生支持,建表时直接加:
balance DECIMAL(12,2) CHECK (balance >= 0) - ⚠️ MySQL 5.7 及更早版本会解析但忽略 CHECK,形同虚设
- ⚠️ 即使 8.0+,LOAD DATA INFILE 加 IGNORE 时也可能绕过约束
- ⚠️ CHECK 不支持子查询、函数调用或跨表校验,复杂逻辑(如查预占额度)仍得靠触发器或应用层
并发超扣无法靠触发器解决
这是根本限制:触发器对每条语句独立生效,但两个并发 UPDATE 都能通过校验并写入,导致最终为负。
- 行级锁只在 UPDATE 语句执行期间持有,触发器不延长锁时间
- 真正防超扣,得在应用层做
SELECT balance FROM accounts WHERE id = ? FOR UPDATE,再判断、再更新 - 如果多入口写入(比如后台脚本、ETL、直连 DBA),触发器只是最后一道防线,不是保险锁
别指望触发器能替代应用层的锁和校验逻辑——它只守得住“单条语句写入时的最终值”,守不住“多个语句并发下的业务一致性”。

















