Redis Lua脚本的详细介绍以及使用入门

Redis Lua脚本的详细介绍以及使用入门。

文章目录

  • Redis Lua脚本的引入
    • 开源软件的可扩展性
    • Redis的扩展性脚本
  • Redis Lua脚本的基本使用
    • 通过EVAL命令执行Lua脚本
    • 通过脚本与Redis交互
  • Java中调用Redis Lua脚本
    • Java调用Lua脚本的方式
  • Redis Lua脚本的使用建议
    • 脚本缓存
    • 脚本缓存稳定性
    • 脚本参数化
    • 脚本超时处理
    • Redis集群中Lua脚本的使用
    • 其他限制
    • Redis 7 Functions
  • Lua脚本相关常用命令
  • 总结
  • 参考

本次的主要分享主题是:Redis Lua脚本的使用入门,主要分为三个方面:

  1. Redis Lua脚本的介绍和基本使用语法。
  2. Java如何中调用Lua脚本操作Redis。
  3. Lua脚本的相关使用建议。

Redis Lua脚本的引入

开源软件的可扩展性

流行的开源软件或者框架,都会非常注重一个特性,那就是“可扩展性”,或者说“可编程性”。

例如Spring框架,就提供了非常多的扩展点,各种PostProcessor、Aware、Lifecycle等等扩展接口,让开发者能够自定义程序运行的逻辑。

又比如Dubbo,它的插件式架构的精髓,就在于开发者可以基于它的SPI机制任意的替换例如Protocol、LoadBalance等等几乎所有的Dubbo组件。

一个框架或者软件的可扩展性够强,往往意味着其足够的灵活,能够满足更多用户的定制化需求或者说二次开发的需求,进而影响到其在开源市场的占有率。

Redis的扩展性脚本

Redis作为最常见的缓存数据库,它的大多数命令专门用于以不同的方式操作不同的核心数据类型,这些命令并不能在一次调用过程中组合使用。

Redis中的可扩展性(可编程性),实际上就是指服务器能够执行任意用户定义的逻辑片段,我们把这样的逻辑片段称为脚本,Redis在2.6.0版本开始引入脚本的功能。
用户编写的脚本实际上在Redis中由一个嵌入式执行引擎执行。目前,Redis只支持一个脚本引擎,即Lua 5.1解释器,但是计划在未来支持更多的脚本引擎。

Redis Lua脚本的好处有很多,最为人熟知的亮点包括:

  1. 很多复杂的业务需要包含多步Redis操作,这些操作常被拆分为一个个单独的API方法。开发人员可以使用Redis脚本的功能实现一个健壮的、特定于应用程序的api。这样的api可以封装业务逻辑,并跨多个键和不同的数据结构维护数据模型。
  2. 复杂业务的多步Redis操作,一般情况下需要多次与Redis服务器交互,需要多次网络IO开销。使用一个Redis脚本来完成多步操作的封装,然后发送给Redis服务器执行,因为脚本在同一个服务器中执行,所以从脚本中读取和写入数据非常有效,仅需要少量的网络IO的开销,给我们带来更好的性能。
  3. Redis脚本和Redis命令一样,它被Redis服务器端单线程的执行,这样就具备了天然的原子性,常常可以被用于实现基于Redis的分布式锁。

Redis Lua脚本的基本使用

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

关于Lua的基本语法,实际上比较的简单,在此不做赘述:。下面介绍如何在Redis中执行Lua脚本。

通过EVAL命令执行Lua脚本

我们可以使用EVAL命令在服务器上执行自定义Lua脚本。例如:

>EVAL "return 'Hello, scripting!'" 0
"Hello, scripting!"

该命令要求返回"Hello, scripting!"字符串。

EVAL命令的完整格式为:

EVAL script numkeys [key [key ...]] [arg [arg ...]]
  • script:Lua脚本。
  • numkeys:指定KEYS[]参数的数量,非负整数。
  • KEYS[]:传入的Redis键参数。
  • ARGV[]:传入的脚本参数。KEYS[]与ARGV[]的索引均从1开始。

在上面的案例中,我们使用参数值0,表示该Lua脚本需要0个KEYS参数。

Lua脚本中支持动态传递参数,执行上下文通过KEYSARGV全局运行时变量来传递使脚本使用的参数。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单例是一个所有脚本中都可以访问的对象实例。它提供了从脚本与Redis交互的API。

在redis对象的函数中,最常见的调用的函数就是redis.call(),该函数将会调用给定的Redis命令并返回其应答,他的语法如下:

redis.call(command [,arg...])
  1. command:redis命令,必须。
  2. arg:动态传递的参数,可选。arg支持通过KEYS和ARGV动态传递。

例如,以下命令要求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/

Java中调用Redis Lua脚本

Java调用Lua脚本的方式

上面我们学习了在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脚本。

  1. 首先我们需要创建一个DefaultRedisScript对象,该对象表示Lua脚本的抽象。
  2. 然后设置设置DefaultRedisScript的返回值类型,支持List、Boolean、Long、String类型,默认是返回String类型。
  3. 然后将Lua脚本代码可以直接设置进去,也可以选择从文件中加载。
  4. 最后通过redisTemplate#execute方法执行脚本获取结果。
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);

Redis Lua脚本的使用建议

脚本缓存

通过EVAL命令执行Lua脚本的时候,每次都需要将脚本的源代码包含在请求中。重复调用EVAL命令来执行同一脚本,既浪费了网络带宽,也在Redis中有一些开销。当然,节省网络和计算资源是关键,因此,Redis为脚本提供了一种缓存机制。

默认情况下,使用EVAL执行的每个脚本都存储在服务器的专用缓存中,缓存的脚本内容通过脚本源代码的SHA1校验和来唯一标识( 缓存在底层的server.lua_scripts 字典中)。
我们可以通过先运行EVAL命令执行一个新的Lua脚本,随后调用INFO memory命令来验证此缓存行为。我们会注意到used_memory_scripts_humannumber_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 必须保证脚本执行的原子性,所以它并不会自动停止。

因此,当脚本运行的时间超过最大执行时间后,以下动作会被执行:

  1. Redis 开始重新接受其他客户端的命令请求,但是只有SCRIPT KILL 和 SHUTDOWN NOSAVE两个命令会被处理,对于其他命令请求, Redis 服务器只是简单地返回 BUSY 错误::BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
  2. SCRIPT KILL命令只能用于在执行期间没有修改数据集的脚本,因为停止只读脚本并不违反脚本引擎的保证原子性,对于有写入数据的脚本无法停止。
  3. 如果脚本已经执行过写命令,那么唯一允许执行的操作就是SHUTDOWN NOSAVE ,它通过停止服务器来阻止当前数据集写入磁盘。

超时处理简单的原理是:

如果配置了lua-time-limit 选项,那么Reids执行Lua脚本之前会在Lua环境里面设置一个超时处理钩子(hook),超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间。

如果超过了最大执行时间,那么超时钩子将定期查看是否有SCRIPT KILL命令或者SHUTDOWN NOSAVE 命令到达服务器,然后执行后续判断。

Redis集群中Lua脚本的使用

一般的Lua脚本在Redis单节点模式以及主从哨兵模式下都能正常使用。

如果是Redis Cluster集群模式下,每个node节点会分配到不同的哈希槽(hash slot,默认16384个),存储数据的时候,会根据CRC16算法计算出key的哈希值,然后对16384取余计算出该key对应的slot,从而实现数据分片。

如果使用Redis集群一次性对多个key执行操作,例如mset,mget命令,包括对Lua脚本中的多个key执行调用时,要求所有操作的key必须在同一个节点上,否则会执行出错

因此Redis 集群对使用Lua脚本的使用增加了额外的限制:

  1. 为了防止key不再同一个node,Redis在执行某个Lua脚本之前,会对KEYS中的所有key进行判断,如果所有的key不是位于同一个slot上,那么报错xxxx command keys must be in same slot注意,即使key所属的不同slot位于同一个node,也还是会报错。
  2. 我们可以通过CLUSTER KEYSLOT命令获取目标Key的哈希槽(hash slot)。
  3. 通过EVAL或者SCRIPT LOAD命令缓存的脚本不会同步到其他节点中。

更好的办法是让需要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参数传递。

Redis 7 Functions

如果有条件,那么使用Lua的更好的方式是使用Redis 7 Functions机制,Reids Funtions是从 7.0版开始提供的另一种可编程的方法,可以看是持久化的Lua函数,类似于关系型数据库的函数。

Redis Funtions支持在Redis服务端定义函数,其好处如下:

  1. 在服务端维护,支持不同的客户端远程调用共同的函数,Lua脚本则需要维护在各个客户端中。一旦Lua脚本更新,那么所有的客户端都需要更改。
  2. 支持函数的嵌套调用,Lua脚本不能在一个脚本中调用另一个脚本。
  3. 另外还支持函数的持久化,Lua脚本则不支持服务端持久化。

关于更多的Redis 7 Functions的信息,后续有机会再介绍。

Lua脚本相关常用命令

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/

你可能感兴趣的:(Redis,6.x,lua,redis,开发语言,分布式锁)