前言
学习Redis时,在锁这方面学的有些云里雾里,在学习Java的时候也没有系统性地学习过juc,所以写下这篇文章,加深对Redis分布式锁的理解
Redis分布式锁的作用与意义
在分布式系统中,多个服务实例可能同时访问共享资源(如数据库、文件、外部API等)。为避免数据竞争或操作冲突,需要一种机制协调不同实例的访问顺序。Redis分布式锁通过Redis的原子性操作实现跨进程的互斥控制,确保同一时间只有一个实例能执行关键操作。
解决的核心问题
- 避免资源竞争:防止多个客户端同时修改同一数据导致不一致(如超卖、重复支付)。
- 保证操作原子性:例如库存扣减、订单状态更新等需严格串行化的场景。
- 替代数据库悲观锁:减少数据库压力,提升并发性能。
关键特性需求
互斥性:锁只能被一个客户端持有。
防死锁:持有锁的客户端崩溃后,锁能自动释放(通过过期时间实现)。
容错性:Redis节点故障时仍能提供服务(需Redlock等算法支持多节点)。
高性能:基于内存的Redis比数据库锁响应更快。
一个简单的分布式锁
@RestController
@RequestMapping("/api/v1/coupon")
public class CouponController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("add")
public JsonData saveCoupon(@RequestParam(value = "coupon_id") int couponId){
//防止其他线程误删
String uuid = UUID.randomUUID().toString();
String localKey = "lock:coupon:"+couponId;
lock(couponId,uuid,localKey);
return JsonData.buildSuccess();
}
private void lock(int couponId,String uuid,String localKey){
//lua脚本
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Boolean nativeLock = redisTemplate.opsForValue().setIfAbsent(localKey, uuid, 30, TimeUnit.SECONDS);
System.out.println(uuid+"加锁状态:"+nativeLock);
if(nativeLock){
//加锁成功
try {
// TODO 做相关业务逻辑
TimeUnit.SECONDS.sleep(10L);
} catch (InterruptedException e) {
} finally {
//解锁
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(localKey), uuid);
System.out.println("解锁状态"+result);
}
}else{
try {
//自旋操作
System.out.println("加锁失败,睡眠5秒。进入自旋");
TimeUnit.MILLISECONDS.sleep(5000);
} catch (InterruptedException e) {
}
//睡眠一会再尝试获取锁
lock(couponId,uuid,localKey);
}
}
}
代码实现的逻辑
加锁:使用 setIfAbsent 原子操作(即 Redis 的 SETNX)
锁自动过期:设置 30 秒的过期时间,防止死锁
锁的归属验证:通过 UUID 确保只有锁的持有者可以解锁
解锁逻辑:使用 Lua 脚本保证解锁操作的原子性
自旋重试:加锁失败时通过递归实现自旋等待
加锁
Boolean nativeLock = redisTemplate.opsForValue().setIfAbsent(localKey, uuid, 30, TimeUnit.SECONDS);
原子性:使用setIfAbsent原子操作,等价于 Redis 命令setnx
锁过期:设置 30 秒过期时间,防止业务异常时锁无法释放
唯一性标识:使用 UUID 作为锁的值,确保只有加锁者可以解锁,同时防止其他线程误删
为什么使用setIfAbsent
上面说过,实现分布式锁要考虑到互斥性,锁只能被一个客户端拥有,如果不实现原子性操作,比如
// 错误示例:非原子操作
if (redis.get("lock") == null) { // 步骤1:检查锁是否存在
redis.set("lock", "1"); // 步骤2:创建锁
}
当多个线程执行步骤一时,可能都没有锁,所以就都会执行步骤二,导致多个线程同时获得锁,那么分布式锁的实现就毫无意义!
Redis 是单线程执行命令的,setIfAbsent作为一个原子命令,在执行过程中不会被其他客户端的命令打断。因此,多个线程并发调用setIfAbsent时,只有一个线程会成功。
为什么设置过期时间
打个比方,在线程执行的时候,网络突然崩溃,导致此线程的锁无法释放,锁会永远存在,其他线程无法获取锁,造成死锁,这就是为什么要设置过期时间,就是为了保证当线程异常时,锁也会在一定时间后进行释放
解锁
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(localKey), uuid);
原子性:Lua 脚本在 Redis 中原子执行,避免了先判断后删除的竞态条件
归属验证:通过比较锁的值(UUID),确保只有锁的持有者可以解锁
返回值:成功解锁返回 1,否则返回 0
lua脚本
lua脚本的作用
在 Redis 中执行 Lua 脚本的最大优势是 原子性:
Redis 会将整个 Lua 脚本作为一个原子操作执行,期间不会插入其他命令。
这避免了多步操作之间的竞态条件(Race Condition),特别适合实现锁的释放逻辑。
脚本解析
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
KEYS[1]:Redis 键名,即锁的唯一标识key(localKey)。
ARGV[1]:传递的参数,即锁的持有者标识(UUID)。
redis.call('get', KEYS[1]) == ARGV[1]
redis.call('get', KEYS[1]):获取锁的当前值(即 UUID)。
比较锁的当前值是否等于传入的 UUID(ARGV[1])。
如果是,则redis.call('del',KEYS[1]),释放锁.
如果不是,返回0。
为什么要判断是否为键的拥有者
打个比方
线程A的key 30秒过期,假如线程A执行很慢超过30秒,则key就被释放了,其他线程B就得到了锁,这个时候线程A执行完成,而B还没执行完成,结果就是线程A删除了线程B加的锁,所以可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁, 那 value 应该是存当前线程的标识或者uuid
解锁实现
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class), // Lua 脚本及返回类型
Arrays.asList(localKey), // KEYS 参数列表
uuid // ARGV 参数列表
);
redisTemplate.execute()方法执行 Redis Lua 脚本,确保原子性。
参数:
RedisScript<T>:封装 Lua 脚本和返回类型。
List<K>:传递给脚本的键名列表(对应 Lua 中的 KEYS 数组)。
Object... args:传递给脚本的参数列表(对应 Lua 中的 ARGV 数组)。
DefaultRedisScript<>(script, Long.class):script就是lua脚本字符串,Long.class指定脚本返回值的类型(对应 Lua 脚本中的return 1或return 0)。
Arrays.asList(localKey),锁的键名,对应KEYS[1]
uuid,对应ARGV[1]
执行流程
-- 获取锁的当前值
local currentValue = redis.call('get', 'lock:coupon:123')
-- 比较当前值是否等于传入的 UUID
if currentValue == '65e0f7c5-4e0d-4a5f-9e0b-3e3e3e3e3e3e' then
-- 是当前持有者,删除锁
return redis.call('del', 'lock:coupon:123')
else
-- 不是当前持有者,返回 0
return 0
end
自旋重试
加锁失败后睡眠5秒,再执行加锁操作,采用了递归
try {
//自旋操作
System.out.println("加锁失败,睡眠5秒。进入自旋");
TimeUnit.MILLISECONDS.sleep(5000);
} catch (InterruptedException e) {
}
//睡眠一会再尝试获取锁
lock(couponId,uuid,localKey);
代码演示
运行后,在游览器访问三次
http://localhost:8080/api/v1/coupon/add?coupon_id=1
将参数传入进去,然后会输出
可以看到,三个进程,依次加锁解锁成功