乐观锁(Optimistic Lock)

  • 每次获取数据的时候,都不会担心数据被修改,所以每次获取数据的时候都不会进行加锁,但是在更新数据的时候需要判断该数据是否被别人修改过。如果数据被其他线程修改,则不进行数据更新,如果数据没有被其他线程修改,则进行数据更新。由于数据没有进行加锁,期间该数据可以被其他线程进行读写操作。
  • 比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。

定义与适用面清楚了,落地还差三块:两种主流实现(version 与 CAS 字段)的 SQL 形态、重试策略的工程细节、以及乐观/悲观的量化选型依据。本文补齐成可直接落库的方案。

实现一:版本号(version)

表加 version 列,读时带出,写时校验并自增:

-- 读
SELECT balance, version FROM account WHERE id = 7; -- 得 version=3
-- 写(校验+更新原子完成)
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 7 AND version = 3; -- 影响行数 0 = 冲突

要点:

  • 校验与更新在同一条 UPDATE 内原子完成,不存在”检查后、更新前”的窗口
  • 影响行数为 0 即冲突:业务选择重试(重新读-改-写)或报错给用户
  • version 单调递增,天然防 ABA
  • ORM 支持:JPA @Version、MyBatis-Plus @Version 自动织入校验,手写 SQL 需自行维护

实现二:条件字段(状态前置 / 业务 CAS)

不引入 version,用业务字段本身做预期值:

-- 库存扣减:以"当前库存"为预期值
UPDATE stock SET count = count - 1 WHERE sku = 'A' AND count > 0;
-- 订单状态机:以"当前状态"为预期值
UPDATE orders SET status = 'PAID', pay_time = NOW()
WHERE id = 99 AND status = 'CREATED';

count > 0 与 status = 'CREATED' 就是乐观校验:并发下只有一个 UPDATE 能匹配到行。状态机流转天然适合这种写法——前置状态即版本号,语义更内聚,还省一列。

重试策略的工程细节

冲突后重试不是简单循环,要注意:

  • 重试上限与退避:3 次左右 + 随机退避(避免冲突风暴下同步重试互相踩踏)
  • 重读再改:重试必须重新 SELECT 最新数据再计算,不能拿旧快照反复提交(否则永远冲突)
  • 事务边界:version 校验 UPDATE 与同事务其他写操作一起提交;长事务持旧 version 会放大冲突率,保持事务短小
  • 用户体验:面向用户的编辑冲突(表单提交)通常不自动重试,而是返回”数据已被他人修改,请刷新”——自动重试会用旧表单覆盖新数据,反而错
  • 监控:冲突率是健康指标;持续高冲突说明热点行,应考虑拆分(如库存分桶)或改排队串行化

乐观 vs 悲观的量化选型

维度 乐观锁 悲观锁(SELECT … FOR UPDATE)
冲突频率假设 低 高
读多写少 优(读无锁) 差(读也加锁)
写密集/热点行 差(重试风暴) 优(排队串行)
死锁风险 无 有(多锁顺序)
实现复杂度 低(一列/一条件) 中(事务与锁顺序管理)

经验阈值:冲突率 <5% 乐观锁收益明显;>20% 重试开销反超加锁,转悲观或串行化(单队列消费、分片热点)。热点账户扣款这类”写写必冲突”场景,乐观锁是最差选择。

与分布式场景的衔接

跨服务的并发更新(微服务各持一部分状态)数据库 version 管不到,需上移:

  • 接口幂等键 + 状态前置校验(业务 CAS 的 API 版:if-match 语义)
  • 分布式锁串行化热点(代价是吞吐)
  • 事件溯源/CRDT 等最终一致模型(彻底放弃同步校验)

乐观锁的精髓是**把冲突检测从”读时加锁”推迟到”写时校验”**,用极低概率的重试换取读路径的零开销。version 与条件字段是它的两种 SQL 形态,重试策略与冲突率监控是它的工程配套;选型时先估冲突率,再决定乐观还是悲观。