1. 从一次库存超卖聊起:并发控制里的乐观与悲观
之前接了个库存系统的性能优化需求,排查到一半时看到一段让我印象极深的代码:先查库存余额,判断是否足够,然后执行扣减。逻辑看着没问题,可压测一上量就频繁出现超卖。原因也不难猜,两个人同时读到还剩 1 件,都判断可以下单,各自扣减了一遍,最后库存变成了 -1。这类问题本质上不是库存逻辑写错了,而是并发控制没有做对。很多团队在处理这种场景时首先想到的是加锁,也就是让同一时刻只有一个请求能碰这条库存记录。但锁也有锁的代价,持有锁的时间越长,吞吐量越难看,死锁、等待、超时这些连锁反应都会跟着来。于是另一种思路开始被频繁提起,也就是本文要聊的 OCC,乐观并发控制(Optimistic Concurrency Control)。
OCC 的核心想法其实非常朴素:默认大家不会互相干扰,所以平时不加锁,各自放心干活;到了提交的时候才检查一下,看看在这段时间里别人有没有改过自己用过的数据。如果发现有人动过,就把这次操作作废重来;如果没人动过,就直接提交。这套逻辑最早来自几十年前的一篇数据库论文,如今早已渗透到关系数据库、缓存系统、内存事务、分布式存储甚至业务代码里的各种实现中。我把这套东西整理成学习笔记,因为 OCC 表面上只是“提交时校验版本号”一句话,但真要在实战里用好它,牵扯到版本机制设计、冲突率评估、重试策略、提交顺序等一系列问题,任何一个环节想当然,都会在线上埋雷。
这篇笔记适合几类人看:写过事务但没真正琢磨过隔离级别是怎么实现的;在分布式系统里见过“版本号”“CAS”这些词但没串成体系;以及准备在业务代码中落地乐观锁,但想知道什么时候该用、什么时候不该用的人。我会从并发控制的两种哲学讲起,逐步拆解 OCC 的执行模型,再落到版本号、冲突率、重试策略等工程细节,最后给一套可以本地运行的 Demo 和排查路径。内容会偏底层但尽量不绕弯,每个结论都会讲清“为什么”。
1.1 OCC 名字里的“乐观”到底指什么
“乐观”两个字的关键在于对冲突频率的预设。如果系统认为大部分情况下操作之间互不干扰,只有少数时刻才会撞车,那自然值得赌一把:不加锁、不排队,让所有人并行前进,只在最后的终点线前做检验。这种策略就像一条不设红绿灯的十字路口,默认车辆能顺畅通过,只在接近路口时让司机各自观察,发现有碰撞风险就停下来再走。反之,“悲观”的思路认为冲突是常态,所以从一开始就给资源加锁,相当于给十字路口装上红绿灯,一个方向放行时另一个方向必须等待。
需要澄清的是,悲观不意味着绝对安全,乐观也不意味着必然高效。悲观控制在低冲突场景下反而会因为频繁加锁、等待而拖慢整体进度,乐观控制在冲突率升高后会因为大量回滚和重试导致系统浪费更多资源。两种策略都有适用边界,OCC 的优势集中在读多写少、事务短、并发度高的场景;如果一段数据被多个事务反复争抢,乐观策略会变成一场灾难。这一点在后面的冲突率讨论里会继续展开。
1.2 悲观控制与乐观控制的分界线:锁的有无
悲观控制的核心工具是锁,锁保证了互斥,让临界区里的操作串行化。常见实现包括关系数据库里的SELECT ... FOR UPDATE、编程语言里的mutex、分布式环境下的分布式锁等。乐观控制则不走锁这条路,它依赖三样东西:确认数据没有被改变过的版本信息、执行过程中的记录、以及提交前的验证逻辑。换句话说,悲观控制在“做”之前先阻止别人,乐观控制在“做”完之后检查别人是否已经插了一脚。
这两种思想的差别在实际效果上非常有意思。悲观控制的问题几乎都出现在等待上:锁持有过久、死锁检测、锁粒度选择,这些都需要额外设计和调优。乐观控制的问题几乎都出现在确认上:版本号怎么更新、并发回滚怎么处理、重试逻辑怎么写。理解了这一层分界线,再去看各种数据库隔离级别的实现,会发觉本质上都是在灵活调配“锁”与“校验”的组合,而不是非黑即白地站队。
2. OCC 三阶段模型:读、校验、写每一步都在防什么
把 OCC 真正落到实现层面,它由三个明确的阶段构成:读阶段(Read Phase)、校验阶段(Validate Phase)、写阶段(Write Phase)。很多资料喜欢用“读-验证-写”这几个字一笔带过,但实践中最容易出问题的恰恰是阶段之间的边界条件,值得一步步拆开看。
2.1 读阶段:事务并不真的“禁止别人碰”
读阶段做的事情是执行事务体里的各种读操作,把需要的数据加载到本地,同时记下这些数据当时的版本信息。这里要注意一个容易误解的地方:读阶段里的“读”只是普通读,数据没有加锁,事务也没有占用任何资源。所以两个事务可以同时读到同一份数据,各自在内存里做计算,互不知晓。库存扣减的例子就是典型的读阶段行为:两个事务都读到剩余库存为 1,各自判断可以扣减。
读阶段不设防,意味着它会读到别人已经修改但尚未提交的数据吗?这在工程实现里取决于底层隔离级别。如果只是纯内存并发,线程读到的永远是当前内存里真实存在的值;如果基于数据库,就需要依赖数据库的快照机制来保证读到的是一致性视图。无论底层如何,OCC 本身的约束是:事务读到的版本信息必须和它读取的数据在逻辑上保持一致,不能出现读了数据 A 的旧版本、却记下了数据 A 的新版本的荒谬情况。这要求业务代码在读取时把版本信息和数据放在一次原子操作中拿到。
2.2 校验阶段:版本号/时间戳的胜负手
校验阶段是 OCC 的灵魂。事务在准备提交前,会重新检查自己在读阶段接触过的数据,确认这些数据对应的版本信息没有发生变化。没有任何变化,说明这段时间里没有其他事务改过它们,可以进入写阶段;如果有任一项变化,说明存在时间窗口里的冲突,事务必须中止并重试。
这里有一个非常关键的工程考量:校验什么、以什么顺序校验。最朴素的实现是逐条比对版本号,但这只能发现“被修改过”的数据,无法发现“被插入过”的数据。考虑一个查询事务,它在读阶段扫描了一个范围,期间另一个事务往这个范围里插入了一条新记录并提交。如果只校验原有记录的版本号,这条新插入记录根本不会被看到,事务的扫描结果就缺失了数据。严谨的 OCC 实现需要记录扫描范围和查询条件,在提交前重新执行一遍类似查询来确认结果集一致。不过这种做法成本很高,所以很多工程实现会直接提高隔离级别或者给区间加锁来规避这类问题,这个权衡在后续章节里会展开。
2.3 写阶段:提交位与可见性
校验通过之后,事务进入写阶段,把本地计算出的新值写入数据,同时推进版本信息。写阶段看起来只是扫尾,实际上也有两个隐藏点。第一,写入必须“原子可见”,即其他事务要么看不到这次写入的任何部分,要么完整看到全部写入结果,不能出现半更新状态。第二,版本信息的更新必须和业务数据更新绑定在同一个原子操作里,业务值变了版本号没变,或者版本号变了业务值没变,都会导致后续校验失效。
在数据库实现里,这两个要求通常靠底层存储引擎的原子操作和日志机制保证。在业务代码里,我们可以借助一次条件更新来完成,例如在关系数据库中执行UPDATE ... SET data = 新值, version = version + 1 WHERE version = 旧版本号,如果更新影响的行数为 0,说明版本号已经被别人改过了,校验失败。这个 SQL 的原子性由数据库单语句事务保证,是业务层 OCC 最常见的落地方式。请注意,这里必须使用“旧版本号”而不是“任意版本号”,否则条件更新就失去了校验意义。
3. 版本号、时间戳与 ABA:落地 OCC 绕不开的细节
前面反复出现“版本信息”这个概念,但版本信息具体用什么表示,直接决定了 OCC 实现的严谨程度。最常用的三种方案是整型版本号、时间戳和内容摘要,各有各的适用场景,也各有各的坑。
3.1 版本号选型:整型版本号、时间戳、内容摘要
整型版本号是最直观的方案:每条数据维护一个数字字段,每次成功更新时加 1,校验时比对当前值是否还是之前读到的那个数。它的好处是简单、性能好、索引友好,WHERE 条件里直接走普通索引就能过滤;缺点是需要业务表额外维护一个字段,凡是更新数据的地方都得同步更新版本号,漏掉一处就会让校验形同虚设。
时间戳方案的思路是用last_modified_time这类字段代替版本号,每次更新把时间刷新为当前时间。它省去了手动管理递增计数的麻烦,但引入了一个新问题:精度。如果数据库时间精度是秒级,两个事务在同一秒内先后提交,第二个事务可能读到的还是旧时间戳,从而误判为“未修改”。更高精度的时间戳普遍存在,但分布式环境下不同机器的时钟偏移会再次干扰判断,所以很多对一致性要求很高的系统宁可用整型版本号也不依赖时间。
内容摘要方案是对整条数据的哈希值进行比对,可以捕获任何字段的任何变化。它的优点是覆盖范围最全,不需要显式维护版本字段,缺点是成本高、且可能哈希碰撞。工程上哈希碰撞的概率极低,真遇到了也可以用更长摘要来规避。三种方案没有绝对优劣,我自己的选型习惯是:单表业务优先整型版本号,字段稀疏且更新频繁的表可以加时间辅助字段,需要严格校验整行数据变化的场景再考虑摘要方案。
| 版本方案 | 优点 | 主要坑点 | 适用场景 |
|---|---|---|---|
| 整型版本号 | 简单直观、性能好 | 需要业务显式维护 | 大多数关系型事务 |
| 时间戳 | 无需递增计数 | 精度不足、时钟偏移 | 更新时间敏感的字段 |
| 内容摘要 | 覆盖全字段变化 | 计算成本高 | 严格全行比对场景 |
3.2 ABA 问题与 CAS 的陷阱
讨论版本号不能不提 ABA 问题。假设线程 A 读到数据值为 1,线程 B 把值改成 2,又改回 1。线程 A 再检查时发现值还是 1,认为没人动过,于是提交成功。但在这段时间里,数据确实被 B 动过两次,某些依赖中间状态的逻辑会被欺骗。单纯比较“最终值相等”无法发现 ABA,因为比较的是状态快照而不是变更历史。
解决 ABA 问题的标准手段是引入带方向的版本号,也就是每次更新都严格递增,哪怕值回到原点,版本号也不会退回。这正是整型版本号自动加 1 的天然优势。如果是基于哈希摘要的方案,摘要本身不会递增,ABA 就有可能被误判为“无变化”。所以在设计乐观锁时,不要只比对业务值,必须额外比对一个单调递增的版本标识。CAS(Compare-And-Swap)指令在处理 ABA 时也面临同样问题,所以才有了带版本号或邮票值的改良版 CAS,底层逻辑与数据库乐观锁完全一致。
3.3 从单行版本号到多行一致性校验
上面讨论的都是单条数据的版本校验,但真实事务往往横跨多张表、多行记录。如果事务 A 更新了表 X 和表 Y 的各一行,事务 B 只更新了表 X 的同一行,那么只校验表 X 的行会发现冲突,还算幸运;但如果事务 A 更新的两张表恰好都与 B 无关,校验全部通过,看起来没问题。真正需要担心的是那些逻辑上必须同时满足的约束:两张表都变了但版本校验只看了一张表,另一张表被悄悄修改而未被发现。
更严谨的做法是给关联数据维护一个共同的事务版本号,比如事务提交时对所有涉及的数据行写入同一个版本号;校验时只要发现任何一行的版本号与预期不符,就判定整体失败。这意味着版本号的更新粒度必须跟事务边界对齐,不能各行独立递增。很多业务系统简化处理为“只校验主表版本号”,这在数据关联简单时问题不大,一旦主表和明细表之间存在业务约束,简化就可能引入难以排查的不一致。我的建议是,对强一致的关联数据,至少要在校验阶段同时读取所有相关行的版本号再统一判断,避免用单行校验掩盖多行不一致的真实情况。
4. 冲突率、活锁与重试策略:什么时候该用 OCC
OCC 并非万能灵药,它的适用性高度依赖冲突率。冲突率是单位时间内发生版本校验失败的请求占总请求的比例。一个简单的估算方式是通过日志统计“版本校验失败次数”和“总提交尝试次数”,也可以从数据库慢查询、重试日志中反推。这个数字的意义在于,它直接决定了系统的资源浪费程度:每次失败都意味着读阶段的计算白做,还要额外付出回滚和重试成本。
4.1 冲突率的定性与定量判断
理论分析通常认为冲突率在 10% 以下时 OCC 表现不错,20% 以上开始明显劣化,超过 40% 时乐观策略的性能会显著不如悲观策略。这个边界不需要背得很精确,关键是建立判断直觉:如果冲突几乎不发生,乐观策略的并行优势最大;如果冲突频繁发生,大量事务都是在做无用功,整体吞吐量会断崖式下跌。实际压测时我倾向于自己画一条冲突率-吞吐量曲线,在系统上人为提高并发数,观察吞吐量何时出现拐点,这个拐点附近就是该场景下 OCC 的适用上限。
还要留意冲突场景的类型差异。同样是 20% 的冲突率,如果冲突集中在少数热点数据上,那么热点数据竞争会形成天然的瓶颈,重试机制又会让负载进一步集中;如果冲突均匀分散在全量数据上,整体系统还能保持相对稳定。所以在评估冲突率时,不仅要看平均数值,还要看冲突的分布,热点行上的冲突率即使不高也值得单独处理,这属于业务层面的数据倾斜问题。
4.2 重试策略:指数退避与随机抖动
冲突发生后怎么办?最简单的策略是立即重试,但这也最容易引发活锁。想象高冲突场景下,大量事务同时读到同一份旧版本的数据,一起提交时只有一个成功,其余全部失败;这些失败的事务如果立刻重试,又恰好再次同时启动,又会形成新一轮的集体失败。这个循环可以一直持续下去,系统吞吐量趋近于零,CPU 却烧得厉害。
解决活锁的办法是让重试节奏变得不确定。指数退避是经典方案:第一次失败后等待 20ms 左右重试,第二次失败等待 40ms,第三次 80ms,以此类推,并加一个随机抖动量,避免多个事务在完全相同的时刻重试。实际项目中抖动要足够大,否则一批请求还是会撞在同一个时间点。例如基础退避时间为min(最大退避, 初始值 * 2^重试次数),抖动范围可以是基础值的 0 到 1 倍。重试上限也需要设置,避免无限循环浪费资源,通常业务上重试 3 到 5 次仍未成功就应该返回失败提示。
提示:重试本身要留意幂等性。如果事务操作不是幂等的,重试会反复执行同一业务动作,造成重复扣款、重复下单等问题。OCC 失败重试前必须确认事务操作已经完整回滚,且业务语义允许再次执行。
4.3 长事务为什么不适合 OCC
OCC 的校验基于事务持有数据的版本信息,事务执行时间越长,持有版本信息越久,期间被其他事务修改的概率就越高。一个秒级完成的小事务,冲突概率很低;一个持续长达数秒甚至跨界点的大事务,即使不做任何实际写入,它依赖的数据也可能被多个事务轮番修改,最终大概率校验失败。长事务配 OCC 会陷入“重试代价极高,不重试只能报错”的两难。
这解释了为什么数据库内部很少让长事务走纯乐观路径,更常见的是混合策略:短事务用乐观校验,长事务在关键路径上加锁或提高隔离级别。如果你正在设计一个面向业务层的乐观锁方案,请务必评估单个事务的执行时间。一个操作超过几百毫秒,就应该重新考虑是否适合用 OCC,或者把大事务拆成多个小事务,缩短每个事务的数据依赖窗口,这样冲突率会显著下降。
5. 数据库、缓存与分布式场景里的 OCC 影子
看完理论,再看 OCC 在真实技术栈里的影子。这个概念绝不是躺在论文里的抽象模型,它早就被揉进了各种主流系统的设计细节中,只是你可能没意识到自己正在使用它。
5.1 关系数据库的 MVCC 与 OCC 的亲戚关系
大多数关系数据库的多版本并发控制(MVCC)在机制上和 OCC 有千丝万缕的联系。MVCC 的写操作通常先在副本上进行,提交时才检查是否符合隔离级别要求,如果发现写冲突就回滚或等待;读操作则直接读快照,不被写锁阻塞。这种“写时不阻塞读,提交时再确认”的思路,骨子里带着乐观的影子。更明显的证据是,数据库支持的“读已提交”和“可重复读”隔离级别之所以能高效运行,就是因为版本机制让读写分离,只有提交时才需要做可见性判断。
MVCC 与纯 OCC 的区别在于:MVCC 通过事务 ID 和提交序号维护了完整的版本链,任何读操作都能找到一个语义一致的历史版本来读取,因此不会像 OCC 那样因为中间状态被修改而直接失败。理解这个区别很重要:MVCC 解决了读的一致性,却不等同于让系统自动处理写冲突。两笔更新同一行的写事务依然只有一个能成功,另一个要么等待、要么失败,具体表现取决于数据库采用的提交协议。讨论 OCC 时不要把 MVCC 当成免费午餐,它只是提供了版本信息基础设施。
5.2 缓存系统的 WATCH/MULTI/EXEC 是怎么体现 OCC 的
很多缓存数据库都提供了事务命令,其中最典型的模式是WATCH、MULTI、EXEC三段式组合。WATCH在事务开始前监控一个或多个键,MULTI开启事务队列,EXEC提交时如果发现被监控的键在 WATCH 之后被别的客户端修改过,就拒绝执行全部队列。这不就是标准的 OCC 三阶段吗?WATCH对应读阶段记录版本,EXEC对应校验和提交,中间业务命令在本地排队执行,冲突了整批作废。
这套命令的工程价值在于它能实现“先检查后更新”的原子性,而不需要显式加锁。经典应用场景是并发抢购:先用WATCH监控库存键,读取库存数量,判断足够后在MULTI队列中写入扣减命令,EXEC提交时如果发现库存键在读取后被其他客户端改过,本次操作失败。这种方案比分布式锁更轻量,也更贴合“读多写少、偶发竞争”的抢购现实。如果你在业务里用过这套命令组合,那你其实已经写过一次 OCC 的落地代码了。
5.3 全文检索引擎的版本控制与乐观更新
全文检索领域同样吸收了 OCC 思想。很多检索引擎允许文档在更新时携带版本号,提交更新时会带一个期望的版本参数,只有当前文档版本号与期望值一致时更新才生效;如果文档已经因为并发更新而版本号变化,这次更新就会失败。这类接口设计本质上就是乐观锁。它的好处在于:检索系统通常允许读取路径与写入路径分离,叠加版本校验后可以避免两个客户端同时覆盖同一文档,而不必引入复杂的锁协调器。
不过检索场景往往偏向最终一致性,版本冲突后的处理也更宽松:要么丢弃更新,要么重新拉取最新文档再尝试合并。你会发现这里 OCC 的重试策略和数据库事务非常相似,只是少了严格的 ACID 语义。这套版本机制在实践里给我最大的启发是,只要数据模型支持显式版本字段,几乎任何存储系统都可以手写乐观锁,不必依赖内置事务。
5.4 应用层乐观锁 SQL 怎么写才不漏坑
回到最接地气的场景:直接用 SQL 实现乐观锁。标准写法是带版本号条件更新,订单表中预置version字段,每次扣减库存时执行下列逻辑:
-- 第一次读库存 SELECT stock, version FROM product WHERE id = 123; -- 业务代码判断 stock 足够 -- 然后用带 version 条件的更新尝试扣减 UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 123 AND version = 刚刚读到的版本号; -- 如果 UPDATE 影响行数为 0,说明版本已变,需要重读并重试这段 SQL 能成立,依赖的是关系数据库单条 UPDATE 语句自身的原子性。很多人问,如果扣减后的 stock 和版本号之间没有逻辑关联,那把它俩放在同一个 SET 子句里会不会有问题?答案是不会,只要 WHERE 条件里的 version 匹配,SET 里的 version 加 1 是原子完成的,其他事务不可能把 stock 改一半又保留旧版本号。真正要小心的是另一个问题:写完这条 UPDATE 后,业务代码是否还用旧版本号去更新其他相关表。如果多条表之间需要保持一致,单条 SQL 就管不住了,必须回到前面提到的多行一致性校验方案。
注意:不要用
UPDATE ... SET stock = stock - 1 WHERE id = 123 AND version = ?这种写法,也就是更新时不递增 version。这样虽然校验了版本,但成功后版本号没变,等于后续所有事务都还能通过校验,乐观锁形同虚设。版本号必须在每次写操作中一同推进。
6. 手写一个可运行的 OCC 学习 Demo,含冲突统计
只聊原理不足以建立直觉,我习惯把关键机制写成能跑的小程序来验证想法。下面这个 Demo 模拟了一个库存扣减场景,用 Python 的线程池模拟并发请求,通过每条库存记录的 version 字段实现乐观锁,并输出冲突率统计。它剔除了数据库依赖,把 OCC 三阶段放在内存里,方便观察冲突如何发生、如何被拦截。
6.1 场景设计与提交逻辑
模拟模型非常朴素:一个全局字典表示商品库存数据,每件商品包含stock和version两个字段;请求线程随机选择一个商品,以 50 毫秒的模拟业务耗时来放大不同请求之间的重叠窗口;扣减操作遵循标准的读-校验-写流程,校验失败就重试,最多重试 3 次。故意加入随机耗时是为了让请求不再整齐排队,更贴近真实场景里的并发形态。
import threading import random import time from collections import defaultdict # 模拟数据库表:product_id -> {"stock": 数量, "version": 版本号} products = { "P001": {"stock": 100, "version": 0}, "P002": {"stock": 50, "version": 0}, } lock = threading.Lock() stats = defaultdict(int) def read_and_check(product_id): # 相当于 SELECT stock, version FROM products WHERE id = ? with lock: p = products[product_id] return p["stock"], p["version"] def try_commit(product_id, old_version, decrement): # 相当于 UPDATE products SET stock = stock - 1, version = version + 1 # WHERE id = ? AND version = old_version with lock: p = products[product_id] if p["version"] != old_version: return False # 版本不一致,提交失败 p["stock"] -= decrement p["version"] += 1 return True def place_order(product_id, decrement=1, max_retry=3): for attempt in range(max_retry): stock, version = read_and_check(product_id) if stock < decrement: stats["insufficient"] += 1 return "库存不足" if try_commit(product_id, version, decrement): stats["success"] += 1 return "下单成功" stats["retry"] += 1 time.sleep(random.uniform(0.01, 0.05)) # 重试前的短暂等待 stats["fail"] += 1 return "重试耗尽,下单失败" def worker(n): for _ in range(n): product_id = random.choice(["P001", "P002"]) place_order(product_id) threads = [threading.Thread(target=worker, args=(40,)) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() print("统计结果:", dict(stats)) print("剩余库存:", {k: v["stock"] for k, v in products.items()})6.2 代码里的三个工程细节
这个 Demo 虽然短,但已经包含了所有必须注意的细节。第一,read_and_check和try_commit分别用同一个互斥锁保护,模拟数据库单行操作的原子性。真正的关系数据库不需要你写锁,它靠自身机制保证单条语句的原子性,但内存字典如果不加锁就会在stock读取和version增加之间出现竞态。第二,try_commit里同时校验旧版本号和更新新版本号,严格保持一致,这是乐观锁正确性的核心。第三,重试前加了随机小睡眠,这是前面讨论过的抖动策略的微型版,防止多个线程在完全同步的节奏里反复碰撞。
把max_retry调成 3、并发线程数从 8 改成 64,就能直观感受到冲突率的变化:线程越多,retry数量越大,fail出现的概率也越高。这就是冲突率-吞吐量曲线在微观层面的体现。你还可以把random.uniform(0.01, 0.05)替换成固定值 0.01,大概率会观察到重试次数增多,因为所有失败线程都僵硬地等待同样长的时间,然后同时苏醒继续竞争。这个现象能帮你记住为什么随机抖动是必要的。
6.3 冲突率分析的输出解读
运行几次之后,输出里success、retry、fail才是真正需要关注的数据。成功数代表真正完成的业务操作,重试数说明发生了多少次冲突,重试耗尽数则反映冲突激烈程度。如果重试数占总请求数的比例超过三成,说明这个并发度下乐观策略已经不太合适,要么限制并发,要么给商品加分布式锁,要么从架构上拆分热点商品。这个判断标准不是拍脑袋,而是从资源浪费角度算出来的,每次重试都重复执行了读阶段,成本翻倍增长。
为了让实验更有说服力,建议把并发线程数和每线程请求数调成多组参数,分别记录冲突比例,画出一条简单曲线。你会在低并发区间看到接近零的冲突,在中并发区间看到线性上升,在高并发区间看到系统吞吐量不再随之增长。再用锁方案跑同一组参数作对比,就能形成一套属于自己的选型依据,而不是听别人说“悲观锁更稳”或“乐观锁更快”就盲目抄作业。
7. 我用 OCC 踩过的坑与排查链路
即使理解了原理,实战场上依然会踩到各种奇怪问题。我自己在几个项目里分别遇到过丢失更新、活锁、校验顺序错误这三类典型事故,每一类都值得单独记录成排查样本。
7.1 丢失更新:版本号忘了从 SELECT 里带出来
第一个事故发生在给一个订单系统引入乐观锁时。那时改动集中在写入路径上,业务代码在UPDATE时带上了版本号条件,但SELECT时没有把version字段放进查询列表。结果每次读到的版本号都是默认值,比如 0,所有事务都拿“版本号为 0”去更新,第一批更新成功后数据版本变 1,后续所有事务的WHERE version = 0全部失败,线上瞬间出现大量更新失败告警。排查过程花了两个小时,最后检查 SQL 日志才发现是读写路径的字段不一致导致的。
这个坑的本质是“校验条件依赖的数据没有完整读取”。乐观锁要求读阶段把版本信息原样带出,项目里改了写路径却忘了同步改读路径的情况非常常见。后来我在代码评审里加了一条硬性要求:凡是为表引入版本号字段,必须同时审查该表所有的SELECT语句,确认查询列中带着version。这条规则虽然机械,但能提前挡住一大半问题。
7.2 活锁:重试没有退避,冲突越高越崩溃
第二个事故来自一次压测实验。我最初实现的乐观锁重试逻辑非常简陋,捕获到冲突就立刻在同一事务里重新执行,没有睡眠,也没有退避。压测刚开始时吞吐量表现不错,但把并发线程从 20 调到 100 之后,系统突然陷入长时间卡顿,请求失败率飙升。数据库监控显示,大量事务都在同一批数据上反复尝试,但几乎没有一个能成功提交。这就是活锁的典型症状,系统没有死锁,却比死锁更隐蔽,因为所有线程都在运行,看起来就是“慢”。
事后我把重试逻辑改成指数退避加随机抖动,并给重试次数设定上限,卡顿现象立刻消失。这次经历让我养成了一个习惯:任何依赖 OCC 的代码都必须在提交失败时记录重试原因和重试次数,监控曲线上如果某条数据的重试次数持续偏高,即便整体冲突率还能接受,也要警觉热点数据已经形成瓶颈。活锁的可怕之处在于它不会主动报错,只能从时序特征里观察出来。
7.3 校验顺序偏差:先写后校验会把不一致永久留存
第三个问题更隐蔽,发生在多行事务上。设计了一批订单相关的数据更新,按我的预想流程是先读取所有数据并记录版本,统一执行业务计算,最后集中校验版本并写入。但代码实现时被拆成了一段一段的,第一段代码先更新了主表数据,第二段代码才去校验明细表版本号。结果就出现了:主表已经写入了新值,明细表校验却发现版本不匹配,事务回滚;但由于主表的写入操作已经执行过,某些中间状态被日志或缓存记了下来,后续重试时读到的主表数据已经不是最初读到的版本,业务逻辑发生了语义偏移。
正确的顺序必须是:校验全部通过,走写阶段;只要有一个版本不匹配,任何一行都不写。这个“先校验再动手”的顺序纪律在多行事务里特别容易被急于优化性能的开发者打破,因为最自然的想法是“先更新主表减少锁持有时间,再校验明细”。我只能说,在 OCC 的世界里,任何提前写入都是危险的,宁可统一校验后再写,也不要贪那点性能。
7.4 排查路径:从日志关键字到冲突率监控
结合这些教训,我沉淀了一条自己的 OCC 问题排查链路。第一步是查日志关键字,把“版本校验失败”、“乐观锁冲突”、“retry 超限”这类关键字都配上结构化日志,能立即定位哪些请求在冲突。第二步是算冲突率,用日志统计冲突次数与总请求数的比值,冲突率正常但业务报错,大概率是版本号字段丢失或更新时没递增;冲突率异常高,则是并发度超过系统承受能力,或者热点数据倾斜。第三步是追溯单条冲突链路,看同一个product_id的重试次数是否集中,集中则说明热点行需要单独处理。
最后一步是回顾隔离级别和事务边界,因为很多 OCC 问题其实掩盖了更底层的问题:事务边界过宽导致长事务高冲突、多条业务规则耦合在同一事务里放大了失败面。把大事务拆分、将纯读操作移出事务,往往比调整重试参数更治本。这个排查链路如今已经写进我团队的复盘模板里,每次线上乐观锁问题都能沿着它快速收敛到根因。