MySQL悲观锁与乐观锁实战:从FOR UPDATE到版本号
2026/9/7 20:42:38 网站建设 项目流程

1. 先搞懂悲观锁:MySQL 里那个“慢但稳”的方案

1.1 悲观锁到底锁的是什么

悲观锁这个概念,名字已经把事情说得很直白了:它默认这个操作一定会发生冲突,所以在操作数据之前,先把这行数据“锁死”,让其他人谁也别想碰,等自己操作完成、事务提交,锁才释放。

如果你用过SELECT ... FOR UPDATE,那你已经在用悲观锁了。这个语句在 MySQL 的 InnoDB 存储引擎下,会对查询命中的记录加排他锁(X锁),之后其他事务要更新、删除、甚至用SELECT ... FOR UPDATE再去查这行,都会被阻塞,直到持有锁的事务提交或者回滚。

这里要特别强调一个我早期踩过的坑:SELECT ... FOR UPDATE本身只是一个查询语句,它不会帮你开启事务,也不会帮你提交事务。锁的持有周期是从执行这条语句开始,到当前事务结束为止。所以你在用悲观锁之前,必须先保证自己在事务里,并且事务最终要显式提交或者回滚,否则锁就会一直开着,直接把其他会话堵死。

MySQL 默认的存储引擎是 InnoDB,它支持行级锁。但要注意,行锁的前提是查询条件必须能命中索引。如果你的查询条件没走索引,InnoDB 会对全表的所有记录加锁,这样整个表都被锁住了,性能会非常难看。关于这一点,后面讲锁等待问题时会再细说。

1.2 SELECT FOR UPDATE 的实操细节与参数选择

悲观锁最常见的用法就是针对单行或者少量行的更新操作。比如你在做一个库存扣减的功能,操作流程是这样的:

-- 开启事务 START TRANSACTION; -- 锁定库存记录,防止其他事务同时修改 SELECT stock FROM product WHERE id = 1001 FOR UPDATE; -- 在业务代码里判断库存是否充足,然后计算新库存 -- 假设新库存为 500 UPDATE product SET stock = 500 WHERE id = 1001; -- 提交事务,释放锁 COMMIT;

这段 SQL 里有几个点值得展开讲一下。

FOR UPDATE除了标准的写法,还有FOR UPDATE NOWAITFOR UPDATE SKIP LOCKED两种变体,这在实际工程中非常有用。默认的FOR UPDATE在遇到锁冲突时,会一直等待直到获取锁,等待时间由innodb_lock_wait_timeout控制,默认是 50 秒。如果你的系统对延时敏感,等 50 秒显然是灾难,所以更推荐用FOR UPDATE NOWAIT:一旦发现锁被别人占用,立刻报错返回,然后你在业务层做重试或者友好提示。

SKIP LOCKED则是直接跳过被锁定的行,这种场景多用于任务队列领取——多个 worker 同时去抢一批任务,已经被别人锁住的任务直接跳过,而不是排队等。所以后来我做任务调度,都优先用SKIP LOCKED,而不是傻等。

关于锁的范围,还有一个容易搞混的地方:SELECT ... FOR UPDATE并不只锁查询出来的行,如果查询条件用到了索引范围,比如WHERE id > 100 AND id < 200,那这个范围里的所有记录都会被加上锁。这叫间隙锁或临键锁,是 InnoDB 解决幻读问题的手段。当然这也会显著扩大锁的范围,在写 SQL 时要把查询条件尽量精确到确定的行。

1.3 悲观锁的核心优缺点

悲观锁最明显的优点就是简单。你只要把FOR UPDATE加在查询语句后面,数据的强一致性几乎就是数据库帮你保证的,不需要业务代码里做太多的判断逻辑,冲突时你就是等着就行,不需要处理重试这类逻辑。

但缺点同样明显,最直接的就是性能差。每一把锁从获取到释放,在并发量高的情况下就是一条单向车道,后面的车只能排队等。我见过一个真实案例:某业务系统的下单接口在高峰期,因为大量并发请求在SELECT ... FOR UPDATE上排队,接口平均耗时从 50ms 涨到了 3 秒,就是这个原因。

另外悲观锁还有一个隐藏成本:锁是在事务内持有的,一个事务如果耗时太长,比如在数据库操作之外还调用了远程 RPC 或者消息队列,那锁的持有时间会被无意义地拉长。这个属于设计问题,但很多人会忽略。后面我会在实操章节专门讨论怎么缩短事务时间。

2. 乐观锁的底层原理:版本号与 CAS 是怎么配合的

2.1 乐观锁不是锁,而是一种冲突检测机制

乐观锁就是一个名字里带“锁”但实际上不加数据库锁的方案。它的核心逻辑是,默认数据不会冲突,所以读的时候直接读,但是在更新的时候要检查一下数据还是不是当初读到的那个版本。如果版本变了,说明有人中途改过数据,这次更新就失败,然后你决定重试,还是提示用户失败。

最常见的实现方式就是版本号机制。在表里加一个字段,通常是version或者update_time。每次更新的时候,把版本号作为更新条件,同时把版本号加一。如果影响行数是 0,说明这次更新的条件已经不满足了,也就是版本冲突了。

这种方式底层用的其实是 MySQL 的原子更新和行锁,所谓“乐观锁”并没有额外的数据库锁机制,它本质是靠唯一更新条件和原子操作来保证并发安全。但它确实能规避长事务中悲观锁的等待问题,因为整个更新操作就是一条 SQL,执行得极快,锁持有时间天然就短。

它的判断过程类似于 CAS(Compare And Swap)——比较当前值和目标值,如果一致就更新,否则放弃。MySQL 里做这一步能用一条 UPDATE 完成,比你读出来再判断再更新要安全得多,因为读-判断-更新之间如果有事务提交,你拿到的判断结果就是过时的。直接把判断放到 UPDATE 的条件里,就能避免这个竞态窗口。

2.2 乐观锁更新语句的设计要点

假设要实现一个文章点赞数的自增功能,表结构里除了like_count,还有一个version字段。那么更新语句应该这样写:

UPDATE article SET like_count = like_count + 1, version = version + 1 WHERE id = 5001 AND version = 12;

这条 SQL 的作用是:只有当前版本还是 12 的时候,点赞数才加 1,版本号变 13。如果另一个请求已经先执行了这条 SQL,把版本号从 12 改成了 13,那么这条语句的影响行数就会是 0,因为 WHERE 条件里version = 12已经不成立了。

这里有几个字段设计上的细节。

版本号字段的类型推荐用整数类型,比如INT或者BIGINT。每次更新时直接version = version + 1,不要用UPDATE ... SET version = 老版本+1这种先把值读出来再更新的写法,因为中间可能有并发修改,只有version = version + 1这种原子自增才是安全的。

另一种常用的版本字段是update_time,也就是最后修改时间。更新条件就写成WHERE update_time = 上次读到的时间,更新后把时间改成一个新值。我个人不太推荐用时间做版本,原因有两个:第一,时间精度如果到秒,两个并发请求可能拿到同一个秒级时间;第二,时间可以被业务代码绕过,而专门用来做版本的字段不容易被误改。

更新语句有几个常见的坑要注意。

第一,UPDATE 的条件里必须要带主键或者唯一索引。如果 WHERE 条件的列上没有索引,InnoDB 会锁全表,影响行数会变成全表扫描后的结果,乐观锁的逻辑就被破坏了。第二,更新语句的 SET 部分和 WHERE 条件部分要各自独立,千万别写成SET like_count = like_count + 1, version = 12,这样版本号没有递增,下一次并发更新就检测不到冲突了。第三,执行完 UPDATE 之后,必须判断影响行数。很多 ORM 框架对 UPDATE 影响行数为 0 的情况有自己的处理方式,有的框架会把 0 当成失败,有的框架会自动把输入参数同步回去,如果不做这个判断,等于白写了乐观锁。

2.3 重试机制与 ABA 问题

乐观锁更新失败之后,不是只能把错误抛给用户。最常见的做法是循环重试。伪代码如下:

for (int i = 0; i < 3; i++) { // 查询文章当前数据,拿到最新版本号 Article article = articleMapper.selectById(5001); // 基于最新数据做操作 int rows = articleMapper.updateWithVersion( article.getId(), article.getLikeCount() + 1, article.getVersion() ); if (rows > 0) { break; // 更新成功 } // 否则进入下一轮循环,重新读取最新版本 }

这里的重试不是无脑重试,每次重试必须重新读取最新数据,基于新数据再执行更新的条件判断。如果重试超过指定次数仍然失败,说明这个数据的更新频率非常高,这时应该返回结果让用户稍后再试,或者走另一个削峰方案,比如把写操作放入队列异步处理。

说到 ABA 问题,这是 CAS 机制的一个经典缺陷。简单来说,就是在线程 A 读取版本号后、执行更新前,线程 B 先把这个数据从版本 1 改成版本 2,然后又改回版本 1。线程 A 在更新时发现版本号还是 1,就以为数据没有被改过,于是更新成功。但实际数据已经被 B 折腾过一圈了,A 的判断条件被绕过了。

如果这个数据只关心最终值,ABA 问题影响不大。但如果数据变化是有业务意义的,比如账户余额从 100 变成 50 又变回 100,这时其他线程基于 100 做的判断可能就是错的。解决办法是版本号只增不减,用自增的数字版本就比较安全,因为自增版本不会回退。如果用update_time作为版本,就一定要保证时间戳不回退。

3. 实战:库存扣减与订单并发场景下的选型对比

3.1 用悲观锁解决库存超卖

库存超卖是电商场景里最经典的并发问题。假设商品表product里有一行 id 为 1001 的数据,库存为 100。用户 A 和用户 B 同时下单,都读取到库存 100,然后都减 1,最终库存会变成 99 而不是 98,实际却卖出了 2 件商品,这就是超卖。

用悲观锁解决这个问题的标准写法是:

START TRANSACTION; SELECT stock FROM product WHERE id = 1001 FOR UPDATE; -- 在业务层判断 stock > 0,然后计算新库存 -- 假如新库存是 50 UPDATE product SET stock = 50 WHERE id = 1001; COMMIT;

这段逻辑里,SELECT ... FOR UPDATE是关键。它保证同一时间只有一个事务能读到这行库存数据,其他事务的FOR UPDATE查询会被阻塞,直到第一个事务提交。所以第二个下单请求拿到的就是第一个请求修改后的库存,不会再基于旧库存去做扣减。

我在实际项目里给这个方案做过一次压测,在 100 个并发请求下,接口耗时从原来的平均值 70ms 涨到了 250ms,原因很简单:100 个请求基本都是串行拿锁。如果你做的是后台管理系统的简单扣减,比如每天只有几百次调用,这完全够用。但如果做的是高并发的下单接口,这个方案就要慎重。

这个方案能保证数据绝对正确,代价是并发能力被锁机制限制住了。所以后来我在高并发场景下很少直接用SELECT ... FOR UPDATE,而是把它留给了对一致性要求极高、并发量不高的内部接口。

3.2 用乐观锁解决库存扣减

同样解决库存超卖,乐观锁的写法更轻量。在商品表里加一个version字段,每次扣减库存的 SQL 是这样:

UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 1001 AND stock > 0 AND version = 5;

这条 SQL 的逻辑是:只有在当前库存大于 0,且版本号还是 5 时,才把库存减 1。两个并发请求同时执行这条 SQL,数据库会根据行锁的规则让它们串行执行,一个成功一个失败。成功的影响行数是 1,失败的是 0,通过影响行数就能判断是否抢到了库存。

我在这里用stock > 0作为条件而不是只依赖version,是为了把库存不足的语义也融合进去。如果只判断version,那么库存为 0 时仍可能执行stock = stock - 1,导致库存变成负数。加上stock > 0这个条件,就能一条 SQL 同时完成“版本检查”和“库存检查”。

但乐观锁也有它的尴尬时刻。当并发冲突特别高时,很多请求会因为版本不匹配而失败,业务方收到失败提示后的体验并不好。比如秒杀场景下,100 个人同时抢 10 件商品,假设有 90 个人都读取到了版本号 1,只有 10 个人更新成功,剩下的 80 个人全部失败,如果让他们立即重试,又会有一批人因为版本号再次冲突而失败,形成连环失败。

这种情况下,我一般会限制重试次数,并且让重试之间做一个短暂的退避,比如每轮重试间隔 50ms 的随机时间,把集中冲突打散。

3.3 选型建议:读写比例与冲突强度决定用哪种锁

很多人问我,项目里到底该用乐观锁还是悲观锁,我的回答都差不多:先看你的并发冲突概率,再看你的数据一致性要求。

先说并发冲突概率。如果系统并发量很低,冲突极少发生,乐观锁体验最好,因为省去了加锁的开销,读操作完全无锁,更新失败的概率很低。如果系统并发量很高,冲突频繁,乐观锁会导致大量更新失败,用户体验会很差,这种情况反而应该用悲观锁,让请求排队执行,虽然每个请求都要等待,但至少稳定的等待时间能接受。

再说数据一致性要求。悲观锁天然提供强一致性,你读到的数据时刻都是最新值。乐观锁只能保证更新时发现冲突,无法保证你在读数据时拿到的一定是最新值。如果你做的是转账、账户余额、订单状态这种高一致性场景,优先考虑悲观锁或分布式锁。如果你做的是点赞数、阅读量、任务领取这种可以接受失败重试的场景,乐观锁绰绰有余。

从实现成本来看,乐观锁通常只需要改表和 update 语句,成本最低。悲观锁需要依赖事务和锁等待机制,如果把握不好事务边界,很容易把锁范围扩大,引发线上故障。我建议新手优先用乐观锁练习,把版本号、影响行数、重试机制都搞清楚,再去碰悲观锁的锁等待和死锁问题。

4. 常见的锁等待、死锁与字段设计问题排查实录

4.1 死锁的形成原因与典型排查方法

死锁指的是两个或两个以上事务互相持有对方需要的锁,导致彼此都在等待,无法执行下去。最常见的情况是:事务 A 先锁了 id=1 的行,又想去锁 id=2 的行;事务 B 先锁了 id=2 的行,又想去锁 id=1 的行。两边都在等对方释放,就死锁了。

在悲观锁场景下,死锁现象特别常见。因为SELECT ... FOR UPDATEUPDATE的加锁顺序如果不一样,就很容易触发死锁。面试时如果你能把这个场景讲清楚,面试官通常会认可你在并发处理上有实战经验。

排查死锁最直接的方式是查看 MySQL 的错误日志,执行SHOW ENGINE INNODB STATUS;,找到LATEST DETECTED DEADLOCK部分。里面会记录死锁涉及的两个事务分别持有哪些锁、在等待哪些锁,以及最后被回滚的事务信息。通过分析这个日志,基本就能定位是哪两条 SQL 的顺序问题。

避免死锁的通用做法有几种:

  • 多个事务访问多个表时,固定访问顺序。比如 A、B 两个事务都先锁 product 表,再锁 order 表,就不会形成环路。
  • 把大事务拆小。事务里处理的 SQL 越多,加的锁越多,死锁概率就越高。
  • 使用NOWAITSKIP LOCKED,避免无限期等待。

死锁还有一个特点:MySQL 会自动检测并回滚其中一个事务,所以死锁不会导致数据库卡死,但会让部分请求失败。所以业务代码里要捕获死锁相关的异常,比如Deadlock found when trying to get lock这样的报错,然后做重试或者提示用户。

4.2 锁等待超时:为什么 SELECT FOR UPDATE 会卡很久

SELECT ... FOR UPDATE执行到一半一直没返回,或者报错Lock wait timeout exceeded,这是悲观锁最常见的运维问题。报错后面的数字就是超时时间,默认 50 秒。

我曾经排查过一个真实问题:一个批量任务在循环中反复执行SELECT ... FOR UPDATE,由于某个事务异常没有提交,导致其他任务全部卡在等待锁的状态,最后大量报错。当时的解决办法是:找到information_schema.innodb_trx表里运行时间最长的事务,用trx_mysql_thread_idKILL掉对应的连接。这条 SQL 在排查锁问题时非常有用:

SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;

查出长时间未提交的事务后,再用KILL 线程ID杀掉它。这种问题多数不是锁本身设计错了,而是事务边界没控制好,比如事务里调用了外部接口没有异常处理,或者忘了提交事务。

另一个锁等待的常见原因是索引失效。前面已经提过,FOR UPDATE的查询条件如果没有走索引,InnoDB 会把全表行锁全部加上。这时候不仅性能差,还会把正常的更新全部堵住。排查方式是执行EXPLAIN看执行计划,确认 key 列有值,而不是 NULL。一个常见的误用是用字符类型字段的隐式转换,比如字段是 VARCHAR,查询条件写WHERE tel = 13800001111,MySQL 会把字符串转成数字去比较,导致索引失效,这一点务必注意。

4.3 乐观锁使用中常见的几个坑

乐观锁代码不复杂,但工程落地时还是有不少容易忽略的细节。

版本号字段暴露给前端是最大的隐患。如果你做的是 Web 项目,前端能直接看到版本号,那你就能手动修改版本号绕过乐观锁检测。我见过有人把 version 字段直接返回给前端,被用户抓包后手动改掉,导致数据更新毫无保护。所以 version 字段必须只存在于服务端,读写接口都不要返回给前端。

更新 SQL 的条件必须和业务逻辑的隔离级别匹配。比如在REPEATABLE READ隔离级别下,普通 SELECT 是快照读,读到的数据是事务开始时的快照,而不是最新版本。如果你在事务开始后先做了一次普通查询,拿到一个旧版本号,然后同事务内执行乐观锁更新,很大概率会失败。这就是为什么我建议把乐观锁的查询和更新放在同一个原子操作,或者尽量不用事务包裹乐观锁逻辑。

还有一个关于UPDATE影响行数的问题。在 MySQL 中,如果更新的值和当前值相同,默认返回的影响行数是 0,但实际并没有发生数据变化。这在乐观锁里可能造成误判:你以为版本冲突导致更新失败,实际是更新了个寂寞。解决方法是使用CLIENT_FOUND_ROWS标志,或者在更新条件里额外加上版本号判断。这个坑比较隐蔽,我建议做乐观锁时最好在业务层明确区分“没有更新”和“更新失败”两种情况。

5. 面试常问的锁问题与思路拆解

5.1 像“什么是乐观锁和悲观锁”这类问题的回答思路

“MySQL 中的乐观锁和悲观锁是什么”这个问题,属于数据库并发控制的入门级面试题,但回答深度差别很大。如果你只是说“乐观锁不加锁,悲观锁加锁”,那面试官基本不会满意。更值得展开的是:乐观锁为什么能做到不加锁,悲观锁加的是什么锁,底层为什么能保证正确性。

我的回答思路一般是这样的:先点出两者都是用来处理并发写冲突的手段,然后明确悲观锁依赖数据库自身的行锁机制,通过SELECT ... FOR UPDATE在事务中锁住记录,保证整个读-判断-写过程串行;乐观锁则不依赖数据库锁,而是通过版本号或条件更新实现原子性,更新时把版本号放入 WHERE 条件来保证不会覆盖别人的修改,再用影响行数判断是否冲突。

如果面试官继续追问 InnoDB 行锁的原理,就要往聚簇索引、唯一索引、临键锁这些方向说。这里要特别注意,InnoDB 的行锁必须建立在索引之上,而且如果查询条件不能确定唯一记录,锁的范围会扩展到间隙甚至整张表。能把「锁与索引的关系」讲清楚,问题基本就过关了。

其实面试官最想听的不是定义,而是你真实项目里怎么选的。所以我在回答时一定会带一个自己的实践案例,比如库存扣减在低并发下用乐观锁、高并发下改悲观锁,或者反过来,把选型理由说清楚。

5.2 加分表达:结合场景解释“为什么这样选”

我特别建议在回答最后加一句:“锁没有好坏之分,只有适不适合当前业务场景。”然后马上给例子:

如果业务是扣费,哪怕并发失败也不能接受账目对不上,这类场景优先悲观锁,用事务+行锁强一致;如果业务是统计类,比如文章阅读量、点赞数,偶尔丢一次更新也无所谓,那乐观锁 + 重试就很合适,性能好实现也简单。

如果面试官追问“那你怎么判断某个场景该用哪种”,你可以从三个维度回答:冲突频率、一致性要求、实现成本。冲突频率低选乐观锁,冲突频率高选悲观锁;一致性要求极高选悲观锁,允许最终一致可以用乐观锁;开发团队对事务控制熟悉度不高,用乐观锁更安全。

另外可以提一下分布式场景。MySQL 的悲观锁只对单库单表生效,一旦涉及分库分表或微服务跨库操作,本地锁就不够用了,需要引入 Redis 分布式锁或者 ZooKeeper 互斥锁。而乐观锁因为不依赖数据库的锁机制,反而更容易跨库迁移,自增版本号在分布式系统里也天然适用。

5.3 容易被追问的细节:这些锁到底锁什么、怎么释放

面试中最容易被问倒的细节有两个:第一,SELECT ... FOR UPDATE在 MySQL 里到底锁住了什么;第二,锁什么时候释放。

第一个问题,如果查询条件命中主键索引,InnoDB 会对主键索引对应的那条记录加锁;如果命中二级索引,InnoDB 会先锁二级索引,再锁对应主键索引;如果条件没有索引,会锁整个表。在可重复读隔离级别下,FOR UPDATE还会额外给扫描到的范围加间隙锁,防止其他事务插入新的记录,这也是悲观锁在高并发下死锁率高的一个原因。

第二个问题,锁的释放只在事务提交或回滚时发生。这意味着你执行完SELECT ... FOR UPDATE之后,并没有马上释放锁,锁还在数据库里待着,直到代码走完COMMITROLLBACK。如果忘写事务提交,锁会一直留着,所有相关请求都会被堵死。这段逻辑在代码里一定要加try-finallytry-with-resources保证事务一定会被关闭。

其实锁释放还有一个细节:在自动提交模式下,单条UPDATE语句执行完成后会立即提交,锁也立即释放。但SELECT ... FOR UPDATE加上显式事务后,锁的释放就变成了人工控制。这一点一开始不习惯的话,很容易出现“代码里查了一句锁,结果后面一堆请求卡住”的线上问题。

综合来看,锁相关的问题说难不难,说简单也不简单,关键看你能不能把自己的实操经历和底层原理串起来讲。最终面试官记住的是你处理过真实问题,而不是你背过多少个概念。

6. 关于这两个锁,我最后想分享的几个实操体会

一开始接触这两个概念时,我也觉得很简单:一个加锁,一个不加锁,就这么回事。但真正在项目里处理并发问题,踩过几次坑后才发现,锁的选择往往不是概念本身,而是业务场景、事务边界、索引设计和异常处理这些细节的组合。

我自己的习惯是,新项目里优先默认乐观锁,因为它的侵入性最小,只需要加个version字段,改一下 update 语句,基本不会影响现有代码结构。只有等压测数据明确显示冲突率偏高,或者业务逻辑里存在“必须先读再写”且一致性要求极高的场景,我才会改成悲观锁。这个顺序比较稳,不至于一开始就把系统锁死。

另外,无论选哪种锁,我都建议你加好监控。锁等待时间、死锁次数、更新失败重试次数这三个指标,是我判断锁方案健康度的关键信号。没有监控的锁方案,就像油门踩到底却不知道发动机温度的车,迟早出问题。

如果你准备动手实践,建议先建一张商品表,写一个带version字段的乐观锁扣减库存脚本,再搭一个事务里执行SELECT ... FOR UPDATE的悲观锁版本,直接对比一下两个方案在并发下的表现。相信我,跑完一遍数据,你对这两个概念的理解会比看十篇文章都深。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询