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

本文梳理乐观锁悲观锁 的核心原理、选型对比、前后端协作方式,并给出通用实战示例。

适合前后端开发、架构设计、技术分享场景阅读。


一、要解决什么问题?

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

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

典型场景:

ini 复制代码
时间线:
─────────────────────────────────────────────────────
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

sql 复制代码
-- 更新时必须带上「读取时的版本号」
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 要开放给前端?

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

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

json 复制代码
{
  "id": "1234567890123456789",
  "version": 1,
  "receiverName": "9876543210987654321"
}

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

为什么不后端自己记?

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

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


三、前后端协作实战示例

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

3.1 后端:实体与 SQL

java 复制代码
// Order.java
private Integer version;
xml 复制代码
<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:列表页批量操作

typescript 复制代码
const payload = selectedRows.map(row => ({
  id: row.id,
  version: row.version,
}));
await batchUpdate(payload);

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

typescript 复制代码
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),转数字丢精度
version 0、1、2 ... 通常 < 1000 number 小整数,无精度问题,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)
java 复制代码
// 分布式锁示例
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 + 状态校验 | | 下单中状态 | 防止重复下单,先改「下单中」再调外部系统 |

java 复制代码
//  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 跨任务防重复生成,必须互斥
库存扣减防超卖 原子 SQL WHERE 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 @Version ORM 自动管理 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

相关推荐
用户8356290780511 小时前
使用 Python 对 PDF 文档进行数字签名的方法
后端·python
苏三说技术1 小时前
Skywalking已全面拥抱AI!
后端
神奇小汤圆1 小时前
Maven 4 要来了:15 年后,Java 构建工具迎来“彻底重构”
后端
Charonmomo1 小时前
react/antd - 不能正确打开标签页问题记录
前端·react.js·前端框架
面试鸭2 小时前
面试官:“知识库搜到多少资料,你就全喂给 AI?”我:“信息越多越准。”她亮出错答案:“那它怎么被废话带跑了?”
后端·面试·求职
米花米唐2 小时前
Spring AI 2.0 会话记忆开发实践
后端
冬枳2 小时前
基于OpenSeagragon封装的前端病理切片展示组件
前端·typescript
从零开始的代码生活_2 小时前
C++ stack、queue 与 priority_queue:容器适配器原理与实战
开发语言·c++·后端·学习·算法
爱勇宝3 小时前
《道德经》第6章:别把团队的创造力用到枯竭
前端·后端