news 2026/4/23 11:31:01

别让并发 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/3/14 21:55:53

PUDN和CSDN区别:源码下载vs技术社区哪个好?

对于国内的开发者而言,PUDN和CSDN是两个绕不开的技术资源平台。它们见证了无数程序员的学习与成长历程,但各自的定位、资源特点和使用体验却有很大差异。了解这些差异,能帮助开发者更高效地利用这些平台,找到真正所需的技术解决方…

作者头像 李华
网站建设 2026/4/15 19:44:05

闭眼入一键生成论文工具,研究生专属千笔ai写作 VS 灵感风暴AI

随着人工智能技术的迅猛迭代与普及,AI辅助写作工具已逐步渗透到高校学术写作场景中,成为研究生完成毕业论文不可或缺的辅助手段。越来越多面临毕业论文压力的学生,开始依赖各类AI工具简化写作流程、提升创作效率。但与此同时,市场…

作者头像 李华
网站建设 2026/4/16 15:57:42

速卖通商品详情页前端性能优化实战

一、项目背景与性能瓶颈分析1.1 速卖通平台特点速卖通(AliExpress)作为全球B2C跨境电商平台,具有以下技术特征:全球覆盖:服务200国家和地区,网络环境差异极大多语言支持:18种语言,覆…

作者头像 李华
网站建设 2026/4/18 13:28:11

LLM自编程:上下文无界

当GPT-4的128K上下文窗口仍被百万字技术文档轻松“撑爆”,当长文本处理不得不依赖繁琐的分块压缩,我们是否误入了“扩大上下文”的军备竞赛?MIT CSAIL团队提出的递归语言模型(RLM)指出了一个反直觉方向:真正…

作者头像 李华
网站建设 2026/4/18 11:59:11

扣子Coze实战:一键打造自己的口播数字人视频(保姆级教程)

大家好,我是汤师爷,专注AI智能体分享~ 相信有不少朋友在问,有没有办法不出境,也能拍出专业的口播视频? 毕竟很多时候,我们想分享内容,却因为不敢出镜,迟迟不敢开始。 录制视频时总是…

作者头像 李华