Lua脚本在Redis里面使用的范围还是很广的,如从数据库中批量将数据导入到Redis中、分布式锁防止锁误删、多操作原子性要求等,这些都会用到Lua脚本。但是这里还是需要注意的是Lua只能保证原子性,不能保证事务性。另外根据对Redis的了解,其本身是提供事务机制的,但是这个事务机制在很多情况是不能回滚的(鸡肋),所以用起来也更少。这里不说具体的事务性,而是来一起看看Lua脚本实现原子操作。
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
这里就简单的介绍一下,如果想深入学习其语法,可以参考RUNOOB里面的具体总结(传送门:Lua脚本教程)。
Redis本身有一个eval
和evalsha
两个命令,都可以用来执行Lua脚本。
直接将脚本文件内容作为参数传入,然后加上脚本执行所需的数据,具体格式如下:
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
系列方法不止这一个,还有其他的方式,详细可以去看一下。
这种方式也会常被用到,将需要执行的脚本提前加载到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脚本到底是在哪些方面用到,下面将举两个例子一起来理解其中奥妙。
这个脚本在上面说eval
和evalsha
的时候都已经说到,但是应用到实际的分布式锁怎么来做,还是看一下,因为这个防误删在具体开发过程中用到的比较多,也是比较简单的用法。
具体为什么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的原子聚合,也没法做到事务。那么到底应该如何实现可靠的事务性?篇幅有限,后面再做解释。