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
| 127.0.0.1:6379>EVAL "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])" 2 "name1" "name2" "疾风剑豪" "快乐风男" (nil)
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1],ARGV[1]);return redis.call('incr',KEYS[2])" 2 "name1" "clickCount" "疾风剑豪" (integer) 6
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)
127.0.0.1:6379>EVAL "redis.call('set',KEYS[1], redis.call('get', KEYS[1]) + ARGV[1])" 1 "quantity" "3" (nil)
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"
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
| String lua = "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])";
List<String> keys = Arrays.asList("key1", "key2");
List<String> args = Arrays.asList("aaaaa", "bbbbb");
Object evalResult = redisTemplate.execute(new RedisCallback<Object>() { @Override public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
Object nativeConnection = redisConnection.getNativeConnection();
if (nativeConnection instanceof Jedis) { return (Object) ((Jedis) nativeConnection).eval(lua, keys, args); }
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
| String lua = "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])";
byte[][] keysAndArgsBytesArray = new byte[][]{"key1".getBytes(), "key2".getBytes(), "aaaaa".getBytes(), "bbbbb".getBytes()};
Object evalResult = redisTemplate.execute(new RedisCallback<Object>() { @Override public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
Object nativeConnection = redisConnection.getNativeConnection();
if (nativeConnection instanceof LettuceConnection) { RedisScriptingCommands commands = ((LettuceConnection) nativeConnection).scriptingCommands(); return (Object) commands.eval(lua.getBytes(), ReturnType.BOOLEAN, 2, keysAndArgsBytesArray); }
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
| String lua = "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])";
Object evalResult = redissonClient.getScript().eval(RScript.Mode.READ_ONLY, lua, RScript.ReturnType.INTEGER, keys, args);
String sha1 = redissonClient.getScript().scriptLoad(lua);
Object evalShaResult = redissonClient.getScript().evalSha(RScript.Mode.READ_ONLY, sha1, RScript.ReturnType.INTEGER, keys, args);
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 KILL和SHUTDOWN NOSAVE命令,其他请求返回busy错误。另外如果脚本内容涉及并已执行过写操作,SCRIPT KILL命令是无法杀死的,只能采用SHUTDOWN NOSAVE命令强制关闭服务器。
6.与事务区别
在原子执行方面,两者均可保证执行的原子性,不会被CPU的线程上下文切换机制中断,因此编写Lua脚本时要控制执行时间,避免长时间阻塞影响其他命令的处理。另外事务在执行过程中某条命令出现异常会记录下来继续向下执行,Lua脚本遇到错误命令则停止执行。因此俩者都不能严格的保证原子性。
在网络开销方面,事务中的每一条命令(包括开启、提交、回滚)都需要向Redis服务器发送一次网络请求,而Lua脚本一组命令仅需要一次请求,并且可以通过sha1校验码减少请求携带的内容,网络开销更小。对于Redis来说相当一大部分开销都来自网络传输,因此Lua脚本的效率更高。
在代码维护方面,事务的命令是写在应用程序中,阅读起来简洁易懂,修改起来也比较容易。Lua脚本在维护方面的优势在于可以热部署,业务逻辑发生改变不需要像事务那样重新编译、启动,只需要修改Lua脚本内容即可,前提是Lua脚本写在文件或缓存中,而不是硬编码在程序中。
在功能支持方面,Lua脚本仅仅支持命令的原子执行,事务不仅支持命令的原子执行,还可以设定监听、回滚的功能(我真觉得没啥卵用)。