一、Lua脚本概述
1.1 Lua脚本简介
Lua 由标准 C 编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译,运行。Lua 脚本可以很容易的被 C/C ++ 代码调用,也可以反过来调用 C/C++ 的函数,这使得 Lua 在应用程序中可以被广泛应用。
Lua 脚本在游戏领域大放异彩,大家耳熟能详的《大话西游II》,《魔兽世界》都大量使用 Lua 脚本。Java 后端工程师接触过的 api 网关,比如 Openresty ,Kong 都可以看到 Lua 脚本的身影。
1.2 Redis中的Lua脚本支持
从 Redis 2.6.0 版本开始, Redis内置的 Lua 解释器,可以实现在 Redis 中运行 Lua 脚本。
使用 Lua 脚本的好处 :
- 减少网络开销:将多个请求通过脚本的形式一次发送,减少网络时延。
- 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其它命令插入。
- 复用:客户端发送的脚本会永久存在 Redis 中,其它客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。
Redis是一款高性能的键值存储数据库,提供了丰富的数据结构和功能,如字符串、列表、哈希、集合、有序集合等。然而,有时候我们需要执行一些复杂的操作,或者需要保证多个操作的原子性,这就需要用到Redis的Lua脚本功能。
Tips: 虽然Redis也支持事务,但Redis 的事务模式具备如下特点:
- 保证隔离性;
- 无法保证持久性;
- 具备了一定的原子性,但不支持回滚;
- 一致性的概念有分歧,假设在一致性的核心是约束的语意下,Redis 的事务可以保证一致性。
Redis内置了Lua脚本引擎,允许用户编写和执行Lua脚本。Lua是一种轻量级、高效的脚本语言,具有简洁的语法和强大的表达能力,非常适合用于编写Redis的扩展脚本。
Lua脚本在Redis服务器端执行,可以访问Redis提供的各种命令和数据结构。脚本执行是原子性的,保证了多个命令的执行是连续、不可中断的。
Redis会将执行过的Lua脚本缓存起来,当需要执行相同的脚本时,直接从缓存中获取,避免了重复解析和编译的开销。
1.3 Redis Lua脚本与事务
从定义上来说, Redis 中的脚本本身就是一种事务,所以任何在事务里可以完成的事,在脚本里面也能完成。并且一般来说,使用脚本要来得更简单,并且速度更快。
使用事务时可能会遇上以下两种错误:
- 事务在执行
EXEC
之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其它更严重的错误,比如内存不足(如果服务器使用maxmemory
设置了最大内存限制的话)。 - 命令可能在
EXEC
调用之后失败。举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面,诸如此类。
对于发生在 EXEC
执行之前的错误,客户端以前的做法是检查命令入队所得的返回值:如果命令入队时返回 QUEUED ,那么入队成功;否则,就是入队失败。如果有命令在入队时失败,那么大部分客户端都会停止并取消这个事务。
Tips: 从 Redis 2.6.5 开始,服务器会对命令入队失败的情况进行记录,并在客户端调用
EXEC
命令时,拒绝执行并自动放弃这个事务。
经过测试 Lua 中发生异常处理方式和 redis 事务一致,可以说这两个东西是一样的,但是 Lua 支持缓存,可以复用脚本,这个是原来的事务所没有的
1.4 Redis Lua脚本的执行流程
对于Redis Lua脚本的执行流程,可以分为加载脚本、编译脚本和执行脚本三个阶段。
Tips: Redis Lua脚本的执行流程包括加载脚本、编译脚本和执行脚本三个阶段。加载脚本时会进行脚本缓存,编译脚本会将脚本转换为可执行的字节码,执行脚本具有原子性和事务性特点。
![]()
1、加载脚本
脚本缓存机制:
- Redis脚本缓存的目的是为了提高脚本的执行效率,避免每次执行脚本都需要重新加载和编译。
- 脚本缓存的实现方式是将脚本的SHA1散列值和脚本内容一起保存在Redis服务器的脚本缓存中。
脚本加载与缓存的关系:
- 脚本加载的流程是将脚本传输给Redis服务器,并通过SHA1散列值判断脚本是否已经存在于缓存中。
- 如果脚本已经存在于缓存中,则直接返回脚本的SHA1散列值。
- 如果脚本不存在于缓存中,则将脚本进行缓存,并返回脚本的SHA1散列值。
- 在使用脚本时,可以通过脚本的SHA1散列值来引用脚本,而不需要每次都传输脚本内容。
2、编译脚本
Lua脚本语法:
- Lua脚本是基于Lua语言的一种脚本语言,具有自己的语法规则。
- 常用的Lua语法元素包括变量、表达式、控制结构、函数等。
脚本编译过程:
- 脚本编译的原理是将Lua脚本解析为一种中间表示形式(字节码)。
- 在编译过程中,会检查脚本的语法错误,并将脚本转换为可执行的字节码。
3、执行脚本
脚本执行的原子性:
- Redis Lua脚本具有原子性特点,即脚本中的所有操作要么全部执行成功,要么全部不执行。
- 这是因为Redis在执行脚本时会将脚本作为一个整体进行执行,不会被其它操作中断。
脚本执行的事务性:
- Redis事务是一种原子性的操作集合,可以将多个操作封装在一个事务中进行执行。
- 在Lua脚本中,可以使用Redis事务的命令(如MULTI、EXEC、WATCH等)来实现事务性操作。
二、Redis 中 Lua 脚本常用命令
2.1 Redis 中 Lua 脚本常用命令列表
Lua命令 | 描述 |
---|---|
EVAL script numkeys key [key ...] arg [arg ...] |
执行 Lua 脚本。 |
EVALSHA sha1 numkeys key [key ...] arg [arg ...] |
执行 Lua 脚本。 |
SCRIPT LOAD script |
将脚本 script 添加到脚本缓存中,但并不立即执行这个脚本。 |
SCRIPT EXISTS script [script ... ] |
查看指定的脚本是否已经被保存在缓存当中。 |
SCRIPT KILL |
杀死当前正在运行的 Lua 脚本。 |
SCRIPT FLUSH |
从脚本缓存中移除所有脚本。 |
2.2 EVAL
命令
命令格式:
|
|
说明:
script
是第一个参数,是一段 Lua 5.1脚本程序,它会被运行在Redis 服务器上下文中,这段脚本不必(也不应该)定义为一个 Lua 函数。numkeys
参数用于指定键名参数的个数,如果 numkeys > 0 则从第三个参数开始的 numkeys 个参数为redis key。key [key ...]
,从 eval 的第三个参数开始算起,使用了numkeys
个键(key),表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用1为基址的形式访问(KEYS[1],KEYS[2]···)。arg [arg ...]
,参数,在 Lua 脚本中通过 ARGV[1], ARGV[2] 获取。
简单实例:
|
|
在 Lua 脚本中通过 redis.call()
来执行了 Redis 命令:
|
|
2.3 EVALSHA
命令
使用 EVAL
命令每次请求都需要传输 Lua 脚本主体(script body) ,若 Lua 脚本过长,不仅会消耗网络带宽,而且也会对 Redis 的性能造成一定的影响。Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。
为了减少带宽的消耗, Redis 实现了 EVALSHA 命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)。
EVALSHA
的思路是将 Lua 脚本先缓存起来,返回给客户端 Lua 脚本的 sha1 摘要。客户端存储脚本的 sha1 摘要,每次请求执行 EVALSHA
命令即可。
EVALSHA
命令的表现如下:
- 如果服务器还缓存了给定的 SHA1 校验和所指定的脚本,那么执行这个脚本
- 如果服务器未缓存给定的 SHA1 校验和所指定的脚本,那么它返回一个特殊的错误,提醒用户使用 EVAL 代替 EVALSHA
EVALSHA
命令基本语法如下:
|
|
示例:
|
|
客户端库的底层实现可以一直乐观地使用 EVALSHA
来代替 EVAL
,并期望着要使用的脚本已经保存在服务器上了,只有当 NOSCRIPT 错误发生时,才使用 EVAL
命令重新发送脚本,这样就可以最大限度地节省带宽。
这也说明了执行 EVAL
命令时,使用正确的格式来传递键名参数和附加参数的重要性:因为如果将参数硬写在脚本中,那么每次当参数改变的时候,都要重新发送脚本,即使脚本的主体并没有改变,相反,通过使用正确的格式来传递键名参数和附加参数,就可以在脚本主体不变的情况下,直接使用 EVALSHA
命令对脚本进行复用,免去了无谓的带宽消耗。
2.4 其它Lua常用命令
1、SCRIPT LOAD
|
|
该命令将脚本 script 添加到Redis服务器的脚本缓存中,并不立即执行这个脚本,而是会立即对输入的脚本进行 SHA1 求值。并返回给定脚本的 SHA1 校验和。如果给定的脚本已经在缓存里面了,那么不执行任何操作。
2、SCRIPT EXISTS
|
|
给定一个或多个脚本的 SHA1 校验和,返回一个包含 0 或 1 的列表,表示校验和所指定的脚本是否已经被保存在缓存当中。
3、SCRIPT KILL
|
|
杀死当前正在运行的 Lua 脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。 这个命令主要用于终止运行时间过长的脚本,比如一个因为 BUG 而发生无限 loop 的脚本,诸如此类。
假如当前正在运行的脚本已经执行过写操作,那么即使执行SCRIPT KILL
,也无法将它杀死,因为这是违反 Lua 脚本的原子性执行原则的。在这种情况下,唯一可行的办法是使用SHUTDOWN NOSAVE
命令,通过停止整个 Redis 进程来停止脚本的运行,并防止不完整(half-written)的信息被写入数据库中。
4、SCRIPT FLUSH
|
|
该命令清除Redis服务端所有 Lua 脚本缓存。
三、Redis 与 Lua 的交互
3.1 Lua中执行redis命令
在 Lua 脚本中,可以使用两个不同函数来执行 Redis 命令,它们分别是:
redis.call()
redis.pcall()
redis.call()
和 redis.pcall()
两个函数的参数可以是任何格式正的 Redis 命令:
|
|
上面这段脚本的确实现了将键 foo 的值设为 bar 的目的,但是,它违反了 EVAL 命令的语义,因为脚本里使用的所有键都应该由 KEYS 数组来传递,就像这样:
|
|
要求使用正确的形式来传递键(key)是有原因的,因为不仅仅是 EVAL
这个命令,所有的 Redis 命令,在执行之前都会被分析,以此来确定命令会对哪些键进行操作。
因此,对于 EVAL
命令来说,必须使用正确的形式来传递键,才能确保分析工作正确地执行。除此之外,使用正确的形式来传递键还有很多其它好处,它的一个特别重要的用途就是确保 Redis 集群可以将你的请求发送到正确的集群节点。不过,这条规矩并不是强制性的,从而使得用户有机会滥用(abuse) Redis 单实例配置(single instance configuration),代价是这样写出的脚本不能被 Redis 集群所兼容。
redis.call()
和 redis.pcall()
的唯一区别在于它们对错误处理的不同。
当 redis.call()
在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因:
|
|
与 redis.call()
不同,redis.pcall()
出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误:
|
|
3.2 Lua 数据类型和 Redis 数据类型之间转换
当 Lua 通过 redis.call()
或 redis.pcall()
函数执行 Redis 命令的时候,命令的返回值会被转换成 Lua 数据结构。同样地,当 Lua 脚本在 Redis 内置的解释器里运行时,Lua 脚本的返回值也会被转换成 Redis 协议(protocol),然后由 EVAL
将值返回给客户端。
数据类型之间的转换遵循这样一个设计原则:如果将一个 Redis 值转换成 Lua 值,之后再将转换所得的 Lua 值转换回 Redis 值,那么这个转换所得的 Redis 值应该和最初时的 Redis 值一样。换句话说,Lua 类型和 Redis 类型之间存在着一一对应的转换关系。
以下列出的是详细的转换规则:
- 从 Redis 转换到 Lua :
|
|
- 从 Lua 转换到 Redis:
|
|
从 Lua 转换到 Redis 有一条额外的规则,这条规则没有和它对应的从 Redis 转换到 Lua 的规则:
|
|
示例:
|
|
3.3 编写Lua脚本
|
|
四、Lua脚本应用场景
4.1 复杂事务操作
在Redis中,事务可以保证一组命令的原子性执行,但有时候需要执行的操作比较复杂,难以用单个命令或者事务来实现。这时候就可以通过Lua脚本来编写复杂的事务逻辑,保证这些操作的原子性。
示例:分布式锁实现
|
|
4.2 定制化数据处理
有时候需要对Redis中的数据进行一些非常规的处理,如数据过滤、转换、聚合等,这时候可以通过Lua脚本来实现定制化的数据处理逻辑。
示例:统计集合中满足条件的元素个数
|
|
4.3 原子性操作
通过Lua脚本可以保证一系列操作的原子性,避免了因为执行多个命令而可能出现的并发问题,确保数据的一致性。
示例:计数器的原子操作
|
|
4.4 复杂数据结构操作
有时候需要对Redis中的复杂数据结构进行操作,如列表、哈希、有序集合等,这时候可以通过Lua脚本来编写复杂的数据操作逻辑,保证操作的原子性和一致性。
示例:哈希表中值的批量更新
|
|
4.5 使用Redis+Lua实现限流
|
|
五、Redis 中的 Lua脚本最佳实践
Lua 脚本在 Redis 中的应用十分灵活,但为了确保脚本的可靠性、性能和安全性,我们需要遵循一些最佳实践。
5.1 参数传递
Lua脚本支持通过KEYS和ARGV参数来传递键名和参数值。在编写脚本时,应该充分利用这两个参数来传递脚本所需的数据,而不是在脚本中直接硬编码数据。
|
|
5.2 错误处理
在 Lua 脚本中,应该充分考虑可能出现的异常情况,并进行相应的错误处理。可以使用 redis.error_reply()
函数来返回错误信息,以便在调用脚本时进行处理。
|
|
5.3 脚本优化
为了提高 Lua 脚本的性能,应该尽量减少不必要的命令调用和循环次数,尽量简化脚本的逻辑。可以将多个操作合并为一个,减少通信开销,提高执行效率。
|
|
5.4 原子性保证
Lua 脚本在 Redis 中的执行是原子性的,但需要注意的是,脚本内部的多个命令执行并不能保证原子性,需要根据具体场景来保证一系列操作的原子性。
|
|
5.5 脚本复用
为了提高代码的复用性和可维护性,可以将常用的 Lua 脚本封装为函数,然后在需要的地方进行调用。
|
|
5.6 安全性考虑
在编写Lua脚本时,需要考虑脚本的安全性,避免出现可能导致安全漏洞的代码。尤其是在接收外部输入时,应该进行参数验证和过滤,防止注入攻击等安全问题。
|
|
5.7 全局变量保护
为了防止不必要的数据泄漏进 Lua 环境,Redis Lua脚本不允许创建全局变量。如果一个 Lua脚本需要在多次执行之间维持某种状态,它应该使用 Redis key 来进行状态保存。
企图在脚本中访问一个全局变量(不论这个变量是否存在)将引起脚本停止,EVAL
命令会返回一个错误:
|
|
Lua 的 debug 工具,或者其它设施,比如打印(alter)用于实现全局保护的 meta table ,都可以用于实现全局变量保护。
实现全局变量保护并不难,不过有时候还是会不小心而为之。一旦用户在脚本中混入了 Lua 全局状态,那么 AOF 持久化和复制(replication)都会无法保证,所以,请不要使用全局变量。
避免引入全局变量的一个诀窍是:将脚本中用到的所有变量都使用 local 关键字定义为局部变量。
5.8 Lua脚本中记录Redis日志
在 Lua 脚本中,可以通过调用 redis.log 函数来写 Redis 日志(log):
|
|
其中, message
参数是一个字符串,而 loglevel
参数可以是以下任意一个值:
redis.LOG_DEBUG
redis.LOG_VERBOSE
redis.LOG_NOTICE
redis.LOG_WARNING
上面的这些等级(level)和标准 Redis 日志的等级相对应。
对于Lua脚本写的日志,只有那些和当前 Redis 实例所设置的日志等级相同或更高级的日志才会被记录。
以下是一个日志示例:
|
|
执行上面的函数会产生这样的信息:
|
|
5.9 沙箱(sandbox)和最大执行时间
脚本应该仅仅用于传递参数和对 Redis 数据进行处理,它不应该尝试去访问外部系统(比如文件系统),或者执行任何系统调用。
除此之外,脚本还有一个最大执行时间限制,它的默认值是 5 秒钟,一般正常运作的脚本通常可以在几分之几毫秒之内完成,花不了那么多时间,这个限制主要是为了防止因编程错误而造成的无限循环而设置的。
最大执行时间的长短由 lua-time-limit
选项来控制(以毫秒为单位),可以通过编辑 redis.conf 文件或者使用 CONFIG GET parameter
和 CONFIG SET parameter value
命令来修改它。
当一个脚本达到最大执行时间的时候,它并不会自动被 Redis 结束,因为 Redis 必须保证脚本执行的原子性,而中途停止脚本的运行意味着可能会留下未处理完的数据在数据集(data set)里面。
因此,当脚本运行的时间超过最大执行时间后,以下动作会被执行:
- Redis 记录一个脚本正在超时运行
- Redis 开始重新接受其它客户端的命令请求,但是只有
SCRIPT KILL
和SHUTDOWN NOSAVE
两个命令会被处理,对于其它命令请求, Redis 服务器只是简单地返回BUSY
错误。 - 可以使用
SCRIPT KILL
命令将一个仅执行只读命令的脚本杀死,因为只读命令并不修改数据,因此杀死这个脚本并不破坏数据的完整性 - 如果脚本已经执行过写命令,那么唯一允许执行的操作就是
SHUTDOWN NOSAVE
,它通过停止服务器来阻止当前数据集写入磁盘
5.10 流水线(pipeline)上下文(context)中的 EVALSHA
在流水线请求的上下文中使用 EVALSHA
命令时,要特别小心,因为在流水线中,必须保证命令的执行顺序。
一旦在流水线中因为 EVALSHA
命令而发生 NOSCRIPT 错误,那么这个流水线就再也没有办法重新执行了,否则的话,命令的执行顺序就会被打乱。
为了防止出现以上所说的问题,客户端库实现应该实施以下的其中一项措施:
- 总是在流水线中使用
EVAL
命令 - 检查流水线中要用到的所有命令,找到其中的
EVAL
命令,并使用SCRIPT EXISTS sha1 sha1 …
命令检查要用到的脚本是不是全都已经保存在缓存里面了。如果所需的全部脚本都可以在缓存里找到,那么就可以放心地将所有EVAL
命令改成EVALSHA
命令,否则的话,就要在流水线的顶端(top)将缺少的脚本用SCRIPT LOAD script
命令加上去。
5.11 redis客户端执行Lua脚本
|
|
--eval
而不是命令模式中的 “eval”,一定要有前端的--
,- 脚本路径后紧跟
key [key …]``,相比命令行模式,少了numkeys
这个key数量值。 key [key …]
和arg [arg …]
之间的,
英文逗号前后必须有空格,否则死活都报错