乐观锁与悲观锁:原理、选型与前后端实践

40 阅读13分钟

本文梳理乐观锁悲观锁的核心原理、选型对比、前后端协作方式,并给出通用实战示例。
适合前后端开发、架构设计、技术分享场景阅读。


一、要解决什么问题?

1.1 并发写冲突:Lost Update(丢失更新)

两个用户几乎同时修改同一条数据时,后保存的修改可能无声覆盖先保存的修改,导致数据丢失。

典型场景:

时间线:
─────────────────────────────────────────────────────
T1  用户甲打开「订单 A」,读到 version = 1,收货人 = 张三
T2  用户乙打开同一条订单,也读到 version = 1,收货人 = 张三
T3  用户乙将收货人改为「李四」并保存 → 成功,DB version = 2
T4  用户甲将收货人改为「王五」并保存,仍带 version = 1 提交
    → 若无乐观锁:王五覆盖李四(乙的修改丢失)
    → 有乐观锁:更新失败,提示刷新(甲需重新读取 version = 2 后再操作)

这就是经典的 Lost Update 问题。乐观锁的目标不是「阻止多人同时看」,而是防止旧快照覆盖新数据

1.2 乐观锁 vs 悲观锁

维度乐观锁(Optimistic Locking)悲观锁(Pessimistic Locking)
基本假设冲突概率低,读多写少冲突概率高,写操作密集
加锁时机提交时才检查是否冲突读取时就锁定记录
实现方式version 字段 / 时间戳SELECT ... FOR UPDATE、分布式锁
并发性能高,读操作无阻塞低,锁持有期间他人需等待
冲突处理更新失败,业务层提示重试等待锁释放或超时
典型场景业务单据编辑(采购计划、订单)库存扣减、秒杀、账户余额

选型口诀:

  • 单据类、表单类、冲突不频繁 → 乐观锁
  • 热点计数、资金、库存 → 悲观锁原子 SQLUPDATE qty = qty - 1 WHERE qty >= 1

二、乐观锁的核心机制

2.1 version 字段

在业务表上增加一个版本号字段,每次成功更新version = version + 1

-- 更新时必须带上「读取时的版本号」
UPDATE biz_order
SET receiver_name = ?,
    update_time = NOW(),
    version = version + 1
WHERE id = ?
  AND version = ?;   -- 前端/调用方传来的旧版本
更新结果含义
affected_rows = 1无人抢先修改,更新成功,version 自动 +1
affected_rows = 0记录已被他人修改,拒绝本次写入

2.2 完整交互流程

sequenceDiagram
    participant 前端
    participant 后端
    participant DB

    前端->>后端: 查询计划(列表/详情)
    后端->>DB: SELECT id, ..., version
    DB-->>前端: 返回数据 + version = 1

    Note over 前端: 用户编辑(可能停留很久)

    前端->>后端: 提交修改 { id, version: 1, ... }
    后端->>DB: UPDATE ... WHERE id=? AND version=1

    alt 仍是 version=1(无人改过)
        DB-->>后端: 更新 1 行,version → 2
        后端-->>前端: 成功
    else 已是 version=2(被人改过)
        DB-->>后端: 更新 0 行
        后端-->>前端: 版本冲突,操作失败
        前端-->>前端: 提示「数据已变更,请刷新后重试」
    end

2.3 为什么 version 要开放给前端?

乐观锁的本质是:调用方声明「我这次修改,是基于我当初看到的那份数据」

后端无法知道用户打开页面时读到的是哪个版本,因此写操作请求中必须携带:

{
  "id": "1234567890123456789",
  "version": 1,
  "receiverName": "9876543210987654321"
}

「我认为当前版本是 1,请在这个前提下帮我改。」

为什么不后端自己记?

方案问题
服务端 Session 记版本多标签页、列表页与详情页交叉操作会互相覆盖
打开编辑时加悲观锁用户挂着页面不保存会长期占锁,Web 体验差
前端传 version(当前方案)无状态、适合 HTTP REST,实现简单

version 不是敏感字段,而是并发控制凭证,暴露给前端是行业标准做法(JPA @Version、MyBatis 手动 version + 1 均如此)。


三、前后端协作实战示例

以下以「业务单据」为例,展示乐观锁在真实项目中的落地方式。

3.1 后端:实体与 SQL

// Order.java
private Integer version;
<update id="updateWithVersion">
  UPDATE biz_order
  SET status = #{status},
      receiver_name = #{receiverName},
      update_time = NOW(),
      version = version + 1
  WHERE id = #{id}
    AND version = #{version}
</update>

3.2 哪些写操作需要带 version?

操作类型是否传 version说明
编辑主表字段典型乐观锁场景
批量规则重算每条记录各自带 id + version
拆分、合并视情况并发频繁时建议补充
仅追加备注可选冲突后果通常可接受

3.3 案例 A:列表页批量操作

const payload = selectedRows.map(row => ({
  id: row.id,
  version: row.version,
}));
await batchUpdate(payload);

3.4 案例 B:详情页编辑保存

await updateOrder({
  id: form.id,
  version: form.version,
  receiverName: form.receiverName,
});

3.5 案例 C:列表与详情交叉操作

若详情页已被他人修改,列表页批量提交时 UPDATE WHERE version = ? 匹配 0 行,需刷新后重试。


四、versionid 的前端类型差异

联调订单时,ID 统一用 stringversion 仍用 number,原因不同:

字段典型值域前端类型原因
id(雪花 ID)15~19 位大整数string超过 JS Number.MAX_SAFE_INTEGER(2^53-1),转数字丢精度
version0、1、2 … 通常 < 1000number小整数,无精度问题,JSON 传 number 即可

version 会溢出吗?

Integer 上限约 21 亿。对业务单据:

  • 每天更新 100 次 → 约 5.8 万年 才溢出
  • 每天更新 1000 次 → 约 5800 年

单据类场景无实际溢出风险。若仍不放心,后端可将字段升级为 BIGINT


五、乐观锁适用场景

✅ 适合乐观锁

  • 业务单据:采购计划、订单、采购订单、合同
  • 表单编辑:多人可查看,偶尔修改,冲突概率低
  • 读多写少:列表浏览为主,写操作相对稀疏
  • 无长事务:用户编辑完即提交,不需要长时间占锁
  • 可接受重试:冲突后提示刷新,用户重新操作即可

⚠️ 乐观锁防不住的情况

  1. 未走 version 校验的接口(如部分备注、拆分接口)
  2. 只校验 version、不校验业务状态(如计划已「已完成」仍被误操作——需额外状态机校验)
  3. 前端收到冲突后不刷新、继续用旧 version 重试
  4. 读-改-写跨多个接口且无事务(中间态可能被他人插入)

六、悲观锁适用场景详解

冲突概率高不能接受「失败让用户刷新重试」、或必须在读取后立即独占资源时,应优先考虑悲观锁(或等价的原子 SQL / 分布式锁)。

6.1 什么情况下该用悲观锁?

满足以下任一条件,通常就不该单靠乐观锁:

判断条件说明
冲突概率高同一行数据在短时间内被多次并发修改(库存、余额、热点 SKU)
必须成功业务不允许「版本冲突,请刷新」——如支付扣款、出库确认
读后即写、间隔极短不存在「用户盯着表单改半小时」的中间态,而是接口内读完立刻改
资源数量有限库存、仓位、名额、单号等「抢完就没了」的资源
跨服务串行多个微服务必须对同一业务键互斥执行(结算生成、对账跑批)
状态机关键节点审批提交、发货确认等「只能成功一次」的状态跃迁

6.2 悲观锁要解决的问题(与乐观锁不同)

乐观锁防的是:旧快照覆盖新数据(Lost Update)。

悲观锁还要防:

  • 超卖:库存 1 件,两人同时下单都成功
  • 重复扣款:同一笔支付回调被处理两次
  • 重复生成:同一结算组并发生成两份报表
  • 脏读引发错误决策:读到中间态后基于错误数据继续写

6.3 典型适用场景分类

A. 库存与仓储

场景为何用悲观锁 / 原子操作常见实现
库存扣减 / 预占 / 释放并发极高,乐观锁大量失败重试,体验与性能都差原子 SQL:UPDATE stock SET qty = qty - n WHERE qty >= n
唯一码库存占用同一 uniqueKey 被多单争抢version 乐观锁 + 唯一键约束()
库位分配 / 拣货锁定同一库位不能分给两单SELECT ... FOR UPDATE 或 Redis 锁
盘点调整调整期间禁止其他出入库业务锁 + 状态字段「盘点中」

说明:库存场景有时用原子 SQL 而非显式加锁,本质仍是悲观思路——更新前假定会冲突,在 DB 层一次性判定成败。

B. 资金、积分与结算

场景为何用悲观锁常见实现
账户余额变动金额绝不能错,不能让用户「刷新重试」行锁 FOR UPDATE 或账户级 Redis 锁
积分增减同余额逻辑同上
结算报表生成同一 tenantId + sellerId + financialEventGroupId 只能跑一次Redis 分布式锁(report-service)
// 分布式锁示例
String lockKey = "settlement:generate:" + tenantId + ":" + bizGroupId;
if (!redisLock.tryLock(lockKey, 600)) {
    throw new ServiceException("结算生成获取分布式锁失败");
}
try {
    return doGenerate(request);
} finally {
    redisLock.unlock(lockKey);
}

C. 秒杀、抢购、限量资源

场景特征实现要点
秒杀下单瞬时万级并发抢少量库存Redis 预减 + DB 最终一致;或令牌桶限流
优惠券领取总量有限原子递减 + 唯一索引防重复领取
活动名额抢完即止悲观锁或 Lua 脚本原子判断

这类场景乐观锁会导致大量请求失败,不适合 Web 表单式重试。

D. 单据关键节点(短事务悲观锁)

与「用户打开表单慢慢改」不同,这类是接口内短事务

| 场景 | 做法 | |------|------|----------| | 批量审批提交 | 先锁住行,再改状态 | | 发货确认 / 出库过账 | FOR UPDATE + 状态校验 | | 下单中状态 | 防止重复下单,先改「下单中」再调外部系统 |

//  OrderRepository.java
queryWrapper.in(OrderEntity::getId, ids).last("FOR UPDATE");

E. 定时任务、消息消费、跑批

场景为何需要锁实现
定时任务单实例多节点部署时只能一个实例跑分布式锁 / DB 锁表
MQ 消费幂等重复消息不能重复执行业务业务唯一键 + 分布式锁
对账 / 报表生成同维度数据禁止并发重算Redis 锁(见结算报表生成)
单号生成防并发重复单号DB 序列 / Redis INCR / 号段锁

F. 明确不适合悲观锁的场景(反向列举)

以下场景不应用数据库悲观锁(FOR UPDATE)长占:

场景原因应改用
用户打开表单编辑半小时锁持有太久,阻塞他人乐观锁 version
列表页批量勾选后操作无法预估用户何时提交乐观锁,提交时校验
纯查询、报表浏览无需锁无锁
仅追加日志、备注冲突后果可接受乐观锁或不做锁

6.4 悲观锁的三种常见实现

flowchart TD
    A[需要悲观锁?] --> B{锁的范围}
    B -->|单库单行| C[&#34;数据库行锁<br/>SELECT ... FOR UPDATE&#34;]
    B -->|单库多行/防死锁复杂| D[&#34;应用层事务 + 有序加锁&#34;]
    B -->|跨服务/跨节点| E[&#34;分布式锁<br/>Redis / Redisson&#34;]
    C --> F{是否只需判断数量?}
    F -->|是| G[&#34;原子 SQL<br/>UPDATE ... WHERE qty >= n&#34;]
    F -->|否| C
实现方式原理优点缺点典型场景
SELECT ... FOR UPDATE事务内锁定查询行,其他事务等待强一致、实现直观阻塞、死锁风险、不宜长事务审批提交、出库确认
Redis / Redisson 分布式锁对业务 key 互斥跨服务、可设超时需处理锁续期、主从切换边缘情况结算生成、定时任务
原子 SQL(条件更新)单条 UPDATE 完成读-判-写性能最好、无显式锁等待只能表达简单条件库存扣减、名额递减
数据库悲观锁注解JPA @Lock(LockModeType.PESSIMISTIC_WRITE)与 ORM 集成与 MyBatis 项目较少直接用JPA 项目

6.5 悲观锁使用注意事项

  1. 锁粒度尽量小:锁一行不锁表,锁「库存行」不锁「整个订单模块」
  2. 必须设超时:分布式锁设 leaseTime;DB 锁依赖事务超时,避免死锁长期挂起
  3. 加锁顺序一致:多行加锁时按固定顺序(如按 id 排序),防止死锁
  4. 锁内不做慢 IO:调第三方 API、发邮件、调第三方 API 应在锁外或异步,否则锁持有时间爆炸
  5. 与乐观锁可叠加:如库存用原子 SQL,单据主表仍用 version 防表单覆盖
  6. 前端通常不感知:悲观锁对前端透明,用户只会感到「稍慢」或「排队」,不像乐观锁需要传 version

6.6 乐观锁 vs 悲观锁:选型决策树

flowchart TD
    Start[并发写操作] --> Q1{用户是否会长时间持有编辑态?}
    Q1 -->|是,分钟~小时级| Opt[乐观锁 version]
    Q1 -->|否,接口内毫秒~秒级| Q2{冲突失败了能否让用户刷新重试?}
    Q2 -->|能| Q3{冲突概率是否很低?}
    Q3 -->|是| Opt
    Q3 -->|否,热点数据| Pes[悲观锁 / 原子 SQL]
    Q2 -->|不能,必须成功| Q4{是否跨微服务?}
    Q4 -->|是| Redis[Redis 分布式锁]
    Q4 -->|否,单库| DB[FOR UPDATE 或 原子 SQL]

6.7 常见业务场景的锁策略对照

业务类型锁策略原因
单据编辑、审核乐观锁 version表单编辑,冲突少,可提示刷新
批量审批、发货确认悲观锁 FOR UPDATE短事务内批量改状态,不能部分失败
库存明细增减乐观锁 + 唯一键 / 原子 SQL高并发;配合条件更新防超卖
结算 / 报表生成分布式锁 Redis跨任务防重复生成,必须互斥
库存扣减防超卖原子 SQLWHERE available_qty >= ? 一次判定

七、前后端协作规范(实践清单)

7.1 后端

  • 表结构增加 version INT/BIGINT NOT NULL DEFAULT 0
  • 所有会改变主表状态的写操作:UPDATE ... WHERE id=? AND version=?,并 version = version + 1
  • affected_rows = 0 时返回明确错误码(如「版本冲突 / 数据已被修改」),不要静默成功
  • 查询接口(列表、详情)返回当前 version,供前端缓存
  • 明确哪些写接口必须带 version,哪些不需要(文档化)

7.2 前端

  • 打开页面时从接口读取并保存 version(列表行、详情 form)
  • 写操作请求体携带读取时的 version,不要用 0 兜底掩盖问题(除非后端约定 0 有特殊语义)
  • 收到版本冲突错误后:刷新数据,用新 version 再操作,禁止盲目重试旧 version
  • idstringversionnumber(单据场景)
  • 列表页批量操作:每条记录各自带自己的 id + version,不能共用一份 version

7.3 产品 / 交互

  • 冲突提示文案统一:「数据已被他人修改,请刷新后重试」
  • 可选:冲突后自动 refresh() 并保留用户输入(高级体验,实现成本较高)

八、常见实现方式速查

实现说明适用场景
版本号字段version 整数递增订单、合同、审批单
时间戳update_time 作为版本依据部分轻量系统,WHERE update_time = ?
JPA @VersionORM 自动管理Spring Data JPA 项目
MyBatis 手动SQL 中 version + 1传统 Java 后端项目
数据库行锁SELECT ... FOR UPDATE短事务批量状态变更
Redis 分布式锁tryLock / Redisson结算报表生成
原子条件更新UPDATE ... WHERE qty >= n库存防超卖

九、与相关概念的区分

概念作用与乐观锁关系
乐观锁 version防止旧数据覆盖新数据本文主题
幂等性同一请求重复提交结果一致互补;乐观锁失败后可安全重试(需刷新 version)
分布式锁跨服务/跨实例互斥悲观思路,用于更高冲突场景
事务隔离级别数据库读写隔离乐观锁在应用层,不依赖 RR/Serializable
业务状态机限制「什么状态下能做什么」与乐观锁叠加使用,各管一层

十、一句话总结

乐观锁 = 提交时核对「你看到的版本还是否最新」;适合表单编辑、冲突少的单据。version 是给前端的并发凭证。

悲观锁 = 操作前先占位,别人等着或失败;适合库存、资金、秒杀、跑批等冲突高、不能让用户刷新重试的场景。

实际项目中,常组合使用:单据编辑用乐观锁 version;批量审批用 FOR UPDATE;跨服务跑批用 Redis 分布式锁;库存扣减用原子 SQL。雪花 id 前端用 stringversionnumber 即可。


十一、参考资料

  • JPA 规范:@Version 注解(javax.persistence.Version
  • JPA 规范:@Lock(PESSIMISTIC_WRITE) 悲观锁
  • 经典问题:Lost Update、超卖、死锁
  • Redisson 分布式锁文档

文档创建:2026-07-16