前言
优惠券"一人一单"要求同一个用户的下单操作不能并发执行。如果服务只运行在一个 JVM 中,可以用 Java 锁控制线程;服务部署到多台机器后,各 JVM 的锁互不可见,就需要一把所有实例都能访问的锁。Redis 可以提供非阻塞的加锁与释放,Lua 脚本则可以解决释放锁时的竞态问题。
一、什么是分布式锁,以及它有哪些特性
1. 为什么需要分布式锁
分布式锁是运行在分布式系统中的互斥锁。它把锁状态放在多个进程都能访问的公共存储中,让不同 JVM、不同服务器上的线程按照同一套规则竞争资源。
本地锁只在当前 JVM 内有效。例如,两个请求分别进入服务实例 A 和 B,即使两个实例的方法都加了 synchronized,它们仍然可以同时执行,因为 A 和 B 并不共享这把 Java 锁。分布式锁把锁状态放到 Redis、数据库或 ZooKeeper 等公共组件中,实例 A 和 B 看到的是同一把锁。
在优惠券"一人一单"场景中,同一用户的两个请求可能进入不同实例。两个实例都会先查询订单,如果没有统一的互斥控制,就可能同时判断为"尚未下单",随后各自创建订单。分布式锁要做的,就是让同一用户的这段关键业务在同一时刻只能由一个线程执行。

线程 1 和线程 3 获取同一把锁成功后执行业务,线程 2 和线程 4 获取失败,需要等待锁释放或直接返回失败。对于"一人一单",锁名可以按用户 ID 区分,例如 lock:order:7;不同用户不必争夺同一把锁。
2. 分布式锁的核心特性
分布式锁需要同时关注以下特性:
- 多进程可见:不同 JVM、不同服务器访问同一个锁 key 时,必须看到一致的持有状态。
- 互斥:同一把锁在同一时刻只能由一个线程持有,其他线程获取失败或等待。
- 高可用:锁服务需要可靠的部署与故障恢复能力。采用 Redis 作为锁服务时,如果 Redis 不可用,获取锁就会失败,不能跳过加锁直接执行受保护的业务。
- 高性能:加锁和释放锁都会出现在业务请求链路中,锁操作的延迟应尽量低,避免把所有请求长时间阻塞在锁上。
- 安全性:锁必须能正确释放,持有者不能误删其他线程的锁;进程异常退出后也不能留下永久锁。
这些特性之间需要结合实现方式权衡。例如,Redis 的 NX 可以保证同一把锁的互斥,过期时间可以避免进程异常后长期占锁,唯一 value 和 Lua 脚本可以避免旧持有者误删新持有者的锁。

二、Redis 锁的获取原理
获取锁需要同时完成两件事:仅在锁不存在时写入,并给锁设置过期时间。对应的 Redis 命令是:
redis
SET lock:order:7 owner-token NX EX 10
NX 表示 key 不存在时才写入;EX 10 表示十秒后过期。它们属于同一条 SET 命令,避免先加锁、再设置过期时间时进程突然退出,留下永不过期的锁。获取失败时直接返回失败,因此锁的获取过程不会阻塞当前线程。
value 不能随手写成固定的 1。锁到期后可能被另一个请求重新获取,释放时必须靠 value 判断当前请求是否仍拥有这把锁。SimpleRedisLock 使用一个 JVM 级 UUID 拼接线程 ID,区分不同实例和线程。
三、实现一:使用 Redis 命令加锁和释放
锁的接口包含尝试获取和释放两个动作:
java
public interface ILock {
/**
* 尝试获取锁
* @Param timeoutSec 锁的持有时间
* @return true:获取成功 false:获取失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
使用 StringRedisTemplate 操作普通 Redis 命令时,获取锁和释放锁分别经过以下步骤。获取锁使用带过期时间的 setIfAbsent,释放锁先读取锁值、判断归属,再删除锁:
java
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString();
private final String name;
private final StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断标示是否一致
if (threadId.equals(id)) {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS) 对应带 NX 和过期时间的 SET;返回 true 表示成功取得锁。Redis 操作的返回值可能为 null,因此用 Boolean.TRUE.equals(success) 判断。锁值由 UUID 和当前线程 ID 组成,避免不同 JVM 中相同线程 ID 被误认为同一个持有者。
释放时先比较锁值,可以避免无条件 DEL 直接误删其他线程的锁。如果自己的锁已经过期,并被另一个线程重新获得,两个锁值不同,当前线程就不应删除它。不过,读取和删除之间仍然存在时间窗口。
四、先判断再删除,为什么仍可能误删
线程 A 读取锁值,确认它与自己的标识相同。此时 A 暂停,锁刚好到期;线程 B 获取相同的锁 key。随后 A 继续执行 DEL,删除的已经是 B 的锁。线程 C 因而也可能获取成功,与 B 并发处理同一业务。
问题出在 GET 和 DEL 是两条独立命令。判断时属于自己,不代表删除时仍属于自己。 要消除这个窗口,就必须让"比较锁值"和"删除锁"在 Redis 中连续执行,中间不允许其他命令插入。
五、实现二:Lua 脚本原子释放锁
unlock.lua 将比较和删除写在同一个脚本中:
lua
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
KEYS1 是锁的 key,ARGV1 是当前线程写入的标识。标识相同才执行 DEL,成功删除返回 1;锁已过期或已属于别人时返回 0。Redis 执行脚本期间不会插入其他客户端命令,因此 GET 与 DEL 之间不会出现被其他线程抢锁的间隙。
Java 端通过 DefaultRedisScript 加载脚本,释放锁时调用脚本;获取锁仍使用带过期时间的 setIfAbsent。相关 import 为:
java
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
脚本字段、静态初始化块和 unlock() 方法可以这样定义:
java
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
//调用lua脚本-判断和删除在一行,保证了原子性,线程安全
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
ClassPathResource 从 resources 目录加载脚本;Long.class 对应脚本返回的整数;execute 的第二个参数传入锁 key,后续参数传入持有者标识。当锁属于 B 时,使用标识 A 执行脚本会返回 0,锁值保持不变;使用标识 B 执行脚本会返回 1,锁被删除。
原子执行的是脚本内部的 GET 与 DEL。获取锁的 SET 是另一条独立命令。Lua 脚本也不提供数据库事务式的出错回滚;它解决的是释放阶段两条命令之间的竞态。
六、两种实现的区别与边界
| 实现 | 获取锁 | 释放锁 | 结果 |
|---|---|---|---|
| Redis 命令版 | 一条 SET 命令完成 NX 与 EX | Java 中分别执行 GET、DEL | 能检查归属,但检查与删除之间可能发生锁过期和重新获取 |
| Lua 脚本版 | 沿用同一条 SET 命令 | 在一个脚本中比较标识并删除 | 避免旧持有者误删新持有者的锁 |
Lua 版解决了误删,但不会延长锁的有效期。如果业务执行时间超过过期时间,其他请求仍可能拿到锁并并发执行业务。因此,它保证释放锁时不误删,不代表锁能覆盖任意长的业务执行时间。
总结
Redis 分布式锁的关键是三步:用 SET NX EX 原子获取一把有期限的锁,用唯一标识记录持有者,再用 Lua 原子地核对并释放。普通命令完成基本的加锁和释放流程,Lua 脚本进一步消除了释放阶段最容易遗漏的竞态窗口。