乐观锁
性能困境
上一节,我用原子更新解决了并发问题,性能提升了 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 中是如何实现的?
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 中如何实现乐观锁?
Redis 乐观锁:WATCH 命令
验证要点
- 命令只用于验证系统状态,读者不需要记具体参数。
Python 实现:
Lua 脚本实现(更高效):
WATCH vs Lua 脚本:
| 方案 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|
| WATCH | 中 | 低 | 简单 CAS 操作 |
| Lua | 高 | 中 | 复杂逻辑,需要原子性 |
推荐:对于点赞计数,直接使用 INCR 更简单高效:
练习 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里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、like_count、version、updated_at,它们决定后续查询和管理能力。
点赞场景是否需要解决 ABA?
点赞场景分析:
- 操作:like_count = like_count + 1
- 中间值变化不重要,只要最终值正确
- 不存在链表等复杂结构
结论:点赞场景不需要解决 ABA 问题
因为即使发生 ABA,最终结果也是正确的需要解决 ABA 的场景:
1. 链表/树等复杂数据结构
2. 需要知道中间状态变化的场景
3. 资源分配/释放场景
4. 转账等金融场景(可能需要知道中间变化)练习 4
在高并发场景下,乐观锁重试次数过多会导致什么问题?如何优化?
问题分析:
问题 1:性能下降
大量线程重试 → CPU 占用高 → 系统吞吐量下降
问题 2:活锁(Livelock)
线程 A 重试 → 线程 B 也重试 → 互相干扰 → 无限重试
问题 3:用户体验差
用户请求长时间等待 → 超时失败 → 用户投诉重试次数统计:
优化方案 1:指数退避(Exponential Backoff)
原理:
- 第一次失败,等待 10ms
- 第二次失败,等待 20ms
- 第三次失败,等待 40ms
- …
好处:
- 减少并发冲突
- 给其他线程执行的机会
- 避免活锁
优化方案 2:自适应重试
优化方案 3:批量合并更新
优化方案 4:分区/分片
对比总结:
| 优化方案 | 适用场景 | 实现复杂度 | 性能提升 |
|---|---|---|---|
| 指数退避 | 通用 | 低 | 中 |
| 自适应重试 | 冲突变化大 | 中 | 高 |
| 批量合并 | 点赞场景 | 中 | 高 |
| 分区分片 | 超高并发 | 高 | 最高 |
推荐:
- 点赞场景:使用 批量合并 或 分区分片
- 一般场景:使用 指数退避
练习 5
设计一个支持”点赞历史回滚”功能的系统,可以撤销任意时刻的点赞操作。
需求分析:
功能需求:
1. 记录每次点赞/取消点赞操作
2. 可以回滚到任意历史状态
3. 支持按时间点查看点赞数
4. 数据一致性保证
技术挑战:
1. 如何存储历史记录?
2. 如何高效回滚?
3. 如何处理并发回滚?方案 1:操作日志表
数据设计要点
- 核心是在
like_operation_log里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、article_id、user_id、created_at、like_count、version,它们决定后续查询和管理能力。
记录操作:
回滚操作:
方案 2:快照表(定期快照)
数据设计要点
- 核心是在
like_count_snapshot里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
- 关键字段包括
id、article_id、like_count、snapshot_time、INSERT,它们决定后续查询和管理能力。
回滚到快照:
方案 3:事件溯源(Event Sourcing)
对比总结:
| 方案 | 实现复杂度 | 回滚效率 | 存储成本 | 适用场景 |
|---|---|---|---|---|
| 操作日志 | 中 | 中 | 中 | 一般回滚需求 |
| 快照表 | 低 | 高 | 高 | 快速回滚 |
| 事件溯源 | 高 | 低 | 低 | 完整历史追溯 |
推荐:
- 一般场景:操作日志 + 定期快照
- 金融级场景:事件溯源
思考题
-
如果同时有 10000 个用户点赞同一篇文章,乐观锁和原子更新的性能差异是多少?
-
如何设计一个”点赞趋势图”功能,显示过去 30 天每天的点赞数变化?
-
如果需要支持”点赞数审计”,每条点赞记录都要可追溯,如何设计?
💡 提示:这些问题没有标准答案,建议结合实际情况深入思考。
