分布式锁(Distributed Lock)详解
一、要解决的核心问题
先看一个场景:同一笔订单,用户快速点了两次"支付",两个请求几乎同时到达两台服务器。如果不加控制,两台机器会同时执行扣款逻辑 → 重复扣款。
这就是并发竞争 问题。本质是:多个执行流同时操作同一份共享资源,导致数据错乱。
单机环境下,用 JVM 的锁(synchronized、ReentrantLock)就能解决。但在分布式/微服务 环境下,服务部署了多个实例(多个 JVM、多台机器),JVM 锁只能锁住本进程内 的线程,锁不住其他机器上 的线程------这时就需要分布式锁。
单机锁(synchronized):只能管住机器A内部的线程
↓ 机器B的线程照样闯进来
分布式锁:在所有机器之外设一个"公共的锁",谁都要先去那里抢
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、核心概念
分布式锁 :借助一个所有服务实例都能访问的外部存储(Redis、ZooKeeper、数据库等)作为"公共裁判",让分布在不同机器上的执行流在操作共享资源前,先去这个外部存储抢占同一把锁,抢到的才能执行,没抢到的等待或放弃。
三个关键角色:
- 锁的 key :标识"锁的是哪个资源",必须和业务资源一一对应(如
order_lock_{订单id}) - 锁的持有者:抢到锁的那个执行流
- 外部存储:Redis / ZooKeeper / DB,充当公共裁判
三、锁的 key 设计(最关键的一点)
分布式锁锁的是具体某个资源 ,不是整段代码。所以 key 要带上资源的唯一标识:
java
// ✅ 正确:按资源维度加锁,不同订单互不阻塞,只拦截"同一订单"的并发
String lockKey = "order_pay_lock_" + orderId;
// ❌ 错误:固定key,会把所有订单的支付串行化,性能灾难
String lockKey = "order_pay_lock";
原则 :锁的粒度要刚好覆盖冲突范围。
- 太粗(固定 key):不相关的操作也被串行化,性能差
- 太细(key 没覆盖冲突资源):锁不住真正的并发
四、分布式锁必须具备的三个特性
| 特性 | 含义 | 不满足会怎样 |
|---|---|---|
| 互斥性 | 同一时刻只有一个持有者 | 锁失效,并发问题依旧 |
| 防死锁 | 持有者宕机也能自动释放(靠超时) | 锁永远占着,资源再也用不了 |
| 可用性 | 加锁/解锁本身高性能、不单点 | 锁服务成为瓶颈或故障点 |
进阶特性:可重入 (同一线程可重复加同一把锁)、锁续期(任务没做完锁自动延期,防止提前释放)。
五、通用示例代码
5.1 封装式写法(try-with-resources)
用 DistributedLockProvider 对分布式锁封装,用 try-with-resources 自动释放,是推荐的写法:
java
public void doBusinessWithLock(Long resourceId) {
// key 带资源id,只锁同一资源
String key = "biz_lock_" + resourceId;
// try-with-resources:lock 实现 AutoCloseable,代码块结束自动释放锁
try (DistributedLock lock = distributedLockProvider.getLock(key, TimeUnit.MINUTES, 10)) {
// 参数1: 锁最长持有时间(租期)10分钟 ------ 防死锁:到期自动释放
// 尝试加锁,最多等5分钟
if (lock.tryLock(TimeUnit.MINUTES, 5)) {
// ===== 抢到锁,执行临界区(操作共享资源) =====
doRealBusiness(resourceId);
// ===== 临界区结束 =====
} else {
// 等待超时仍没抢到锁 ------ 说明有并发在处理,放弃或抛异常
log.warn("获取锁失败,resourceId={}", resourceId);
}
} catch (Exception e) {
log.warn("业务执行异常,resourceId={}", resourceId, e);
throw e;
}
// 退出 try 块时,DistributedLock.close() 被自动调用 → 释放锁
}
三个时间参数的含义(容易混淆):
- 租期(lease time):锁最长持有多久,到期自动释放(防死锁),上例 10 分钟
- 等待时间(wait time):抢不到锁时愿意等多久,上例 5 分钟
- 关系:租期要 > 业务最长执行时间,否则业务没跑完锁就被自动释放了
5.2 基于 Redis 的原生实现原理(理解底层)
抛开封装,分布式锁最常见的是 Redis 实现。核心是一条原子命令:
SET lockKey 唯一值 NX PX 30000
NX:key 不存在才设置成功(保证互斥------只有第一个能设成功)PX 30000:30 秒后自动过期(防死锁------持有者宕机也会到期释放)唯一值:标识持有者身份(防误删------只能删自己加的锁)
伪代码:
java
public boolean tryLock(String key, String uniqueValue, long expireMillis) {
// SET key value NX PX expire ------ 原子操作,保证互斥
return redis.set(key, uniqueValue, "NX", "PX", expireMillis);
}
public void unlock(String key, String uniqueValue) {
// 用Lua脚本保证"判断是不是自己的锁 + 删除"的原子性
String lua =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redis.eval(lua, Collections.singletonList(key),
Collections.singletonList(uniqueValue));
}
为什么解锁要用 Lua 脚本? 因为"先判断是不是自己的锁,再删除"是两步操作,中间可能被打断(比如判断完锁刚好到期、被别人抢走,你再删就误删了别人的锁)。Lua 脚本在 Redis 里原子执行,杜绝这个竞态。
为什么解锁要校验唯一值? 防止 A 的锁到期自动释放后 B 抢到了锁,A 的业务跑完来解锁时误删了 B 的锁。
5.3 对比:单机锁(仅供理解差异,分布式环境不可用)
java
// 单机锁:只在当前JVM有效,多实例部署下锁不住其他机器
private final Object lock = new Object();
public void doBusiness(Long id) {
synchronized (lock) { // 只能管住本进程的线程
doRealBusiness(id);
}
}
六、主流实现方案对比
| 方案 | 代表 | 优点 | 缺点 |
|---|---|---|---|
| Redis | Redisson、原生 SET NX | 性能高、使用广 | 极端场景(主从切换)可能锁失效,需 RedLock 等增强 |
| ZooKeeper | Curator | 强一致、天然防死锁(临时节点) | 性能略低、运维成本高 |
| 数据库 | 唯一索引 / select for update |
无需额外中间件 | 性能差、易成瓶颈,不推荐高并发 |
| etcd | --- | 强一致,云原生场景常用 | 生态相对小众 |
工程中通常用成熟封装(如 Redisson),它内置了可重入、锁续期(Watchdog 看门狗自动续期)、RedLock 等能力,不建议自己手撸原生实现。
七、关键设计要点与坑
1. 租期 vs 业务时长的矛盾
- 租期设太短:业务没跑完锁就被自动释放,别人进来 → 并发问题重现
- 租期设太长:持有者真宕机了,别人要等很久
- 解法:用锁续期(Watchdog)------业务没结束就自动延长租期,Redisson 默认支持
2. 一定要防死锁(锁必须能自动释放)
- 加锁必须带超时/租期,否则持有者宕机 → 锁永久占用 → 该资源彻底卡死
3. 解锁要防误删
- 只能删"自己加的锁"(校验唯一值 + Lua 原子解锁)
4. 加锁失败的处理策略
- 快速失败:抢不到立刻返回/抛异常(适合"重复提交"场景,本需求即如此)
- 阻塞等待:等一段时间(适合"必须执行、可以排队"场景)
- 根据业务选择,别无脑死等
5. 锁 ≠ 事务
- 分布式锁保证"同一时刻只有一个在执行",但不保证数据库操作的原子性
- 通常锁 + 事务配合:锁保证串行进入,事务保证进入后的数据一致性
6. 释放锁要在 finally / try-with-resources
- 业务异常也必须释放锁,否则其他请求被卡住。封装的
AutoCloseable是最省心的方式。
八、分布式锁 vs 其他防重手段
分布式锁不是唯一方案,根据场景选择:
| 手段 | 适用场景 |
|---|---|
| 分布式锁 | 需要严格串行执行临界区,防并发写冲突 |
| 数据库唯一索引 | 防重复插入(如防重复下单),简单直接 |
| 乐观锁(版本号/CAS) | 冲突概率低,update ... where version=? |
| 幂等设计(唯一业务号) | 防重复请求产生重复结果,常与锁配合 |
| 状态机校验 | 如"已审核的不能再审核",用状态流转天然防重 |
实际中常组合使用 :如本需求既有分布式锁(防并发),又有审核状态校验(checkAuditStatus,防重复审核),双重保险。
九、常见误区
- 用固定 key:把所有资源的操作串行化,性能灾难。key 必须带资源标识。
- 加锁不设超时:持有者宕机导致死锁,资源永久卡住。
- 解锁不校验持有者:误删别人的锁。
- 租期短于业务时长:锁提前释放,并发问题重现(需锁续期)。
- 以为加了分布式锁就不用事务:锁管并发顺序,事务管数据一致,二者职责不同。
- 异常路径不释放锁:必须 finally/try-with-resources 释放。
- 过度依赖锁:能用数据库唯一索引/幂等/乐观锁解决的轻量场景,不必上分布式锁。
十、一句话总结
分布式锁用一个所有实例共享的外部裁判 (Redis/ZooKeeper 等),让跨机器的执行流在操作同一资源前先抢占按资源 id 设计的 key ,保证互斥 ;同时必须带超时租期防死锁 、唯一值防误删 、异常也能释放。它解决的是"多实例并发操作同一资源"的问题,常与事务、状态校验、幂等设计配合使用。