对于Redis分布式锁的一些思考

前言

学习Redis时,在锁这方面学的有些云里雾里,在学习Java的时候也没有系统性地学习过juc,所以写下这篇文章,加深对Redis分布式锁的理解

Redis分布式锁的作用与意义

  在分布式系统中,多个服务实例可能同时访问共享资源(如数据库、文件、外部API等)。为避免数据竞争或操作冲突,需要一种机制协调不同实例的访问顺序。Redis分布式锁通过Redis的原子性操作实现跨进程的互斥控制,确保同一时间只有一个实例能执行关键操作。

解决的核心问题

  1. 避免资源竞争:防止多个客户端同时修改同一数据导致不一致(如超卖、重复支付)。
  2. 保证操作原子性:例如库存扣减、订单状态更新等需严格串行化的场景。
  3. 替代数据库悲观锁:减少数据库压力,提升并发性能。

关键特性需求

互斥性:锁只能被一个客户端持有。

防死锁:持有锁的客户端崩溃后,锁能自动释放(通过过期时间实现)。

容错性: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

将参数传入进去,然后会输出

可以看到,三个进程,依次加锁解锁成功

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值