Redis Lua脚本的详细介绍以及使用入门。
本次的主要分享主题是:Redis Lua脚本的使用入门,主要分为三个方面:
流行的开源软件或者框架,都会非常注重一个特性,那就是“可扩展性”,或者说“可编程性”。
例如Spring框架,就提供了非常多的扩展点,各种PostProcessor、Aware、Lifecycle等等扩展接口,让开发者能够自定义程序运行的逻辑。
又比如Dubbo,它的插件式架构的精髓,就在于开发者可以基于它的SPI机制任意的替换例如Protocol、LoadBalance等等几乎所有的Dubbo组件。
一个框架或者软件的可扩展性够强,往往意味着其足够的灵活,能够满足更多用户的定制化需求或者说二次开发的需求,进而影响到其在开源市场的占有率。
Redis作为最常见的缓存数据库,它的大多数命令专门用于以不同的方式操作不同的核心数据类型,这些命令并不能在一次调用过程中组合使用。
Redis中的可扩展性(可编程性),实际上就是指服务器能够执行任意用户定义的逻辑片段,我们把这样的逻辑片段称为脚本,Redis在2.6.0版本开始引入脚本的功能。
用户编写的脚本实际上在Redis中由一个嵌入式执行引擎执行。目前,Redis只支持一个脚本引擎,即Lua 5.1解释器,但是计划在未来支持更多的脚本引擎。
Redis Lua脚本的好处有很多,最为人熟知的亮点包括:
Lua 是一种轻量小巧的嵌入式脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入其他应用程序中,从而为应用程序提供灵活的扩展和定制功能。常见应用如游戏开发中的脚本。
关于Lua的基本语法,实际上比较的简单,在此不做赘述:。下面介绍如何在Redis中执行Lua脚本。
我们可以使用EVAL命令在服务器上执行自定义Lua脚本。例如:
>EVAL "return 'Hello, scripting!'" 0
"Hello, scripting!"
该命令要求返回"Hello, scripting!"字符串。
EVAL命令的完整格式为:
EVAL script numkeys [key [key ...]] [arg [arg ...]]
在上面的案例中,我们使用参数值0,表示该Lua脚本需要0个KEYS参数。
Lua脚本中支持动态传递参数,执行上下文通过KEYS和ARGV全局运行时变量来传递使脚本使用的参数。KEYS表示预先填充了脚本执行前提供给脚本的所有键名参数,而ARGV则用于类似的目的,但通常用于预先填充常规参数。
注意,KEYS和ARGV数组的参数取值从1开始。
例如,一个较为完整的EVAL命令的格式例如:
>EVAL "return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }" 2 key1 key2 arg1 arg2 arg3
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"
5) "arg3"
该命令表示返回一个数组,内部的元素均来自于KEYS和ARGV数组参数。
redis单例是一个所有脚本中都可以访问的对象实例。它提供了从脚本与Redis交互的API。
在redis对象的函数中,最常见的调用的函数就是redis.call(),该函数将会调用给定的Redis命令并返回其应答,他的语法如下:
redis.call(command [,arg...])
例如,以下命令要求Redis服务端设置一个key为zhuzhan,value为v3的缓存:
>EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 zhuzhan v3
"OK"
以下命令要求从Redis服务端获取key为zhuzhan的缓存:
>EVAL "return redis.call('get', KEYS[1])" 1 zhuzhan
"v3"
更复杂一些的,我们实现“判断如果某个key不存在,则设置它”的功能,也就是常见的分布式锁获取锁:
>EVAl "return redis.call('set',KEYS[1], ARGV[1], ARGV[2], ARGV[3] , ARGV[4])" 1 lock lock nx px 60000
"OK"
然后我们实现“如果存在某个key并且值相等,则删除key”的功能,也就是分布式锁的解锁:
EVAL "if (redis.call('get',KEYS[1])==ARGV[1]) then
return redis.call('DEL',KEYS[1])
end
return 0"
1 x y
"1"
更多操作参见Lua API:https://redis.io/docs/manual/programmability/lua-api/
上面我们学习了在Redis客户端中直接执行Lua脚本的方法,但是更常见的,我们是直接通过Java客户端操作Redis,那么Java如何通过Lua脚本操作Redis呢?
实际上,通过Java客户端调用Lua脚本相比于命令行客户端更加简单。
比如最常见的Jedis客户端,我们可以调用jedis#eval方法执行Lua脚本,例如:
public static final String prefix = "test:";
public static final List KEYS = Collections.singletonList(prefix + "jedis");
public static final List ARGV = Stream.of("jedis", "nx", "px", "60000").collect(Collectors.toList());
Object eval = jedis.eval("return redis.call('set',KEYS[1], ARGV[1], ARGV[2], ARGV[3] , ARGV[4])", KEYS, ARGV);
System.out.println(eval);
另外,如果是springboot项目,那么更加推荐使用redisTemplate客户端来执行Lua脚本。
DefaultRedisScript<Boolean> objectDefaultRedisScript = new DefaultRedisScript<>();
//返回值类型
objectDefaultRedisScript.setResultType(Boolean.class);
//直接设置Lua
//objectDefaultRedisScript.setScriptText("return redis.call('set',KEYS[1], ARGV[1], ARGV[2], ARGV[3] , ARGV[4])");
//设置Lua资源
objectDefaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/lock.lua")));
//执行脚本
Boolean execute = redisTemplate.execute(objectDefaultRedisScript, KEYS, ARGV.toArray());
System.out.println(execute);
通过EVAL命令执行Lua脚本的时候,每次都需要将脚本的源代码包含在请求中。重复调用EVAL命令来执行同一脚本,既浪费了网络带宽,也在Redis中有一些开销。当然,节省网络和计算资源是关键,因此,Redis为脚本提供了一种缓存机制。
默认情况下,使用EVAL执行的每个脚本都存储在服务器的专用缓存中,缓存的脚本内容通过脚本源代码的SHA1校验和来唯一标识( 缓存在底层的server.lua_scripts 字典中)。
我们可以通过先运行EVAL命令执行一个新的Lua脚本,随后调用INFO memory命令来验证此缓存行为。我们会注意到used_memory_scripts_human和number_of_cached_scripts等指标会随着每一个新脚本的执行而增长。
即使是通过EVAL执行相同的脚本,也会花费额外的开销,例如词法分析、语法分析等等。
对于这个问题,Redis提供了一个优化措施。
我们可以先调用SCRIPT LOAD script加载Lua脚本, 该命令会返回给客户端一个跟该Lua脚本向关联的SHA1校验和, 该命令并不会执行脚本,而是编译脚本并将其加载到服务器的缓存中。
后续调用该Lua脚本的时候, 只需通过EVALSHA SHA1 numkeys key [key …] arg [arg …]命令, 将SHA1当做参数进行传递,使用从服务器返回的SHA1摘要执行缓存的脚本即可。
下面是一个加载并执行缓存脚本的例子:
>SCRIPT LOAD "return 'cache script'"
"7d55b8f344d4d16a2d72bf255c993b3640f730ad"
>EVALSHA 7d55b8f344d4d16a2d72bf255c993b3640f730ad 0
"cache script"
默认情况下Redis将会一直在内存中缓存执行过的Lua脚本。但是,Eval脚本被视为客户端应用程序的一部分,Lua脚本的缓存并不会在Redis服务端进行持久化,也就是说Redis脚本缓存总是不稳定的。
因为缓存脚本没有提供持久化能力,其可以在服务器重新启动时清除,也可以在副本承担主角色时的故障转移期间清除,或者通过SCRIPT FLUSH命令显式清除。
在使用EVALSHA命令时,若SHA1值对应的脚本未缓存至Redis中,Redis会返回NOSCRIPT错误,此时我们需要通过EVAL或SCRIPT LOAD命令将目标脚本缓存至Redis中后进行重试。
>EVALSHA ffffffffffffffffffffffffffffffffffffffff 0
"NOSCRIPT No matching script. Please use EVAL."
实际上,通过redisTemplate#execute()方法执行的Lua脚本,其内部就是使用的此逻辑:首先计算出Lua脚本的SHA1校验和,后续调用时,直接通过EVALSHA命令执行。因此,对于方法参数DefaultRedisScript,也是推荐单例实现的,即不必每次调用该方法创建一个新的DefaultRedisScript。
基于脚本缓存的考虑,在应用程序运行时不断的动态生成脚本并且通过Eval发送给Redis执行很可能会耗尽主机用于缓存脚本的内存资源,动态生成脚本是一种反模式。
因此,脚本应该尽可能通用,也就是参数化,将共同的部分作为一个脚本源代码,对于动态变更的数据尽量作为参数传递,而不是每次动态生成一个新的脚本发给Redis执行。
这样的好处除了是可以节省脚本缓存资源,还可以节省网络带宽开销和计算开销。
例如,下面两个命令就是让应用程序根据需要动态生成不同的脚本源代码:
> EVAL "return 'Hello'" 0
"Hello"
> EVAL "return 'Scripting!'" 0
"Scripting!"
但这样会让Redis缓存两个Lua脚本,我们对其进行参数化,变化的部分数据通过参数传递,如下:
>SCRIPT LOAD "return ARGV[1]"
"098e0f0d1448c0a81dafe820f66d460eb09263da"
>EVALSHA 098e0f0d1448c0a81dafe820f66d460eb09263da 0 Hello
"Hello"
>EVALSHA 098e0f0d1448c0a81dafe820f66d460eb09263da 0 Parameterization!
"Parameterization!"
这样,Redis就只需要在第一次调用的时候缓存改脚本并获取SHA1 校验和,对于后续的执行,只需要使用EVALSHA命令输入脚本的 SHA1 校验和以及脚本参数,就可以调用之前存储的脚本。
Lua脚本在Redis中和普通命令一样是原子执行的,Lua脚本执行过慢,将会导致Redis阻塞,将会影响所有连接的客户端。
脚本还有一个最大执行时间限制,它的默认值是5s,可以通过编辑redis.conf 文件或者使用 CONFIG GET parameter 和 CONFIG SET parameter value 命令来修改 lua-time-limit 选项来控制(单位ms)。
执行时间超过了最大执行时间的Lua脚本被称为慢脚本。
即使一个脚本执行超时,因为 Redis 必须保证脚本执行的原子性,所以它并不会自动停止。
因此,当脚本运行的时间超过最大执行时间后,以下动作会被执行:
超时处理简单的原理是:
如果配置了lua-time-limit 选项,那么Reids执行Lua脚本之前会在Lua环境里面设置一个超时处理钩子(hook),超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间。
如果超过了最大执行时间,那么超时钩子将定期查看是否有SCRIPT KILL命令或者SHUTDOWN NOSAVE 命令到达服务器,然后执行后续判断。
一般的Lua脚本在Redis单节点模式以及主从哨兵模式下都能正常使用。
如果是Redis Cluster集群模式下,每个node节点会分配到不同的哈希槽(hash slot,默认16384个),存储数据的时候,会根据CRC16算法计算出key的哈希值,然后对16384取余计算出该key对应的slot,从而实现数据分片。
如果使用Redis集群一次性对多个key执行操作,例如mset,mget命令,包括对Lua脚本中的多个key执行调用时,要求所有操作的key必须在同一个节点上,否则会执行出错。
因此Redis 集群对使用Lua脚本的使用增加了额外的限制:
更好的办法是让需要Lua脚本操作的key都位于同一个slot,hashTag是Redis集群支持的一个特性,是一种确保在同一个slot中分配多个key的方法,目的是为了让Redis集群支持多key操作。
为key设置hashTag的方式很简单,我们只需要在key中追加{xxx}字符串即可,Redis会取key中第一个遇到的{}中的字符串来进行slot计算。详细逻辑见:https://redis.io/docs/reference/cluster-spec/#hash-tags
例如,原来的key为:key1和key2,如果按照正常计算方式,他们可能不会位于同一个slot,为他们分配相同的hashTag之后变成:{test}key1 {test}key2。此时集群会根据hashTag决定key分配到的slot,因为他们的hashTag都是test,因此它们会被分配到同一个slot。
例如集群模式下,执行如下命令:
EVAL "redis.call('mset',KEYS[1],ARGV[1],KEYS[2],ARGV[2])" 2 key1 key2 value1 value2
将会收到报错 ERR ‘key1’ and ‘key2’ not in the same slot。
我们采用hashTag改写key之后,将会正常执行:
EVAL "redis.call('mset',KEYS[1],ARGV[1],KEYS[2],ARGV[2])" 2 {test}key1 {test}key2 value1 value2
将会执行成功!
Redis包含一个嵌入式Lua 5.1解释器。解释器运行用户定义的临时脚本和函数。脚本在一个沙箱上下文中运行,只能访问特定的Lua包:https://redis.io/docs/manual/programmability/lua-api/。
不要访问系统环境: Lua脚本脚本永远不应该尝试访问Redis服务器的底层主机系统。这包括文件系统、网络和执行系统调用的任何其他尝试,而不是API所支持的那些尝试。我们的Lua脚本应该只对存储在Redis中的数据进行操作。
无法定义全局变量和函数: 所有变量和函数定义都必须声明为局部的。为此,需要在声明前加上local关键字。默认支持的全局变量有:redis对象、KEYS、ARGV
只通过KEYS传递key: 为了确保在独立部署和集群部署环境中正确执行脚本(上面讲到的Redis集群环境执行Lua脚本的问题),脚本访问的所有Redis的key的必须显式通过KEYS参数传递。
如果有条件,那么使用Lua的更好的方式是使用Redis 7 Functions机制,Reids Funtions是从 7.0版开始提供的另一种可编程的方法,可以看是持久化的Lua函数,类似于关系型数据库的函数。
Redis Funtions支持在Redis服务端定义函数,其好处如下:
关于更多的Redis 7 Functions的信息,后续有机会再介绍。
EVAL | EVAL script numkeys [key [key …]] [arg [arg …]] | 执行给定的脚本和参数,并返回结果。参数说明:script:Lua脚本。numkeys:指定KEYS[]参数的数量,非负整数。KEYS[]:传入的Redis键参数。ARGV[]:传入的脚本参数。KEYS[]与ARGV[]的索引均从1开始。 |
---|---|---|
EVALSHA | EVALSHA sha1 numkeys key [key …] arg [arg …] | 给定脚本的SHA1校验和,Redis将再次执行脚本。使用EVALSHA命令时,若sha1值对应的脚本未缓存至Redis中,Redis会返回NOSCRIPT错误,请通过EVAL或SCRIPT LOAD命令将目标脚本缓存至Redis中后进行重试。 |
SCRIPT LOAD | SCRIPT LOAD script | 将给定的script脚本缓存在Redis中,并返回该脚本的SHA1校验和。该命令并不会执行脚本。 |
SCRIPT EXISTS | SCRIPT EXISTS script [script …] | 给定一个(或多个)脚本的SHA1,返回每个SHA1对应的脚本是否已缓存在当前Redis服务。脚本已存在则返回1,不存在则返回0。 |
SCRIPT KILL | SCRIPT KILL | 停止正在运行的Lua脚本。该命令是中断长时间运行的脚本(也就是慢脚本)的唯一方法,除非关闭服务器。一旦脚本的执行持续时间超过了配置的最大执行时间阈值(默认5s),脚本就被视为慢脚本。SCRIPT KILL命令只能用于在执行期间没有修改数据集的脚本(因为停止只读脚本并不违反脚本引擎的保证原子性),对于有写入数据的脚本无法停止。 |
SCRIPT FLUSH | SCRIPT FLUSH | 清空当前Redis服务器中的所有Lua脚本缓存。该命令是强制Redis刷新脚本缓存的唯一方法。 |
本次我们介绍了Redis Lua脚本的入门概念,使用方式,以及一些核心知识点,当然还有一些知识点没有详细讲解,比如Redis 7 函数、Lua脚本的复制等等。
更多的内容在Redis官网都有详细介绍,下来后可以相互交流讨论。
https://redis.io/topics/cluster-spec/
https://redis.io/commands/eval/
https://redis.io/docs/manual/programmability/
https://redis.io/docs/manual/programmability/lua-api/