在多线程、多进程以及分布式环境中,并发访问共享资源是常态。如果控制不当,就会出现数据不一致、更新丢失、超卖、重复执行等严重问题。而锁机制,正是解决这些并发控制难题的核心手段。
本文将围绕 “原理 → 场景 → 代码 → 线上坑 → 架构策略” 这条主线,为你系统梳理悲观锁、乐观锁与分布式锁的正确使用姿势。
一、为什么一定要理解“锁”?
可以一句话总结:
锁的本质:用性能换取一致性。
- 不加锁:系统并发高,吞吐量可能不错,但数据正确性无法保证。
- 加锁:数据强一致性得以保障,但往往会牺牲部分性能,导致吞吐下降、延迟上升。
因此,真正的工程能力体现于:在业务可接受的一致性要求与系统性能之间,找到那个精妙的平衡点。
① 悲观锁(Pessimistic Locking)
1️⃣ 核心思想
先上锁,再操作,默认一定会发生冲突。
在访问或修改共享数据之前,就预先获取锁,从而阻塞其他并发线程或事务。
2️⃣ 典型实现
✔ 数据库层面(以MySQL为例)
通过 SELECT ... FOR UPDATE 实现行级锁(需要在事务中)。
START TRANSACTION;
SELECT stock
FROM product
WHERE id = 1
FOR UPDATE;
UPDATE product
SET stock = stock - 1
WHERE id = 1;
COMMIT;
👉 FOR UPDATE 会使该行被锁定,其他尝试读取或修改此行的事务将被阻塞,直到当前事务提交或回滚。
✔ Java 代码层面
使用 ReentrantLock 或 synchronized 关键字。
private final Lock lock = new ReentrantLock();
public void decrease() {
lock.lock();
try {
if (stock > 0) {
stock--;
}
} finally {
lock.unlock();
}
}
3️⃣ 适用场景
- ✅ 写多读少的业务
- ✅ 冲突概率很高的并发更新
- ✅ 强一致性要求极高的场景
典型业务:库存扣减、金融账户转账、余额更新。
4️⃣ 优缺点
| 优点 |
缺点 |
| 强一致性保障 |
阻塞等待,性能开销大 |
| 实现简单直观 |
容易引发死锁(如锁顺序不当) |
| 安全可靠 |
长事务持有锁会严重影响系统吞吐 |
⚠️ 生产级大坑:FOR UPDATE 变“锁全表”
错误示例:
SELECT * FROM product
WHERE stock > 0
FOR UPDATE;
如果 stock 字段没有建立索引,在默认的 REPEATABLE-READ 隔离级别下,InnoDB 会使用 Next-Key Lock 机制,可能锁住整张表或大片索引区间,导致其他所有并发操作被阻塞。
✔ 正确姿势:
SELECT stock FROM product
WHERE id = 1
FOR UPDATE;
👉 核心要点:FOR UPDATE 的条件字段必须命中索引!
🎯 实战技巧
- 缩短事务时间:锁内只做必要的核心操作。
- 避免锁内远程调用:不要在锁保护的代码块中进行 RPC 或耗时 IO 操作。
- 使用
tryLock():尝试获取锁,设置超时时间,避免无限期等待导致死锁。
② 乐观锁(Optimistic Locking)
1️⃣ 核心思想
不加锁,先操作,提交时再做冲突检测。
假设并发冲突的概率很低,只在数据提交更新时检查版本号是否被修改过。
2️⃣ 典型实现
✔ 数据库 Version 字段
为数据表增加一个 version 字段,每次更新时作为条件。
UPDATE product
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND version = 10;
👉 执行后,通过检查 SQL 的“影响行数”是否为 0 来判断是否发生了冲突(即其他人已修改过数据)。
✔ Java CAS (Compare And Swap)
利用 JUC 包下的原子类实现无锁更新。
AtomicInteger count = new AtomicInteger(0);
public void increment() {
int oldVal, newVal;
do {
oldVal = count.get();
newVal = oldVal + 1;
} while (!count.compareAndSet(oldVal, newVal));
}
3️⃣ 适用场景
- ✅ 读多写少的业务
- ✅ 冲突概率较低的并发场景
- ✅ 追求极致性能,希望减少锁开销
典型业务:文章阅读量统计、点赞数更新、系统配置修改。
4️⃣ 优缺点
| 优点 |
缺点 |
| 无阻塞,吞吐量高 |
高并发冲突下,失败重试频繁,性能反而差 |
| 不会产生死锁 |
需要额外维护版本号字段或时间戳 |
| 实现相对轻量 |
CAS 操作存在 “ABA”问题 |
🔥 线上事故案例:乐观锁“重试风暴”
- 背景:一个秒杀系统使用数据库乐观锁来扣减库存。
- 并发:瞬时 QPS 高达 5万。
- 结果:
- 更新失败率超过 95%。
- 大量请求进入无限重试循环。
- 应用服务器 CPU 被打满,响应时间 RT 飙升,最终导致服务雪崩。
根本原因:乐观锁适用于低冲突场景,而秒杀是典型的高冲突写热点场景,强行使用必然引发“重试风暴”。
✔ 正确优化策略
- ✅ Redis 预扣库存:将库存热点数据前置到缓存,在缓存层进行预减。
- ✅ 消息队列削峰:使用 Apache Kafka 或 RabbitMQ 将瞬时高并发请求序列化、异步化处理。
- ✅ 有限重试 + 退避:为乐观锁更新设置合理的重试次数和退避时间。
int retry = 3;
while (retry-- > 0) {
if (update()) break;
Thread.sleep(50); // 退避等待
}
🎯 实战技巧
version 字段使用 int 或 bigint 类型。
- 预估冲突率,冲突高时必须限制重试次数。
- 解决 CAS 的 ABA 问题,可以考虑使用
AtomicStampedReference。
③ 分布式锁(Distributed Lock)
1️⃣ 为什么需要分布式锁?
单机锁(如 synchronized、ReentrantLock)只能控制单个 JVM 进程内的线程互斥。
- 🚫 多服务实例部署时无效。
- 🚫 集群定时任务可能被重复执行。
- 🚫 多个服务需要互斥访问共享的分布式资源(如一个公共文件)。
2️⃣ 常见实现方式对比
| 方案 |
原理简述 |
| DB 唯一索引 |
向特定表中插入一条代表锁的记录(基于唯一索引),插入成功即获锁。 |
| Redis |
利用 SET key value NX PX timeout 命令,实现基于缓存的锁。 |
| ZooKeeper |
利用临时顺序节点的特性,客户端通过创建节点和监听来竞争锁。 |
3️⃣ Redis 分布式锁的正确姿势
基础加锁命令:
SET lock_key unique_client_id NX PX 30000
- NX:仅当
key 不存在时才设置,实现互斥性。
- PX 30000:设置锁的自动过期时间为 30 秒,防止持有者崩溃导致锁永远无法释放。
- unique_client_id:必须使用唯一值(如 UUID),作为锁的持有者标识,用于安全释放。
⚠️ 致命大坑 1:误删他人持有的锁
错误操作:
DEL lock_key
风险:如果当前线程持有的锁已经过期自动释放,且被另一个线程获取。此时执行 DEL,就会删除别人刚加上的锁,导致锁完全失效。
✔ 正确解法:使用 Lua 脚本保证原子性
释放锁时,必须先验证 value 是否仍为自己当初设置的值,再执行删除。
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
⚠️ 致命大坑 2:业务未完成,锁已过期
风险:锁的过期时间 TTL 设置过短,业务逻辑还未执行完锁就释放了,导致其他线程也能进入临界区,造成并发问题。
✔ 解决方案:
- 合理评估 TTL:设置的锁过期时间必须远大于业务逻辑的平均执行时间。
- 使用 WatchDog 自动续期:这是 Redisson 等客户端库提供的核心功能,后台线程会定期检查并延长持有锁的过期时间。
4️⃣ Redisson 客户端示例
Redisson 封装了完善的分布式锁实现,解决了上述大部分难题。
RLock lock = redissonClient.getLock("order:" + orderId);
// 尝试获取锁,最多等待5秒,锁持有超时时间为30秒
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
try {
// 你的核心业务逻辑在这里执行
// Redisson 的 WatchDog 会自动为你续期
} finally {
lock.unlock(); // 解锁时会自动校验持有者
}
}
👉 优势:自动续期、可重入、防误删,开箱即用。
🎯 分布式锁最佳实践
- 锁粒度要细:以业务ID(如
order:123)为键,避免大范围锁。
- 必须设置过期时间:防止死锁。
- 锁 Value 必须全局唯一:用于标识锁的持有者。
- 删除前必须校验归属:使用 Lua 脚本保证“获取-校验-删除”的原子性。
④ 三种锁对比总结
| 维度 |
悲观锁 |
乐观锁 |
分布式锁 |
| 核心思想 |
先锁后操作,防患于未然 |
先操作后校验,假设冲突少 |
跨进程/节点的互斥协调 |
| 性能特点 |
较低(有阻塞开销) |
很高(无阻塞) |
中等(有网络开销) |
| 冲突处理 |
阻塞等待 |
重试或失败 |
竞争获取 |
| 主要范围 |
单机 / 单数据库事务内 |
单机 / 单数据库 |
跨服务、跨节点的分布式环境 |
⑤ 高级架构策略:锁的组合拳
🎯 生产级“双保险”设计
对于极其重要的核心业务流程(如支付回调、订单状态机流转),可以采用组合策略:
1. 分布式锁(Redis/ZK) -> 2. 数据库乐观锁(Version)
- 第一层:分布式锁保证跨服务实例的全局互斥。
- 第二层:数据库乐观锁保证即使在极端情况下(如锁超时、网络分区导致脑裂),数据库层面的更新也能保持最终正确性(幂等)。
- 适用场景:支付处理、订单状态流转、全局幂等性控制。
🎯 秒杀等高并发场景推荐方案
面对瞬时超高并发的写热点,应尽量避免直接竞争数据库锁。
1. Redis 预扣减库存 -> 2. 消息队列削峰 -> 3. 数据库异步最终扣减
这个方案的核心思想是将同步的锁竞争,转化为异步的、串行化的消息处理,从而保护数据库,提升整体吞吐。
⑥ 认知升级:真正的高并发优化方向
一个常见的误区是认为“只要选对了锁,系统性能就能提升”。
实际上,锁本身不会提升性能,它只是一种控制并发、保证正确性的成本开销。
真正的高并发优化方向应该是:
- 减少锁竞争:通过数据分片(Sharding)、资源隔离等手段。
- 去共享化设计:使用 ThreadLocal、副本等避免直接共享。
- 异步化与非阻塞:利用回调、Future、反应式编程模型。
- 无锁数据结构:如 Disruptor 的 RingBuffer,在特定场景下性能极高。
总结
回顾一下三种锁的核心选择逻辑:
- 悲观锁:适用于冲突概率高、强一致性要求的场景。
- 乐观锁:适用于冲突概率低、追求高性能的场景。
- 分布式锁:跨多个服务或进程实现互斥访问的必备工具。
最终的工程原则是:没有“最好”的锁,只有“最适合”当前业务场景的锁。
在实践中,我们常常看到这些组合策略被广泛应用:
- 乐观锁 + 有限次数重试
- 分布式锁 + 数据库乐观锁(双保险)
- Redis 缓存 + 消息队列 + 异步化处理
希望这份从原理到实战的指南,能帮助你在 Java、MySQL、Redis 等技术栈中更游刃有余地处理并发问题。如果你有更多的实战经验或疑问,欢迎在 云栈社区 与大家交流探讨。