多线程调用同一段MySQL代码引发死锁问题排查求助
问题分析与解决方案
一、死锁的核心原因
你遇到的死锁,根本问题出在Universe行的锁升级冲突,同时你对SERIALIZABLE隔离级别的理解存在偏差:
纠正SERIALIZABLE的误解
SERIALIZABLE保证的是事务执行结果等价于串行执行,但它不会强制事务按固定顺序获取锁。它依赖范围锁/共享锁实现隔离,但如果事务的锁获取逻辑形成循环等待,死锁依然会发生。
具体死锁场景
你的每个事务执行流程是:
- 锁定自身User行(排他锁)
- 查询同一个Universe行(SERIALIZABLE下普通查询会加共享锁)
- 尝试更新Universe行(需要将共享锁升级为排他锁)
当多个并发事务同时执行到步骤2时,都会持有Universe行的共享锁。等到步骤3要升级排他锁时,每个事务都在等待其他事务释放共享锁,形成循环等待——这就是死锁的由来。
另外,updatePotSize和updatePayout采用的"先查询再更新"两步操作,会放大锁冲突概率:findOne加共享锁,update要升级排他锁,中间的时间窗口足够其他事务也加上共享锁。
二、死锁调试技巧
- 查看数据库死锁日志
- 对于MySQL InnoDB,执行
SHOW ENGINE INNODB STATUS;,输出里的LATEST DETECTED DEADLOCK段会显示死锁的事务ID、锁类型、等待链,精准定位冲突的行和锁。
- 对于MySQL InnoDB,执行
- 添加锁流程日志
- 在关键锁操作(比如
getBuyingPower、updatePotSize)前后打印日志,记录事务ID、操作表、行ID、时间戳,直观看到锁的获取顺序和冲突点。
- 在关键锁操作(比如
- 监控锁状态
- 用数据库自带工具(比如MySQL的
INFORMATION_SCHEMA.INNODB_LOCKS和INNODB_LOCK_WAITS表)实时查看当前持有和等待的锁。
- 用数据库自带工具(比如MySQL的
三、具体修复方案
1. 将Universe的更新改为原子操作
把updatePotSize和updatePayout里的"查询+更新"两步合并成单条原子更新语句,避免共享锁的获取和升级:
// 重构updatePotSize const updatePotSize = async (universeId, transaction) => { try { await Universes.update( { potSize: sequelize.literal('potSize + 1') }, { where: { id: universeId }, transaction } ); } catch (error) { console.error('Unable to update pot size:', error.message); throw error; } }; // 重构updatePayout const updatePayout = async (universeId, amount, transaction) => { try { if (isNaN(amount) || amount === null) { throw new Error('Invalid amount value'); } await Universes.update( { payout: sequelize.literal('payout + ' + amount) }, { where: { id: universeId }, transaction } ); // 如需返回新值,直接加排他锁查询 const updatedUniverse = await Universes.findOne({ where: { id: universeId }, attributes: ['payout'], transaction, lock: transaction.LOCK.UPDATE }); return updatedUniverse.payout; } catch (error) { console.error('Unable to update payout:', error); throw error; } };
原子更新会直接对目标行加排他锁,跳过共享锁步骤,从根源避免锁升级导致的死锁。
2. 调整锁获取顺序(可选)
如果原子更新还不够,强制所有事务先获取Universe的排他锁,再操作User行,确保锁获取顺序完全一致:
// 事务最开始先锁定Universe await Universes.findOne({ where: { id: universeId }, transaction, lock: transaction.LOCK.UPDATE }); // 再执行User相关操作 const buyingPower = await getBuyingPower(userId, transaction); // ...后续逻辑
这种方式会降低并发度,但能彻底避免死锁,适合Universe更新频率极高的场景。
3. 优化重试机制
你现有的重试次数太少(3次),且要确保操作的幂等性:
- 增加重试次数到5-10次,每次重试间隔100-500ms(指数退避)
- 确保
createStockOwner等写入操作幂等:比如给stockuuid加唯一键约束,重试时如果发现已存在则直接返回,避免重复创建
四、总结
SERIALIZABLE隔离级别不会自动解决死锁,它只是保证事务结果的串行一致性。死锁的本质是循环等待的锁依赖,解决核心是消除循环等待:要么统一锁获取顺序,要么用原子操作避免锁升级。
内容的提问来源于stack exchange,提问作者Marc-9
相关产品推荐
相关产品推荐

