news 2026/4/21 23:42:04

别让并发 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/18 5:22:32

SpringBoot 项目中 yml 配置文件的所有属性引用 / 加载方式

核心前提SpringBoot 配置加载有明确的优先级(高优先级覆盖低优先级),整体优先级从高到低大致为:自定义配置源 > 命令行参数 > 系统属性(-D) > 环境变量 > 配置中心(Nacos/Apollo&am…

作者头像 李华
网站建设 2026/4/18 9:38:01

好写作AI:当答辩现场变身“AI模拟考场”,社恐学霸也能carry全场!

如果你的答辩准备还停留在:对着镜子自问自答,最后只练熟了“谢谢老师的提问”这一句——是时候召唤你的24小时AI答辩陪练了!深夜的实验室,生物工程专业的小赵正经历第N次“答辩模拟”:他刚讲完PPT,对面空荡…

作者头像 李华
网站建设 2026/4/21 17:41:49

找不到符号错误怎么办?原因和解决方法

在编程或文本处理中,“找不到符号”是一个常见的错误提示,通常意味着系统无法识别某个标识符,如变量名、函数名或类名。这不仅会影响代码的编译和运行,还可能导致项目无法正常进行。理解其成因和解决方案,对于提高开发…

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

好写作AI:当“学术困难户”遇上AI,逆袭剧本是这样写的

如果你也曾是那个:对着空白文档发呆三小时,参考文献读不懂,导师的修改建议每个字都认识但连起来就不明白的“学术困难生”——别慌,你的逆袭剧本可能只需要一个AI助教。深夜的宿舍楼,只有一盏灯还亮着。大四的小李盯着…

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

肿瘤微环境代谢及信号调控介导的T淋巴细胞功能抑制:机制与治疗展望

一、肿瘤微环境对T淋巴细胞的免疫抑制机制及治疗策略研究综述 肿瘤微环境是阻碍T淋巴细胞浸润并削弱其抗肿瘤功能的关键屏障。该环境中存在的代谢异常及可溶性因子可显著抑制T细胞活性。2022年8月,Navin Kumar Verma及其团队于《eBioMedicine》发表综述《Obstacles…

作者头像 李华