ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL乐观锁与悲观锁实战:从原理到库存扣减压测全解析

MySQL乐观锁与悲观锁实战:从原理到库存扣减压测全解析

前阵子帮某电商项目做库存压测,500个并发用户抢购100件限量商品,结果一出来,订单表里躺着131条成功记录。超卖的原因一句话就能说清:扣库存的代码是先SELECT再UPDATE,两个动作之间被并发线程插了队。当时我没急着改代码,而是把团队拉在一起,把乐观锁和悲观锁这件事彻底掰扯清楚了一遍,再把方案落地、压测对比、排查踩坑全部走完。这篇文章就是那次过程的全记录,写给所有做后台开发、正在被并发更新困扰的同路人。

先说结论性的经验:锁不是越强越好,也不是越弱越好,而是要和业务冲突概率匹配。很多人一上来就上分布式锁,或者直接SELECT FOR UPDATE,其实大部分场景用一条带条件的UPDATE就解决了。乐观锁和悲观锁只是两种并发控制思路,落地到MySQL里,各有各的写法、代价和应用范围。下面我把两种方案从原理到实战拆开讲。

1. 先讲清楚:这两个锁到底在锁什么

1.1 从数据库事务的“并发三兄弟”说起

并发场景下,数据库要解决三个问题:脏读、不可重复读、幻读。脏读是读了别人没提交的数据,不可重复读是同一行读两次结果不一样,幻读是同一个查询条件读到了多出来的行。MySQL的InnoDB引擎靠MVCC(多版本并发控制)加锁组合来应对这些问题,而乐观锁和悲观锁的不同,本质上是面对这三个问题时采取的不同姿态。

悲观锁的姿态是“我不相信别人”,所以在操作数据之前先把数据锁住,让别人动不了,干完再放行。乐观锁的姿态是“我信任大多数人”,正常操作不设防,只在提交更新那一刻检查一下,看看这段时间有没有人动过这行数据,动过就放弃或重来。

这个区别直接决定了它们在性能和并发度上的天花板。悲观锁是串行化的,同一时间只有一个事务能碰某行数据,但换来的是强一致性;乐观锁是并发的,所有事务同时读、同时算,只有最后写的时候竞争一下,换来的是高吞吐,代价是冲突时需要重试。

1.2 悲观锁的思路:先堵住门再干活

悲观锁在MySQL里最典型的实现就是SELECT ... FOR UPDATE。它依托的是InnoDB的行级排他锁:事务A对某一行执行FOR UPDATE之后,事务B再对这行执行FOR UPDATE,就会被阻塞在这里,直到事务A提交或回滚。

很多新手有个误解,以为FOR UPDATE是全表锁,其实不是。InnoDB默认用的是行锁,前提是WHERE条件能命中索引。如果查询条件没走索引,行锁就会退化成对扫描到的所有行加锁,表现上跟锁表差不多。

用悲观锁做扣库存的流程大概是:

BEGIN; -- 1. 锁住这行库存 SELECT stock FROM product_stock WHERE product_id = 100 FOR UPDATE; -- 2. 业务判断有没有库存 -- 3. 够卖就更新 UPDATE product_stock SET stock = stock - 1 WHERE product_id = 100; COMMIT;

从SELECT FOR UPDATE到COMMIT,这中间其他事务只能排队等着。如果你在业务代码里还做了远程调用、缓存写入、消息推送,那这个锁的持有时间会被拉得很长,数据库的连接池很快就不够用了。

1.3 乐观锁的思路:干活时不设防,提交时对账

乐观锁在MySQL里没有对应的原生锁语法,它靠的是应用层的版本判断。常见做法是给表加一个version字段,每次更新都带上版本条件:

-- 事务A:读取到 version=7 SELECT stock, version FROM product_stock WHERE product_id = 100; -- 业务计算,然后更新 UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 100 AND version = 7;

这里有个关键动作:UPDATE执行完以后要检查影响行数。如果返回1,说明更新成功,期间没人改过这行;如果返回0,说明你手里的version已经过时了,这行数据被别的线程改了。这时候你就知道这次操作冲突了,需要重新读取再算一次。

简单来说,悲观锁是把冲突解决放在“开始”,乐观锁是把冲突解决放在“提交”。前者靠数据库阻塞,后者靠应用层重试。

2. 悲观锁实战:从SELECT FOR UPDATE到死锁排查的全链路

2.1 悲观锁的正确用法和它的底层代价

先说正确姿势。FOR UPDATE必须放在事务里,事务提交或回滚后锁才会释放。常见错误是开了事务忘了提交,或者用了自动提交模式,SELECT FOR UPDATE之后锁立刻释放,等于完全没锁。

-- MySQL默认autocommit=1,必须显式开启事务 SET autocommit = 0; BEGIN; SELECT stock FROM product_stock WHERE product_id = 100 FOR UPDATE; -- 业务逻辑... UPDATE product_stock SET stock = stock - 1 WHERE product_id = 100; COMMIT;

FOR UPDATE加的是排他锁,也叫写锁、X锁。事务A持有这行数据的X锁时,事务B既不能FOR UPDATE,也不能UPDATE、DELETE,但事务B如果只是普通SELECT,走的是快照读,MVCC机制会让他读到之前版本的数据,并不会被阻塞。这个点很多人踩坑:以为加了FOR UPDATE别人就读不到这行了,结果代码里在另一个接口用普通SELECT读数据,读到的是旧值,怀疑是锁失效了,其实是快照读和当前读的区别。

如果只想阻止别人写,但允许别人读,可以用LOCK IN SHARE MODE(MySQL 8.0.19之后叫FOR SHARE),加共享锁S锁。S锁和S锁兼容,S锁和X锁互斥。但实际业务里这个用得少,因为共享锁基本只适合那些“读也要排队”的罕见场景,多数时候快照读已经够用了。

2.2 悲观锁的三个反模式,每一个都是线上事故的种子

先说第一个反模式:锁范围过大。最常见的是查询条件没走索引,导致行锁升级成表锁。比如product_id字段没有索引,你执行FOR UPDATE,InnoDB为了锁定目标行,只能把主键索引扫描过的所有行都加锁,扫描了800行就锁800行。查的时候感觉不明显,一旦并发量上来,整个表所有更新都开始互相阻塞。解决方式很简单:给WHERE条件的字段建索引,让InnoDB精准定位到行。

第二个反模式:在事务里做重活。有次我们排查线上问题,发现某个接口慢得离谱,查了锁等待,根源是事务里调了外部服务,一次HTTP请求通了2秒。这2秒内,FOR UPDATE的锁一直挂在身上,后面所有抢同一行数据的请求全在排队。如果外部服务再超时,事务迟迟不提交,后面的请求能排到数据库报超时。原则很简单:拿到锁之后的事务,只做数据库操作和必要的内存计算,把所有RPC、消息、缓存清理都挪到事务外面。

第三个反模式:不考虑死锁。死锁是两个事务互相持有对方需要的锁,比如事务A先锁了行1再想锁行2,事务B先锁了行2再想锁行1,两边都不让,就卡死了。InnoDB有死锁检测机制,默认开启,检测到死锁会主动回滚代价较小的那个事务,然后抛一个ERROR 1213(40001)错误。代码如果没有对死锁做重试,就会直接报错给用户。规避方式:多个表或者多行数据的加锁顺序保持一致,比如都按主键ID从小到大加锁,死锁概率能降低一大截。

2.3 悲观锁的性能账本到底怎么算

从性能角度算笔账。悲观锁在并发高的时候,竞争的线程全部阻塞在等待队列里,数据库的并发查询能力不断被锁等待消耗,吞吐量上不去。我们压测过一个典型的扣库存场景:1000个线程抢100个库存,悲观锁方案的处理方式是一个一个排队执行,虽然不会超卖,但QPS上不去,而且线程大量阻塞,CPU和连接池压力明显。锁等待时间一长,还会碰到innodb_lock_wait_timeout的50秒默认超时,直接报锁等待超时错误。

但悲观锁有个被低估的优点:当一个事务拿到锁之后,后续读取、判断、更新都在事务内完成,数据一致性有数据库兜底,应用代码写起来很直白,不需要处理重试逻辑。对那种冲突几乎无法避免的场景,比如同一笔订单的金额调整,悲观锁反而更可靠,因为它把“争”的时间提前到了你方便处理的阶段。

3. 乐观锁实战:版本号、时间戳、条件UPDATE,三种写法各有讲究

3.1 三种实现方式的SQL模板及其适用边界

乐观锁最常见的实现是版本号。表结构里加一个int类型的version字段,每次更新成功就把version加1。更新语句得同时满足两个条件:主键匹配、version匹配。

UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 100 AND version = 7;

第二种是时间戳。不加version,而是比较update_time。原理一样,但时间戳有个副作用:如果同一毫秒内有两个事务按相同时间读取,都满足时间条件,可能会发生误判。相比起来,自增的version字段更严谨,推荐优先用version。

第三种是把业务条件直接写进WHERE,比如扣库存最经典的那条:

UPDATE product_stock SET stock = stock - 1 WHERE product_id = 100 AND stock > 0;

这条没有version字段,但它靠“库存必须大于0”这个业务约束完成了冲突检测。如果两条并发同时执行,InnoDB的行锁机制会让它们串行执行,第二条等到第一条提交后再执行时,库存已经不满足stock > 0,影响行数为0,就不会出现负库存。

很多团队觉得乐观锁一定要加version字段,其实不是。条件UPDATE是乐观锁的变体,核心思想没变:不加锁阻塞,靠WHERE条件挡住冲突。我个人的建议是,字段少、只想解决“防止超卖”这种单一问题时,直接用条件更新,简单有效;字段多、需要防的是“整个记录的任意修改”时,用version更通用。

3.2 影响行数为0之后怎么办:重试策略要写在事务外面

这是乐观锁实战里最容易被忽略的部分。很多人写完UPDATE,发现影响行数为0,就在代码里直接抛异常,用户看到的就是“系统繁忙”。正确的做法是设计重试。

我在某项目中采用了这样的逻辑(伪代码逻辑):

最大重试次数 = 3 for (int i = 0; i < 最大重试次数; i++) { 开启事务 读取 stock, version 业务判断 UPDATE 设置 stock-1, version+1 WHERE id=商品ID AND version=旧version 如果影响行数 == 1: 提交事务, 返回成功 如果影响行数 == 0: 回滚事务 记录重试次数 继续下次循环(重新读取最新版本) } 如果重试满3次仍然失败: 返回“操作太频繁,请稍后再试”

这里有个技术细节:每次重试要开启新的数据库事务,不要在同一个事务里循环重试。原因是事务里的快照读隔离级别下,第一次SELECT的结果在一个REPEATABLE READ事务里是固定的,你重试读到的还是旧版本,UPDATE永远失败。回滚之后重新开始一个事务,才能读到最新版本。

重试次数也要控制。我们压测时发现,库存只剩10件、有500个线程在抢的场景,前几轮重试成功率很低,三轮之后基本能拿到最新版本,所以3次重试是成本和成功率之间的平衡点。重试太多会把数据库连接占用时间拖长,反而把系统拖垮。重试之后还是失败,说明这单业务确实做不了了,要给用户一个明确的失败提示,而不是反复尝试。

3.3 乐观锁的两个隐藏问题:ABA和长事务

先说ABA问题。这是CAS类方案的通病。假设一个线程读到version=7,中间另一个线程把数据改了又改回原样,version变到8又变回到9,第一个线程提交时条件写的是version=7,显然匹配不上,这里其实不会误通过。所以用version字段本身就能规避ABA,因为version是单调递增的,改一次变一次。

但如果用的不是version而是某个“状态值”,比如status字段从1改成2又改回1,那ABA就会真的发生。防止方式是给表加一个update_count之类的自增字段,任何修改都把它加1。这是我在项目里从“状态CAS”换到“版本号”的直接原因,不值得去赌状态组合的唯一性。

另一个坑是长事务。乐观锁本身不加锁,但它的读取和UPDATE之间,如果业务代码隔了很久,比如中间查了个第三方接口,这期间数据库里这行数据可能已经被改了好几次。你以为你基于的是最新版本,其实已经过期,提交时必然冲突。乐观锁应用有个不成文的原则:读数据到提交更新的间隔,必须被压缩到最小。业务计算和数据校验应该在读出来之后立刻执行,不要在里面夹带重活。

3.4 乐观锁的临界条件:这些时候它并不合适

乐观锁适合读多写少、冲突频率低的场景,读多于写的比例越高,收益越明显。反过来,如果某个热点数据的写冲突极其频繁,比如一个爆款商品的库存,每毫秒都有一堆线程在抢,那么乐观锁会进入“读取-失败-重试-再失败”的循环,重试风暴会把数据库负载抬得很高,整体上不一定比悲观锁好。

判断方法其实很简单:压测时统计一下更新失败率。如果失败率超过20%,就得想想是改重试策略还是干脆换悲观锁。我们有一次压测某限量商品,100并发抢5件库存,乐观锁方案最终虽然没超卖,但系统因为重试风暴响应变慢,最后我们针对这种超热点场景在Redis里做了库存预扣减,MySQL只记账,把冲突挡在了缓存层。这个思路后面细说。

4. 同场对比:选悲观锁还是乐观锁,我的判断标准

4.1 冲突概率是第一个决策变量

选型的第一步永远是评估冲突概率。如果两个并发操作同一行数据的概率很低,乐观锁是首选,因为它没有阻塞、没有死锁、一直保持高并发。如果请求基本都要打在同一行上,比如秒杀、抢购、单账号余额变动,悲观锁或分布式锁的“排队”效果反而是更可控的。

我常用一个比喻:悲观锁像图书馆里借书要填单排队,你拿书的时候别人不能拿;乐观锁像开放书架,所有人自由拿书,结账时才发现书被人拿走了,只好回去重拿。人少的时候,开放书架效率完胜;人多了,抢来抢去反而比排队还慢。

4.2 业务容忍度决定了要不要重试

第二个维度是业务方对“失败重试”的容忍度。

乐观锁的失败是显式的,影响行数为0,需要上层决定重试还是放弃。像购物车的预占库存、推荐位的喜好更新,失败后重新读再写一次完全没感觉,选乐观锁。像资金转移、订单状态流转这种一旦更新失败就必须立刻给准确反馈的,乐观锁的重试逻辑会带来额外的复杂度,重试期间资金状态不确定,这种场景我倾向于用悲观锁,让数据库锁保证同一笔资金在同一时刻只有一个事务能处理。

4.3 事务时长是压死骆驼的最后一根稻草

第三个维度是事务时长,对悲观锁尤其致命。锁的持有时间越长,数据库连接池被占用的时间越长,锁等待队列越深。所以一个事务里如果必须调用外部服务,哪怕只是一次几十毫秒的缓存读取,悲观锁的代价都会被放大。反过来,如果事务都是几十毫秒内的本地数据库读写,悲观锁的代价是可以接受的。

我把这个维度做成了一张决策表,项目组评审方案时直接对号入座:

判断维度倾向悲观锁倾向乐观锁
冲突概率高,热点行集中更新低,读多写少
失败容忍失败必须排队或不允许失败失败后重试无感知
事务时长短,只做本地DB操作可接受稍长,事务外处理
死锁风险需要规范和监控无死锁
实现复杂度低,SQL直接支持需自己控制版本字段和重试
典型场景资金变动、状态机流转、后台审核库存扣减、积分变更、资料编辑

4.4 混合使用才是常态

选型不是二选一,一个系统里完全可以同时存在两种策略。库存扣减这种高频单行操作,用条件更新当乐观锁;跨账户转账这种强一致场景,用悲观锁包裹两个账户的更新。某订单系统我们就是这么设计的:订单主表的状态流转用悲观锁,防止两笔并发把订单状态从“待支付”改成两个不同的终态;订单中的优惠券、积分明细、活动快照这些表,全部走乐观锁version,冲突概率低,重试也很容易。

混合使用的时候注意隔离:悲观锁事务要短,乐观锁重试逻辑要独立,两者不要互相嵌套在同一事务里。否则事务范围一大,乐观锁带来的并发优势就被悲观锁的锁等待抵消了。

5. 一个完整的落地案例:库存扣减从需求到压测

5.1 需求与表结构设计

需求本身很典型:某商品有100件库存,允许多用户同时下单,要求在任何并发下不超卖。我们用的表结构去掉业务字段简化为:

字段类型说明
idbigint主键
product_idbigint商品ID,建立唯一索引
stockint当前剩余库存
versionint乐观锁版本号
update_timedatetime更新时间

接口逻辑只做一件事:检查库存是否充足,充足则扣减。没有引入订单表、支付表,先把最核心的扣数逻辑验证清楚。

5.2 三种方案的实现对比

方案一,悲观锁:

BEGIN SELECT stock FROM t WHERE product_id=#{pid} FOR UPDATE if stock > 0: UPDATE t SET stock=stock-1 WHERE product_id=#{pid} COMMIT else: ROLLBACK

方案二,版本号乐观锁:

BEGIN SELECT stock, version FROM t WHERE product_id=#{pid} if stock > 0: UPDATE t SET stock=stock-1, version=version+1 WHERE product_id=#{pid} AND version=#{oldVersion} 如果影响行数==1: COMMIT 如果影响行数==0: ROLLBACK,重试 else: ROLLBACK

方案三,条件更新:

UPDATE t SET stock = stock - 1 WHERE product_id = #{pid} AND stock > 0

选中影响行数判断是否扣减成功,不需要版本字段,也不需要显式事务。数据库层面的行锁保证同一时刻只有一个UPDATE能成功,失败的线程影响行数为0,直接返回库存不足。

三个方案都能保证不超卖,区别在于代码复杂度、并发表现、以及对重试的要求。方案三最简洁,但只适用于“单字段业务约束”的场景,没法防止其他字段被并发修改。方案二通用性最好,几乎可以套在任何需要并发控制的表上。

5.3 压测结果与选择

我们用了1000个并发线程抢100件库存,模拟三分钟内的扣减请求。数据是压测工具打出来的真实趋势,不是精确值,但能反映相对差异:

方案成功订单数平均响应时间失败/重试情况数据库锁等待事件
悲观锁100,无超卖平均350ms,后段涨到800ms大量阻塞,无显式失败大量,锁等待明显
版本号乐观锁100,无超卖平均120ms,整体平稳重试率约60%,最终成功基本无锁等待
条件更新100,无超卖平均80ms,整体平稳大量更新影响行数为0基本无锁等待

悲观锁在这个场景下表现最差,因为100个库存对应的是纯粹的写热点,线程全在排队等锁,压测工具端的平均响应时间被拉高,连接池也一度告急。版本号和条件更新方案效果不错,但条件是建在product_id的唯一索引上,如果WHERE条件没有索引,三个方案全部会退化。

这个结果也验证了前面说的判断逻辑:库存扣减是典型的“冲突集中在极少数行、单行更新、可重试”场景,乐观锁天然契合。最后我们上线用的方案三,把扣减做成一条UPDATE,配合库存校验和失败提示,压测达标,线上运行稳定。

6. 锁问题排查的实用手段:三个命令看清锁状态

6.1 通过information_schema定位事务和锁等待

线上如果出现大量请求积压,第一步不是看代码,而是查数据库当前事务状态:

SELECT * FROM information_schema.innodb_trx\G

这张表能看到所有正在运行的事务,重点看trx_state、trx_started、trx_rows_locked、trx_query几个字段。如果某个事务的trx_started时间非常早,却一直没提交,它八成就是锁等待链路的源头。trx_query显示的是当前正在执行的SQL,可以定位到具体是哪个接口的操作。

再进一步,MySQL 8.0版本可以直接查:

SELECT * FROM performance_schema.data_locks\G SELECT * FROM performance_schema.data_lock_waits\G

前者展示每个锁的记录、锁类型、锁模式、锁住的索引记录,后者展示谁在等谁的锁,LOCK_WAIT_STARTED字段还能看到等待了多久。通过这两张表的关联,基本能把“事务A锁住了哪一行、事务B在等这一行”的完整链路还原出来。

6.2 用SHOW ENGINE INNODB STATUS读死锁现场

遇到ERROR 1213死锁,第一反应别慌,先打印死锁现场:

SHOW ENGINE INNODB STATUS\G

输出里找到LATEST DETECTED DEADLOCK这一段,里面会记录两个死锁事务各自的SQL语句、持有的锁、等待的锁。从这些信息能直接看出是哪个表的哪两行数据形成了环路。我见过最多的死锁组合是:事务A先更新订单主表再更新明细表,事务B先更新明细表再更新订单主表,两个事务在不同顺序下互相等待。根治方案就是统一加锁顺序,先主表后明细表,并且把两个update都放在同一个事务里。

处理锁等待还有一种快速手段:当确认某个事务是“僵尸事务”,已经确定不会提交时,可以直接把它终止来释放锁。

-- 先从innodb_trx里查到trx_mysql_thread_id KILL <thread_id>;

注意这个操作要谨慎,杀掉的是整个数据库会话,如果事务里还有别的重要操作也会被回滚。

6.3 从监控和参数层面提前规避

锁问题的排查不能只靠出问题时临时看,要在参数层面提前预防。innodb_lock_wait_timeout是锁等待超时时间,默认50秒,对在线业务来说太长了。如果某个请求排队超过5秒,用户早就走了,所以我会把它调低到5秒或3秒,宁可让请求快速失败暴露问题,也不要让它在后台默默卡住几十秒。innodb_deadlock_detect保持默认开启,让数据库在死锁时自动回滚代价较小的一个,应用层再对死锁异常做重试即可。

监控方面建议把information_schema.innodb_trx的扫描做成定时任务,检查是否存在长事务、锁等待数量是否超过阈值,连续两个周期超过阈值就告警。线上系统不可能完全不锁,但锁等待的“数量”和“时长”是必须控制的指标。

我个人在这些年的项目里积累的经验是:先评估冲突概率,再决定上不上锁,最后才考虑用哪种锁。很多并发问题其实一条原子UPDATE就能解决,纠结于乐观锁和悲观锁反而是在过度设计。真要动手时,优先想清楚三个问题:冲突多不多,事务长不长,业务能不能容忍重试。想明白这三个问题,锁的方案基本就定了。最后再分享一个小技巧:做并发改造时,先把当前所有可能并发写同一行的SQL列出来,逐条确认有没有走索引、事务范围有多大、会不会出现环路加锁,这三条检查完,锁相关的坑至少能避开八成。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进