我在接手一个订单库存模块时遇到过一种典型并发问题:两个用户几乎同时下单,影响到同一条库存记录,后台日志显示两条扣减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,失败就让请求快速返回或走后端重试队列。这样做既能保住数据一致性,又不会把数据库的连接槽位打满。并发控制没有银弹,想清楚锁的持有时间和冲突成本,比背方案更重要。