1.Lua简介

Lua是一个轻量级的可嵌入的脚本语言,并且每次的执行都是作为一个原子命令来执行。Lua的体积很小,是因为自身并没有像其他语言那样提供强大的库,因此Lua不适合用来开发独立应用。但是Lua脚本由标准C编写而成,可以很容易实现和C/C++代码的互相调用,因此常嵌入其他语言开发的应用程序(例如Redis)中,从而为应用程序提供灵活的扩展和定制功能。

Redis在2.6版本后支持Lua脚本功能,嵌入Lua脚本的原因无非就是利用其原子执行的特性,开发者可以定制一套命令满足某个业务场景。虽然Redis自身的事务机制也能提供原子操作,但对于分布式锁、秒杀扣库存(一个用户只能抢购一次)等高并发的应用场景,执行效率远低于Lua脚本。

2.常用命令

名称 版本 描述
EVAL 2.6.0 执行一段Lua脚本字符串,每次都需要将完整的lua脚本传递给redis服务器
SCRIPT LOAD 2.6.0 将一段lua脚本缓存到redis中并返回一个sha1校验码,仅仅存储并不会执行
EVALSHA 2.6.0 传递tag字符串到redis服务器,执行对应脚本(逻辑复用、减少网络带宽)
SCRIPT EXISTS 2.6.0 传递一个sha1校验码串redis服务器,判断服务器中是否存在
SCRIPT FLUSH 2.6.0 清除服务器上的所有缓存的脚本
SCRIPT KILL 2.6.0 杀死正在运行的脚本
SCRIPT DEBUG 3.2.0 设置调试模式,可设置同步、异步、关闭,同步会阻塞所有请求

2.1 EVAL

命令格式: EVAL script numkeys key [key …] arg [arg …]
  • script: 一段Lua脚本字符串,要传具体的命令内容,不能传tag字符串
  • numkeys: 指定Lua脚本需要处理键的数量,其实就是key数组的长度(必填,没数组填0)
  • key [key …]: 表示脚本命令中用到了哪些Redis的键(非必传)
  • arg [arg …]: 附带参数(非必传),在script中可以通过ARGV[index]获取

例如对一个键为name1的字符串,设置值为疾风剑豪,使用Lua命令实现:
图片

更多时候我们希望借助Lua脚本实现多条命令的原子操作,下面举几个执行多条命令的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 同时设置俩个String键值对,并且key的值通过附带参数传入(不需要返回值)
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])" 2 "name1" "name2" "疾风剑豪" "快乐风男"
(nil)

// 设置一个String键值对,并对另一个键值对递增,然后返回递增后的值
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1],ARGV[1]);return redis.call('incr',KEYS[2])" 2 "name1" "clickCount" "疾风剑豪"
(integer) 6

// 同时修改某个hash结构的俩个属性(name、age)
127.0.0.1:6379>EVAL "redis.call('hset',KEYS[1], ARGV[1], ARGV[2]);redis.call('hset',KEYS[1], ARGV[3], ARGV[4])" 1 "user" "name" "张三" "age" "18"
(nil)

// 对某个值为数字的字符串,值+3
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1], redis.call('get', KEYS[1]) + ARGV[1])" 1 "quantity" "3"
(nil)

// 对某个值为数字的字符串,值+3,并返回增加后的值
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1], redis.call('get', KEYS[1]) + ARGV[1]);return redis.call('get', KEYS[1])" 1 "quantity" "3"
"7"

// 设置一个字符串,并对key2的值递增,如果key2的值并非数字,执行报错,但第一条命令仍然执行成功
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1],ARGV[1]);redis.call('incr',KEYS[2])" 2 "key1" "key2" "二桃杀三士"
(nil)

// 上述命令后面再加一个正常命令,执行仍然报错,但是第一条命令生效,第三条不生效,可以判定遇到异常命令直接终止脚本执行
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1],ARGV[1]);redis.call('incr',KEYS[2]);redis.call('set',KEYS[3],ARGV[1])" 3 "key1" "key2" "key3" "二桃杀三士"
(nil)

注: script中的每个call都可以替换为pcall,如果Redis命令调用发生了错误,call将抛出一个Lua类型的错误,再强制EVAL命令把错误返回给命令的调用者,而pcall将捕获错误并返回表示错误的Lua表类型。

2.2 SCRIPT LOAD

将Lua脚本内容存储到Redis服务器,然后返回sha1校验码,但不会执行脚本内容。应用程序可以将sha1保存起来,后续需要执行此命令,只需要传sha1字符串即可。

命令格式: EVAL LOAD script

2.3 EVALSHA

与EVAL命令几乎一致,只是将Lua脚本内容换成sha1校验码了。这么做一方面是提高Lua脚本的复用性,保证一个脚本逻辑可以在应用程序的多个地方调用,另一方面是减少网络带宽(特别是并发时),毕竟多数场景Lua脚本内容的长度是远大于sha1校验码长度的,这种方式可以减少网络传输的消耗。

命令格式: EVALSHA sha1 numkeys key [key …] arg [arg …]

2.4 SCRIPT EXISTS

检查一或多个sha1校验码在redis服务器的缓存中是否存在,返回一个元素内容为0或1的有序列表,列表长度和传入的sha1校验码数组长度一致,通过返回列表的下坐标判断传入的sha1校验码是否存在。

命令格式: SCRIPT EXISTS sha1 [sha1 ...]

2.5 SCRIPT FLUSH

清空Redis服务器的所有Lua脚本缓存,此命令不需要任何参数,并且返回值永远是OK。

命令格式: SCRIPT FLUSH

2.6 SCRIPT KILL

杀死Redis服务器当前正在执行的脚本,也不需要任何参数,当且仅当脚本没有执行过写操作,这个命令才生效。此命令主要用于终止运行时间过长的脚本,或因为编写不当导致无限循环的脚本。如果当前脚本已经执行了写操作,则kill命令执行失败并报错,这主要为了保证脚本命令的原子性。

命令格式: SCRIPT KILL

3.复杂命令

上述的EVAL命令仅仅是将多个Redis原生命令打包并原子执行,但实际开发中我们可能会遇到更复杂的需求,比如涉及到一些if、boolean、运算符等判断,或者迭代器遍历等逻辑处理,这就需要去了解Lua的数据类型以及语法,才可以编写出业务复杂的脚本。具体的可以参考菜鸟教程:

https://www.runoob.com/lua/lua-iterators.html

3.1 弹出list多个元素

利用list的lpop命令,一次请求弹出多个元素,直到获取到n个元素或list为空,在Lua脚本中可以将list的键、指定数量n设置为参数,然后编写一个脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 返回结果集合
local list = {};

-- 元素数量参数转化为lua数据类型
local num = tonumber(ARGV[1]);

-- 循环
while (num > 0)
do
-- 弹出第一个元素(如果为空返回nil)
local item = redis.call('LPOP', KEYS[1]);

-- 如果为空,终止循环(虽然命令返回nil,但逻辑判断要用布尔)
if item == false then
break;
end;

-- 将元素添加到返回结果集
table.insert(list, item);

-- 元素数量递减1
num = num -1;
end;

return list;

3.2 DDOS防护

防止DDOS攻击的一个简单方法就是限制n秒内同IP的访问次数,在Lua脚本中可以将n秒、ip地址、访问限制数设置为参数,然后编写一个防御脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- ip地址对应value递增,并返回给局部变量cnt
local cnt = redis.call('INCR', KEYS[1])

-- 如果递增后的值大于访问限制数,返回1
if cnt > tonumber(ARGV[1])
then
return 1
end

-- 如果递增后的值等于1,说明key之前不存在。要么之前没有访问,要么正好到下一个时间窗,设置key的过期时间(参数n秒)
if cnt == 1
then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0;

Lua不支持精度数据类型,如果返回值可能携带小数,一定要使用toString()函数转化为字符串返回,否则小数部分会丢失!

4.客户端实现

4.1 RedisTemplate

RedisTemplate客户端的execute重载方法支持Lua脚本的执行,但方法仅支持EVAL命令,并且集群模式调用会抛出不支持异常,因此只能通过redis的原始connection对象来执行脚本命令,并且底层使用Jedis、Lettuce,调用的api也有所不同。

底层依赖Jedis客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// lua脚本
String lua = "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])";

// keys数组
List<String> keys = Arrays.asList("key1", "key2");

// args数组
List<String> args = Arrays.asList("aaaaa", "bbbbb");

// 通过connection执行EVAL命令
Object evalResult = redisTemplate.execute(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {

// 获取connection
Object nativeConnection = redisConnection.getNativeConnection();

// 单机模式(jedis)
if (nativeConnection instanceof Jedis) {
return (Object) ((Jedis) nativeConnection).eval(lua, keys, args);
}

// 集群模式(jedis)
if (nativeConnection instanceof JedisCluster) {
return (Object) ((JedisCluster) nativeConnection).eval(lua, keys, args);
}

return null;
}
});

底层依赖Lettuce客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// lua脚本
String lua = "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])";

// keys、args字节数组的数组(前2个字节数组为keys,后俩个为args)
byte[][] keysAndArgsBytesArray = new byte[][]{"key1".getBytes(), "key2".getBytes(), "aaaaa".getBytes(), "bbbbb".getBytes()};

// 通过connection执行EVAL命令
Object evalResult = redisTemplate.execute(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {

// 获取connection
Object nativeConnection = redisConnection.getNativeConnection();

// 单机模式(lettuce)
if (nativeConnection instanceof LettuceConnection) {
RedisScriptingCommands commands = ((LettuceConnection) nativeConnection).scriptingCommands();
return (Object) commands.eval(lua.getBytes(), ReturnType.BOOLEAN, 2, keysAndArgsBytesArray);
}

// 集群模式(lettuce)
if (nativeConnection instanceof LettuceClusterConnection) {
RedisScriptingCommands commands = ((LettuceConnection) nativeConnection).scriptingCommands();
return (Object) commands.eval(lua.getBytes(), ReturnType.BOOLEAN, 2, keysAndArgsBytesArray);
}

return null;
}
});

4.2 Redisson

Redisson客户端执行Lua脚本代码就简洁多了,也不需要考虑单机/集群模式,支持同步和异步两种形式调用,并且可以通过RScript.Mode枚举参数控制请求发送到主节点还是从节点(如果存在):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// lua脚本
String lua = "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])";

// 执行EVAL命令,READ_ONLY优先发送到从节点,READ_WRITE发送到主节点
Object evalResult = redissonClient.getScript().eval(RScript.Mode.READ_ONLY, lua, RScript.ReturnType.INTEGER, keys, args);

// 将Lua脚本内容上传到Redis服务器
String sha1 = redissonClient.getScript().scriptLoad(lua);

// 执行sha1脚本
Object evalShaResult = redissonClient.getScript().evalSha(RScript.Mode.READ_ONLY, sha1, RScript.ReturnType.INTEGER, keys, args);

// 判断一或多个sha1是否存在
String sha1Test = "asdasdasdasdasd";
List<Boolean> exist = redissonClient.getScript().scriptExists(sha1, sha1Test);

5.其他细节

5.1 主从复制

如果Redis的持久化策略是AOF,那么每一条Lua脚本执行完毕后,都会将整个脚本内容发送到从节点,但由于主从节点的操作系统环境存在差异(特别是时间),因此Redis脚本禁用了Lua语言库中与文件系统调用(例如获取当前时间)相关函数,确保主节点与从节点的数据一致性。

在确保主从节点的数据一致性上,Redis还对Lua的随机函数做了特殊处理,替换了Lua的math.random和math.randomseed函数,使得每次执行脚本生成的随机值都是相同的(根据参数)。

5.2 脚本超时&死循环

为了防止Lua脚本执行时间过长,导致其他命令阻塞,Redis提供了一个超时时间的参数lua-time-limit(单位毫秒),来控制脚本的执行时间。但是这个参数并没有啥卵用,Redis不会因为超时就中断脚本的运行,仅仅在日志中打印警告,这是因为必须保证Lua脚本执行的原子性。

Lua脚本执行超时后,Redis服务器开始允许其他客户端的请求,但是仅仅处理SCRIPT KILLSHUTDOWN NOSAVE命令,其他请求返回busy错误。另外如果脚本内容涉及并已执行过写操作,SCRIPT KILL命令是无法杀死的,只能采用SHUTDOWN NOSAVE命令强制关闭服务器。

6.与事务区别

在原子执行方面,两者均可保证执行的原子性,不会被CPU的线程上下文切换机制中断,因此编写Lua脚本时要控制执行时间,避免长时间阻塞影响其他命令的处理。另外事务在执行过程中某条命令出现异常会记录下来继续向下执行,Lua脚本遇到错误命令则停止执行。因此俩者都不能严格的保证原子性。

在网络开销方面,事务中的每一条命令(包括开启、提交、回滚)都需要向Redis服务器发送一次网络请求,而Lua脚本一组命令仅需要一次请求,并且可以通过sha1校验码减少请求携带的内容,网络开销更小。对于Redis来说相当一大部分开销都来自网络传输,因此Lua脚本的效率更高。

在代码维护方面,事务的命令是写在应用程序中,阅读起来简洁易懂,修改起来也比较容易。Lua脚本在维护方面的优势在于可以热部署,业务逻辑发生改变不需要像事务那样重新编译、启动,只需要修改Lua脚本内容即可,前提是Lua脚本写在文件或缓存中,而不是硬编码在程序中。

在功能支持方面,Lua脚本仅仅支持命令的原子执行,事务不仅支持命令的原子执行,还可以设定监听、回滚的功能(我真觉得没啥卵用)。

评论