Springboot(五十四)SpringBoot3实现redis分布式锁

180 阅读6分钟

我们在Springboot项目中分别整合了redis和redission框架。

 

下边我记录一下再框架中分别使用redis和redission实现分布式锁的代码。

 

一:redis-lua脚本实现分布式锁

lua 本身是不具备原子性的, 但由于Redis的命令是单线程执行的,它会把整个Iua脚本作为一个命令执行,会阻塞其间接受到的其他命令,这就保证了lua脚本的原子性。

 

Lua 脚本好处:

1)      原子性:Lua脚本的所有命令在执行过程中是原子的,避免了并发修改带来的问题。

2)      减少网络往返次数:通过在服务器端执行脚本,减少了客户端和服务器之间的网络往返次数,提高了性能。

3)      复杂操作:可以在Lua脚本中执行复杂的逻辑,超过了单个Redis命令的能力。

lua 脚本使用注意点

1)      由于Redis执行lua脚本其间,无法处理其他命令,因此如果lua脚本的业务过于复杂,则会产生长时间的阻塞,因此编写Lua脚本时应尽量保持简短和高效。

2)      Redis默认限制lua执行脚本时间为5s,如果超过这个时间则会终止且抛错,可以通过lua-time-limit调整时长。

 

但是,redis-lua分布式锁有一个小小的问题,他不具备可重入性。针对这个问题,我对redis-lua分布式锁实现了封装。代码如下所示:

RedisLuaUtils.java

package com.modules.redis.utils;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.time.Duration;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * @author camellia
 * redis 加锁工具类
 */
@Slf4j
public class RedisLuaUtils
{
    /**
     * 超时时间(毫秒)
     */
    private static final long TIMEOUT_MILLIS = 15000;

    /**
     * 重试次数
     */
    private static final int RETRY_TIMES = 10;

    /***
     * 睡眠时间(重试间隔)
     */
    private static final long SLEEP_MILLIS = 100;

    /**
     * 用来加锁的lua脚本
     * 因为新版的redis加锁操作已经为原子性操作
     * 所以放弃使用lua脚本
     */
    private static final String LOCK_LUA =
            "if redis.call("setnx",KEYS[1],ARGV[1]) == 1 " +
                    "then " +
                    " return redis.call('expire',KEYS[1],ARGV[2]) " +
                    "else " +
                    " return 0 " +
                    "end";

    /**
     * 用来释放分布式锁的lua脚本
     * 如果redis.get(KEYS[1]) == ARGV[1],则redis delete KEYS[1]
     * 否则返回0
     * KEYS[1] , ARGV[1] 是参数,我们只调用的时候 传递这两个参数就可以了
     * KEYS[1] 主要用來传递在redis 中用作key值的参数
     * ARGV[1] 主要用来传递在redis中用做 value值的参数
     */
    private static final String UNLOCK_LUA =
            "if redis.call("get",KEYS[1]) == ARGV[1] "
                    + "then "
                    + " local number = redis.call("del", KEYS[1]) "
                    + " return tostring(number) "
                    + "else "
                    + " return tostring(0) "
                    + "end ";

    /**
     * 检查 redisKey 是否上锁(没加锁返回加锁)
     *
     * @param redisKey redisKey
     * @param template template
     * @return Boolean
     */
    public static Boolean isLock(String redisKey, String value, RedisTemplate<ObjectObject> template)
    {

        return lock(redisKey, value, template, RETRY_TIMES);
    }

    private static Boolean lock(String redisKey, String value, RedisTemplate<ObjectObject> template, int retryTimes)
    {
        boolean result = lockKey(redisKey, value, template);

        // 循环等待上一个用户锁释放,或者锁超时释放
        while (!(result) && retryTimes-- > 0)
        {
            try
            {
                log.debug("lock failed, retrying...{}", retryTimes);
                Thread.sleep(RedisLuaUtils.SLEEP_MILLIS);
            }
            catch (InterruptedException e)
            {
                return false;
            }
            result = lockKey(redisKey, value, template);
        }

        return result;
    }

    /**
     * 加锁
     * @param key
     * @param value
     * @param template
     * @return
     */
    private static Boolean lockKey(final String key, final String value, RedisTemplate<ObjectObject> template)
    {
        try
        {
            /*RedisCallback<Boolean> callback = (connection) -> connection.set(
                    key.getBytes(StandardCharsets.UTF_8),
                    value.getBytes(StandardCharsets.UTF_8),
                    Expiration.milliseconds(RedisLuaUtils.TIMEOUT_MILLIS),
                    RedisStringCommands.SetOption.SET_IF_ABSENT
            );
            //System.out.println("callback:"+callback);
            boolean result = template.execute(callback);
            //System.out.println("result:"+result);
            return result;//*/
            Boolean nativeLock = template.opsForValue().setIfAbsent(key,value, Duration.ofSeconds(RedisLuaUtils.TIMEOUT_MILLIS));
            System.out.println("加锁成功:"+nativeLock);
            return nativeLock;//*/
        }
        catch (Exception e)
        {
            log.info("lock key fail because of ", e);
            //throw new Exception("redis 连接失败!");
            return false;
        }
    }


    /**
     * 释放分布式锁资源(解锁)
     *
     * @param redisKey key
     * @param value value
     * @param template redis
     * @return Boolean
     */
    public static Integer releaseLock(String redisKey, String value, RedisTemplate<ObjectObject> template)
    {
        try
        {
            /*RedisCallback<Boolean> callback = (connection) -> connection.eval(
                    UNLOCK_LUA.getBytes(),
                    ReturnType.BOOLEAN,
                    1,
                    redisKey.getBytes(StandardCharsets.UTF_8),
                    value.getBytes(StandardCharsets.UTF_8)
            );
            return template.execute(callback);
            //*/
            // Integer result = template.execute(new DefaultRedisScript<>(UNLOCK_LUA,Integer.class), Collections.singletonList(redisKey),value);
            List<Object> list = new CopyOnWriteArrayList<>();
            list.add(redisKey);
            Integer result = template.execute(new DefaultRedisScript<>(UNLOCK_LUA,Integer.class), list, value);
            return result;//*/
        }
        catch (Exception e)
        {
            log.info("release lock fail because of ", e);
            return 0;
        }
    }
}

 

上边的工具类中,我封装了过期时间和重试次数。你可以根据你的需求调整这两个参数。增加锁的性能。

 

测试一下:

@GetMapping("java/testRedis")
public void testRedis()
{
    // 使用多线程来模拟多用户并发操作
    for (int i = 0; i < 20; i++)
    {
        final int temp = i;
        new Thread(() -> {
            System.out.println("开始创建订单:"+temp);
            // 生成唯一uuid
            String uuid = UUID.randomUUID().toString();
            // 上锁
            Boolean isLock = redisLuaUtilsStater.isLock("Locks", uuid, redisTemplate);

            if (!isLock)
            {
                // System.out.println("锁已经被占用:"+temp);
            }
            else
            {   // 获取到锁,做对应的处理。
                System.out.println("获取到锁!"+temp);
                Boolean res = redisUtil.set("tests""test:"+temp);
                String username = "redis";
                // 将记录写入数据库,这一步可以换成其他操作
                String ip = "0.0.0.0";
                Browse browse = new Browse();
                browse.setUsername(username.toString());
                browse.setArticleTitle("test:"+temp);
                browse.setIp(ip);
                browse.setIsWeixin((byte) '0');
                articleDao.addBrowse(browse);
                System.out.println("redis写入状态:"+res+" - "+temp);
            }
            /*try
            {
                Thread.sleep((long) 1);
            }
            catch (InterruptedException e)
            {
                throw new RuntimeException(e);
            }//*/
            //一定要记得释放锁,否则会出现问题,解锁会校验uuid,校验失败,解锁也就失败
            Integer res = redisLuaUtilsStater.releaseLock("Locks", uuid, redisTemplate);
            System.out.println("redis-lock解锁状态:"+res+" - "+temp);
        }).start();
    }
}

 

具体操作代码中都有注释。参照即可。

 

数据库写入操作那部分可以整体换成其他操作。都可以。

 

二:redission实现分布式锁

Redission分布式锁的底层是setnx和lua脚本(保证原子性)

1.是可重入锁。

2.Redisson 锁支持自动续期功能,这可以帮助我们合理控制分布式锁的有效时长,当业务逻辑执行时间超出了锁的过期时间,锁会自动续期,避免了因为业务逻辑执行时间过长而导致锁提前释放。Redission锁提供的看门狗,一个线程成功索取锁后,看门狗会给持有锁的线程续期。

3.Redission锁支持等待锁,一个while循环不断等待,能提升性能。

4.Redission锁的红锁解决分布式锁的主从一致性问题,红锁:在多个redis实例上(n/2 + 1)创建锁,获取锁时要求在多个实例上都能获取锁成功。但这样性能太低了。

测试代码如下:

// =============================================================
// redission 框架测试
@Autowired
private RedissonClient redissonClient;

@Resource
private ArticleDao articleDao;

/**
 * 分布式锁案详解
 * http://127.0.0.1:8001/java/redissonLockLock?lock=redissionlocklock
 */
@GetMapping("redissonLockLock")
public void redissonLockLock(String lock)
{
    RLock rLock = redissonClient.getLock(lock);
    // 使用多线程来模拟多用户并发操作
    for (int i = 0; i < 20; i++)
    {
        final int temp = i;
        new Thread(() -> {
            System.out.println("开始创建订单:"+temp);
            // 生成唯一uuid
            String uuid = UUID.randomUUID().toString();
            // 获取锁,并设置锁的自动释放时间。
            try
            {
                // 上锁
                //waitTime:等待获取锁的最大时间量; leaseTime:锁的自动释放时间(每个线程占用锁的最大时间); unit:时间单位。
                // tryLock(10, 30, TimeUnit.SECONDS):此行代码尝试在10秒内获得锁,如果成功,锁将在30秒后自动释放。
                boolean tryLock = rLock.tryLock(3000010, TimeUnit.MILLISECONDS);
                if (tryLock)
                {
                    // 开启看门狗模式,锁到期自动续期
                    rLock.lock(15, TimeUnit.MILLISECONDS);
                    // 模拟执行业务逻辑
                    log.error("开始执行业务逻辑......");
                    // 获取到锁,做对应的处理。
                    System.out.println("获取到锁!"+temp);
                    String username = "redis";
                    // 将记录写入数据库,这一步可以换成其他操作
                    String ip = "0.0.0.0";
                    Browse browse = new Browse();
                    browse.setUsername(username.toString());
                    browse.setArticleTitle("test:"+temp);
                    browse.setIp(ip);
                    browse.setIsWeixin("0");
                    int res = articleDao.addBrowse(browse);
                    System.out.println("redis写入状态:"+res+" - "+temp);
                    log.error("业务逻辑执行完成......");
                }
                else
                {
                    log.error("锁已存在......");
                }
            }
            catch (InterruptedException e)
            {
                //throw new RuntimeException(e);
                System.out.println("出错了!");
            }
            finally
            {
                // 解锁报错:attempt to unlock lock, not locked by current thread by node id: 544b01e7-babb-4223-b855-00baab04f050 thread-id: 666
                // 解决:https://segmentfault.com/a/1190000042576825?sort=newest
                if (rLock.isLocked() && rLock.isHeldByCurrentThread())
                {
                    rLock.unlock();
                    log.error("释放锁完成……");
                }
                else
                {
                    log.error("锁已过期......");
                }
            }
        }).start();
    }
}

 

三:小结

理论上来说,对性能要求比较高的地方,建议使用redis+lua脚本方式(我自己封装的工具类)来实现加锁。

Redission实现分布式锁功能相对复杂,具体流程如下:

**1)     ** 获取锁对象

**2)     ** 尝试加锁

**3)     ** 处理加锁结果

**4)     ** 自旋等待与订阅通知

**5)     ** 续命机制

 

所以相对来说,redission实现分布式锁的性能相对我们自己封装的redis+lua脚本分布式锁较低~

 

以上大概就是redis实现分布式锁的两种方式。

 

有好的建议,请在下方输入你的评论。