直接UPDATE余额存在并发覆盖风险,必须用SELECT FOR UPDATE加行锁并置于事务中;更安全的做法是弃用余额字段,改用交易流水表+balance_after实现可追溯、防重、原子更新。

UPDATE 语句不能直接用于余额安全更新
直接写 UPDATE accounts SET balance = balance - 100 WHERE user_id = 123 看似简单,但在并发场景下会出错:两个请求同时读到 balance=500,各自减100后都写入400,实际应为300。这不是语法错误,而是逻辑漏洞。MySQL 不保证 SET balance = balance - X 的原子性跨请求——它只对单次执行原子,不防并发覆盖。
必须用事务 + SELECT FOR UPDATE 锁住最新行
余额更新本质是“读-校验-写”三步,必须在一个事务里完成,并用行锁阻塞其他并发请求。关键不是锁整张表,而是锁住该用户最新的那条记录(或账户行)。
- 先查当前余额并加锁:
SELECT balance FROM accounts WHERE user_id = 'U001' FOR UPDATE - 在应用层判断是否足够(如扣款前检查
balance >= 100) - 再执行更新:
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'U001' - 最后
COMMIT或ROLLBACK
漏掉 FOR UPDATE,或把它放在 UPDATE 之后,等于没锁——SELECT 和 UPDATE 之间存在竞态窗口。
存储过程封装时,IF 判断必须配 BEGIN END
SQL Server 或 MySQL 存储过程中写条件校验,比如“余额不足则报错”,常见错误是省略 BEGIN...END。例如:
IF @balance < 100
THROW 50001, '余额不足', 1
UPDATE accounts SET balance = balance - 100 WHERE user_id = @uid这段代码里,THROW 受 IF 控制,但 UPDATE 永远执行——因为没 BEGIN,IF 只管下一行。正确写法必须包裹:
IF @balance < 100
BEGIN
THROW 50001, '余额不足', 1
END
UPDATE accounts SET balance = balance - 100 WHERE user_id = @uid另外注意:@balance = NULL 永远不成立,要用 @balance IS NULL 判断空值。
更可靠的做法:用交易明细表替代余额字段直改
生产环境真正安全的余额系统,几乎都不在 accounts 表里存 balance 字段。而是只维护 account_transaction 流水表,每次变动插入一条记录,并带 balance_after 字段。查询余额时 SELECT balance_after FROM account_transaction WHERE user_id = ? ORDER BY created_at DESC LIMIT 1。
这样做的好处:
- 所有变更可追溯、可对账
- 并发冲突自然落到流水插入上,靠
trade_no唯一索引防重 - 余额计算逻辑由应用控制,数据库只做原子插入
- 避免因事务回滚导致余额与流水不一致
如果你正在设计新系统,别花时间修存储过程里的锁逻辑——直接按明细驱动建模。老系统迁移时,也优先考虑加流水表双写,逐步下线直改余额的路径。

















