乐观锁

性能困境

上一节,我用原子更新解决了并发问题,性能提升了 5-7 倍。

但我还是不放心。

原子更新虽然快,但有个隐患:无法控制更新时机。

场景:用户点赞,然后立即取消点赞

原子更新:
T1: UPDATE articles SET like_count = like_count + 1  → 101
T2: UPDATE articles SET like_count = like_count - 1  → 100

看起来没问题?
但如果 T2 先执行呢?

T2: UPDATE articles SET like_count = like_count - 1  → 99
T1: UPDATE articles SET like_count = like_count + 1  → 100

结果:最终是 100,但中间过程不对!

虽然最终结果正确,但我想更好地控制更新的顺序和时机。

于是,我开始研究乐观锁

乐观锁原理

什么是乐观锁?

悲观锁:
- 假设一定会发生冲突
- 先加锁,再操作
- 其他事务必须等待

乐观锁:
- 假设大概率不会发生冲突
- 不加锁,直接操作
- 提交时检查是否有冲突
- 如果有冲突,重试或失败

乐观锁的核心思想:CAS(Compare and Swap)

CAS 操作:
1. 读取当前值 V
2. 计算新值 N
3. 检查当前值是否还是 V
4. 如果是,更新为 N
5. 如果不是,说明有人修改了,重试

实现方式

方式 1:版本号

数据设计要点

  • 这是一次表结构演进:随着业务能力增加,把新状态、新时间点或新归属关系补进数据模型。

方式 2:时间戳

数据设计要点

  • 这是一次表结构演进:随着业务能力增加,把新状态、新时间点或新归属关系补进数据模型。

方式 3:条件更新

数据设计要点

  • 这里关注数据模型和约束关系,不需要记住具体语法。

点赞场景实现

方案:条件更新

对于点赞计数,不需要额外字段,直接用 like_count 作为条件即可。

问题:死锁风险

我测试了一下,发现一个严重问题:

场景:两个用户同时点赞

用户 A:
1. 读取 like_count = 100
2. 准备更新

用户 B(同时):
1. 读取 like_count = 100
2. 准备更新

用户 A:
UPDATE articles SET like_count = 101 WHERE id = 1 AND like_count = 100
→ 成功!affected = 1

用户 B:
UPDATE articles SET like_count = 101 WHERE id = 1 AND like_count = 100
→ 失败!affected = 0(因为 like_count 已经是 101 了)

用户 B 重试:
1. 读取 like_count = 101
2. UPDATE articles SET like_count = 102 WHERE id = 1 AND like_count = 101
→ 成功!

最终:102 ✅

看起来正确!但如果有事务呢?

用户 A(事务):
BEGIN;
SELECT like_count FROM articles WHERE id = 1;  → 100
(准备更新)

用户 B(事务,同时):
BEGIN;
SELECT like_count FROM articles WHERE id = 1;  → 100
(准备更新)

用户 A:
UPDATE articles SET like_count = 101 WHERE id = 1 AND like_count = 100;
→ 等待用户 B 释放锁?还是成功?

用户 B:
UPDATE articles SET like_count = 101 WHERE id = 1 AND like_count = 100;
→ 等待用户 A 释放锁?

死锁了!

解决方案:减少事务范围

结论:对于点赞场景,乐观锁反而不如原子更新简单高效。

乐观锁适用场景

虽然点赞场景不需要乐观锁,但我研究了它的适用场景:

场景 1:编辑文章

适用原因:

  • 编辑冲突概率低
  • 用户修改文章后,应该知道是否有人同时修改了
  • 不需要加锁,体验更好

场景 2:库存扣减

适用原因:

  • 秒杀场景,冲突概率高
  • 但使用乐观锁可以避免长时间锁等待
  • 失败后让用户重试,体验比直接失败好

场景 3:账户余额

适用原因:

  • 账户转账,数据一致性要求高
  • 冲突概率中等
  • 乐观锁可以避免死锁问题

乐观锁 vs 悲观锁 vs 原子更新

我整理了三种方案的对比:

方案实现复杂度性能适用场景一致性
悲观锁冲突概率极高、强一致性要求强一致
乐观锁冲突概率低、需要知道冲突情况强一致
原子更新简单计数、不需要额外逻辑强一致

点赞场景分析

点赞场景特点:
- 冲突概率:低(同一篇文章同时被大量点赞的概率不高)
- 一致性要求:中等(点赞数稍微不准确可以容忍)
- 逻辑复杂度:低(只是 +1/-1 操作)

结论:原子更新最适合点赞场景

库存场景分析

库存场景特点:
- 冲突概率:高(秒杀场景,大量用户同时抢购)
- 一致性要求:高(库存不能出错)
- 逻辑复杂度:中(需要检查库存是否充足)

结论:悲观锁或乐观锁(根据用户体验需求选择)

转账场景分析

转账场景特点:
- 冲突概率:中(同一账户可能同时有多个交易)
- 一致性要求:极高(余额不能出错)
- 逻辑复杂度:高(涉及多个账户)

结论:悲观锁或乐观锁 + 分布式事务

课后练习

练习 1

乐观锁的 CAS 操作在 Java 中是如何实现的?

参考答案(3 个标签)
乐观锁CASJava并发

Java 中的 CAS 实现

Java 通过 java.util.concurrent.atomic 包提供 CAS 操作:

底层原理

CPU CAS 指令

  • x86 架构:cmpxchg 指令
  • ARM 架构:LDXR/STXR 指令

ABA 问题

问题:CAS 只检查值是否变化,但不关心变化了几次

时间线:
T1: 线程 A 读取 value = 100
T2: 线程 B 修改 value = 200
T3: 线程 B 又修改 value = 100
T4: 线程 A 执行 CAS(100, 101)
    → 成功!因为值还是 100

但中间过程被忽略了!

解决方案:版本号

点赞场景应用

练习 2

在 Redis 中如何实现乐观锁?

参考答案(3 个标签)
Redis乐观锁WATCH命令

Redis 乐观锁:WATCH 命令

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。

Python 实现

Lua 脚本实现(更高效)

WATCH vs Lua 脚本

方案性能复杂度适用场景
WATCH简单 CAS 操作
Lua复杂逻辑,需要原子性

推荐:对于点赞计数,直接使用 INCR 更简单高效:

练习 3

如何处理乐观锁的”ABA 问题”?

参考答案(3 个标签)
ABA问题乐观锁版本号

ABA 问题定义

初始状态:value = A

时间线:
T1: 线程 1 读取 value = A
T2: 线程 2 修改 value = B
T3: 线程 2 又修改 value = A
T4: 线程 1 执行 CAS(A, C)
    → 成功!因为值还是 A

问题:线程 1 不知道中间 value 变成了 B,可能导致逻辑错误

ABA 问题的危害

示例:链表操作

初始:
head → A → B → C

线程 1:删除 A(CAS(head, A, B))
T1: 读取 head = A

线程 2:删除 B,然后插入新的 A
T2: CAS(head, A, B) → head = B
T3: CAS(head, B, A_new) → head = A_new(新插入的节点)

线程 1:
T4: CAS(head, A, B.next)
    → 但 A 已经不存在了!
    → 可能导致内存泄漏或逻辑错误

解决方案:版本号/时间戳

Java AtomicStampedReference

数据库设计

数据设计要点

  • 核心是在 articles 里保存业务事实,而不是把规则散落在应用逻辑里。
  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 关键字段包括 idlike_countversionupdated_at,它们决定后续查询和管理能力。

点赞场景是否需要解决 ABA?

点赞场景分析:
- 操作:like_count = like_count + 1
- 中间值变化不重要,只要最终值正确
- 不存在链表等复杂结构

结论:点赞场景不需要解决 ABA 问题
      因为即使发生 ABA,最终结果也是正确的

需要解决 ABA 的场景

1. 链表/树等复杂数据结构
2. 需要知道中间状态变化的场景
3. 资源分配/释放场景
4. 转账等金融场景(可能需要知道中间变化)

练习 4

在高并发场景下,乐观锁重试次数过多会导致什么问题?如何优化?

参考答案(3 个标签)
乐观锁重试策略性能优化

问题分析

问题 1:性能下降

大量线程重试 → CPU 占用高 → 系统吞吐量下降

问题 2:活锁(Livelock)

线程 A 重试 → 线程 B 也重试 → 互相干扰 → 无限重试

问题 3:用户体验差

用户请求长时间等待 → 超时失败 → 用户投诉

重试次数统计

优化方案 1:指数退避(Exponential Backoff)

原理

  • 第一次失败,等待 10ms
  • 第二次失败,等待 20ms
  • 第三次失败,等待 40ms

好处

  • 减少并发冲突
  • 给其他线程执行的机会
  • 避免活锁

优化方案 2:自适应重试

优化方案 3:批量合并更新

优化方案 4:分区/分片

对比总结

优化方案适用场景实现复杂度性能提升
指数退避通用
自适应重试冲突变化大
批量合并点赞场景
分区分片超高并发最高

推荐

  • 点赞场景:使用 批量合并分区分片
  • 一般场景:使用 指数退避

练习 5

设计一个支持”点赞历史回滚”功能的系统,可以撤销任意时刻的点赞操作。

参考答案(3 个标签)
数据回滚系统设计高级功能

需求分析

功能需求:
1. 记录每次点赞/取消点赞操作
2. 可以回滚到任意历史状态
3. 支持按时间点查看点赞数
4. 数据一致性保证

技术挑战:
1. 如何存储历史记录?
2. 如何高效回滚?
3. 如何处理并发回滚?

方案 1:操作日志表

数据设计要点

  • 核心是在 like_operation_log 里保存业务事实,而不是把规则散落在应用逻辑里。
  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 关键字段包括 idarticle_iduser_idcreated_atlike_countversion,它们决定后续查询和管理能力。

记录操作

回滚操作

方案 2:快照表(定期快照)

数据设计要点

  • 核心是在 like_count_snapshot 里保存业务事实,而不是把规则散落在应用逻辑里。
  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
  • 关键字段包括 idarticle_idlike_countsnapshot_timeINSERT,它们决定后续查询和管理能力。

回滚到快照

方案 3:事件溯源(Event Sourcing)

对比总结

方案实现复杂度回滚效率存储成本适用场景
操作日志一般回滚需求
快照表快速回滚
事件溯源完整历史追溯

推荐

  • 一般场景:操作日志 + 定期快照
  • 金融级场景:事件溯源

思考题

  1. 如果同时有 10000 个用户点赞同一篇文章,乐观锁和原子更新的性能差异是多少?

  2. 如何设计一个”点赞趋势图”功能,显示过去 30 天每天的点赞数变化?

  3. 如果需要支持”点赞数审计”,每条点赞记录都要可追溯,如何设计?

💡 提示:这些问题没有标准答案,建议结合实际情况深入思考。