本文梳理乐观锁 与悲观锁 的核心原理、选型对比、前后端协作方式,并给出通用实战示例。
适合前后端开发、架构设计、技术分享场景阅读。
一、要解决什么问题?
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、分布式锁 |
| 并发性能 | 高,读操作无阻塞 | 低,锁持有期间他人需等待 |
| 冲突处理 | 更新失败,业务层提示重试 | 等待锁释放或超时 |
| 典型场景 | 业务单据编辑(采购计划、订单) | 库存扣减、秒杀、账户余额 |
选型口诀:
- 单据类、表单类、冲突不频繁 → 乐观锁
- 热点计数、资金、库存 → 悲观锁 或 原子 SQL (
UPDATE 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 完整交互流程
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 行,需刷新后重试。
四、version 与 id 的前端类型差异
联调订单时,ID 统一用 string,version 仍用 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。
五、乐观锁适用场景
✅ 适合乐观锁
- 业务单据:采购计划、订单、采购订单、合同
- 表单编辑:多人可查看,偶尔修改,冲突概率低
- 读多写少:列表浏览为主,写操作相对稀疏
- 无长事务:用户编辑完即提交,不需要长时间占锁
- 可接受重试:冲突后提示刷新,用户重新操作即可
⚠️ 乐观锁防不住的情况
- 未走 version 校验的接口(如部分备注、拆分接口)
- 只校验 version、不校验业务状态(如计划已「已完成」仍被误操作------需额外状态机校验)
- 前端收到冲突后不刷新、继续用旧 version 重试
- 读-改-写跨多个接口且无事务(中间态可能被他人插入)
六、悲观锁适用场景详解
当冲突概率高 、不能接受「失败让用户刷新重试」 、或必须在读取后立即独占资源时,应优先考虑悲观锁(或等价的原子 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 悲观锁的三种常见实现
| 实现方式 | 原理 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
SELECT ... FOR UPDATE |
事务内锁定查询行,其他事务等待 | 强一致、实现直观 | 阻塞、死锁风险、不宜长事务 | 审批提交、出库确认 |
| Redis / Redisson 分布式锁 | 对业务 key 互斥 | 跨服务、可设超时 | 需处理锁续期、主从切换边缘情况 | 结算生成、定时任务 |
| 原子 SQL(条件更新) | 单条 UPDATE 完成读-判-写 | 性能最好、无显式锁等待 | 只能表达简单条件 | 库存扣减、名额递减 |
| 数据库悲观锁注解 | JPA @Lock(LockModeType.PESSIMISTIC_WRITE) |
与 ORM 集成 | 与 MyBatis 项目较少直接用 | JPA 项目 |
6.5 悲观锁使用注意事项
- 锁粒度尽量小:锁一行不锁表,锁「库存行」不锁「整个订单模块」
- 必须设超时 :分布式锁设
leaseTime;DB 锁依赖事务超时,避免死锁长期挂起 - 加锁顺序一致:多行加锁时按固定顺序(如按 id 排序),防止死锁
- 锁内不做慢 IO:调第三方 API、发邮件、调第三方 API 应在锁外或异步,否则锁持有时间爆炸
- 与乐观锁可叠加 :如库存用原子 SQL,单据主表仍用
version防表单覆盖 - 前端通常不感知 :悲观锁对前端透明,用户只会感到「稍慢」或「排队」,不像乐观锁需要传
version
6.6 乐观锁 vs 悲观锁:选型决策树
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 -
id用string,version用number(单据场景) - 列表页批量操作:每条记录各自带自己的
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 前端用 string,version 用 number 即可。
十一、参考资料
- JPA 规范:
@Version注解(javax.persistence.Version) - JPA 规范:
@Lock(PESSIMISTIC_WRITE)悲观锁 - 经典问题:Lost Update、超卖、死锁
- Redisson 分布式锁文档
文档创建:2026-07-16