文章目录
- [Redis 脚本](#Redis 脚本)
-
- 运行脚本的两种方式
- 原子性与性能陷阱
- 只读脚本
- 沙箱与执行时间限制
- Lua脚本
-
- [基础用法:EVAL 命令](#基础用法:EVAL 命令)
- [参数传递机制:KEYS vs ARGV](#参数传递机制:KEYS vs ARGV)
- [深度解析:为什么必须区分 Keys 和 Args?(集群模式)](#深度解析:为什么必须区分 Keys 和 Args?(集群模式))
- [脚本与 Redis 的交互](#脚本与 Redis 的交互)
- 脚本缓存与优化
- 脚本复制模式
-
- [逐字复制(Verbatim Replication - 旧模式)](#逐字复制(Verbatim Replication - 旧模式))
- [效果复制(Effects Replication - 现代默认模式)](#效果复制(Effects Replication - 现代默认模式))
- [EVAL 标志位 (Redis 7.0+)](#EVAL 标志位 (Redis 7.0+))
- [其他 SCRIPT 命令](#其他 SCRIPT 命令)
- [Redis Functions](#Redis Functions)
- [Redis Lua API](#Redis Lua API)
Redis 脚本
Redis 提供了一个编程接口,允许你在服务器本身执行自定义脚本。在 Redis 7 及更高版本中,你可以使用 Redis 函数来管理和运行脚本。在 Redis 6.2 及更低版本中,你使用带有 EVAL 命令的 Lua 脚本进行编程。
- 核心价值 :
- 数据局部性:代码在数据所在的地方运行,无需通过网络将大量数据搬运到客户端处理后再传回。
- 原子性:脚本执行期间,服务器被阻塞,类似于事务,保证操作的完整性。
- 减少网络 RTT:一次脚本调用可以完成数十次 Redis 命令的操作。
- 封装业务逻辑:构建特定于应用程序的 API。
运行脚本的两种方式
Redis 提供了两种运行脚本的方式,这在架构设计上有着本质的区别。
-
Eval 脚本 (EVAL - Redis 2.6.0+)
- 模式:即发即用。客户端直接发送 Lua 源代码给 Redis 执行。
- 特点 :
- 代码跟随应用:脚本源代码存储在应用程序代码库中,Redis 只是做临时缓存。
- 缺点:随着应用规模扩大,代码维护变得困难。每次执行可能需要重新传输代码,或者依赖不稳定的缓存机制。
-
Redis 函数 (Redis Functions - Redis 7.0+)
- 模式:存储过程式。函数作为数据库的一等公民存在。
- 特点 :
- 代码跟随数据库:函数通过管理员操作(如加载模块一样)加载到服务器,所有客户端只需通过函数名调用。
- 优势:实现了脚本与应用逻辑的解耦。函数可以被独立地部署、测试和版本管理。
Functions模式 函数在服务端
上传函数库
调用函数名 f1
调用函数名 f1
已加载
管理员部署
Redis
App A
App B
函数库
优势: 逻辑集中管理, 易维护
EVAL模式 脚本在应用端
发送源码
发送源码
发送源码
缓存
App A
Redis
App B
App C
临时缓存
缺点: 每个应用都需要维护代码
原子性与性能陷阱
Redis 保证脚本执行的原子性。在脚本运行期间,阻塞 所有服务器活动。这意味着脚本的所有效果要么全部发生,要么都不发生。这种语义与 MULTI/EXEC 事务相同。
- 警告 :这种阻塞模式是针对所有连接的客户端的。如果你写了一个慢脚本,整个 Redis 实例将停摆,所有其他业务请求都会超时或报错。
- 原则:脚本运行通常在微秒级完成,但应避免在脚本中执行耗时算法。
只读脚本
Redis 7.0 引入了只读脚本的严格定义和优化。
- 定义:只执行不修改数据的命令。
- 触发方式 :使用
no-writes标志,或使用_RO后缀的命令(如EVAL_RO,FCALL_RO)。 - 高级特性 :
- 可在副本上执行:非常适合利用副本进行读扩展。
- 可安全杀死 :当脚本超时时,可以使用
SCRIPT KILL强制终止。 - 不受 OOM 阻塞:当 Redis 内存超限时,只读脚本不会失败(因为它们不分配数据)。
- 故障切换时可用:在主从切换导致写暂停期间,只读脚本仍可执行。
- 权限控制:可以配置 ACL 用户仅允许执行只读脚本。
注意:PUBLISH, SPUBLISH, PFCOUNT 在脚本中被视为写命令,因为它们涉及数据传播。
沙箱与执行时间限制
脚本在沙箱中运行。
- 限制:无法访问文件系统、网络或其他系统调用。
- 目的:防止恶意代码破坏服务器或窃取数据。
- 原则:脚本只能操作 Redis 内部的数据。
默认超时时间为 5 秒 (通过 busy-reply-threshold 配置)。当脚本超时后,Redis 的处理流程非常特殊:
- Redis 记录日志,警告脚本运行时间过长。
- Redis 开始接受其他客户端的命令,但回复
BUSY错误(除了特定的管理命令)。
如何终止超时脚本?
- 情况 1:脚本只读,尚未写入数据
- 操作 :
SCRIPT KILL或FUNCTION KILL。 - 结果:脚本被立即终止,服务恢复正常。
- 操作 :
- 情况 2:脚本已执行了写操作
- 操作 :
SCRIPT KILL失效。 - 原因:为了保证原子性,不能让脚本停在半路,否则数据状态会被破坏。
- 唯一解法 :
SHUTDOWN NOSAVE。 - 结果 :直接终止服务器进程,不保存当前数据集。这是为了数据一致性而牺牲可用性的最后手段。
- 操作 :
否
是
其他客户端请求
尝试杀死
否 (只读)
是 (已写)
必须执行
脚本开始执行
运行时间 > 5秒?
继续执行
进入 BUSY 状态
返回 BUSY 错误
脚本是否修改了数据?
SCRIPT KILL 成功
服务恢复
SCRIPT KILL 失败
违反原子性
SHUTDOWN NOSAVE
服务器重启,数据丢失
Lua脚本
Redis 允许用户在服务器端上传并执行 Lua 脚本。脚本可以使用程序化控制结构并调用大多数 Redis 命令来访问数据库。由于脚本在服务器内部执行,因此读写数据的效率非常高。
- 原子性保证 :Redis 保证脚本的原子执行。在脚本运行期间,整个服务器的所有活动都会被阻塞。这意味着脚本对数据产生的所有修改要么全部发生,要么都不发生。
- 核心优势 :
- 数据局部性:直接在数据所在的位置执行逻辑,减少网络延迟。
- 复杂逻辑封装:可以在服务器端实现简单的条件更新、跨键操作或组合多种数据类型的原子操作。
Redis 使用嵌入式的 Lua 5.1 解释器作为执行引擎。
基础用法:EVAL 命令
使用 EVAL 命令可以执行脚本。其基本语法如下:
text
EVAL script num_keys [key1 ... keyN] [arg1 ... argN]
- script: Lua 脚本的源代码字符串。
- num_keys : 关键参数 。指定后面紧跟的参数中有多少个是键名,剩下的则是普通参数。
- [key1 ... keyN]: 传递给脚本的键名。
- [arg1 ... argN]: 传递给脚本的普通参数。
参数传递机制:KEYS vs ARGV
在 Lua 脚本内部,可以通过两个全局变量访问参数:
- KEYS : 数组,包含了所有在
num_keys中指定的键名。 - ARGV: 数组,包含了所有剩余的普通参数。
bash
# 注意这里的 1,表示有 1 个 key (user:1000),后面是参数
> EVAL "return { KEYS[1], ARGV[1], ARGV[2] }" 1 user:1000 name "Alice"
1) "user:1000"
2) "name"
3) "Alice"
深度解析:为什么必须区分 Keys 和 Args?(集群模式)
在 Redis 集群模式下,数据是分片存储的。客户端发送命令时,必须明确知道哪些参数是 Key,以便根据 Key 计算出哈希槽,从而路由到正确的节点。 如果你在 Lua 脚本里动态拼接 Key 名,或者 num_keys 填写错误,会导致以下问题:
- 路由错误:Redis 无法确定脚本在哪里执行,可能发送到错误的节点。
- CROSSSLOT 错误:如果脚本试图访问不同节点上的 Key,Redis 会直接拒绝执行。
参数解析
计算哈希槽
客户端执行 EVAL
解析 num_keys=1
Keys: user:1000
Args: name Alice
Redis 集群节点 A
执行 Lua 脚本
KEYS[1]=user:1000
ARGV[1]=name...
脚本与 Redis 的交互
在 Lua 脚本中,主要通过两个函数调用 Redis 命令:
redis.call():- 遇 Redis 命令时,如果发生错误(例如对 String 类型执行
LPUSH),错误会直接返回给客户端,并中断脚本执行。
- 遇 Redis 命令时,如果发生错误(例如对 String 类型执行
redis.pcall():- 命令发生错误时,错误会被捕获并返回给 Lua 环境作为返回值,脚本不会中断,可以继续执行后续代码(类似于 try-catch)。
脚本缓存与优化
每次都传输完整的 Lua 源码会消耗网络带宽和 CPU。为此,Redis 提供了脚本缓存机制。 当 Redis 执行一个脚本时,它会计算脚本内容的 SHA1 摘要,并将其缓存在服务器内存中。
SCRIPT LOAD script: 将脚本加载到缓存,返回 SHA1 摘要。脚本不会立即执行。EVALSHA sha1 num_keys ...: 使用 SHA1 摘要执行缓存中的脚本。
缓存执行 EVALSHA
- 加载 SCRIPT LOAD 2. 返回 SHA1 3. 后续只发 SHA1 客户端
Redis
直接执行 EVAL
每次发送完整脚本
客户端
Redis
注意 :脚本缓存是易失 的。服务器重启、故障切换或手动执行 SCRIPT FLUSH 都会清空缓存。因此,智能的客户端库通常会在遇到 NOSCRIPT 错误时,自动回退到 EVAL 或重新加载脚本。
脚本复制模式
由于脚本可能修改数据,Redis 必须将这些修改同步到副本(Replica)。在 Redis 的发展过程中,有两种复制模式:
逐字复制(Verbatim Replication - 旧模式)
这是 Redis 5.0 之前的默认模式。
- 机制 :主节点将完整的 Lua 脚本代码发送给副本,副本重新执行该脚本。
- 要求 :脚本必须是确定性 的。因为副本会再次运行脚本,所以不能使用
TIME、RANDOMKEY、SRANDMEMBER或 Lua 的math.random(),否则主从数据会不一致。 - 缺点:浪费 CPU(副本也要计算一遍)。
效果复制(Effects Replication - 现代默认模式)
从 Redis 5.0 开始,并在 7.0 强制启用。
- 机制 :主节点执行完脚本后,只将脚本产生的实际 Redis 命令 (如
SET,INCR)打包成事务发送给副本。副本不需要执行 Lua 代码,只执行这些命令。 - 优势 :
- 允许非确定性 :可以使用
TIME等命令,因为主从只执行产生的SET命令。 - 节省 CPU:副本只需执行简单的 Redis 命令。
- 允许非确定性 :可以使用
Redis Replica Redis Master Client Redis Replica Redis Master Client 效果复制模式 (Redis 5.0+) 主节点执行 Lua 只打包产生的写命令 副本只执行 Redis 命令 无需重新计算 Lua EVAL script (含 TIME 命令) TIME ->> 获取当前时间 SET key value MULTI ... SET key value ... EXEC EXEC
EVAL 标志位 (Redis 7.0+)
默认情况下,Redis 认为脚本既读又写。从 Redis 7.0 开始,可以通过在脚本第一行添加 Shebang 注释来声明标志位,从而优化脚本行为。
lua
#!lua flags=no-writes,allow-stale
-- Lua 脚本内容
常用标志:
- no-writes: 声明脚本不修改数据(只读)。这允许脚本在副本上执行,或在内存溢出时继续运行。
- allow-stale: 允许脚本在从节点数据过期(Replication Offset 太大)时执行。
其他 SCRIPT 命令
SCRIPT FLUSH: 清空整个脚本缓存。仅用于多租户场景或测试。SCRIPT EXISTS sha1 [sha2 ...]: 检查给定的 SHA1 摘要是否存在于缓存中。SCRIPT KILL: 强制终止一个运行时间过长的慢脚本。注意:仅适用于尚未执行写命令的脚本,以免破坏原子性。SCRIPT DEBUG: 控制 Redis Lua 调试器。
Redis Functions
Redis Functions 是 Redis 7 引入的一套全新 API,用于在服务器端管理和执行自定义代码。它是对旧版 EVAL 脚本机制的全面升级,旨在解决代码管理、持久化和部署维护的痛点。Redis Functions 基于底层 Lua 引擎(未来可能支持其他语言),但通过"库"的概念进行了封装。
- 函数:一段具体的执行逻辑,有唯一的名字。
- 库:包含一个或多个函数的容器,不可变。更新库时必须整体替换。
开发实战:从编写到调用
- 编写 :Redis Functions 的代码必须以 Shebang 行开头,声明引擎(如 Lua)和库名。
lua
#!lua name=mylib
local function my_hset(keys, args)
-- keys[1] 是我们传入的第一个 Key (即 Hash 的名字)
local hash = keys[1]
-- 获取当前服务器时间
local time = redis.call('TIME')[1]
-- 执行 HSET: 插入时间戳,然后插入用户传入的字段
-- unpack(args) 将数组展开成独立参数
return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end
-- 将函数注册到库中
redis.register_function('my_hset', my_hset)
- 加载:
bash
# -x 表示从标准输入读取文件内容,REPLACE 表示覆盖同名旧库
redis-cli -x FUNCTION LOAD REPLACE < mylib.lua
- 调用:
bash
# 1 表示有 1 个 Key (user:1000),剩下的 "name" "Alice" 是普通参数
> FCALL my_hset 1 user:1000 name "Alice"
(integer) 2
> HGETALL user:1000
1) "_last_modified_"
2) "1678123456" <-- 自动添加的时间戳
3) "name"
4) "Alice"
集群环境下的部署
Redis Cluster 不会自动将函数同步到新节点。
-
操作 :需要管理员手动使用
redis-cli工具将函数加载到所有主节点。bashredis-cli --cluster-only-masters --cluster call host:port FUNCTION LOAD ... -
新节点 :使用
--cluster add-node时,会自动从现有节点复制函数库。
临时实例与冷启动
对于作为纯缓存使用的 Redis 实例(重启不加载 RDB/AOF),如果需要启动时就加载好函数,可以使用:
bash
redis-cli --functions-rdb
这个命令会导出一个只包含函数的 RDB 文件,供 Redis 启动时预加载。
Redis Lua API
详情请看官方文档。