分布式锁
约 2800 字大约 9 分钟
2025-07-03
4.1 基本原理和实现方式对比
分布式锁:在分布式系统或集群模式下,多个进程可见且互斥的锁。其核心思想是让所有进程使用同一把锁,以保证程序串行执行。 分布式锁需要满足以下条件:
- 可见性:多个进程都能感知到锁的状态变化。
- 互斥:保证同一时刻只有一个线程持有锁,使得程序串行执行。
- 高可用:即使部分节点出现故障,锁服务仍然可用。
- 高性能:加锁和释放锁的操作需要足够快,以减少对系统性能的影响。
- 安全性:保证锁的正确性和安全性,防止误删等问题。
常见的分布式锁实现方式有以下三种:
- MySQL:利用 MySQL 自身的锁机制实现。但由于 MySQL 性能相对较低,因此较少使用。
- Redis:利用 Redis 的
SETNX
命令实现。如果SETNX
命令返回成功,则表示获取锁;否则,表示获取锁失败。 - Zookeeper:利用 Zookeeper 的临时节点和 Watcher 机制实现。
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用 Mysql 本身的互斥锁机制 | 利用 setnx 这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接, 自动释放锁 | 利用锁超时时间, 到期释放 | 临时节点, 断开连接自动释放 |
4.2 Redis 分布式锁的实现核心思路
实现 Redis 分布式锁需要实现以下两个基本方法:
- 获取锁:
- 互斥:确保只能有一个线程获取锁。
- 非阻塞:尝试一次获取锁,成功返回
true
,失败返回false
。
- 释放锁:
- 手动释放:由持有锁的线程手动释放锁。
- 超时释放:在获取锁时设置一个超时时间,即使持有锁的线程发生异常,锁也能在超时后自动释放,避免死锁。
核心思路是利用 Redis 的 SETNX
命令。当多个线程尝试获取锁时,第一个线程执行 SETNX
命令成功,Redis 中就会存在该 key,表示该线程抢到了锁,然后执行业务逻辑,最后释放锁。其他线程在尝试执行 SETNX
命令时会失败,需要等待一段时间后重试。

4.3 实现分布式锁
4.3.1 加锁逻辑
首先定义锁的基本接口:
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 超时时间,单位秒
* @return true代表获取锁成功;false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
然后,实现 ILock
接口,创建 SimpleRedisLock
类。
- 利用
SETNX
命令进行加锁,并设置过期时间,防止死锁,保证加锁和设置过期时间的原子性。 - 通过
DEL
命令删除锁。
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String keyPrefix = "lock:";
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean isSuccess = stringRedisTemplate.opsForValue().setIfAbsent(keyPrefix + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(isSuccess);
}
@Override
public void unlock() {
stringRedisTemplate.delete(keyPrefix + name);
}
}
代码解释:
setIfAbsent
方法返回一个Boolean
对象(注意不是boolean
),表示操作是否成功。如果 key 不存在,则设置成功并返回TRUE
。 如果 key 已经存在,则设置失败并返回FALSE
。 如果发生异常,可能会返回null
。return Boolean.TRUE.equals(isSuccess);
:equals
方法会处理null
输入。Boolean.TRUE.equals(null)
会返回false
,而不会抛出异常。
4.3.2 修改业务代码
在业务代码中,使用 SimpleRedisLock
类进行加锁和释放锁。
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(voucher.getBeginTime())) {
return Result.fail(MessageConstants.ACTIVITY_NOT_STARTED);
}
// 3. 判断秒杀是否结束
if (now.isAfter(voucher.getEndTime())) {
return Result.fail(MessageConstants.ACTIVITY_COMPLETED);
}
// 4. 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail(MessageConstants.STOCK_OUT);
}
Long userId = UserHolder.getUser().getId();
// 5. 获取锁
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order" + userId, stringRedisTemplate);
boolean isLock = simpleRedisLock.tryLock(100);
// 5.1 失败则返回错误信息
if (!isLock) {
return Result.fail(MessageConstants.SINGLE_ORDER_LIMIT_REACHED);
}
// 5.2 成功则创建订单
try {
IVoucherOrderService proxy = (IVoucherOrderService)
AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
simpleRedisLock.unlock();
}
}
4.4 Redis 分布式锁误删情况说明
在使用分布式锁时,可能会遇到一个潜在的问题:
- 线程 1 成功获取锁,并在执行业务逻辑。
- 线程 1 在执行业务逻辑时发生阻塞,导致锁自动释放。
- 线程 2 尝试获取锁,并成功获取锁。
- 当线程 1 恢复执行时,它会执行删除锁的逻辑,从而错误地删除了线程 2 持有的锁。
- 线程 3 又可以获取锁,导致线程 2 和线程 3 同时执行业务,从而引起线程安全问题。

为了确保只有锁的持有者才能释放锁,我们需要在 Redis 中存储锁时,将线程的唯一标识(例如 UUID 或线程 ID)作为锁的值。在释放锁时,首先比较 Redis 中存储的锁的值与当前线程的标识是否一致。如果一致,则释放锁;否则,不释放锁。

4.5 解决 Redis 分布式锁误删问题
修改之前的分布式锁实现,满足:
- 在获取锁时存入线程标识(可以使用 UUID 表示)。
- 在释放锁时,先获取锁中的线程标识,判断是否与当前线程标识一致。
- 如果一致,则释放锁。
- 如果不一致,则不释放锁。

@Override
public boolean tryLock(long timeoutSec) {
String threadId = IdPrefix + Thread.currentThread().getId();
Boolean isSuccess = stringRedisTemplate.opsForValue().setIfAbsent(keyPrefix + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(isSuccess);
}
@Override
public void unlock() {
// 获取线程标识
String threadId = IdPrefix + Thread.currentThread().getId();
// 获取 Redis 中的标识
String id = stringRedisTemplate.opsForValue().get(keyPrefix + name);
if (threadId.equals(id)) {
stringRedisTemplate.delete(keyPrefix + name);
}
}
4.6 分布式锁的原子性问题
在使用分布式锁时,即使加入了身份验证来避免误删锁,仍然存在由于操作非原子性导致锁被错误删除的极端情况。
考虑以下场景:
- 线程 1 成功获取锁,并在执行业务逻辑。
- 线程 1 准备删除锁,并已通过身份验证,确认锁属于自己。
- 在删除锁操作执行过程中,线程 1 发生卡顿(例如,由于 JVM 垃圾回收导致的卡顿)。
- 在线程 1 卡顿期间,锁的过期时间到达,锁被自动释放。
- 线程 2 获取到锁,开始执行业务。
- 线程 1 从卡顿中恢复,继续执行之前的删除锁操作,此时它删除了线程 2 持有的锁。
- 线程 3 获取到锁,开始执行业务。此时线程 2 和 3 并发执行,可能导致并发安全问题。
在这个场景中,尽管线程 1 进行了身份验证,但由于拿锁、比对、删锁这一系列操作不是原子性的,导致了误删锁的问题。条件判断失效,因为在判断之后到真正执行删除操作之间,锁的状态可能已经发生变化。

4.7 使用 Lua 脚本解决多条命令原子性问题
Redis 提供了 Lua 脚本功能,允许在单个脚本中执行多条 Redis 命令,从而保证这些命令执行的原子性。这意味着脚本中的所有命令要么全部执行成功,要么全部不执行,避免了因部分命令失败导致的数据不一致问题。
Redis 提供了 redis.call
函数,用于在 Lua 脚本中调用 Redis 命令,其语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
例如,要执行 set name jack
命令,对应的 Lua 脚本如下:
# 执行 set name jack
redis.call('set', 'name', 'jack')
如果需要先执行 set name Rose
,然后再执行 get name
命令,脚本可以这样编写:
# 先执行 set name Rose
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
编写完成 Lua 脚本后,需要使用 Redis 命令来调用它,常用的命令是 EVAL
。EVAL
命令的语法如下:
EVAL script numkeys key [key ...] arg [arg ...]
其中:
script
:是 Lua 脚本的内容。numkeys
:是 key 的数量。key
:是 key 的名称。arg
:是其他参数。
为了提高脚本的灵活性,可以将脚本中的 key 和 value 作为参数传递。key 类型的参数会放入 KEYS
数组,其他参数会放入 ARGV
数组。在脚本中,可以通过 KEYS
和 ARGV
数组来获取这些参数。
4.8 利用 Java 代码调用 Lua 脚本改造分布式锁
在使用 Redis 实现分布式锁时,释放锁的过程需要保证原子性。标准的释放锁流程通常包含以下步骤:
- 获取锁中存储的线程标识。
- 判断该标识是否与当前线程的标识一致。
- 如果一致,则释放锁(删除 key)。
- 如果不一致,则不执行任何操作。
为了保证上述流程的原子性,可以使用 Lua 脚本来实现:
-- 这里的 KEYS[1] 就是锁的 key,这里的 ARGV[1] 就是当前线程标识
-- 获取锁中的标识,判断是否与当前线程标识一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
在 Java 代码中,可以使用 RedisTemplate
的 execute
方法来执行 Lua 脚本。首先,需要定义一个 DefaultRedisScript
对象,并设置其 script 内容和返回类型。
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用 lua 脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
在这个 Java 代码片段中:
UNLOCK_SCRIPT
是一个DefaultRedisScript
对象,用于封装 Lua 脚本。setLocation
方法指定了 Lua 脚本的资源位置(通常是一个.lua
文件)。setResultType
方法设置了脚本的返回类型为Long
。stringRedisTemplate.execute
方法用于执行 Lua 脚本,它接受UNLOCK_SCRIPT
、key 列表和参数列表作为参数。在这里,key 是KEY_PREFIX + name
,参数是ID_PREFIX + Thread.currentThread().getId()
,也就是当前线程的唯一标识。
通过以上代码改造,可以确保释放锁的操作具有原子性,避免了并发情况下锁被错误释放的问题,实现了“拿锁,比对锁,删除锁”的原子操作。这对于构建可靠的分布式系统至关重要。