别让并发 Bug 毁掉你的系统:从 HR 项目实战聊聊数据库锁的正确姿势


上周五下午,测试同事突然在群里甩出一张截图:"同一个员工,怎么出现了两条转正申请?"

我心里一沉------这不就是经典的并发重复提交问题吗?

两个请求几乎同时到达服务器,各自查了一遍"有没有进行中的申请",都查到"没有",然后各自创建了一条。结果就是:一个员工,两条转正申请,审批人一脸懵。

这个 Bug 让我重新审视了项目中所有涉及"先查后写"的业务逻辑,最终用 SELECT ... FOR UPDATE 彻底解决了问题。今天就结合我们 HR 管理系统的真实代码,聊聊数据库锁到底怎么用、什么时候用、有哪些坑。


一、先搞清楚:为什么"先查后写"会出问题?

很多业务逻辑都有这样的模式:

先查询数据库,判断条件是否满足,再执行写入操作。

比如:

  • 提交转正申请前,先查有没有进行中的申请
  • 扣减库存前,先查库存够不够
  • 转账前,先查余额是否充足

在单线程环境下,这完全没问题。 但在并发场景下,两个请求可能在"查"和"写"之间形成时间窗口:

css 复制代码
请求A:查询 → 没有重复申请 → 准备创建...
请求B:查询 → 没有重复申请 → 准备创建...
请求A:                              → 创建成功 ✓
请求B:                              → 创建成功 ✓  ← 重复了!

这就是经典的 TOCTOU(Time of Check to Time of Use) 问题。查和写之间没有原子性保证,并发一来就翻车。


二、解决方案:SELECT ... FOR UPDATE

SELECT ... FOR UPDATE 是 MySQL InnoDB 引擎提供的悲观锁机制。它的核心语义很简单:

在事务内执行 SELECT 时,对命中的行加排他锁(X 锁)。其他事务如果也想锁这些行,必须等当前事务结束。

关键点:

  • 锁的是,不是表(前提是命中了索引)
  • 锁的释放时机是事务结束(COMMIT / ROLLBACK),不是语句结束
  • 必须在事务中使用,否则语句执行完锁就没了,毫无意义

加了锁之后,并发流程变成了这样:

css 复制代码
请求A:开启事务 → FOR UPDATE 锁定用户行 → 查询无重复 → 创建申请 → 提交事务,释放锁
请求B:开启事务 → FOR UPDATE 锁定用户行 → 等待...(被阻塞)
请求B:                                   → 拿到锁 → 查询发现已有申请 → 拒绝 → 回滚

"校验 + 创建"被串行化了,问题解决。


三、实战代码:HR 系统中的落地方案

我们的 HR 系统后端使用 NestJS + Prisma + MySQL,涉及大量申请类业务:转正、离职、请假、加班、出差、补卡等。每一种申请都有防重复提交的需求。

核心锁方法application.service.ts):

typescript 复制代码
/**
 * 申请创建并发防护:锁定当前申请人行,确保"校验 + 创建"串行执行
 */
private async lockApplicantRowForUpdate(
  tx: Prisma.TransactionClient,
  applicantId: number
) {
  await tx.$queryRaw`
    SELECT id FROM users WHERE id = ${BigInt(applicantId)} FOR UPDATE
  `;
}

只有一行 SQL,但它是整个并发防护的基石。

转正申请中的使用

typescript 复制代码
async createRegularizationApplication(applicantId: number, dto) {
  const result = await this.prisma.$transaction(async (tx) => {
    // 第一步:锁定申请人行,阻塞同一用户的并发请求
    await this.lockApplicantRowForUpdate(tx, applicantId);

    // 第二步:业务校验(此时已经串行,不会有并发问题)
    const user = await tx.user.findUnique({ where: { id: BigInt(applicantId) } });
    this.assertEligibleForRegularizationApplication(user);

    // 第三步:防重复校验
    await this.assertNoDuplicateApplication(tx, applicantId, 'regularization');

    // 第四步:创建申请记录
    const application = await tx.application.create({ ... });
    return application;
  });
  return result;
}

这个模式在项目中被复用了 9 次,覆盖了所有申请类型:

申请类型 防重口径
转正申请 存在待审批/审批中时禁止;已通过则永久禁止
离职申请 存在待审批/审批中/已通过时禁止
请假申请 存在待审批/审批中时禁止
加班申请 存在待审批/审批中时禁止
出差申请 存在未闭环出差单时禁止
补卡申请 存在待审批/审批中时禁止
入职申请 身份证号不可重复

每种申请的防重规则不同,但锁的策略完全一致:先锁用户行,再做业务校验,最后写入。


四、为什么锁 users 表而不是 applications 表?

这是一个值得思考的设计决策。

我们锁的是 users 表的申请人行,而不是 applications 表。原因是:

1. 防重校验的维度是"人",不是"申请"

业务规则是"同一个人不能重复提交某类申请"。锁的粒度应该和业务校验的粒度一致。

2. 申请记录还不存在

并发场景下,两个请求都还没创建申请记录,applications 表里根本没有可以锁的行。而 users 表的记录是确定存在的。

3. 主键查询,锁粒度最小

WHERE id = ? FOR UPDATE 命中的是主键索引,InnoDB 只会锁定这一行,不会影响其他用户的操作。


五、踩坑记录:这些细节不注意就翻车

坑 1:没有索引,行锁变表锁

InnoDB 的行锁是基于索引实现的。如果 WHERE 条件没有命中索引,锁的范围会急剧扩大。

sql 复制代码
-- 命中主键索引,只锁一行(推荐)
SELECT id FROM users WHERE id = 1001 FOR UPDATE;

-- 没有索引的字段,可能锁大量行甚至全表(危险)
SELECT id FROM users WHERE phone = '13800138000' FOR UPDATE;

建议:FOR UPDATE 的 WHERE 条件务必命中主键或唯一索引。

坑 2:事务内做了耗时操作

锁的持有时间 = 事务的持续时间。如果在事务内调用了外部 API、发送邮件、做复杂计算,锁就会被长时间持有,其他请求全部排队等待。

typescript 复制代码
// 错误示范:事务内调用外部服务
await this.prisma.$transaction(async (tx) => {
  await this.lockApplicantRowForUpdate(tx, applicantId);
  await this.sendEmailNotification();  // 可能耗时数秒,锁一直不释放!
  await tx.application.create({ ... });
});

// 正确做法:事务内只做最小必要的数据库操作
const result = await this.prisma.$transaction(async (tx) => {
  await this.lockApplicantRowForUpdate(tx, applicantId);
  return await tx.application.create({ ... });
});
await this.sendEmailNotification();  // 事务外发邮件

原则:锁内只做最小必要读写。

坑 3:死锁

多个事务交叉锁定不同资源时,可能产生死锁:

css 复制代码
事务A:锁用户1 → 等待锁用户2...
事务B:锁用户2 → 等待锁用户1...

规避方法:

  • 固定加锁顺序:比如总是按用户 ID 从小到大加锁
  • 缩短事务时间:减少锁持有时长
  • 做死锁重试:捕获死锁错误码,短暂等待后重试

坑 4:RR 隔离级别下的间隙锁

MySQL 默认的 REPEATABLE READ 隔离级别下,范围查询 + FOR UPDATE 会触发 Next-Key Lock,不仅锁记录本身,还会锁住记录之间的"间隙"。

sql 复制代码
-- 这条语句可能锁住的不只是 id=1001 的行
-- 还包括 id 在某个范围内的间隙
SELECT * FROM applications WHERE user_id = 1001 FOR UPDATE;

所以我们选择锁 users 表的主键行,等值主键查询的锁定范围最精确。


六、FOR UPDATE 不是万能的

SELECT ... FOR UPDATE 很好用,但它有明确的边界:

适合的场景:

  • 单库事务内的资源竞争
  • 有明确主键的并发串行化
  • 库存扣减、余额变更、幂等防重

不适合的场景:

  • 跨数据库、跨服务的分布式锁 → 用 Redis / ZooKeeper / etcd
  • 高吞吐写热点(大量请求锁同一行)→ 考虑乐观锁或队列削峰
  • 全局任务调度锁 → 用分布式锁中间件

选型速查:

场景 推荐方案
事务内资源竞争,有明确主键 SELECT ... FOR UPDATE
冲突概率低,追求吞吐 乐观锁(version 字段)
跨服务全局互斥 Redis / ZK / etcd 分布式锁

最终原则:先保证一致性,再优化性能。


七、总结

回到开头那个 Bug。修复方案其实就一行 SQL:

sql 复制代码
SELECT id FROM users WHERE id = ? FOR UPDATE;

但要用好它,需要理解背后的原理:

  • 为什么要锁:消除"先查后写"的并发时间窗口
  • 锁什么:锁业务校验维度对应的行,用主键命中索引
  • 锁多久:事务结束才释放,所以事务要尽可能短
  • 什么时候不用:跨服务场景、高吞吐热点、分布式协调

数据库锁不是什么高深的技术,但它是保证数据一致性的最后一道防线。希望这篇文章能帮你在自己的项目中少踩一个坑。


欢迎关注公众号FishTech Notes,一块交流使用心得!

相关推荐
小高不会迪斯科18 小时前
CMU 15445学习心得(二) 内存管理及数据移动--数据库系统如何玩转内存
数据库·oracle
e***89018 小时前
MySQL 8.0版本JDBC驱动Jar包
数据库·mysql·jar
l1t18 小时前
在wsl的python 3.14.3容器中使用databend包
开发语言·数据库·python·databend
失忆爆表症20 小时前
03_数据库配置指南:PostgreSQL 17 + pgvector 向量存储
数据库·postgresql
AI_567820 小时前
Excel数据透视表提速:Power Query预处理百万数据
数据库·excel
SQL必知必会21 小时前
SQL 窗口帧:ROWS vs RANGE 深度解析
数据库·sql·性能优化
Gauss松鼠会21 小时前
【GaussDB】GaussDB数据库开发设计之JDBC高可用性
数据库·数据库开发·gaussdb
+VX:Fegn089521 小时前
计算机毕业设计|基于springboot + vue鲜花商城系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
识君啊1 天前
SpringBoot 事务管理解析 - @Transactional 的正确用法与常见坑
java·数据库·spring boot·后端
一个天蝎座 白勺 程序猿1 天前
破译JSON密码:KingbaseES全场景JSON数据处理实战指南
数据库·sql·json·kingbasees·金仓数据库