遇到问题

在做项目中,遇到一个点赞的业务逻辑需要实现原则操作,即在 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() 方法释放锁。

问题与不足

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

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

确保删除的是自己的锁

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

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 脚本优点:

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 脚本实现篇

原文地址:https://blog.csdn.net/qq_17224327/article/details/131019544

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任

如若转载,请注明出处:http://www.7code.cn/show_36790.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱suwngjj01@126.com进行投诉反馈,一经查实,立即删除!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注