热点账户扣款事务必须控制在100ms内提交,超300ms易引发锁等待雪崩;根本解法是缩小事务边界、移除非DB操作、分片降压,并严控隐式长事务。

直接结论:热点账户扣款事务必须控制在 100ms 内提交,超过 300ms 就大概率引发锁等待雪崩;靠调大 innodb_lock_wait_timeout 只是掩盖问题,不是解决。
为什么 UPDATE account SET balance = balance - X WHERE id = ? 会卡住整个队列
InnoDB 对同一行加的是排他锁(X Lock),所有并发请求只能串行排队。哪怕 SQL 执行本身只要 5ms,只要事务没 COMMIT,锁就一直不释放。而实际中常见陷阱包括:
- 事务里混了 HTTP 调用(如风控校验)、日志写入、Redis 操作——这些非 DB 动作把锁持有时间从毫秒拉到秒级
- 应用层用了 Spring @Transactional 但没配
timeout,底层连接空闲超时(wait_timeout)设为 8 小时,导致异常挂起的事务长期持锁 - 扣款前查余额用了
SELECT ... FOR UPDATE却没走索引,触发间隙锁(Gap Lock),锁住一大片范围
把非数据库操作全部移出事务边界
这是见效最快、成本最低的改造。事务只保留“读余额 → 判断是否足够 → 扣减 → 写日志(可异步)”中最核心的原子部分。
- 反例:
BEGIN; SELECT balance FROM account WHERE id = 123 FOR UPDATE; CALL risk_check_api(); UPDATE account SET balance = balance - 100 WHERE id = 123; COMMIT;——risk_check_api()失败前,id=123 这行一直被锁 - 正例:
BEGIN; SELECT balance FROM account WHERE id = 123 FOR UPDATE; UPDATE account SET balance = balance - 100 WHERE id = 123 AND balance >= 100; COMMIT;—— 扣款失败直接回滚,锁在 COMMIT 后立刻释放 - 风控、通知、审计等后续动作,统一挪到事务外,用消息队列或本地异步线程兜底;失败走补偿任务,不重试进事务
用乐观锁替代悲观锁(适合中低并发)
当 QPS SELECT ... FOR UPDATE,改用带条件的单条 UPDATE,避免锁竞争。
- 建表时加
version字段和balance校验:UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 123 AND version = 5 AND balance >= 100; - 应用层检查
ROW_COUNT():等于 1 表示成功;等于 0 表示版本冲突或余额不足,可立即重试或返回失败 - 注意:不要依赖
SELECT version后再拼 UPDATE —— 中间可能被其他事务修改,必须用原子条件更新
高并发场景必须分片 + 异步合并
QPS > 5000 时,单行无论如何优化都会成为瓶颈。必须把“一个账户”逻辑拆成“N 个子账户”,物理分散压力。
- 建分片表:
CREATE TABLE account_shard (account_id BIGINT, shard_id TINYINT, balance BIGINT, PRIMARY KEY(account_id, shard_id)); - 扣款时随机选片:
UPDATE account_shard SET balance = balance - 100 WHERE account_id = 123 AND shard_id = FLOOR(RAND() * 16) AND balance >= 100; - 查总余额用
SELECT SUM(balance) FROM account_shard WHERE account_id = 123;,聚合开销可控 - 关键点:分片数不能拍脑袋定。先用
SHOW ENGINE INNODB STATUS\G观察锁等待集中在哪些 trx_id,再结合sys.innodb_lock_waits确认热点行,倒推需要多少 shard 才能让锁等待归零
最容易被忽略的是“隐式长事务”:比如某次支付回调因网络超时没收到响应,应用层没设 @Transactional(timeout = 3),结果这个事务挂着 5 分钟,锁住整行。线上一定要定期跑 SELECT * FROM information_schema.INNODB_TRX WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 10 抓钉子户。这不是预防手段,是底线监控。


















