Redis Lua实战

Redis Lua 这个技术,我之前就在关注,今天有空,我把项目中基于Redis实现的ID生成器改成用lua脚本实现,防止并发id冲突问题

Redis中使用Lua的好处

  • 减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延
  • 原子操作。redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。
  • 复用。客户端发送的脚步会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。

Redis Lua脚本与事务

从定义上来说, Redis 中的脚本本身就是一种事务, 所以任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。

使用事务时可能会遇上以下两种错误:

  • 事务在执行 EXEC 之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)。
  • 命令可能在 EXEC 调用之后失败。举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面,诸如此类。

对于发生在 EXEC 执行之前的错误,客户端以前的做法是检查命令入队所得的返回值:如果命令入队时返回 QUEUED ,那么入队成功;否则,就是入队失败。如果有命令在入队时失败,那么大部分客户端都会停止并取消这个事务。

不过,从 Redis 2.6.5 开始,服务器会对命令入队失败的情况进行记录,并在客户端调用 EXEC 命令时,拒绝执行并自动放弃这个事务。

在 Redis 2.6.5 以前, Redis 只执行事务中那些入队成功的命令,而忽略那些入队失败的命令。 而新的处理方式则使得在流水线(pipeline)中包含事务变得简单,因为发送事务和读取事务的回复都只需要和服务器进行一次通讯。

至于那些在 EXEC 命令执行之后所产生的错误, 并没有对它们进行特别处理: 即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行。

经过测试lua中发生异常处理方式和redis 事务一致,可以说这两个东西是一样的,但是lua支持缓存,可以复用脚本,这个是原来的事务所没有的

了解更多事务相关信息,看这个网站

如何在Redis中使用lua

在redis里面使用lua脚本主要用三个命令

  • eval
  • evalsha
  • script load
    eval用来直接执行lua脚本,使用方式如下
EVAL script numkeys key [key ...] arg [arg ...]

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

key代表要操作的rediskey
arg可以传自定义的参数
numkeys用来确定key有几个
script就是你写的lua脚本
lua脚本里面使用KEYS[1]和ARGV[1]来获取传递的key和arg

lua语法详见lua教程

在用eval命令的时候,可以注意到每次都要把执行的脚本发送过去,这样势必会有一定的网络开销,所以redis对lua脚本做了缓存,通过script load 和 evalsha实现
script load命令会在redis服务器缓存你的lua脚本,并且返回脚本内容的SHA1校验和,然后通过evalsha 传递SHA1校验和来找到服务器缓存的脚本进行调用,这两个命令的格式以及使用方式如下

SCRIPT LOAD script
EVALSHA sha1 numkeys key [key ...] arg [arg ...]

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

redis> EVALSHA 232fd51614574cf0867b83d384a5e898cfd24e5a 0
"hello moto"

SHA1有如下特性:不可以从消息摘要中复原信息;两个不同的消息不会产生同样的消息摘要,(但会有1×10 ^ 48分之一的机率出现相同的消息摘要,一般使用时忽略)。

spring-data-redis操作lua

上面讲的是如何在redis控制台调用lua脚本,现在我们来讲下怎么在java里面调用
在java里面调用redis一般使用jedis,对于调用lua脚本来讲,spring-data-redis包做的封装使用起来更加方便,底层也是基于jiedis,所以我们这边直接讲spring-data-redis中的redisTemplate如何来调用lua
先导入依赖

<dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-redis</artifactId>
                <version>1.8.1.RELEASE</version>
</dependency>

然后我们使用StringRedisTemplate这个类来操作

@Resource
private StringRedisTemplate stringRedisTemplate;

public <T> T runLua(String fileClasspath, Class<T> returnType, List<String> keys, Object ... values){
        DefaultRedisScript<T> redisScript =new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(fileClasspath)));
        redisScript.setResultType(returnType);
        return stringRedisTemplate.execute(redisScript,keys,values);
    }

这个框架把lua脚本封装成RedisScript对象,并且可以将lua脚本执行的结果自动转换为配置的java类型,然后只要直接调用execute方法即可
并且这个execute逻辑中封装了evalsha的优化,源码如下

protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,
            byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {

        Object result;
        try {
            result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
        } catch (Exception e) {

            if (!exceptionContainsNoScriptError(e)) {
                throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
            }

            result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
        }

        if (script.getResultType() == null) {
            return null;
        }

        return deserializeResult(resultSerializer, result);
    }

因为sha1的算法是通用的,所以在java客户端可以提前算出SHA1校验和,然后用evalsha来执行脚本,如果SHA1对应的脚本,那么还是用eval来执行,eval执行一次后,下次都可以直接调用evalsha了,减少网络开销

lua Debug

我们写完一个lua脚本,lua和redis的数据类型是不一致的,存在一个转换,并且如果遇到复杂逻辑的lua脚本,如果不能debug,只在自己脑子里面走这个逻辑,是不科学的,如果redis lua也提供了debug功能,要在redis客户端执行
在运行lua的eval,加上-ldb即可开启debug功能,debug只支持eval命令

./redis-cli --ldb --eval /tmp/script.lua mykey somekey , arg1 arg2

然后提供了一些调试命令

lua debugger> help
Redis Lua debugger help:
[h]elp               Show this help.
[s]tep               Run current line and stop again.
[n]ext               Alias for step.
[c]continue          Run till next breakpoint.
[l]list              List source code around current line.
[l]list [line]       List source code around [line].
                     line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
                     to show before/after [line].
[w]hole              List all source code. Alias for 'list 1 1000000'.
[p]rint              Show all the local variables.
[p]rint <var>        Show the value of the specified variable.
                     Can also show global vars KEYS and ARGV.
[b]reak              Show all breakpoints.
[b]reak <line>       Add a breakpoint to the specified line.
[b]reak -<line>      Remove breakpoint from the specified line.
[b]reak 0            Remove all breakpoints.
[t]race              Show a backtrace.
[e]eval <code>       Execute some Lua code (in a different callframe).
[r]edis <cmd>        Execute a Redis command.
[m]axlen [len]       Trim logged Redis replies and Lua var dumps to len.
                     Specifying zero as <len> means unlimited.
[a]abort             Stop the execution of the script. In sync
                     mode dataset changes will be retained.

Debugger functions you can call from Lua scripts:
redis.debug()        Produce logs in the debugger console.
redis.breakpoint()   Stop execution as if there was a breakpoint in the
                     next line of code.

用redis.debug() 可以打日志
用redis.breakpoint()在lua脚本里打断点
s和n都是跳到下行代码
c是跳到下个断点
list可以展示当前这条代码前后的代码

写个简单的lua脚本来测试下

local value1 = ARGV[1]
local value2 = ARGV[2]
redis.debug(value1)
redis.debug(value2)
if(value1>value2)
then
return "a"
else
return "b"
end

《Redis Lua实战》 lua debug

可以看到用起来还是挺方便的

更多细节看官方教程

项目实战

在我们项目中使用redis生成全局id,代码如下

 @Autowired
    private RedisTemplate<String,Long> redisTemplate;


    public  String nextID(){
        String key = Prefix+simpleDateFormatThreadLocal.get().format(new Date());
        Long existedID = redisTemplate.opsForValue().get(key);
        if(existedID!=null){
            redisTemplate.opsForValue().set(key,existedID+1);
            return key+String.format("%04d",existedID+1);
        }else{
            redisTemplate.opsForValue().set(key,1L);
            return key+"0001";
        }
    }

这段代码是存在问题的,在并发的情况下,get方法可以访问到相同的key,就会出现id重复的问题,测试代码如下

System.out.println("current:"+idGenerator.currentID());
        Integer threadSize =5;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for(int i =0 ;i<100;i++){
                    System.out.println(Thread.currentThread().getName()+":"+idGenerator.nextID());
                }
                countDownLatch.countDown();
            }
        };
        for(int i =0;i<threadSize;i++){
            new Thread(runnable,"Thread"+i).start();
        }
        countDownLatch.await();
        System.out.println("current:"+idGenerator.currentID());

当然这边我们也可以使用乐观锁或者分布式锁来实现,但是锁自旋的逻辑还是有潜在危险的
如果用lua来实现,把这个阻塞动作放在redis服务器,那我们的代码就会很健壮了
新建一个lua脚本

local key = KEYS[1]
local id = redis.call('get',key)
if(id == false)
then
    redis.call('set',key,1)
    return key.."0001"
else
    redis.call('set',key,id+1)
    return key..string.format('%04d',id + 1)
end

对应调用java代码如下

public String nextIDLua(){
        String key = Prefix+simpleDateFormatThreadLocal.get().format(new Date());
        DefaultRedisScript<String> redisScript =new DefaultRedisScript<>();
        redisScript.setLocation(new ClassPathResource("lua/genID.lua"));
        redisScript.setResultType(String.class);
        //System.out.println(redisScript.getSha1());
        return redisTemplate.execute(redisScript,(RedisSerializer<?>) redisTemplate.getKeySerializer(),(RedisSerializer<String>)redisTemplate.getKeySerializer(),Lists.newArrayList(key));
    }

把上面那个测试方法修改一下,进行测试
可以发现,第一份代码在多线程并发下是存在id重复问题的。
第二份代码避免了这个问题

全套demo代码地址请点击

    原文作者:土豆肉丝盖浇饭
    原文地址: https://www.jianshu.com/p/366d1b4f0d13
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞