news 2026/10/20 8:07:16

MySQL并发控制:SELECT FOR UPDATE与悲观锁、乐观锁实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL并发控制:SELECT FOR UPDATE与悲观锁、乐观锁实战

我在接手一个订单库存模块时遇到过一种典型并发问题:两个用户几乎同时下单,影响到同一条库存记录,后台日志显示两条扣减SQL都执行成功了,但最终库存只减了一笔。排查之后发现,整个团队对MySQL锁机制的理解停留在“update会自动加锁”这个层面,连SELECT ... FOR UPDATE和乐观锁都没怎么用过。这篇文章就围绕MySQL中SELECT ... FOR UPDATE与悲观锁、乐观锁的关系做一次系统梳理,覆盖锁原理、SQL写法、选型依据和常见坑,适合刚开始处理数据一致性的后端开发、DBA,以及日常写业务代码时经常面临并发更新的同学参考。

1. 为什么并发更新会丢数据:先看没有锁会发生什么

1.1 一个标准的库存扣减最小案例

假设我们有一张非常简单的库存表:

CREATE TABLE inventory ( id INT PRIMARY KEY, sku_code VARCHAR(32) UNIQUE, stock INT NOT NULL, version INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB;

初始数据是id=1, stock=10。正常业务会先查出剩余库存,判断数量够,再扣减。于是两个请求可能都会执行类似这样的逻辑:

-- 事务A START TRANSACTION; SELECT stock FROM inventory WHERE id = 1; -- 假设读到 stock = 10,判断可以扣减 UPDATE inventory SET stock = stock - 1 WHERE id = 1; COMMIT; -- 事务B几乎同时执行 START TRANSACTION; SELECT stock FROM inventory WHERE id = 1; -- 也读到 stock = 10,也判断可以扣减 UPDATE inventory SET stock = stock - 1 WHERE id = 1; COMMIT;

最后这个字段变成9,而不是8。这里要澄清一个容易混淆的点:UPDATE本身在InnoDB里是会加行锁的,两个事务如果同时对同一条记录执行UPDATE,后者一定会等前者释放锁。真正出问题的是“先SELECT出来、在应用层判断、再UPDATE回去”这个读改写(read-modify-write)过程。

SELECT在没有加锁前缀时是快照读,两个事务都能读到10;然后各自基于10去扣减,成了典型的丢失更新。锁是加在UPDATE执行瞬间的,但那时已经来不及发现数据被改过了。换句话说,问题的本质不是MySQL没锁,而是业务层没有把“读”和“写”放进同一个受保护的操作窗口里。

1.2 丢失更新需要同时满足哪些条件

复盘这个案例,能总结出丢失更新发生的三个必要条件:

  • 两个或多个事务并发地读取同一个数据项。
  • 至少一个事务的写入决策依赖读取到的旧值。
  • 没有一种机制能强迫后写者基于最新值重新判断。

只要这三个条件同时成立,就会出现“最后写入覆盖前面写入”的效果。MVCC(多版本并发控制)能解决读写互相阻塞的问题,让读不会等写、写不会等读,但它并不能解决写写冲突下的丢失更新。很多人误以为“MySQL默认隔离级别是REPEATABLE READ,所以不会有并发问题”,这个理解是有偏差的。REPEATABLE READ保证的是同一事务内多次快照读的结果一致,它不保证两个事务的读改写序列正确。

带图片的话,可以这样理解:两个人在同一个文档上编辑,各自打开的是同一个旧版本副本,然后分别保存,后保存的人把前面人的改动覆盖了。数据库里的丢失更新和这个场景非常像。

1.3 快照读与当前读:为什么会读到旧数据

要真正理解SELECT ... FOR UPDATE,先得知道InnoDB里存在两种读取方式。

普通SELECT是快照读。它会根据当前事务的开始时间或第一条快照读语句时间,生成一个一致性视图,之后读取时直接读这个视图里的历史版本,不需要加锁,也不会被别的事务的写操作阻塞。

SELECT ... FOR UPDATE、UPDATE、DELETE则是当前读。当前读必须拿到数据行的最新已提交版本,并且对读取到的记录加锁。如果某条记录正在被其他事务修改,当前读会一直等到对方提交或回滚。

有个生活化的类比:一群人共用一台打印机。快照读相当于看打印任务的备份清单,不会影响正在打印的人;当前读相当于直接走到打印机边上准备修改打印队列,如果打印机正被别人占着,你得等TA用完。

正是因为SELECT默认不带锁,普通查询才会读到并发事务尚未提交或被修改前的内容。想让“查询后立刻处理”这个动作具备一致性,就需要显式地把读变成当前读,这就是FOR UPDATE存在的意义。

2. SELECT ... FOR UPDATE 到底做了什么:悲观锁的加锁链路

2.1 从语法到锁类型:FOR UPDATE 在事务内加了一把排他锁

SELECT ... FOR UPDATE必须在事务里使用,单独执行时,如果连接处于自动提交模式,语句执行完立刻提交,锁也会马上释放,等于没加。正确用法是先开启事务,再执行查询:

START TRANSACTION; SELECT stock, version FROM inventory WHERE id = 1 FOR UPDATE; -- 在这里做业务判断 UPDATE inventory SET stock = stock - 1 WHERE id = 1; COMMIT;

这行查询会对id=1这条记录加一把排他锁,也就是X锁。X锁的特点是:同一时刻只能有一个事务持有;其他事务想对这行执行UPDATE、DELETE、SELECT ... FOR UPDATE,都必须等待;但普通SELECT可以继续读,因为快照读不需要碰锁。

这种锁策略之所以被称为悲观锁,是因为它默认“冲突一定会发生”。就像过独木桥,每个人过桥前先申请“桥归我管”,别人都等着,等我过完再把控制权交出去。代价是并发度下降,收益是逻辑非常简单,只要锁没加错,并发下不会出现读改写竞争。

2.2 加锁范围:主键、二级索引和无索引条件差别巨大

FOR UPDATE到底锁一行还是锁一堆,取决于WHERE条件怎么走索引。这点是使用悲观锁时必考的知识点,也是最容易被忽视的。

第一种情况,按主键定位:

SELECT * FROM inventory WHERE id = 1 FOR UPDATE;

主键是唯一索引,InnoDB能精确定位到一条记录,其他事务对id=1的写操作会被阻塞,但对id=2完全没影响。这是粒度最理想的情况。

第二种情况,按二级唯一索引定位:

SELECT * FROM inventory WHERE sku_code = 'SKU-1001' FOR UPDATE;

如果sku_code上有唯一索引,InnoDB会先在二级索引里找到对应记录,对二级索引记录加锁,再回到主键索引加锁。最终锁定的仍然是一条记录,并发影响面可控。

第三种情况,按未加索引的字段查询:

SELECT * FROM inventory WHERE supplier_id = 'S-88' FOR UPDATE;

如果supplier_id没有索引,查询只能全表扫描。InnoDB会把扫描过程中碰到的所有记录全部加锁,结果就是一个事务执行下来,整张表能写的记录几乎都被堵住。严格来说这不是表级锁,但效果和锁表非常接近。这种问题是生产环境最容易踩的雷,后面我会单独展开。

所以使用FOR UPDATE之前,一定要先看一眼WHERE条件的索引。一个简单习惯是执行EXPLAIN确认访问类型是const、eq_ref或range,而不是ALL。

2.3 和普通 SELECT、UPDATE 的关系:FOR UPDATE 到底多了什么

有同学会问:既然UPDATE本身也会加锁,为什么不直接写UPDATE,还要先SELECT ... FOR UPDATE?

关键区别在于业务动作的先后顺序。如果只有一个更新操作,不依赖查询结果来决定是否更新,那么直接UPDATE就行,不需要FOR UPDATE。但现实中很多业务是“先查出来,判断状态,再决定改不改”,比如订单状态从“已支付”流转到“已发货”,处理逻辑可能包括查询订单、核对金额、校验库存、写入多张表。这个判断和写入之间有一个时间窗口,如果不在事务开始时用FOR UPDATE把目标行锁住,其他事务完全可以在查询之后、更新之前把状态改掉。

UPDATE本身是当前读加写锁,它锁定的是“语句执行时匹配到的行”。而FOR UPDATE是把“读”的时点提前锁定,相当于你先把资源预定下来,再慢慢做后面的事情。用通俗的话说,UPDATE是边读边锁,FOR UPDATE是先锁后读。处理复杂业务时,后者能避免“看到的状态”和“操作时的状态”不一致。

另外,FOR UPDATE只影响同一记录上的写和当前读,不阻塞普通SELECT。这意味着如果所有读操作都用普通查询,互相之间完全并行,只有当真正想要修改记录时,事务才会被排队。悲观锁在这个层面并不是完全串行,只是把修改串行化了。

3. 乐观锁:不锁数据,靠版本号挡住冲突

3.1 “乐观”二字的含义与核心思路

和悲观锁相反,乐观锁默认“冲突是少数情况”,不提前加数据库锁,也不阻塞任何人。它利用的是条件更新时的原子性:把“数据是否被别人改过”的判断,放进UPDATE的WHERE条件里。

最常用的是版本号方案。给业务表额外增加一个version字段,每次读取数据时拿到版本号,更新时带上“版本号必须等于我读到的值”这个条件,并且同步把版本号加一。如果更新影响行数等于0,说明这期间版本号已经变了,也就是数据被别人改过,本次更新视为失败,可以选择重试或中止。

这种思路不只存在于数据库,很多分布式协调服务里也有类似设计。本质上是把冲突检测从“数据库锁”转移到“业务字段比对”,把加锁时间从“整个事务期间”缩短到“单条UPDATE执行瞬间”。

3.2 一份可以直接抄的乐观锁 SQL 写法

继续用库存表举例。业务第一次查询:

SELECT id, sko_code, stock, version FROM inventory WHERE id = 1;

应用层拿到stock=10, version=3,在内存里计算新库存9。接下来不是直接更新,而是执行:

UPDATE inventory SET stock = 9, version = 4 WHERE id = 1 AND version = 3;

UPDATE执行时,如果这行记录当前version还是3,说明没人动过,条件成立,这一行被更新,影响行数为1。如果期间有另一个事务把version改成了4,条件不成立,影响行数为0,应用层就知道这次更新失败了。

检测失败以后怎么办?常见做法有两个:

  • 重新查询最新stock和version,重复上面的过程,直到成功或达到重试上限。
  • 直接把失败返回给前端,提示“操作太频繁,请重试”。

为了方便复用,可以把版本号更新写成一条固定格式的语句:

UPDATE t SET business_field = #{newValue}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion};

这里有个小细节:SET里的version = version + 1写的是自增表达式,不是在应用层算好的固定值。这样即使同一事务内多次更新,版本号也不会出错。

必须承认,乐观锁里的UPDATE也会加行锁,但它只在语句执行的极短时间持有锁,不会像FOR UPDATE那样跨多个查询和判断持有锁。所以高并发读多写少场景下,乐观锁通常能带来高得多的吞吐。

3.3 乐观锁不适用的情况

乐观锁看似简单,但也有不适用的场景。

首先是写冲突频繁的业务。比如某个热门商品的库存只有3件,几万个用户同时在抢,几乎每次版本比对都会失败,应用层就得反复重试。重试本身会放大数据库压力,可能造成“重试风暴”。这种情况用悲观锁排队反而可控。

其次是长事务场景下的ABA问题。严格说版本号能识别修改次数,但如果业务只关心“终态是否变化”,比如订单状态从“待支付”到“已取消”,即便中间状态被来回修改多次,最终状态没变,也需要额外小心处理。版本号方案默认每一次修改都有意义,如果需求只关心最终值,字段值条件更新可能比版本号更合适。

第三,乐观锁要求应用层写重试逻辑,这比直接加FOR UPDATE多了一层代码复杂度。很多团队第一次引入乐观锁时,只做了条件更新,忘了处理影响行数为0的后续逻辑,结果失败请求静默吞掉,反而造成数据不更新。这一点需要在设计阶段就想清楚。

4. 悲观锁与乐观锁的选型对照:性能、业务与实现成本

4.1 一张可以直接保存的对照表

把两套方案放在一张表里看,选择会清晰很多:

对比维度SELECT ... FOR UPDATE乐观锁版本号
加锁时机查询的同时加锁,持续到事务结束只在UPDATE执行瞬间加行锁
冲突处理后到的事务排队等待条件不满足,立即失败返回
实现难度SQL简单,业务逻辑不变需要增加version字段和重试逻辑
并发能力相对低,受锁等待影响相对高,读和普通写互不阻塞
长事务风险高,锁长时间占用低,不需要锁跨语句持有
典型场景订单支付、状态流转、金额操作库存扣减、计数器、点赞收藏
死锁风险有,范围锁和锁顺序不当会产生较低,但仍可能在UPDATE时产生锁等待
对索引的依赖高,无索引会锁全表同样依赖索引,但影响面可控

这张表不是标准答案,而是选型时的思考框架。遇到具体业务,还要结合并发量、事务时长、冲突概率和团队对重试逻辑的维护能力一起判断。

4.2 我实际观察到的两类项目选择

我接触过的项目里,凡是资金相关、强一致的模块,基本都用悲观锁。典型场景是“订单支付后更新余额”,因为这种更新必须严格串行,用FOR UPDATE可以做到一条记录同一时间只有一个事务在处理,逻辑简单,不容易引入脏数据。

反过来,统计类和社交类模块更愿意用乐观锁。比如文章的阅读数、商品的收藏数,这些字段会被高频累加,允许一定的失败重试,也不希望一个用户的长事务把其他用户的请求全部堵住。用版本号或者用单条条件累加更新,通常比FOR UPDATE更合适。

还有一类折中做法:对核心主记录用悲观锁,对可丢弃的统计数据用乐观锁。比如下单时锁定订单记录,防止状态被并发修改;同时更新商品销量时用UPDATE ... WHERE stock > 0这样的条件更新,冲突就失败,不再重试。这种混合使用在真实系统里比非此即彼更常见。

4.3 怎么判断你的场景该用哪边

我的判断习惯是先问三个问题。

冲突概率高不高?如果同一行记录大概率会被并发修改,悲观锁更容易保证正确性,也更省应用层重试成本;如果冲突概率低,乐观锁足够。

事务需要执行多久?FOR UPDATE把锁从读取一直 hold 到COMMIT,如果事务里还要调用外部接口、做网络请求、批量导入数据,锁的占用时间会非常长。这种情况应该尽量缩小事务边界,或者改用乐观锁。

能不能承受锁等待超时?悲观锁遇到被占用时,会一直等到innodb_lock_wait_timeout结束,默认50秒;而乐观锁遇到冲突会立刻返回失败。对用户体验要求高的场景,立刻失败可能比无脑等待更好。

5. 用 FOR UPDATE 最容易踩的坑:锁堆积、全表锁与死锁

5.1 事务不提交,锁在连接池里悄悄堆积

前面强调了FOR UPDATE的锁要等事务结束时才释放。实际项目里最常见的故障是:开发者在事务里执行了SELECT ... FOR UPDATE,但因为某一步异常没有走到COMMIT,既没有ROLLBACK,连接也没关,于是锁一直挂着。

这种问题的隐蔽之处在于,单独执行SELECT ... FOR UPDATE时,MySQL会返回查询结果,看起来一切正常。可一旦这条连接被连接池复用,事务可能延续到下一次请求。最终表现是连接池被耗尽,后面所有访问同一行记录的请求全部阻塞。

排查时可以查一下正在执行的事务:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx;

如果看到trx_state = RUNNING且trx_started很久没变,大概率就是有事务没关。业务代码里务必把事务控制在最短范围,用try...catch...finally保证提交或回滚:

try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); // 查询、判断、更新 conn.commit(); } catch (Exception e) { conn.rollback(); throw e; }

如果使用的框架带了声明式事务注解,还要注意事务方法不能把SELECT和后续更新拆到不同方法,避免锁的范围超出预期。

5.2 条件字段没索引,FOR UPDATE 锁住了整张表

这是第二个高频事故。业务同学给supplier_id这种普通字段加了FOR UPDATE,但这个字段没有索引。前面说过,InnoDB锁不了“不存在”的记录,它会锁住扫描路径上的所有记录。两个事务明明操作的是不同行,也可能互相阻塞,数据库并发直接从“行级”退化成“全表级”。

一个很反直觉的事实是:即便WHERE条件能筛选出小结果集,只要字段没有索引,优化器就只能做全表扫描,锁的范围跟着变成全表。可以用EXPLAIN提前验证:

EXPLAIN SELECT * FROM inventory WHERE supplier_id = 'S-88' FOR UPDATE;

重点看type字段。如果是ALL,意味着全表扫描,锁的范围很可能失控。这种情况下必须补索引:

ALTER TABLE inventory ADD INDEX idx_supplier_id (supplier_id);

补完索引再跑EXPLAIN,type通常会变成ref或range,锁的影响面就会收缩到匹配行。

5.3 间隙锁引发的插入阻塞与死锁

默认隔离级别REPEATABLE READ下,使用FOR UPDATE做范围查询或非唯一索引查询时,InnoDB不仅会锁命中的记录,还可能锁住索引区间。这叫间隙锁。间隙锁的作用是防止幻读,副作用是另一个事务往这个区间插入新记录时,也会被阻塞。

比如执行:

SELECT * FROM inventory WHERE stock > 5 FOR UPDATE;

即便库存表里没有满足条件的其他记录,InnoDB也可能给stock索引上的间隙加锁,其他事务想插入一条stock=8的新记录也要等待。

两个事务如果以不同顺序去锁相同的记录集合,还可能产生死锁。比如事务A先锁id=1再锁id=2,事务B先锁id=2再锁id=1,两者都会卡在下一步,InnoDB会检测到死锁并回滚其中一个事务。定位这类问题,除了看应用日志里的“Deadlock found”,还可以用MySQL自带的诊断命令:

SHOW ENGINE INNODB STATUS;

重点关注LATEST DETECTED DEADLOCK部分,里面会显示两个事务各自持有的锁和正在等待的锁。实际开发中,我通常会要求系统里所有对同一组表的加锁顺序保持一致,并且尽量用主键或唯一索引来定位单行,缩小锁范围。

5.4 锁等待诊断:几条查询直接找到“谁堵了谁”

当线上出现Lock wait timeout exceeded错误时,首先要找出持有锁的事务和等待锁的事务。MySQL提供的视图在不同版本有变化,下面这几条比较通用。

查看所有当前事务:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;

查看锁等待关系:

SELECT * FROM performance_schema.data_lock_waits;

这条查询可以帮你看到REQUESTING_ENGINE_TRANSACTION_ID和BLOCKING_ENGINE_TRANSACTION_ID。找到阻塞者对应的线程ID后,进一步查它正在执行什么SQL。如果确实是一个长时间未提交的事务,可以和业务方确认后结束它。生产环境请谨慎处理,先评估影响再决定是否中断。

MySQL 8.0对FOR UPDATE还提供两个很有用的后缀:NOWAIT和SKIP LOCKED。加上NOWAIT后,如果目标行被其他事务锁住,语句不会等待,而是直接报错返回;加上SKIP LOCKED会跳过已经被锁的行,适合做任务队列之类的抢单场景。这也是一种避免锁等待堆积的有效方式:

SELECT * FROM inventory WHERE id = 1 FOR UPDATE NOWAIT;

不过这两个后缀对版本有要求,升级前需要确认业务数据库的版本支持情况。

最后分享一点我的实操体会

接触并发控制这几年,我最大的感受是:悲观锁和乐观锁不是竞争关系,而是两种不同压力场景下的互补工具。SELECT ... FOR UPDATE并不复杂,复杂的是规划好事务边界、确认好索引、想清楚锁被阻塞时业务该怎么办。乐观锁也不是“不加锁所以一定快”,它的快是建立在冲突不高、重试可控的前提下的。

现在我写任何一段并发更新逻辑前,都会先自己过一遍:这段逻辑是否真的在事务里?条件字段有没有索引?冲突概率高不高?如果是FOR UPDATE,是否加了NOWAIT或合理设置了超时时间?如果是乐观锁,影响行数为0时到底该怎么处理?

另外,再分享一个小技巧:对于预估会高频抢占同一行的业务,与其让大量请求都挂在那里等锁,不如先用一个短连接跑一次SELECT ... FOR UPDATE NOWAIT,失败就让请求快速返回或走后端重试队列。这样做既能保住数据一致性,又不会把数据库的连接槽位打满。并发控制没有银弹,想清楚锁的持有时间和冲突成本,比背方案更重要。

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

AI Agent架构演进:从单次LLM调用到Harness Runtime的完整路径

最近身边不少团队都在同一个问题上打转:AI Agent架构到底怎么选?有人用一次LLM调用就把Demo跑通了,觉得再上复杂框架纯属折腾;也有人一上来就直奔完整的Harness Runtime,结果被状态同步、异常恢复、日志回放这些事拖得…

作者头像 李华
网站建设 2026/10/20 8:06:28

LangGraph高可用设计:节点故障熔断与检查点恢复实战

做LangGraph落地也有小半年了,团队从最初“画图一时爽”的状态,到现在被生产环境的几个故障折腾得够呛。凡是多节点、有状态、依赖外部API的Agent工作流,迟早会碰到同一道坎:节点故障、服务熔断、自动恢复。今天把这段时间踩过的坑…

作者头像 李华
网站建设 2026/10/20 8:05:57

实测10个AI论文网站:从选题到盲审的本科生高效写作指南

又到毕业季,本科生论文这座山是真的难爬。从选题、文献综述、初稿、降重到盲审,每一步都有人卡壳。我的导师今年给教研室整理了一份工具清单,核心思路不是让AI替你写论文,而是用AI网站把“检索信息、整理框架、打磨语言、核对格式…

作者头像 李华
网站建设 2026/10/20 8:05:20

Python实现RSA算法:从数学原理到加解密实践完整解析

RSA算法是信息安全领域最经典的公钥加密方案,其核心数学基础涉及素数判定、欧拉函数、模逆元运算和快速模幂算法。这篇文章我会把RSA从数学原理到Python代码实现完整过一遍,覆盖密钥生成的每个环节、加解密流程的完整设计,以及我实际调试中踩…

作者头像 李华
网站建设 2026/10/20 8:03:51

中专大数据与会计专业学数据分析:从Excel到BI的实战价值

这几年我经常被两类人问同一个问题:中专大数据与会计专业学数据分析,到底有什么用?一类是学生本人,觉得专业名里挂着“大数据”三个字,但翻了课表也没搞明白大数据到底在哪;另一类是家长,担心孩…

作者头像 李华
网站建设 2026/10/20 8:03:39

ESP32+MicroPython驱动BNO08X:免写滤波的姿态解算方案

1. 一个自带姿态解算的IMU:BNO08X到底解决了什么问题如果嵌入式项目需要姿态数据,早期我第一反应肯定是买裸的九轴或六轴IMU传感器,然后自己写互补滤波、卡尔曼滤波,再解决陀螺零偏、加速度噪声这些问题。这套东西不是不能做&…

作者头像 李华