不能靠AFTER UPDATE触发器实现并发安全的余额扣减,因其不自动加锁,需配合SELECT ... FOR UPDATE显式行锁和事务隔离级别;触发器仅适合BEFORE UPDATE阶段的事后校验。

触发器里不能靠 AFTER UPDATE 做余额扣减的并发安全
直接在 AFTER UPDATE 触发器里检查余额、抛错或回滚,对并发无效。MySQL 的触发器运行在当前事务上下文中,但不会自动加锁;多个并发事务仍可能同时读到同一笔余额(比如都是 100),各自扣减后都写入 90,造成超扣。真正起作用的是事务本身的隔离级别 + 显式锁机制,触发器只是辅助校验环节。
实操建议:
- 余额表必须有主键(如
user_id),否则SELECT ... FOR UPDATE无法生效 - 所有扣减操作必须包裹在显式事务中,并使用
SELECT ... FOR UPDATE先锁定行 - 触发器只做“事后校验”,不承担锁或控制逻辑 —— 比如在
BEFORE UPDATE中检查NEW.balance 并用 <code>SIGNAL报错 - 避免在触发器里再发起新查询(如查订单状态),易引发死锁或不可预测行为
BEFORE UPDATE 触发器里用 SIGNAL 校验余额是否足够
这是最常用且安全的触发器用法:在更新前拦截非法状态。它不改变并发行为,但能防止脏数据写入。
示例:
DELIMITER $$
CREATE TRIGGER check_balance_before_update
BEFORE UPDATE ON user_account
FOR EACH ROW
BEGIN
IF NEW.balance < 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Insufficient balance';
END IF;
END$$
DELIMITER ;注意点:
-
NEW.balance是即将写入的值,不是当前值 —— 所以必须由上层事务保证它已正确计算(比如SET balance = balance - 50) -
SIGNAL会中止当前语句并回滚该语句级变更,但不会自动回滚整个事务(除非外层没捕获) - MySQL 5.5+ 支持
SIGNAL,低版本需用INSERT INTO ... SELECT引发错误来模拟
事务隔离级别选 READ COMMITTED 还是 REPEATABLE READ?
对余额场景,REPEATABLE READ(MySQL 默认)反而更危险:它允许“幻读”和“间隙锁”,但在无索引条件时可能锁住更大范围;而 READ COMMITTED 能让每次 SELECT ... FOR UPDATE 都读最新已提交值,减少误判。
关键差异:
- 在
REPEATABLE READ下,事务 A 第一次查余额是 100,中间事务 B 扣减并提交为 50,A 再次查仍是 100 —— 若 A 此时执行UPDATE ... SET balance = balance - 80,就会写出负数(触发器才拦住) - 在
READ COMMITTED下,A 每次SELECT ... FOR UPDATE都能看到 B 的提交结果,天然规避上述问题 - 但要注意:
READ COMMITTED不提供可重复读,若业务依赖多次读一致,得自行加锁或改逻辑
真正可靠的并发扣减必须靠 SELECT ... FOR UPDATE + 显式事务
触发器只是守门员,锁才是防线。以下是最小可行模式:
BEGIN;
SELECT balance FROM user_account WHERE user_id = 123 FOR UPDATE;
-- 应用层判断:if (balance >= 50) { ... }
UPDATE user_account SET balance = balance - 50 WHERE user_id = 123;
COMMIT;要点:
-
FOR UPDATE在READ COMMITTED和REPEATABLE READ下都生效,但锁范围不同:前者只锁匹配行,后者可能锁间隙 - WHERE 条件必须命中索引,否则升级为表锁 —— 查看
EXPLAIN确认type是const或ref - 不要把校验逻辑(比如查订单是否有效)放在触发器里,应在事务内、锁之后完成,否则可能因锁等待超时或死锁失败
复杂点在于:锁粒度、事务生命周期、应用重试策略 —— 这些没法靠触发器解决,得靠代码兜底。比如扣减失败后,是提示用户重试,还是自动进队列异步处理,取决于业务容忍度。


















