直接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 <p>这段代码里,<code>THROW</code> 受 IF 控制,但 <code>UPDATE</code> 永远执行——因为没 <code>BEGIN</code>,IF 只管下一行。正确写法必须包裹:</p><pre class="brush:php;toolbar:false;">IF @balance <p>另外注意:<code>@balance = NULL</code> 永远不成立,要用 <code>@balance IS NULL</code> 判断空值。</p><h3>更可靠的做法:用交易明细表替代余额字段直改</h3><p>生产环境真正安全的余额系统,几乎都不在 <code>accounts</code> 表里存 <code>balance</code> 字段。而是只维护 <code>account_transaction</code> 流水表,每次变动插入一条记录,并带 <code>balance_after</code> 字段。查询余额时 <code>SELECT balance_after FROM account_transaction WHERE user_id = ? ORDER BY created_at DESC LIMIT 1</code>。</p><p>这样做的好处:</p>
- 所有变更可追溯、可对账
- 并发冲突自然落到流水插入上,靠
trade_no唯一索引防重 - 余额计算逻辑由应用控制,数据库只做原子插入
- 避免因事务回滚导致余额与流水不一致
如果你正在设计新系统,别花时间修存储过程里的锁逻辑——直接按明细驱动建模。老系统迁移时,也优先考虑加流水表双写,逐步下线直改余额的路径。










