☰
select for update 锁机制全解析:行锁、间隙锁、死锁与正确用法
2026/10/19 10:07:00 网站建设 项目流程

并发扣库存超卖、账务多扣、重复下单——这些线上事故几乎都能追溯到同一行代码:select ... for update。很多人对它有个错觉:只要加了for update,数据就安全了。但实际生产中,我见过太多人因为不了解它的加锁边界和释放时机,反而制造出更严重的锁等待、死锁,甚至直接把数据库拖垮。这篇文章不打算讲一堆教科书理论,而是从真实的踩坑链路出发,把for update的原理、常见误区、排查思路和正确用法一次说透。

1. 从一次库存超卖说起:for update不是"加个select"那么简单

1.1 超卖现场与一份典型的错误代码

先还原一个场景。某仓储系统的库存表大概是这个结构:

CREATE TABLE `stock` ( `id` bigint NOT NULL AUTO_INCREMENT, `sku_id` bigint NOT NULL, `quantity` int NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku` (`sku_id`) ) ENGINE=InnoDB;

业务逻辑很普通:用户下单,扣减库存。某开发者的第一版代码长这样:

@Transactional public boolean deduct(Long skuId, int num) { Stock stock = stockMapper.selectBySkuId(skuId); // 普通select if (stock.getQuantity() < num) { throw new BizException("库存不足"); } int rows = stockMapper.deduct(skuId, num); // update stock set quantity = quantity - num return rows > 0; }

看着没什么问题:先查库存,够就扣,不够就抛异常。但上线第一天就出事了——两个用户同时下单,都查到库存还剩1件,都判断"够了",然后各自执行扣减。结果库存扣成-1,超卖。

问题出在哪?普通select查的是快照。在InnoDB的MVCC机制下,事务A和事务B查到的数据是"某一时刻的快照",彼此看不到对方还没提交的修改。所以两个事务都算出"库存充足",这是典型的"读己之快照,写他人之坟"。

1.2 快照读与当前读:for update真正解决的问题

要理解for update,必须先分清两个概念。

快照读(Snapshot Read):也就是普通select,读的是undo log中构造的历史版本。它不加锁,并发性能好,但读到的可能是旧数据。当前读(Current Read):select ... for update、select ... lock in share mode、insert/update/delete都属于当前读,读的是数据的最新已提交版本,并且会对读取的记录加锁,防止其他事务同时修改。

还是刚才的扣库存场景,改成for update之后:

@Transactional public boolean deduct(Long skuId, int num) { Stock stock = stockMapper.selectBySkuIdForUpdate(skuId); if (stock.getQuantity() < num) { throw new BizException("库存不足"); } int rows = stockMapper.deduct(skuId, num); return rows > 0; }

事务A执行select ... for update后,持有这行库存记录的锁;事务B执行同样的for update时,会被阻塞住,直到事务A提交或回滚、释放锁之后,B才读到A修改后的最新库存。这样B就能看到quantity已经变成0,从而正确抛出"库存不足"。这才是for update的核心价值:把并发下"判断-修改"的竞态窗口给堵死。

不过,堵死了竞态,也堵出新的问题。锁有了,锁在哪、锁多久、锁多大,这是接下来三个最重要的坑。

2. 锁的作用域:你以为锁了一行,实际锁了一片

2.1 决定加锁范围的三个关键因素

很多人以为select ... for update where id = 1就只锁id=1这一行。这个理解在特定条件下成立,但不绝对。InnoDB行锁到底锁多少行,取决于三个因素:索引类型、查询条件形态、隔离级别。

先看索引类型。InnoDB的行锁是加在索引记录上的。如果where条件命中主键或唯一索引,并且是等值精确匹配,那么它只锁这一条索引记录——这是最理想的"行锁"。如果where条件命中普通索引(非唯一索引),情况就复杂了:InnoDB锁住满足条件的索引记录之外,还会对这些记录之间的间隙加锁。最怕的是where条件连索引都没有,那InnoDB只能走聚簇索引全表扫描,把扫描途中碰到的每一行都加锁——效果跟锁表基本没区别。

再看查询条件形态。等值查询和范围查询的加锁策略完全不同。等值查未命中的记录时,会在不存在的那个位置留下一个"间隙锁";范围查询(比如where id > 100 and id < 200)会锁住落在范围内的所有记录及其间隙。最后是隔离级别。MySQL默认的REPEATABLE READ下,间隙锁是默认启用的;切到READ COMMITTED后,间隙锁会被禁用,但可重复读能力也随之丢失。这也是很多人在切换隔离级别后,明明代码没动,行为却变了的原因。

2.2 等值查询、范围查询、索引失效三种情况对比

下面这张表是我整理过的加锁对照,基本覆盖了日常开发最常见的场景(以InnoDB、REPEATABLE READ为例):

查询形态条件类型加锁范围
等值查询命中主键/唯一索引id = 100,记录存在仅该记录上的行锁(Record Lock)
等值查询未命中唯一索引id = 999,记录不存在该位置附近主键索引上的间隙锁(Gap Lock)
等值查询命中普通索引sku_code = 'A001'普通索引记录锁 + 记录间间隙锁 + 对应主键记录锁
范围查询id > 100范围内所有记录锁 + 范围内所有间隙锁(含边界间隙)
无索引条件goods_name = '手机'聚簇索引全表扫描路径上的所有记录锁,等价于锁整表

注意看最后一行。在实际生产里,我排查过不止一次"数据库突然所有写操作都卡住"的故障,最后定位到根因都是某个for update或者update语句的where字段没索引。比如运营后台有人执行了一个select * from orders where buyer_nick = 'xxx' for update,buyer_nick没有索引,这一下就把orders表所有扫描行都锁住了。整个订单写入链路全部阻塞,直到那个事务被kill。

所以,for update使用的第一个铁律:where条件必须能精准命中主键或唯一索引,否则你拿到的根本不是"行锁",而是"表级噩梦"。

2.3 间隙锁与幻读的隐藏陷阱

间隙锁(Gap Lock)是REPEATABLE READ级别下防幻读的关键手段。它锁的是一个区间,目的是阻止其他事务在这个区间内插入新记录。

举个例子,事务A执行:

select * from stock where id between 10 and 20 for update;

它不只会锁住id=10到20之间已存在的记录,还会把这个区间内所有"空位"锁住。事务B想插入一条id=15的新记录,会被阻塞,直到事务A提交。这本来是防止幻读的正常设计,但对业务并发度杀伤极大:你只是想锁几行数据做更新,结果连"在区间里插新数据"的能力都被冻结了。

我见过一个比较典型的翻车案例:一个配置表需要批量加锁读取后做汇总,用select * from config where type = 'member' for update,type是一个普通索引,结果在package config_type_id=6的间隙上产生锁等待,导致新配置项插入全部排队。处理办法其实有多种:把查询条件改成主键等值、把隔离级别降到READ COMMITTED、或者缩小查询范围。具体选哪种,要看业务能否接受可重复读能力被削弱。

3. 连环坑:事务失效、锁持有过久、死锁的完整翻车链路

3.1 事务边界失效:第一个坑80%的人踩过

for update的锁是在事务提交或回滚时释放的。这句话听着是常识,但一旦代码里事务边界没弄对,"锁等于没锁"的情况就极其常见。

最常见的翻车姿势有两类。

第一类,忘了包事务。你的DAO里写了一条带for update的SQL,但调用它的Service方法没有@Transactional注解,或者事务配置是autocommit模式。这个时候,select ... for update执行完,MySQL就自动提交了,锁立刻释放。后面跟着的update等操作,根本不在同一个事务里。两个并发请求都能先后拿到锁,超卖问题原样复发。

第二类,@Transactional失效。这个坑更隐蔽。Spring的声明式事务是基于动态代理实现的,当出现以下情况时,注解不会生效:

@Service public class StockService { // 方法A自己调用了同类中的方法B,而方法B才是带事务的方法 public void outerMethod() { this.innerDeduct(); } @Transactional public void innerDeduct() { ... } }

因为this.innerDeduct()走的是当前对象直接调用,没经过Spring代理,事务注解自然不生效。同理,private方法、final方法,或者把@Transactional放在接口方法上而实现类实现方式不对,都可能出现"我以为在事务里,其实没在"的局面。排查这类问题有个很土但有效的办法:查一下连接是否在事务中,比如看一下数据库performance_schema里的会话状态,或者直接打印当前线程的事务状态。

3.2 持锁时长失控:锁内做外部调用的后果

解决了"没进事务",下一个坑是"在事务里待太久"。

for update的锁是全事务持有的——只要事务没提交,锁就一直被占着。而事务什么时候提交,取决于事务里的代码跑了多久。很多开发者会在锁定的代码块里做一些跟数据库一致性无关的事:

@Transactional public Order createOrder(Long skuId, int num) { Stock stock = stockMapper.selectBySkuIdForUpdate(skuId); if (stock.getQuantity() < num) { throw new BizException("库存不足"); } // 这里调用远程库存锁定服务,网络超时3秒 remoteInventoryService.lock(skuId, num); // 这里又调了一次用户积分接口,耗时2秒 userService.deductPoints(...); // 最终才扣库存、生成订单 stockMapper.deduct(skuId, num); orderMapper.insert(...); return order; }

这串代码里,锁从select for update开始,到整个方法末尾事务提交才释放。中间的远程调用耗时5秒、网络重试再来3秒——这段时间内,所有想扣同一件商品库存的请求全部堵在for update上。一个下单QPS只有十几的接口,能因为单次持锁8秒,把整个库存链路卡死。

**事务边界的设计原则是:锁内只做必要的数据库读取和一致性判断,其他能挪走的操作一律挪出事务。**远程调用、消息发送、文件操作、耗时的纯计算,都不该出现在事务里。实在没法避免,就缩小事务粒度,或者把"预占库存"和"确认扣减"拆成两步独立事务,中间用本地消息表保证最终一致性。

3.3 死锁AB-BA:怎么避免与怎么恢复

死锁是for update并发场景下的经典事故。最典型的是AB-BA型:

  • 事务A:select ... for update where id = 1,然后select ... for update where id = 2
  • 事务B:select ... for update where id = 2,然后select ... for update where id = 1

如果两个事务各持有一把锁,又都在等对方释放另一把锁,就出现了循环等待。InnoDB的死锁检测机制会在发现死锁后,选择回滚其中一个事务(通常是undo量少、代价较小的那个),并抛出Deadlock found when trying to get lock错误。

死锁发生后,业务方的处理通常是:捕获到Deadlock异常,然后重试整个事务。但从根源上,更有效的手段是所有事务都按同一顺序加锁。比如约定"多行资源操作时,先锁id小的,再锁id大的"。这样A锁1等2、B锁1等2,B会等A释放,不会形成循环。

还需要注意,innodb_deadlock_detect默认开启,但高并发下它本身也有成本。当每秒事务数非常高、热行竞争激烈时,死锁检测的代价很可观。这时有些团队会考虑关闭死锁检测,靠innodb_lock_wait_timeout兜底——但这属于权衡项,不建议没压测过就改。

4. 锁等待实战排查:一条SQL卡死后的完整定位流程

4.1 show engine innodb status的第一手信息

线上突然出现大量超时报警,日志里清一色是Lock wait timeout exceeded,怎么定位是哪条SQL在等锁、哪个事务在持锁?第一步,登录数据库执行:

show engine innodb status\G

输出内容很长,重点看TRANSACTIONS段落。它会列表展示所有活跃事务,特别是处于LOCK WAIT状态的,会明确写出当前等待的锁、等待时间、以及造成阻塞的事务ID。比如输出里会出现类似这样的信息:

---TRANSACTION 712345, ACTIVE 15 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 182930, OS thread handle 1398...

仅仅这一条还不够,因为show engine innodb status展示信息量虽大,但可读性差。它适合快速确认"是不是有锁等待",但要看清楚谁阻塞谁,还得靠下面的系统表。

4.2 用系统表找出阻塞者与等待者

以MySQL 8.0为例,最实用的是这三组表:

  • performance_schema.data_locks:当前所有事务持有的锁记录
  • performance_schema.data_lock_waits:锁等待关系
  • sys.innodb_lock_waits:整合好的视图,直接给出等待者和阻塞者的线程ID

一条查询就能定位:

SELECT waiting.pid AS waiting_pid, waiting.blocking_pid AS blocking_pid, waiting.waiting_query, waiting.blocking_query, waiting.wait_age FROM sys.innodb_lock_waits AS waiting;

sys.innodb_lock_waits会返回等待线程的PID(对应performance_schema.threads里的PID,也就是information_schema.processlist中的ID)、阻塞线程的PID,以及各自的SQL语句。查到阻塞PID后,先确认它是不是仍在执行的正常事务:

select * from information_schema.innodb_trx\G

里面的trx_id、trx_started(事务开始时间)、trx_rows_locked(锁行数)很关键。如果发现一个事务已经跑了几十分钟,锁了一大批行还没提交,基本就是罪魁祸首。

在MySQL 5.7里,performance_schema.data_locks还不存在,用的是information_schema.innodb_locks和innodb_lock_waits,语义差不多,对应的sys.innodb_lock_waits视图在5.7里也有。如果你用的是阿里云等托管数据库,可能还有独立提供的V$LOCK之类的视图,原理都是一样的。

4.3 一时无法杀掉阻塞事务时的处理策略

确认阻塞方之后,最直接的手段是kill这个线程:

kill 182930;

但有几种情况不能贸然kill。第一,阻塞事务可能是核心交易事务,kill掉会导致业务回滚,可能引发更多补偿问题。第二,如果阻塞方本身是高频正常的持锁事务,杀掉它只是治标,治本要改代码的加锁范围和顺序。

实际处理中,我建议按这样的优先级决策:

  1. 先判断持锁事务是否"年老体弱"——比如锁了几千行、活跃十几分钟、执行计划明显是全表扫描。这类大概率是坏SQL,直接kill。
  2. 如果持锁事务年轻且行为正常,那就看blocking_query判断它是业务主流程还是后台任务。后台任务可暂缓,前台主流程就让对方的调用方先降级重试。
  3. 找出根本原因后,改造SQL或加索引,否则后续还会复发。

另外提一句,innodb_lock_wait_timeout默认是50秒,业务等锁超过50秒就会抛超时。如果某些核心接口对响应时间要求高,可以在连接级别或全局适当调小,比如10秒甚至5秒,让等待方更快失败,而不是无限堆在数据库里。不过需要权衡:调太小,瞬时并发高峰的请求会误伤。

5. 正确编码方案:短事务、精准索引、原子操作与乐观锁兜底

5.1 事务边界设计:锁内只干锁内的事

经过了前面的踩坑复盘,最后给出一套可以直接落地的设计规范。

第一,for update必须放在整个事务的最前面,尽早获取锁,然后把判断逻辑和修改动作紧凑完成。这不是说让你锁越早越好,而是指"锁的获取"要在事务中靠前的位置,减少"还没来得及锁就被并发改了"的窗口期。锁获得了,后面的操作要尽量短平快。

第二,锁内的代码量要尽量少。我在前面提过,远程调用、消息发送不要出现在锁内。如果团队里有人写了这种代码,code review要重点盯住。一个比较狠的规范是:凡是for update的代码块,只允许纯数据库操作和JVM内存计算,禁止IO、网络、Sleep。

第三,考虑给线上库存扣减场景换一种更原子的写法。单纯扣库存,不读旧值做复杂判断的话,一条update就能搞定:

update stock set quantity = quantity - #{num} where sku_id = #{skuId} and quantity >= #{num}

quantity >= #{num}直接把"库存是否足够"下推到数据库原子条件上,不需要先select再update。影响行数为0,就是库存不足。这种写法天然免疫超卖,而且不需要for update,并发性能最好。

5.2 从update where条件看for update是否必要

既然update自带当前读和行锁,什么时候还需要额外加一次for update?这是很多人的困惑。我的判断标准是:只有当你需要"基于某个业务实体的当前状态,做跨表、跨逻辑的一致性决策"时,for update才有必要。

举个例子:用户在秒杀活动里下单,既要扣库存,又要给用户加积分,还要写订单。扣库存可以用原子update解决,但"订单金额计算"可能需要读取用户等级、优惠券状态,这些数据需要保持一致。如果不在事务里锁住核心的库存行,两个并发下单请求可能同时读到同一份用户优惠券状态,导致都按优惠价结算,最后对不上账。

在这种场景下,for update锁住核心行,让第二个事务在锁外排队,保证"先到先得、数据一致"。所以它真正解决的不是"扣减"问题,而是**"读取旧值并进行决策"的一致性**问题。这也是我在方案设计时反复权衡的核心。

5.3 参数调优与兜底方案

最后的落地上,有几个参数和配套方案值得提一下。

  • innodb_lock_wait_timeout:建议根据接口超时时间设置,我习惯设10秒左右,既给排队请求一定机会,又不会让调用方无限等待。
  • transaction-isolation:如果业务能接受READ COMMITTED,就把默认的REPEATABLE READ改掉,消除间隙锁带来的额外竞争。大多数互联网业务的数据一致性依赖的是"行锁+事务",而非"可重复读"本身,所以降级代价通常可控。
  • 乐观锁兜底:在for update之外,给关键表加version字段,更新时带条件where version = ?。这样即使for update因为某些原因失效,也能在最后写操作兜底一次,防止脏写。
  • 分布式场景补充:分库分表之后,for update只能锁单库单表节点。如果业务涉及跨分片的一致性,需要通过分布式锁(如基于缓存或数据库独立表)补足。这个属于很大的主题,本文就不展开了。

每次上线前,我会把涉及for update的事务脚本单独跑一遍并发压测,重点看两个指标:单事务平均持锁时间和锁等待超时率。如果持锁时间超过50毫秒,说明事务里干的事太多了,要拆;如果锁等待超时率超过0.1%,说明加锁范围设计不合理,优先查索引命中情况。这套标准帮我在这类问题上少踩了很多坑。

关于for update,简单总结成一句话:它是把并发问题的冲突点提前暴露出来的工具,而不是掩盖问题的魔法。锁的范围、锁的时长、锁的顺序,每一项都要在设计时想清楚。如果你正在处理类似的库存、账户或订单一致性场景,建议把本文的排查链路存下来,遇到锁等待时按图索骥。

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

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

立即咨询