ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

乐观锁与悲观锁:并发控制策略的原理、对比与实战选型

乐观锁与悲观锁:并发控制策略的原理、对比与实战选型

设想一个场景:你负责的系统上线第一天晚上,运营给你打电话说同一件商品被卖出了两件,但后台库存只扣了一次。你打开日志,发现有两个人几乎在同一毫秒发起了购买请求,两个请求都读到了库存数,然后各自完成了扣减。那一刻你意识到,代码逻辑在单线程下跑得再顺,一旦并发进来,数据就可能在眼皮底下悄悄错掉。

这就是并发控制要解决的问题。而在所有解决手段里,最先被问到的一定是这两兄弟:乐观锁和悲观锁。很多刚接触这块的人会被“乐观”“悲观”两个词带偏,以为是一种心态或者编程风格。其实它俩是两种截然不同的并发控制策略,代表了对数据冲突的两种预判,也直接决定了你系统的吞吐量、用户体验和代码复杂度。

这篇内容我会把两把锁掰开揉碎讲透,包括它们各自的工作机制、代码层面长什么样、数据库层面怎么落地、开销差在哪、以及到底什么场景该用哪一种。不是教科书式罗列,而是按真实项目里的选型思路来聊。

1. 并发失控的根源:先读后写带来的时间差

想理解两把锁,先要理解它们到底在防什么。最典型的竞态条件就是“读-改-写”三步。假设库存表里有条记录,数量是10。用户A和用户B同时来买1件,正确的最终结果应该是8,而现实往往是9。

为什么?因为在并发环境下,A和B几乎同时执行了第一步“读”,都读到了数量10,然后各自在内存里把它改成9,最后各自写回数据库。后写的人覆盖先写的人,于是库存从10变成了9,多卖出去一件。问题不在哪一步写错了,而在于“读取数量”和“写回数量”之间的时间窗口里,另一个线程插了进来。

这就是临界区的概念。所谓锁,归根结底就是保护这段“读-改-写”的临界区,让同一时刻只有一个线程在里面折腾。

但“怎么保护”这件事,出现了两条完全不同的路线。

悲观锁的思路是:我怀疑这里一定会打架。所以我进临界区之前,先把门锁上,钥匙我拿着,别人给我外面等着。等我把读、改、写全部干完,再把门打开让下一个进来。核心就四个字:先锁后做。

乐观锁的思路是:我觉得大家不会撞在一起。所以我不锁门,而是给这条数据贴一个标签,比如版本号。每个人进去前先看一眼标签,走的时候再核一下:如果标签还是我看到的那个,说明没人动过,我放心写;如果标签已经变了,说明有人抢先了,那我不写了,重新读一遍再来一次。核心也四个字:先做后验。

你看,这两条路线没有谁更“高级”,只是对不同冲突概率和性能诉求做出的不同取舍。

2. 悲观锁:先锁后做的保守派,靠阻塞换取绝对安全

悲观锁的核心机制是互斥。它把并发问题从根上抹掉了——同一时刻只允许一个线程操作数据,其他线程想进来?排队等着。系统层面最常见的实现方式就是数据库的行锁、表锁,以及Java里的synchronized、ReentrantLock,还有分布式场景下的Redis分布式锁。

2.1 数据库里的悲观锁就是一条带锁的查询

在关系型数据库里,悲观锁通常通过SELECT ... FOR UPDATE来实现。比如库存扣减场景:

-- 开启事务 BEGIN; -- 锁定这一行数据,其他事务操作同一行会被阻塞 SELECT stock, version FROM product WHERE id = 100 FOR UPDATE; -- 在这里拿到结果,然后在代码里判断stock是否足够,再执行更新 UPDATE product SET stock = stock - 1 WHERE id = 100; -- 提交事务,释放锁 COMMIT;

流程很清晰:SELECT ... FOR UPDATE会锁定id为100这一行,事务提交之前,其他事务如果也想对同一行执行FOR UPDATE或者UPDATE,就必须等着。这就把“读-改-写”变成了一个原子操作,因为查询、判断、更新全在同一个事务里,中间不会有第二个人打扰。

如果业务复杂度更高,比如创建订单的同时还要扣掉购物车里的商品、更新优惠券状态,悲观锁的玩法就是在事务里按照固定顺序把相关行全部锁住,然后慢慢处理。只要都在一个事务里,锁就一直持有,直到COMMIT或ROLLBACK才释放。

2.2 编程语言层面的悲观锁:线程级互斥

除了数据库锁,服务内存里也需要悲观锁。典型的就是Java的ReentrantLock:

private final ReentrantLock lock = new ReentrantLock(); public void deductStock(int quantity) { lock.lock(); try { // 检查库存、扣减库存,这段代码同一时刻只有一个线程能进 int stock = productDao.getStock(productId); if (stock < quantity) { throw new RuntimeException("库存不足"); } productDao.updateStock(productId, stock - quantity); } finally { lock.unlock(); } }

这种锁保护的是同一台机器上的多个线程。如果服务是分布式的,多个实例之间没法共享内存锁,就得用Redis分布式锁或者数据库行锁来替代。

悲观锁最核心的特征就是“阻塞”。当一个线程持有锁时,其他线程不会继续执行,而是挂起等待。这在并发量很小、冲突几乎必然发生的场景下非常稳定,不需要额外的重试逻辑,写代码也比较直接。

2.3 悲观锁付出的代价比你想象的要大

悲观锁最大的问题也是阻塞本身。高并发下,大量线程在锁上排队,CPU消耗在上下文切换上,数据库连接被长时间占用,系统吞吐量会断崖式下跌。更严重的是,锁用的不好还会引发死锁:两个事务各自锁了对方需要的数据,然后互相等待,谁也释放不了。

我见过一次典型的死锁,发生在订单和库存的联合扣减里。事务1先锁了订单表再锁库存表,事务2先锁了库存表再锁订单表。两边同时执行,恰好互相踩住对方的锁,数据库检测到死锁后强制回滚了其中一个事务,业务方看到的表象就是“偶发下单失败”。解决方案就是约束所有事务都按同一个顺序去拿锁,比如不管哪个分支,都先锁库存再锁订单。

悲观锁说到底是在用“执行速度”换“数据安全”。当冲突频繁时它是合理的,当冲突很少时它的性价比就很低了——大部分线程其实没撞车,却要白白排队等锁。

3. 乐观锁:先做后验的乐观派,用版本号替代阻塞

乐观锁跟悲观锁的哲学完全相反。它默认大家很少打架,所以不在操作之前加锁,而是在数据里打上标记,提交时核对标记有没有被别人改过。如果没被改过,就执行更新;如果被改过,说明这期间有人抢先了,那就重新读取数据,再试一次。

3.1 版本号机制是数据库乐观锁的标准姿势

最常见的实现方式是给表加一个version字段。每次更新数据时,version加1。更新语句的WHERE条件里带上旧的version。如果更新影响行数为1,说明更新成功;如果影响行数为0,说明version被改了,这次操作失败,需要重试。

-- 第一次读取 SELECT stock, version FROM product WHERE id = 100; -- 假设 version = 3 -- 更新时带上version条件 UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 3;

这段SQL的执行原理很巧妙。数据库在执行UPDATE时会检查WHERE条件,如果此时version已经被其他事务改成4,那么version = 3这个条件就不成立,影响行数就是0。通过影响行数,我们就能知道数据在读取和更新之间有没有被“动过”。

代码层面配合重试逻辑:

public void deductStockWithOptimisticLock(int quantity) { boolean updated = false; while (!updated) { Product product = productDao.findById(productId); if (product.getStock() < quantity) { throw new RuntimeException("库存不足"); } int rows = productDao.updateStockByVersion( productId, product.getStock() - quantity, product.getVersion() ); if (rows == 0) { // 更新失败,说明version变了,重试 continue; } updated = true; } }

有人会问,为什么要用version字段而不是直接比较stock的值?因为UPDATE ... WHERE stock = 旧库存在业务上不够可靠,比如有小数精度问题,或者两个操作先加后减恰好让库存回到同一个值,就可能误判。version是一个只增不减的独立标记,只要你更新了数据,它必定变化,判断最干净。

3.2 CAS与无锁编程:乐观锁的另一种形态

除了数据库里的版本号,乐观锁在编程语言层面还有一个更底层的形态——CAS(Compare and Swap)。Java里的AtomicInteger、AtomicLong就是基于CAS实现的。

CAS的逻辑是:先比较内存中的值是否等于预期值,如果相等,就换上新值;如果不相等,操作失败。这整个比较和替换是CPU级别的原子指令,不需要锁,也不会阻塞其他线程。正因如此,在高并发计数、限流器、ID生成器这类很简单的场景里,CAS能做到比悲观锁高一个数量级的吞吐。

但CAS有个著名的坑叫ABA问题:线程1读到值A,线程2把它改成B又改回A,线程1再去做CAS时发现还是A,就以为没人动过,实际上数据经历了一次完整的中间状态。在业务系统里,ABA问题可以通过加版本号来解决,这又回到了上面说的version字段。在纯CAS场景下,可以用AtomicStampedReference这种带戳的原子类来识别数据是否被“回卷”过。

3.3 乐观锁的真正成本是重试而不是失败

很多人以为乐观锁没有锁开销,所以性能一定更好。其实只说对了一半。乐观锁确实消除了阻塞等待,但代价是“乐观的前提一旦不成立”,同一批数据被多个事务竞争时,后面的事务会反复重试。

假设库存只有10件,来了100个并发请求。悲观锁下这100个请求是排队执行,效率低但每个请求都有明确结果;乐观锁下所有请求同时读到version=3,然后一起去更新,只有第一个能成功,剩下99个全返回影响行数为0,需要重新读、重新算、再更新。如果数据一直没有更新成功,这99个请求就会在这个循环里空转。

所以在高冲突场景下,乐观锁的重试会放大数据库压力,反而比悲观锁更慢。这也是很多电商库存场景最终选择悲观锁或混合方案的原因——抢购这种冲突高度集中场景,乐观锁理论上可以但实际体验并不好。

4. 谁适合哪种场景:一次并发选型的完整对比

这一节我们做个比较粗颗粒度的对比,然后落到一个具体的决策路径上。我先列个表,虽然不能覆盖所有细节,但日常选型够用了。

对比维度悲观锁乐观锁
核心思路操作前加锁,阻塞其他事务提交时校验版本,失败则重试
冲突处理阻塞等待重试
并发吞吐量低,锁排队高,无锁设计
实现复杂度简单直接需要处理重试逻辑
死锁风险存在,需要规避不存在
数据库压力连接被锁占用,长事务拖垮连接池频繁更新失败,额外查询开销
最适场景冲突概率高、并发量可控冲突概率低、并发量高

这张表的关键点不在“谁好谁坏”,而在第一行:悲观锁用“阻塞”来处理冲突,乐观锁用“重试”来处理冲突。阻塞是让线程停下来等,重试是让线程反复干活,两者都有代价,只是代价形态不同。阻塞的代价是等待时间和系统资源挂起,重试的代价是浪费数据库查询和更新次数。

从应用角度来看,有两条经验可以直接复用。

第一,单机内存里的高并发计数、令牌生成、秒杀计数器这类场景,优先考虑乐观锁或CAS。因为冲突概率虽然高,但操作耗时极短,重试代价很小,而锁阻塞反而会限制吞吐。

第二,跨系统的业务操作,比如开启事务、调用远程接口、更新多个表、或者一次操作耗时可能超过几十毫秒,优先考虑悲观锁或带超时时间的分布式锁。因为业务操作越长,乐观锁的重试窗口越大,重试时你可能需要重新执行前置的调用,那个代价就完全不可控了。

我踩过一次典型的选型失误。某个管理后台的配置更新接口,当时为了追求并发性能,用了乐观锁版本号。结果上线后发现,两个管理员同时修改一份长配置,A把整份配置的“基础信息”改了,B把“权限模块”改了,两个人的更新都因为版本冲突而失败。表面上看原因是版本号颗粒度太粗,整条数据只有一个version,任何字段变更都会互相阻塞。后来我把数据拆成按模块分表,再换回悲观锁,问题反而简单了。本质问题是,这个低频管理场景本来就该用悲观锁,没必要给自己加难度。

5. 选型实战:从库存、订单到配置文件,到底该怎么选锁

从理论到落地,中间还隔着一层“具体业务”。很多新人看完对比后依然困惑:我到底该用哪个?我给一个三步走的决策流程,你来用实际场景套一遍就会发现清晰很多。

5.1 第一步:评估冲突概率

先看你的业务里,多个并发请求同时操作同一条数据的概率有多高。

  • 高冲突(抢购、秒杀、热卖商品下单、活动限量发放):优先悲观锁。
  • 低冲突(后台配置、用户个人资料更新、长尾商品库存、评论数累加):优先乐观锁。

有人坚持说“低冲突就用乐观锁,高冲突就用悲观锁”,这个结论本身没错,但要注意同一个系统里可能同时存在两种冲突类型。比如电商的商品模块,热门商品是高冲突,冷门商品是低冲突,那就不要一刀切。热门SKU走悲观锁,普通SKU走乐观锁,完全可以共存。

5.2 第二步:评估操作链路耗时

这一步评估的是临界区的“长度”。如果一次操作耗时短,悲观锁的占用时间就短,排队代价也可控。如果操作耗时长,比如在事务里调了外部接口、做了文件处理、跨了数据源,悲观锁会把资源放在一个长时间的等待里,风险很高,此时要么用乐观锁+重试,要么缩小锁粒度、精简事务。

这也是分布式系统里特别强调“不要在事务里做远程调用”的原因。一旦远程调用变慢,你手里的数据库事务就卡住了,连接池很快消耗殆尽,整个业务链路全部瘫痪。

5.3 第三步:想清楚失败后怎么处理

悲观锁的失败通常表现为“获取不到锁”或者“获取锁超时”,对应的处理策略是快速失败还是阻塞等待要提前定义清楚。乐观锁的失败表现为“更新影响行数为0”,对应的策略是重试,但重试多久、重试几次、是否引入退避间隔,都需要你写代码时想明白。

举个例子,自动化任务里的乐观锁更新,可以循环重试5次,每次隔500毫秒;用户手动点击的接口,重试2次就该放弃了,因为用户不想等太久,与其让他傻等还不如直接告诉他“操作失败,请重试”。

5.4 四种典型场景的具体落法

我挑四个最常见的业务场景,分别给出我个人的推荐方案,供你参考。场景不同,侧重点完全不同。

第一个是库存扣减。如果库存是热点商品,推荐数据库悲观锁加事务,或者Redis分布式锁。因为在热点场景下,乐观锁的不断重试会放大数据库压力,而且每次重试都要重新发起事务,数据库写放大很明显。也可以用“预扣+确认”的模式,下单时只锁定库存不实际减扣,支付完成后再真正扣减,这是另一套更复杂的方案,但核心仍然是悲观锁思路。

第二个是用户个人资料的修改。用户只会偶尔改自己的资料,且每个用户操作的是自己独立的数据,冲突概率几乎为零。这时如果还用悲观锁,就是白白给每个请求加了一把锁,浪费性能。直接用version字段的乐观锁就够了,甚至完全不锁都行,因为MySQL的行级UPDATE本身就有隐式锁机制。

第三个是订单状态流转,比如待支付转已付款、已付款转已发货。这种变更是有方向性的,而且通常只会有一个操作员在推进某条订单。推荐使用乐观锁,状态机里加version,每次流转校验当前状态是预期的上一个状态,防止重复提交导致状态错乱。

第四个是分布式场景下的定时任务。多台服务器同时启动一个定时任务,防止重复执行,推荐使用Redis分布式锁。因为这里是抢锁,不是抢数据,用悲观锁思路最直观:谁抢到锁谁执行,任务执行完释放锁,另一个服务器就不用执行了。如果用乐观锁,问题反而复杂,因为你不知道该拿什么字段当版本号。

5.5 别忘了还有混合方案

有个经验之谈:实际项目里,很多核心链路根本不会只用一种锁。比如一个下单流程,进入库存扣减前先用分布式锁限制同一用户的并发,保护库存数据时再用数据库悲观锁兜底,同时在订单状态落库时用版本号防止状态重复流转。三层锁各管一段,各取所长。

这种做法听起来比单一锁方案复杂,但正因为分层,每道锁的持有时间都很短,系统整体性能反而更好。设计锁方案时不要有洁癖,别非要证明“我用了其中一种就一定比其他方案强”。合适才是唯一标准。

6. 写在最后的一些实在经验

做并发控制这几年,我自己最大的体会是:不要把乐观锁和悲观锁当成一个“只能二选一”的技术选型,而是把它们理解为两种并发场景下的应对模式。锁的问题,本质上是个“冲突管理”的问题。冲突来了,你可以选择让别人等,也可以选择让别人重来。等待考验的是资源调度能力,重来考验的是业务幂等能力。

我现在的习惯是,每写一处并发代码前,都会先问自己三个问题:这条数据被多个请求抢的概率多大?一次操作从开始到结束大概要多久?如果操作失败了,重跑一遍的代价是多大?这三个问题的答案基本能帮我筛掉九成的错误选择。

最后分享一个小技巧:无论你用哪种锁,都要设计好降级通道。某次大促活动里,流量突然暴增,热点商品用悲观锁在数据库层面扛不住连接池压力,如果当时没有预埋一个“一旦等待超过阈值就直接失败并提示稍后重试”的开关,整个订单服务可能都会被拖垮。锁是保护工具的,但在极端流量下,它自己也可能成为被击穿的对象。给锁加超时,给失败加兜底,这才是生产环境里最实用的法则。

RELATED READING

延伸阅读

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