(redis + lua脚本 )实现redis分布式锁

2 篇文章 0 订阅
1 篇文章 0 订阅

一、前言

基于redis实现分布式锁,其实有很多,基于 redisson,基于 jedis,等都可以实现,springBoot 默认提供 redis 操作工具 redisTemplate ,我们可以基于它配合lua 进行实现。

简单业务场景不需要使用 redisson

redisson本身其实是基于lua脚本来保证原子性的,使用redisson需要额外引用依赖,还要单独去配置,还要增加学习成本去了解redisson相关接口,但是如果我们的需求不是那么复杂,没有必要哦,

完美分布式锁的几个条件

1、 互斥性:在任意时刻,只有一个客户端能持有锁,这是最根本的。
2、 原子性:加锁时涉及到,两个操作(setnx 、expire或者是 setex)要不都执行
3、 不会发生死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
4 、解铃还须系铃人:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
5、 具有容错性,只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。(可选)
6、公平锁/非公平锁(可选): 公平锁的意思是按照请求的顺序获得锁,非公平锁就是无效的。
7、支持阻塞和非阻塞(可选):非阻塞:获取不到锁,返回错误结构,阻塞:获取不到锁,自旋等待锁

8、高可用(可选):加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
9、可重入性(可选):同一节点的同一个线程如果获取了锁,可以再次获取锁。
注:
自己写的分布式锁,再优秀,其实都有缺点的,如果追求完美请直接参考redisson,redisson可以解决所有你遇到锁的问题。

二、redis锁 必备知识点

redis指令必备

很多博文上来就贴出代码,会让新手有点懵圈,其实要想深入了解,必先了解redis实现锁的几个指令。
更多指令可参考: http://redisdoc.com/string/set.html
加锁:setnx 、expire(或者是 setex)
释放锁:get、del
返回值的问题:返回值成功有 返回 “OK”,有返回 1 注意区分。

2.1 setnx:

setnx 命令是当key不存在时设置key,但setnx不能同时完成expire设置失效时长,不能保证setnx和expire的原子性。我们可以使用set命令完成setnx和expire的操作,并且这种操作是原子操作。

说明:

SET key value NX 的效果等同于执行 SETNX key value
为什么使用 setnx 比较好,set key value nx 返回值是 nil 不友好
在这里插入图片描述

含义:

将 key 的值设为 value ,当且仅当 key 不存在。 若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

返回值:

设置成功,返回 1 。 设置失败,返回 0 。

2.2 expire:

含义:

指定key 设置失效时间默认单位是秒。如果想要获取剩余过期时间可以用过 TTL指令
返回值
设置成功,返回 1 。 设置失败,返回 0 。

2.3 setex:

含义:

Setex 命令为指定的 key 设置值及其过期时间。如果 key 已经存在, SETEX 命令将会替换旧的值。

说明:

设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
我认为:
网上很多帖子用 setnx 和 setex 实现,其实都还好,使用效果一样的。
与 expire 相比 setex 会 重新设置key的值。
如果我们的锁后面考虑续约的话,就用expire 比较好。

我认为:

网上很多帖子用 setnx 和 setex 实现,其实都还好,使用效果一样的。
与 expire 相比 setex 会 重新设置key的值。
如果我们的锁后面考虑续约的话,就用expire 比较好。

2.4 setpx:

含义:

设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。

lua 脚本需要简单了解

  • KEYS[1]

  • ARGV[1]

  • KEYS[1] 用来表示在redis 中用作键值的参数占位,主要用來传递在redis 中用作keyz值的参数。

  • ARGV[1]用来表示在redis 中用作参数的占位,主要用来传递在redis中用做 value值的参数。

三、干货来袭

依赖就一个,redis 配置跟随系统,无效额外配置。

  <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
 </dependency>

这里实现的并不是完美锁,但是一般场景可用

3.1 通用接口

定义 加锁/加锁接口。

public interface RedisLuaLock {


    /**
     * 加锁
     * @param key   key
     * @param value 值用于解锁时判断
     * @return Boolean
     */
    Boolean tryLock(String key, String value);

    /**
     * 自定义加锁过期时间
     * @param key   key
     * @param value 值用于解锁时判断
     * @param time  默认单位是 秒
     * @return Boolean
     */
    Boolean tryLock(String key, String value, Integer time);

    /**
     * 释放锁操作
     * @param key
     * @param value
     * @return
     */
    Boolean releaseLock(String key, String value);
    }

3.2 加锁解锁实现细节

import com.aaa.mybatisplus.config.redis.DelayTask;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * redisTemplate 基于 lua  实现分布式锁 版本一
 * @author liuzhen.tian
 */
@Slf4j
@Component
public class RedisLuaLockImpl implements RedisLuaLock{

    @Autowired
    private RedisTemplate redisTemplate;

    private DefaultRedisScript<Boolean> tryLockScript;

    private DefaultRedisScript<Boolean> releaseLockScript;

    /**
     *默认加锁时间 10s
     */
    public static final Integer DEFAULT_TIME =10;

    @PostConstruct
    public void initLUA() {
        tryLockScript = new DefaultRedisScript();
        tryLockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/lock.lua")));
        tryLockScript.setResultType(Boolean.class);

        releaseLockScript = new DefaultRedisScript();
        releaseLockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/unlock.lua")));
        releaseLockScript.setResultType(Boolean.class);


    }

    @Override
    public Boolean tryLock(String key, String value) {
        return tryLock( key, value, DEFAULT_TIME);
    }

    /**
     * 获取lua结果
     * @param key   key
     * @param value 值用于解锁时判断
     * @param time  默认单位是 秒
     * @return Boolean
     */
    @Override
    public Boolean tryLock(String key,String value,Integer  time) {

        // 封装参数
        List<String> keyList = Arrays.asList(key,String.valueOf(time),value);
        Boolean result= (Boolean)redisTemplate.execute(tryLockScript, keyList);
        // 使用下面的也可以,下面这个是基于 事务也能保证原子性,出于效率问题,还是使用lua 进行加锁。
        // Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value, Long.parseLong(time), TimeUnit.SECONDS);
        log.info("redis set result:"+result);
        return result;
    }

    /**
     * 释放锁操作
     * @param key
     * @param value
     * @return
     */
    @Override
    public Boolean releaseLock(String key, String value) {
        // 封装参数
        List<Object> keyList = new ArrayList();
        keyList.add(key);
        keyList.add(value);
        Boolean result = (Boolean) redisTemplate.execute(releaseLockScript, keyList);
        return result;
    }

}

3.3 lua 脚本

在resources 下面新建一个 luascript文件夹,新建 lock.lua,unlock.lua 即可。
为什么使用lua脚本呢

为啥不用 redistemplate 的 方法去实现呢,为了保证加锁,原子性,怎么说,加锁实际上是俩个操作,设置值和设置失效时间。如果多个线程并发过来,会执行错乱。

加锁脚本 lock.lua

-- 加锁脚本

local  lockKey   = KEYS[1]
local  lockTime  = KEYS[2]
local  lockValue = KEYS[3]
local result_1 = redis.call('SETNX', lockKey, lockValue)
if result_1 == 1
then
local result_2 = redis.call('expire', lockKey,lockTime)
return result_2
else
return 0
end;


解锁脚本 unlock.lua

-- 解锁脚本

local lockKey   = KEYS[1]
local lockValue = KEYS[2]
local result_1  = redis.call('get', lockKey)
if result_1 == lockValue
then
local result_2 = redis.call('del', lockKey)
return result_2
else
return 0
end;

3.4 测试

测试的话,大家就比较随意了,你可以在controller里面写成接口,通过压测工具譬如 jmeter 去压测接口,也可以使用java 实现 多线程测试。

基于jdk 的 CountDownLatch 和 Semaphore,我们可以直接去使用代码去压测我们的锁。

@Slf4j
@SpringBootTest
public class RedisLuaTest {

    @Autowired
    // @Qualifier("redisLuaLockImplV2")
    @Qualifier("redisLuaLockImpl")
    RedisLuaLock redisLuaLock;

    // 总的请求个数
    public static final int requestTotal = 20;

    // 同一时刻最大的并发线程的个数
    public static final int concurrentThreadNum = 20;


    @Test
    public  void mainTest() throws InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        CountDownLatch countDownLatch = new CountDownLatch(requestTotal);
        Semaphore semaphore = new Semaphore(concurrentThreadNum);
        for (int i = 0; i< requestTotal; i++) {
            executorService.execute(()->{
                try {
                    semaphore.acquire();

                    //执行的方法
                    lockLuaTest();

                    semaphore.release();
                } catch (InterruptedException e) {
                    log.error("exception", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("请求完成");
    }

    /**
     * 执行测试的方法
     */
    private  void lockLuaTest() {
        String value = UUID.randomUUID().toString();
        try {
            Boolean aaa = redisLuaLock.tryLock("aaa", value, 10);
            if (!aaa) {
                System.out.println("已经加锁,请等待!");
            }else {
                Thread.sleep(6*1000);
                System.err.println("首先执行的线程");
            }

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            redisLuaLock.releaseLock("aaa", value);
        }

    }

}

3.5 测试结果

同时释放20个线程去获取锁,最终只有一个线程,拿到锁。
在这里插入图片描述

四、总结

抛开实现完美锁的其他条件,上面代码实现了,原子性,一致性,不会发生死锁,理论上已经满足很多场景使用。
小缺陷:
其实这个还有小缺陷的,但是没有异步续约,也就是锁超时问题,虽然我们加锁方法可以设置带超时时间的方法。但是如果我们加锁的方法,其业务逻辑执行的时间超过了,我们设置的超时时间,怎么办呢。
解决方法:
参考redisson中看门狗的机制,在加锁的时候,开启一个守护线程进行异步续约,本次代码没有列出来,具体可以参考我的github

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值