news 2026/4/16 13:31:26

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
别让并发 Bug 毁掉你的系统:从 HR 项目实战聊聊数据库锁的正确姿势


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

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

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

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


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

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

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

比如:

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

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

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

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


二、解决方案:SELECT … FOR UPDATE

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

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

关键点:

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

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

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

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


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

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

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

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

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

转正申请中的使用

asynccreateRegularizationApplication(applicantId:number,dto){constresult=awaitthis.prisma.$transaction(async(tx)=>{// 第一步:锁定申请人行,阻塞同一用户的并发请求awaitthis.lockApplicantRowForUpdate(tx,applicantId);// 第二步:业务校验(此时已经串行,不会有并发问题)constuser=awaittx.user.findUnique({where:{id:BigInt(applicantId)}});this.assertEligibleForRegularizationApplication(user);// 第三步:防重复校验awaitthis.assertNoDuplicateApplication(tx,applicantId,'regularization');// 第四步:创建申请记录constapplication=awaittx.application.create({...});returnapplication;});returnresult;}

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

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

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


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

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

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

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

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

2. 申请记录还不存在

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

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

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


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

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

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

-- 命中主键索引,只锁一行(推荐)SELECTidFROMusersWHEREid=1001FORUPDATE;-- 没有索引的字段,可能锁大量行甚至全表(危险)SELECTidFROMusersWHEREphone='13800138000'FORUPDATE;

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

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

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

// 错误示范:事务内调用外部服务awaitthis.prisma.$transaction(async(tx)=>{awaitthis.lockApplicantRowForUpdate(tx,applicantId);awaitthis.sendEmailNotification();// 可能耗时数秒,锁一直不释放!awaittx.application.create({...});});// 正确做法:事务内只做最小必要的数据库操作constresult=awaitthis.prisma.$transaction(async(tx)=>{awaitthis.lockApplicantRowForUpdate(tx,applicantId);returnawaittx.application.create({...});});awaitthis.sendEmailNotification();// 事务外发邮件

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

坑 3:死锁

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

事务A:锁用户1 → 等待锁用户2... 事务B:锁用户2 → 等待锁用户1...

规避方法:

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

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

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

-- 这条语句可能锁住的不只是 id=1001 的行-- 还包括 id 在某个范围内的间隙SELECT*FROMapplicationsWHEREuser_id=1001FORUPDATE;

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


六、FOR UPDATE 不是万能的

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

适合的场景:

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

不适合的场景:

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

选型速查:

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

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


七、总结

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

SELECTidFROMusersWHEREid=?FORUPDATE;

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

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

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


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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 13:03:09

格式总出错?AI论文工具 千笔ai写作 VS 灵感ai,本科生专属利器!

随着人工智能技术的迅猛迭代与普及,AI辅助写作工具已逐步渗透到高校学术写作场景中,成为本科生完成毕业论文不可或缺的辅助手段。越来越多的学生在面对繁重的论文写作任务时,开始依赖各类AI工具来提升效率、简化流程。然而,市场上…

作者头像 李华
网站建设 2026/4/16 13:00:49

2026 大专大数据技术专业证书含金量排名大专适用!

进入2026年,数字化转型的浪潮已非未来趋势,而是当下各行各业的生存常态。大数据技术作为核心驱动力,创造了海量人才需求。对于大专院校大数据技术专业的学生而言,身处学历与技能双重竞争的环境中,如何精准发力&#xf…

作者头像 李华
网站建设 2026/4/16 3:53:08

光速AI:玻璃纤维如何替代硅基大脑

AI at light speed: How glass fibers could replace silicon brains 想象一下不依赖电子而使用光来更快、更高效执行任务的计算机。来自芬兰某大学和法国某大学两个研究团队的合作,现已展示了一种利用光和光纤处理信息的新方法,为构建超快计算机开辟了可…

作者头像 李华
网站建设 2026/4/16 12:46:35

C语言鼠标钩子源码实现与全局监控教程

鼠标钩子是Windows系统提供的一种机制,允许应用程序监视或拦截系统中的鼠标消息。理解其源码实现,对于开发需要全局鼠标监控、自定义鼠标行为或制作辅助工具的程序至关重要。本文将围绕C语言实现鼠标钩子的几个核心环节展开。 鼠标钩子是什么 从编程角度…

作者头像 李华