浅谈 Redis 的 setNX 分布式锁

遇到问题

在做项目中,遇到了一个点赞的业务逻辑需要实现原则性操作,即在 Redis 中存储两个键值对,点赞的时候需要将 set 集合中加入点赞的用户 id,并且将被点赞用户的总赞数 + 1。

我不希望在这两个业务之间插入其他命令执行,需要保证执行业务的原子性,所以第一想法是使用 Redis 本身的事务(MULTI)来实现。但是由于我使用的是 Redis 集群,Redis 只支持单机的事务管理,集群并不支持事务功能。于是乎我决定使用 setNX 分布式锁来实现需求。

确保加锁的原子性

首先想到的是使用 setNX 命令来实现加锁操作:SET locKey uuid EX time NX

在 Redis 2.6.12 版本之后,Redis 支持原子命令加锁,我们可以通过向 Redis 发送 「set key value EX 过期时间 NX」 命令,实现原子的加锁操作。

注意,这里在设置值的时候,value 应该是随机字符串,比如 UUID,而不是随便用一个固定的字符串进去,为什么这样做呢?

value 的值设置为随机数主要是为了更安全的释放锁,释放锁的时候需要检查 key 是否存在,且 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

保证加锁原子性的原因

如果不能保证加锁操作的原子性,则会出现线程不安全的问题。即采取分步加锁的操作,先执行 set key value ,再执行 expire key time,如果这两步之前出现了问题,由于 expire 缺乏原子性,就会导致锁无法被释放的问题(没有设置过期时间),其他用户无法解锁操作自己的业务逻辑。

SpringBoot 中的具体实现

对应到我项目的需求,实现了点赞功能的分布式锁如下:

public void like(int userId, int entityType, int entityId) {
    // 获取要进行点赞操作的key
    String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);

    // 获取分布式锁key(使用业务的key生成锁key,用锁保证此业务key的原子性)
    String LikeLock = RedisKeyUtil.getLockByName(entityLikeKey);

    // 获取随机字符串
    String uuid = CommunityUtil.generateUUID();

    // 创建分布式锁
    Boolean store = redisTemplate.opsForValue().setIfAbsent(LikeLock, ip, 3, TimeUnit.SECONDS);
    if (store) { // 拿到锁,执行业务

        // 执行业务逻辑...

        // 执行业务完毕,删除锁
        redisTemplate.delete(LikeLock);
    } else {
        // 没有拿到锁,间隔一点时间(不要一直循环)重试
        try {
            Thread.sleep(100);
            like(userId, entityType, entityId);
        } catch (InterruptedException e) {
            // ...异常处理
            e.printStackTrace();
        }
    }
}

大致流程就是,通过 RedisTemplate 的 setIfAbsent() 方法获取原子锁,并设置了锁自动过期时间为 3 秒,setIfAbsent() 方法返回 true,表示加锁成功,加锁成功后进行业务逻辑处理,执行完逻辑之后调用 delete() 方法释放锁。

问题与不足

这么操作有一个很明显的弊端,一旦业务执行超时,锁自动失效的话,会造成删除错锁的线程安全问题!列举一个场景:

  • 业务逻辑 1 首先持有锁,开始执行自己的业务逻辑。
  • 业务逻辑 1 由于网络波动等原因,在锁到期之前未能执行完毕自己的业务逻辑,但是锁到期自动释放了,此时 Redis 中没有锁了。
  • 业务逻辑 2 也来获取锁,顺利持有锁后开始执行自己的业务逻辑。
  • 业务逻辑 1 终于执行完毕了自己的业务,删除了锁。注意,此时业务逻辑 1 删除的其实是业务逻辑 2 的锁,导致了线程安全问题。

针对上述情况,我们可以采取在删除锁之前校对是不是自己的锁来避免业务安全问题。

确保删除的是自己的锁

实现的代码很简单,只需要在删除的时候进行判断就好啦。

SpringBoot 中的具体实现

public void like(int userId, int entityType, int entityId) {
    // 获取要进行点赞操作的key
    String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);

    // 获取分布式锁key(使用业务的key生成锁key,用锁保证此业务key的原子性)
    String LikeLock = RedisKeyUtil.getLockByName(entityLikeKey);

    // 获取随机字符串
    String uuid = CommunityUtil.generateUUID();

    // 创建分布式锁
    Boolean store = redisTemplate.opsForValue().setIfAbsent(LikeLock, ip, 3, TimeUnit.SECONDS);
    if (store) { // 拿到锁,执行业务

        // 执行业务逻辑...

        // 执行业务完毕,删除锁
        String uuidLock = (String) redisTemplate.opsForValue().get(LikeLock);
        if (uuidLock != null && uuid.equals(uuidLock)) {
            redisTemplate.delete(LikeLock);
        }
    } else {
        // 没有拿到锁,间隔一点时间(不要一直循环)重试
        try {
            Thread.sleep(100);
            like(userId, entityType, entityId);
        } catch (InterruptedException e) {
            // ...异常处理
            e.printStackTrace();
        }
    }
}

这样我们就解决了误删其他人的锁的 BUG,但是这样就线程安全了嘛?

问题与不足

这么操作依然还是有线程安全问题,因为并不能保证确认是自己的锁和删除锁的原子性!还是列举一个场景:

  • 业务逻辑 1 执行删除时,查询到的 LikeLock 值确实与自己的 uuid 相等。
  • 业务逻辑 1 执行删除前,LikeLock 刚好过期时间已到,被 redis 自动释放,在 redis 中没有了 LikeLock,没有了锁。
  • 业务逻辑 2 获取了 LikeLock,加锁成功,开始执行自己的业务。
  • 业务逻辑 1 此时执行了删除操作,会把业务逻辑 2 的 LikeLock 删除,导致出现进程安全问题。

针对上述情况,我们要采取 LUA 脚本来确保删除操作的两条命令的原子性。

确保删除锁的原子性

我们采取 LUA 脚本使两条命令在 Redis 客户端当做一个脚本整体运行,中间不会插入其他命令。

Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

lua 脚本优点:

  • 减少网络开销:原先多次请求的逻辑放在 redis 服务器上完成。使用脚本,减少了网络往返时延
  • 原子操作:Redis 会将整个脚本作为一个整体执行,中间不会被其他命令插入(想象为事务)
  • 复用:客户端发送的脚本会永久存储在 Redis 中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑

SpringBoot 中的具体实现

在 SpringBoot 中,是使用 DefaultRedisScript 类来加载脚本的,并设置相应的数据类型来接收 Lua 脚本返回的数据,这个泛型类在使用时设置泛型是什么类型,脚本返回的结果就是用什么类型接收。

public void like(int userId, int entityType, int entityId) {
    // 获取要进行点赞操作的key
    String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);

    // 获取分布式锁key(使用业务的key生成锁key,用锁保证此业务key的原子性)
    String LikeLock = RedisKeyUtil.getLockByName(entityLikeKey);

    // 获取随机字符串
    String uuid = CommunityUtil.generateUUID();

    // 创建分布式锁
    Boolean store = redisTemplate.opsForValue().setIfAbsent(LikeLock, ip, 3, TimeUnit.SECONDS);
    if (store) { // 拿到锁,执行业务

        // 执行业务逻辑...

        // 执行业务完毕,删除锁
        String uuidLock = (String) redisTemplate.opsForValue().get(LikeLock);
        if (uuidLock != null && uuid.equals(uuidLock)) {
            // 定义lua 脚本
            // -- lua删除锁:
            // -- KEYS和ARGV分别是以集合方式传入的参数,对应上文的LikeLock和uuid。
            // -- 如果对应的value等于传入的uuid。
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            // 使用redis执行lua执行
            DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
            redisScript.setScriptText(script);
            // 设置一下返回值类型 为Long
            // 因为删除判断的时候,返回的0,给其封装为数据类型。如果不封装那么默认返回String 类型,
            // 那么返回字符串与0 会有发生错误。
            redisScript.setResultType(Long.class);
            // 第一个要是script 脚本 ,第二个需要判断的key(KEYS[1]),第三个就是key所对应的值(ARGV[1])。
            redisTemplate.execute(redisScript, Arrays.asList(LikeLock), uuidLock);
        }
    } else {
        // 没有拿到锁,间隔一点时间(不要一直循环)重试
        try {
            Thread.sleep(100);
            like(userId, entityType, entityId);
        } catch (InterruptedException e) {
            // ...异常处理
            e.printStackTrace();
        }
    }
}

这样就完美解决了我的业务需求。…真的么?

setNX 锁在非单机模式下的缺陷

只能说,在单机 Redis 模式下,setnx 分布式锁,简直是无敌!

但是 setnx 锁最大的缺点就是它加锁时只作用在一个 Redis 节点上,即使 Redis 通过 Sentinel(哨岗、哨兵) 保证高可用,如果这个 master 节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况,下面是个例子:

  1. 在 Redis 的 master 节点上拿到了锁;
  2. 但是这个加锁的 key 还没有同步到 slave 节点;
  3. master 故障,发生故障转移,slave 节点升级为 master 节点;
  4. 上边 master 节点上的锁丢失。

有的时候甚至不单单是锁丢失这么简单,新选出来的 master 节点可以重新获取同样的锁,出现一把锁被拿两次的场景。

锁被拿两次,也就不能满足安全性了…

那么该如何解决问题呢?

可以通过使用 Redisson + RedLock 的分布式锁来解决集群模式下的缺陷。
具体实现可以参考这一篇博客:Redis 分布式锁—Redisson+RLock 可重入锁实现篇

我的项目暂时就使用这种方式了,毕竟 Redis 的 master 故障本身就是低概率事件,感觉够用了哈哈,后续优化的话会再写一篇博客的。

巨人的肩膀

redis—分布式锁存在的问题及解决方案(Redisson)

redis 14 分布式锁(UUID 防误删、LUA 脚本保证删除的原子性、锁的实现原则)

Redis 分布式锁—SETNX+Lua 脚本实现篇

  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

叁柚木

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值