MySQL账号注册并行请求触发死锁的解决方法咨询
死锁原因确认
你的判断基本正确,本质是InnoDB的间隙锁(GAP Lock)机制:当使用FOR UPDATE查询不存在的行时,InnoDB不会加行锁,而是会给查询条件覆盖的索引范围加间隙锁,避免其他事务插入该范围的数据引发幻读。多个事务可以同时持有同一范围的间隙锁,当两个持锁事务同时尝试往该间隙插入数据时,会互相等待对方释放锁,最终触发死锁。
适配需求的解决方案
你需要保留前置校验逻辑、避免自增ID浪费的需求,优先推荐以下两种方案:
方案1:使用MySQL用户级锁按邮箱维度隔离(推荐)
利用MySQL自带的GET_LOCK()函数实现细粒度的分布式锁,完全避免间隙锁触发,也不会浪费自增ID:
- 每个邮箱对应唯一的锁名称,只有拿到对应锁的请求才能进入注册逻辑
- 锁是连接级别的,超时时间可自定义,不会长期占用资源
TypeORM代码改造示例:
public async registerUser(email: string, password: string, displayName?: string) { const connection = await getConnection(); // 生成邮箱对应的唯一锁名 const lockName = `user_reg_${email}`; // 尝试获取锁,最大等待时间设置为3秒,可根据业务调整 const getLock = await connection.query(`SELECT GET_LOCK(?, 3) as lock_status`, [lockName]); if (getLock[0].lock_status !== 1) { throw new UserError('操作频繁,请稍后重试'); } try { return await connection.transaction(async (manager) => { const hasAlreadyRegistered = await this.findUser(email, manager); if (hasAlreadyRegistered) throw new UserError('Email has already been registered.'); const user = new User(); user.email = email; user.displayName = displayName || randomBytes(8).toString('hex'); user.strategies = [authentication]; await manager.save(user); logger.silly('> Created user row.'); return user; }); } finally { // 无论事务成功失败,都释放锁 await connection.query(`SELECT RELEASE_LOCK(?)`, [lockName]); } }
方案2:调整事务隔离级别关闭间隙锁
如果不想引入用户级锁,可以将注册事务的隔离级别调整为读已提交(READ COMMITTED),该级别下InnoDB会禁用普通查询的间隙锁,从根源上避免本次死锁:
- 注意该方案下并行请求同时注册同一邮箱时,第二个请求插入会触发唯一键冲突,你可以捕获该异常后返回已注册提示,实际生产中这种冲突概率极低,自增ID浪费的量级可以忽略
- TypeORM中设置事务隔离级别示例:
await connection.transaction('READ COMMITTED', async (manager) => { // 原有逻辑不变 })
补充注意事项
- 确保
findUser方法确实加了悲观写锁,TypeORM中写法参考:
await manager.findOne(User, { where: { email }, lock: { mode: 'pessimistic_write' } });
- 如果是极低并发场景,也可以临时用表锁替代,但不推荐高并发场景使用
内容的提问来源于stack exchange,提问作者Vizious Developer
相关产品推荐
相关产品推荐

