Redis+Lua实现分布式锁

Redis+Lua实现分布式锁

Lua 脚本功能是 Reids 2.6 版本的最大亮点, 通过内嵌对 Lua 环境的支持, Redis 解决了长久以来不能高效地处理 CAS (check-and-set)命令的缺点, 并且可以通过组合使用多个命令, 轻松实现以前很难实现或者不能高效实现的模式。

1. Lua脚本在redis交互大致如下
在这里插入图片描述

交互过程:Redis 客户端发送一段Lua脚本到redis服务端,该Lua脚本会作为一个可执行的函数体,内部不能再自定义新的函数。redis服务端接收到Lua脚本命令后,进行解析执行,将结果返回给客户端。

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

2.2 Lua特性
a) 轻量级: 采用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,并且很方便嵌入到其他程序中。
b) 可扩展: 提供了非常易于使用的扩展接口和机制:由宿主语言(通常是C或C++)提供这些功能

2.3 使用场景
a) 游戏开发
b) 独立应用脚本
c) Web 应用脚本
d) 扩展和数据库插件如:MySQL Proxy 和 MySQL WorkBench
e) 安全系统,如入侵检测系统

3. Redis执行Lua脚本命令模式
EVAL和EVALSHA用于使用从2.6.0版开始内置于Redis中的Lua解释器来执行脚本。

3.1 EVAL模式
命令:EVAL luascript numkeys key [key …] arg [arg …]
script 参数是一段 Lua 脚本程序,numkeys 参数用于指定redis健名参数的个数, redis健名参数 key [key …] 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。附加参数 arg [arg …] ,可以在 Lua 中通过全局变量 ARGV 数组访问,访问的形式 ARGV[1] 、 ARGV[2] 等。

3.1.2 EVAL 使用列子
(1) 在本地按默认方式启动redis服务(默认端口6379):
>redis-server
(2) 在本地按默认方式启动redis客户端(默认端口6379):
>redis-cli

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

3.1.3 EVAL模式的缺点
EVAL模式需要每次发送Lua脚本到redis服务端,因而会产生额外的网络带宽开销。

其实redis内部提供了缓存机制,完全可以利用这一点进行性能改善,下面介绍的EVALSHA模式就是利用了这一点。

3.2 EVALSHA模式
命令:EVALSHA sha1 numkeys key [key …] arg [arg …]
EVALSHA根据给定的 sha1 校验码,对缓存在服务器中的脚本进行求值。参数传递方式与EVAL相同。

3.2.1 如何知道Lua脚本的sha1校验码
命令:SCRIPT LOAD script
script load命令会在redis服务器缓存你的Lua脚本,并且返回脚本内容的SHA1校验和,然后通过evalsha 传递SHA1校验和来找到服务器缓存的脚本进行调用

redis> SCRIPT LOAD "return 'hello moto'"
        "232fd51614574cf0867b83d384a5e898cfd24e5a"

redis> EVALSHA "232fd51614574cf0867b83d384a5e898cfd24e5a" 0
       "hello moto"

3.2.2 EVALSHA 模式的优点

前面提到eval模式每次都要把执行的脚本发送过去,这样势必会有一定的网络开销,evalsha最大的好处是降低了网络开销。

  • 如果服务器缓存了给定的 SHA1 校验和所指定的脚本,那么执行对应的这个脚本,如果服务器端给定的 SHA1 校验和所指定的脚本已过期或者丢失,那么它返回一个特殊的错误,提醒用户使用 EVAL 代替 EVALSHA。

4.Redis中执行Lua脚本的原子性
Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。
需要注意的是Lua脚本执行具有原子性的,因此脚本中不宜使用大量的命令或者影响执行效率的脚本,尤其是在高并发的情况下,执行开销较大的Lua脚本更不可取,会导致整个redis服务器阻塞。

5. Lua脚本中如何使用Redis命令
在Lua脚本中采用如下两个不同的Lua函数进行Redis命令调用

redis.call()  
redis.pcall()

redis.call()类似于redis.pcall(),唯一的区别是如果Redis命令调用将导致错误,redis.call()将引发Lua错误,反过来会强制EVAL返回错误到命令调用者,而redis.pcall将捕获错误并返回一个Lua错误码。

一个Lua调用Redis例子:

> eval "return redis.call('set','foo','bar')" 0
OK

6.使用Lua脚本实现分布锁

分布式锁的两种场景(参考:https://juejin.im/post/58b3a93c1b69e60058b49767):

  • (1)如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效, 比如worker中防止多个服务器重复计算;
  • (2)如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用有安全风险的算法,比如基于时间假设的算法,如redlock;那应该使用什么技术呢?Martin认为,应该考虑类似Zookeeper的方案,或者支持事务的数据库。 那考虑性能呢?应该尽可能缩小锁的范围,能不应锁就不用锁

6.1 lock 脚本:
在项目的classpath下创建lock.lua 脚本

//调用系统时间作为锁标识:相当于线程获取了锁,并给锁加一个标志,这个标志是独有的,
不被其他线程所知或者所得;其中KEYS[1] 是传入redis的key参数,不同的key值对应不同的锁
local ostime=redis.call('TIME') ;
local time=string.format("%d",ostime[1]*1000000+ostime[2]) ;
//CAS模式设值,保证锁只能被一个线程设置成功
local flag = redis.call("setnx",KEYS[1],time);
if(flag > 0) then  //锁标识设置成功
    // 对获取的锁设置过期时间(根据业务场景增加过期设置)
	redis.call("expire",KEYS[1],ARGV[1]);
	return redis.call("get",KEYS[1]);
else
	return "0";  // 锁标识设置失败
end

6.2 unLock 脚本:
在项目的classpath下创建unLock.lua 脚本

local  lockKey = redis.call("get",KEYS[1]);
local  lk = ARGV[1];
//增加锁标识判断,防止错误删除:防止没有获取到锁的线程进行锁的释放
if(lockKey == lk) then
	redis.call("del",KEYS[1]);
	return true;
else
	return false;
end

6.1、6.2 非必要步骤: 可以在代码中直接硬编码,然后进行脚本加载,这里仅仅是方便维护查看。

6.3 java语言简单使用如下:

public class RedisLuaLock {
    public static JedisPool jedisPool;
    public static String lockScriptSha;
    public static String unLockScriptSha;
    //初始化redispool
	static {
   		JedisPoolConfig config = new JedisPoolConfig();
    	config.setMaxTotal(20);
    	config.setMaxIdle(10);
    	config.setMinIdle(5);
   	    jedisPool = new JedisPool(config, "127.0.0.1", 6379);
	}
	
    public static void main(String[] args) {
        TestMain tm = new TestMain();
        // 使用redisClient 提供的scriptLoad 方法进行lua脚本加载,返回脚本的sha校验码
        // 也可在代码中直接硬编码,然后进行脚本加载
        lockScriptSha = jedisPool.getResource().scriptLoad(tm.getLuaScript("lock.lua"));
        unLockScriptSha = jedisPool.getResource().scriptLoad(tm.getLuaScript("unlock.lua"));
     
        String lockKey = "lockKey1";
        for (int i = 0; i < 80; i++) {
            new Thread(() -> {
                Long lock = tm.getLocksha(lockKey, 30);
                System.out.println(Thread.currentThread().getName() + "=====lock:" + lock);
                if (lock <= 0) {
                    return;
                }
                System.out.println(Thread.currentThread().getName() + "=====unlock:"
                        + tm.unlocksha(lockKey, lock));
            }).start();
        }
    }

   //释放锁方式1:通过eval模式:参数lock --线程的锁标识
    public boolean unlock(String lockKey, Long lock) {
        Long flag = (Long) jedisPool.getResource().eval(new RedisLuaLock().getLuaScript("unlock.lua"),
                Arrays.asList(lockKey), Arrays.asList(lock.toString()));
        if (flag > 0) {
            return true;
        }
        return false;
    }
    //释放锁方式2:通过evalsha模式:参数lock --线程的锁标识
    public boolean unlocksha(String lockKey, Long lock) {
        Long flag = (Long) jedisPool.getResource().evalsha(unLockScriptSha,
                Arrays.asList(lockKey), Arrays.asList(lock.toString()));
        if (flag > 0) {
            return true;
        }
        return false;
    }
    
   //获取锁方式1:通过eval模式:参数lock --线程的锁标识
    public Long getLock(String lockKey, Integer expireTime) {
        return Long.valueOf((String) jedisPool.getResource().eval(new RedisLuaLock().getLuaScript("lock.lua"),
                Arrays.asList(lockKey), Arrays.asList(expireTime.toString())));
    }
    
   //获取锁方式2:通过evalsha模式:参数lock --线程的锁标识
    public Long getLocksha(String lockKey, Integer expireTime) {
        return Long.valueOf((String) jedisPool.getResource().evalsha(lockScriptSha,
                Arrays.asList(lockKey), Arrays.asList(expireTime.toString())));
    }

    //工具方法:获取lua配置脚本
    private String getLuaScript(String pathName) {
        ClassLoader loader = getClass().getClassLoader();
        InputStream inputStream = loader.getResourceAsStream(pathName);
        StringBuilder lua = new StringBuilder();
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new InputStreamReader(inputStream));
            String cmdline = null;
            while ((cmdline = reader.readLine()) != null) {
                lua.append(cmdline);
            }
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e1) {
                }
            }
        }
        return lua.toString();
    }
}

通过模拟测试:eval模式下的锁获取性能比evalsha差很多,读者可以自行验证。

最佳实践建议:上述获取锁的过程可以增加超时获取,线程一直阻塞获取锁;释放锁也尽量保证释放成功;另外锁的过期时间设置需要结合业务进行设置,过期时间需要大于业务正常执行完成的时间,否则业务还未完成就释放锁,进而造成业务并发问题。

以上如有问题,请批评指正。

你可能感兴趣的:(Redis+Lua实现分布式锁)