Lua脚本在Redis里面使用的范围还是很广的,如从数据库中批量将数据导入到Redis中、分布式锁防止锁误删、多操作原子性要求等,这些都会用到Lua脚本。但是这里还是需要注意的是Lua只能保证原子性,不能保证事务性。另外根据对Redis的了解,其本身是提供事务机制的,但是这个事务机制在很多情况是不能回滚的(鸡肋),所以用起来也更少。这里不说具体的事务性,而是来一起看看Lua脚本实现原子操作。

Lua

概念

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

特性

  • 轻量级:它用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,可以很方便的嵌入别的程序里。
  • 可扩展: Lua提供了非常易于使用的扩展接口和机制:由宿主语言(通常是C或C++)提供这些功能,Lua可以使用它们,就像是本来就内置的功能一样。
  • 其他特性
  • 支持面向过程(procedure-oriented)编程和函数式编程(functional programming);
  • 自动内存管理;只提供了一种通用类型的表(table),用它可以实现数组,哈希表,集合,对象;
  • 语言内置模式匹配;闭包(closure);函数也可以看做一个值;提供多线程(协同进程,并非操作系统所支持的线程)支持;
  • 通过闭包和table可以很方便地支持面向对象编程所需要的一些关键机制,比如数据抽象,虚函数,继承和重载等。

这里就简单的介绍一下,如果想深入学习其语法,可以参考RUNOOB里面的具体总结(传送门:Lua脚本教程)。

Redis如何执行Lua

Redis本身有一个evalevalsha两个命令,都可以用来执行Lua脚本。

eval

直接将脚本文件内容作为参数传入,然后加上脚本执行所需的数据,具体格式如下:

eval script numkeys key [key ...] arg [arg ...]
  • script:是具体脚本代码
  • numkeys:表示传入参数中key的个数
  • arg:也表示传入的参数

下面看一下示例代码:

eval "if redis.call('get',KEYS[1]) == ARGV[1] then return 1 else return 0 end" 1 testKey values

传入的键个数是1,那么KEYS[1]对应的值就是testKey,后面的values就是对应ARGV[1]。脚本中首先执行get,根据testKey查询,将查询的结果和ARGV[1]做比较,如果相等返回1,如果不等则返回0。(注意这里的KEYS和ARGV角标是从1开始,而不是0)

这段脚本看起来很熟悉,其实很多地方都用到,它就是用来防止误删分布式锁key的脚本。如果映射到Jedis中的API就是直接调用eval方法。然后将脚本参数依次传入即可。代码示例如下:

Jedis jedis = jedisPool.getResource();
jedis.eval("if redis.call('get',KEYS[1]) == ARGV[1] then return 1 else return 0 end 1 testKey values",1,"testKey");

jedis提供的eval系列方法不止这一个,还有其他的方式,详细可以去看一下。

evalsha

这种方式也会常被用到,将需要执行的脚本提前加载到Redis中,加载完成后会返回一个唯一的表示标识符,这个表示符就是sha值。使用evalsha执行脚本的时候就不需要传入具体的脚本内容,只要传入这个sha值即可。具体格式如下:

evalsha sha1 numkeys key [key ...] arg [arg ...]

和上面eval基本相同,就是讲脚本script换成sha1,具体就不解释啦。但是这个脚本如何导入呢?看下面的命令:

script load "if redis.call('get',KEYS[1]) == ARGV[1] then return 1 else return 0 end"

这个命令执行完后会返回一个sha值,将这个sha值替换原来脚本的位置即可。

这段代码在Jedis都有对应API体现,在Java代码中实现如下:

Jedis jedis = jedisPool.getResource();
String sha = jedis.scriptLoad("if redis.call('get',KEYS[1]) == ARGV[1] then return 1 else return 0 end 1 testKey values");
jedis.evalsha(sha,1,"testKey");

这个实现的好处就是sha值是固定的,可以缓存起来,需要使用的时候就不需要传入具体脚本内容啦。

示例的这段脚本可能很短没什么感觉,但是实际使用中脚本文件可能会比较长,从传输上来说都不是最佳的选择。

Lua脚本应用

Lua脚本语言和使用方式都说了,现在来看一下Lua脚本到底是在哪些方面用到,下面将举两个例子一起来理解其中奥妙。

Redis分布式锁防误删

这个脚本在上面说evalevalsha的时候都已经说到,但是应用到实际的分布式锁怎么来做,还是看一下,因为这个防误删在具体开发过程中用到的比较多,也是比较简单的用法。

具体为什么Redis分布式锁会出现误删,可以参考一下我之前的博客:Redis实现分布式锁。里面一步一步进化说的很清楚,这里就不多说啦。

看一下实际代码中的实现:

@Component
public class RedisDel {
    @Autowired
    private JedisPool jedisPool;
    public void delLock(String key, String value) {
        try (Jedis resource = jedisPool.getResource()) {
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            resource.eval(script, Collections.singletonList(key), Collections.singletonList(value));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在不同线程加锁的时候,虽然key是相同的,但是value不同,在删除脚本中会根据value值来判断当前删除锁是否为自己设置的锁,是就删除,否则就不会删除。如果将这个命令分开写,先查后删,这样就会导致整个删除过程不符合原子性,如果出现异常可能会导致执行中断。比如执行查询后,在执行删除的时候网络波动,删除命令没有发出,这样就会导致删除失败。如果采用Lua脚本方式,就将删除和查询聚合,保证执行的原子性。

投票系统防丢失

在本系列博客的第三篇上面有写到投票和抢红包的例子,但是这例子本身是有问题的,那就是单个功能非原子性,比如用户的ID已经加入到已投票人员的队列中后发生异常(或网络波动),实际被投票的文章投票数量并没有增加,是不是很尴尬。红包也是同样的问题。这种问题就需要用Lua来控制原子性。

那下面来一起看一下这个代码应该怎么优化。

投票的原来代码:

//投票,一篇文章一个人只能投票一次
public static void vote(Long articleId, Long userId) {
    Jedis resource = JEDIS_POOL.getResource();
    try {
        //检查当前用户是否已经投过票(使用set)
        Long addResult = resource.sadd(ARTICLE_VOTE + articleId, userId.toString());
        if (addResult == 0) {
            System.out.println("此用户已为此文章投过票,请勿重复投票!");
            return;
        }
        resource.zincrby(ARTICLE_QUEUE, 1D, ARTICLE_PREFIX + articleId);
        System.out.println(String.format("投票成功,用户:【%s】,文章:【%s】", userId, articleId));
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        resource.close();
    }
}

使用Lua优化后代码:

//投票,一篇文章一个人只能投票一次
public static void vote(Long articleId, Long userId) {
    Jedis resource = JEDIS_POOL.getResource();
    try {
        /**
         * evalsha方法参数:
         * 第一个:表示脚本加载到Redis中后返回的sha值
         * 第二个:表示传入的参数中KEYS的个数
         * 第三个到第N个:除了KEYS以外都是ARGV,按照顺序来,如KEYS的个数是2,那么后面第三第四两个参数就是KEYS[1]、KEYS[2],后面再有参数都是ARGV
         * 在jedis中evalsha方法不止一个,传入的参数个数和格式也不一样,上面以一个栗子说明
         */
        Object evalsha = resource.evalsha("312ab5bfe03c5428c92a6e743ab48c1c325ff424", 1, articleId.toString(), userId.toString());
        Long result = Long.valueOf(evalsha.toString());
        if (result == -1) {
            System.out.println("此用户已为此文章投过票,请勿重复投票!");
        } else {
            System.out.println(String.format("投票成功,用户:【%s】,文章:【%s】", userId, articleId));
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        resource.close();
    }
}

这里就将两次操作聚合成一次操作,执行evalsha命令即可,但是具体的Lua脚本是什么呢?看下面的步骤。

Lua脚本内容

  • 首先是写好Lua脚本,可以是文件,也可以是一串代码(字符串),实现的脚本如下:
local setResult = redis.call('sadd','article:votes:' .. KEYS[1],ARGV[1]) 
if setResult ~= 0 then
    return redis.call('zincrby','article:queues',1,'article:prefix:' .. KEYS[1]) 
else 
    return -1 
end

这段代码是不是很简单,根据上面对evalsha命令的理解,这里就不具体解释里面的KEYS和ARGV参数了,可以对照着看一下。

  • 第二步是将Lua脚本加载到Redis中,命令如下:
script load "local setResult = redis.call('sadd','article:votes:' .. KEYS[1],ARGV[1]) if setResult ~= 0 then return redis.call('zincrby','article:queues',1,'article:prefix:' .. KEYS[1]) else return -1 end"

注意这里需要把上面的Lua脚本变成一行,如果有回车存在,复制命令到Redis客户端上是有问题的,因为他会自动回车,把这个命令分为多行执行,就会出错。

另外加载Lua脚本不止通过客户端一种,还可以通过redis-cli命令来实现,命令如下:

redis-cli -h 127.0.0.1 -p 6379 -a 12345678 script load "lua脚本"

这是在Lua脚本比较短的时候这样使用,当脚本过长的时候可以将Lua脚本写在文件中,通过命令加载文件中的内容,命令如下:

redis-cli -h 127.0.0.1 -p 6379 -a 12345678 script load "$(cat lua_script_file_location)"

总结

Lua脚本并不是想象中的那么难,但是这里对Redis基本功的要求比较高,如执行Redis的命令后返回值是什么,因为Lua脚本一般下文中的执行逻辑要根据上文的执行结果来确定,这个就需要对Redis本身的命令了解的很熟悉。然后就是Lua的语法,可以自己去学习一下,文中也提供了Lua教程的链接,快速入门没问题。

这种原子性有什么用?在Redis中我们都知道所有客户端的命令发到服务端都是放在一个队列中,然后按照顺序执行,且是单线程执行,如此看来是没有多线程并发的问题。

如果上面的几种功能本身不出现异常或者网络波动问题,也不会存在并发的问题,但是这种"如果"一旦存在就需要给出解决方案。这里使用Lua脚本聚合成单个原子操作,一旦发送到Redis成功,就会被正常执行,如果失败,原子内的所有命令就不会执行。

但是整个过程说的都是原子性,并没有提到事务,因为Lua脚本里面不包含事务,如果Lua脚本本身内部出现异常,那也会出现Lua内的上下文执行不完整(上文执行、下文未执行)。Redis本身的事务也是鸡肋,不能用起来。引入Lua的原子聚合,也没法做到事务。那么到底应该如何实现可靠的事务性?篇幅有限,后面再做解释。