相关 Redis 教程系列

  1. 如何在 Ubuntu 18.04 上安装和保护 Redis
  2. 如何连接到 Redis 数据库
  3. 如何管理 Redis 数据库和 Keys
  4. 如何在 Redis 中管理副本和客户端
  5. 如何在 Redis 中管理字符串
  6. 如何在 Redis 中管理 List
  7. 如何在 Redis 中管理 Hashes
  8. 如何在 Redis 中管理 Sets
  9. 如何在 Redis 中管理 Sorted Sets
  10. 如何在 Redis 中运行事务
  11. 如何使 Redis 中的 Key 失效
  12. 如何解决 Redis 中的故障
  13. 如何从命令行更改 Redis 的配置
  14. Redis 数据类型简介
  15. Redis 中如何使用 Lua 脚本
  16. Redis 常用命令指南

Redis 中如何使用 Lua 脚本

Lua:Redis 用户指南

您可能听说过 Redis 具有嵌入式脚本语言,但尚未尝试过?以下是您在 Redis 服务器上使用 Lua 功能时需要了解的核心内容。

你好,Lua!

我们的第一个 Redis Lua 脚本仅返回一个值,并未与 Redis 进行任何实质性的交互:

local msg = "Hello, World!"
return msg

这很简单。第一行使用消息设置了一个局部变量,第二行将该值从 Redis 服务器返回给客户端。将此文件另存为 hello.lua 并按以下方式运行:

redis-cli --eval hello.lua

连接说明

redis-cli 示例假定您在本地运行 Redis 服务器。

运行此命令将打印 "Hello, World!"EVAL 命令的第一个参数是完整的 Lua 脚本(在这里,我们使用 cat 命令从文件中读取脚本)。第二个参数是脚本将访问的 Redis Key 的数量。我们简单的 "Hello World" 脚本不访问任何键,因此我们使用 0

访问键和参数

假设我们正在构建一个 URL 缩短器。每次出现 URL 时,我们都希望存储它并返回一个唯一的数字,该数字可用于以后访问 URL。

我们将使用 Lua 脚本从 Redis 获取唯一的 ID(使用 INCR),然后立即将 URL 存储在以唯一 ID 为键的哈希(Hash)中:

local link_id = redis.call("INCR", KEYS[1])
redis.call("HSET", KEYS[2], link_id, ARGV[1])
return link_id

我们在此处首次使用 call() 函数访问 Redis。call() 的参数是发送给 Redis 的命令:首先是 INCR <key>,然后是 HSET <key> <field> <value>。这两个命令将按顺序运行——该脚本执行时 Redis 不会执行任何其他操作,并且运行速度非常快。

我们正在访问两个 Lua 表(Table):KEYSARGV。表是关联数组,是 Lua 构造数据的唯一机制。出于我们的目的,您可以将它们视为您最熟悉的任何语言中的数组,但请注意,这两种 Lua 特性常使刚接触该语言的人们困惑:

  • 表是基于 1 的索引(1-based indexing):索引从 1 开始。因此,mytable 的第一个元素是 mytable[1],第二个元素是 mytable[2],依此类推。
  • 表不能包含 nil 值:如果某个操作产生的表为 [ 1, nil, 3, 4 ],则结果将为 [ 1 ]——该表在第一个 nil 值处被截断

调用此脚本时,还需要传递 KEYSARGV 表的值。在原始 Redis 协议中,命令如下所示:

EVAL $incrset.lua 2 links:counter links:url https://blog.jsdiff.com/

调用 EVAL 时,在脚本之后,我们提供脚本将要访问的 KEYS 数目(此处为 2),然后列出我们的 KEYS,最后为 ARGV 提供值。

通常,当我们使用 Redis Lua 脚本构建应用程序时,Redis 客户端库将负责指定键数。上面的代码块是出于完整性考虑而显示的,以下是在命令行上执行此操作的更简单方法:

redis-cli --eval incrset.lua links:counter links:urls , https://blog.jsdiff.com/

当使用 --eval 时,逗号用于分隔 KEYSARGV 项目。

为了清楚起见,这是我们的原始脚本,这次将 KEYSARGV 展开了:

local link_id = redis.call("INCR", "links:counter")
redis.call("HSET", "links:urls", link_id, "https://blog.jsdiff.com")
return link_id

为 Redis 编写 Lua 脚本时,应仅通过 KEYS 表访问所访问的每个键。ARGV 表用于传递参数——这是我们要存储的 URL 的值。

条件逻辑:递增与检查

上面的示例为我们的 URL 缩短器保存了链接,但是我们还需要跟踪 URL 的访问次数。为此,我们将在 Redis 的哈希中保留一个计数器。当用户附带一个链接标识符时,我们将检查它是否存在,如果存在则增加我们的计数器:

if redis.call("HEXISTS", KEYS[1], ARGV[1]) == 1 then
  return redis.call("HINCRBY", KEYS[1], ARGV[1], 1)
else
  return nil
end

每次有人单击短链接时,我们都会运行此脚本来跟踪该链接是否再次共享。我们使用 EVAL 调用脚本并传入 links:visits 作为我们的单个 Key,并将从上一个脚本返回的链接标识符作为单个参数传递。

没有哈希的脚本看起来几乎一样。这是一个仅在标准 Redis Key 存在的情况下递增它的脚本:

if redis.call("EXISTS", KEYS[1]) == 1 then
  return redis.call("INCR", KEYS[1])
else
  return nil
end

SCRIPT LOAD 与 EVALSHA

请记住,当 Redis 运行 Lua 脚本时,它将不会运行其他任何东西。最佳的脚本应尽可能简短,仅扩展 Redis 原有的原子操作,避免复杂逻辑。Lua 脚本中的错误可能导致 Redis 服务器阻塞——最好使内容简短并易于调试。

即使它们通常很短,我们也不必每次都想运行完整的 Lua 脚本。在实际的应用程序中,您将在应用程序启动时(或在部署时)向 Redis 注册每个 Lua 脚本,然后稍后通过其唯一的 SHA-1 标识符调用这些脚本。

redis-cli SCRIPT LOAD "return 'hello world'"
# "5332031c6b470dc5a0dd9b4bf2030dea6d65de91"

redis-cli EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
# "hello world"

在实时应用程序中,通常不需要显式调用 SCRIPT LOAD,因为 EVAL 隐式加载了传递给它的脚本。只有未找到脚本时,应用程序才能尝试 EVALSHA 乐观地返回到 EVAL

何时使用 Lua?

Redis 对 Lua 的支持与 WATCH / MULTI / EXEC 块有几分重叠,后者将操作分组以便它们被一起执行。那么,您如何选择一个使用另一个?MULTI 块中的每个操作都必须是独立的,但是对于 Lua 而言,后续操作可以取决于早期操作的结果。使用 Lua 脚本还可以避免使用 WATCH 时可能使客户端饿死的竞争状况。

Lua 内置库

Redis Lua 解释器加载七个库:basetablestringmathdebugcjsoncmsgpack。前几个是标准库,可让您执行任何语言所期望的基本操作。最后两个让 Redis 理解 JSON 和 MessagePack——这是一个非常有用的功能。

拥有公共 API 的 Web 应用通常广泛使用 JSON。因此,也许您在正常的 Redis Key 中存储了一堆 JSON Blob,并且您想访问其中的一些特定值,就像您将它们存储为哈希一样。借助 Redis JSON 支持,这很容易:

if redis.call("EXISTS", KEYS[1]) == 1 then
  local payload = redis.call("GET", KEYS[1])
  return cjson.decode(payload)[ARGV[1]]
else
  return nil
end

在这里,我们检查 Key 是否存在,如果不存在则快速返回 nil。然后,我们从 Redis 中获取 JSON 值,并使用 cjson.decode() 进行解析,然后返回请求的值。

redis-cli set apple '{ "color": "red", "type": "fruit" }'
# OK

redis-cli --eval json-get.lua apple , type
# "fruit"

将此脚本加载到 Redis 服务器中,可以将 Redis 中存储的 JSON 值视为哈希值。如果您的对象很小,那么即使我们必须解析每次访问的值,这实际上也相当快。

如果您正在为需要性能的系统开发内部 API,那么您可能会选择 MessagePack 而不是 JSON,因为它更小,更快。幸运的是,与 Redis(在大多数地方一样),MessagePack 几乎可以替代 JSON:

if redis.call("EXISTS", KEYS[1]) == 1 then
  local payload = redis.call("GET", KEYS[1])
  return cmsgpack.unpack(payload)[ARGV[1]]
else
  return nil
end

数字运算与类型转换

Lua 和 Redis 具有不同的类型系统,因此了解跨 Redis-Lua 边界时值如何变化很重要。当一个数字从 Lua 返回到 Redis 客户端时,它变成一个整数——小数点后的任何数字都将被删除:

local indiana_pi = 3.2
return indiana_pi

运行此脚本时,Redis 将返回 3 的整数——您会丢失有趣的 pi。看起来很简单,但是当您在脚本中间开始与 Redis 交互时,事情会变得有些棘手。一个例子:

local indiana_pi = 3.2
redis.call("SET", "pi", indiana_pi)
return redis.call("GET", "pi")

这里的结果值是一个字符串:"3.2"。为什么?Redis 没有专用的数字类型。当我们首次使用 SET 该值时,Redis 将其保存为字符串,从而丢失了 Lua 最初认为该值是浮点数这一事实的所有记录。当我们稍后提取值时,它仍然是一个字符串。

使用 GET / SET 进行访问的 Redis 中的值应视为字符串,除非像 INCRDECR 对它们进行数字操作时除外。这些特殊的数字运算实际上将返回整数答复(并根据数学规则处理存储的值),但是 Redis 中存储的值的“类型”仍然是字符串值。

Redis 使用 Lua 的常见错误

这些是我们在 Redis 中使用 Lua 时最常见的错误:

  • 索引从 1 开始:与大多数流行语言不同,表在 Lua 中是基于 1 的。KEYS 表中的第一个元素是 KEYS[1],第二个元素是 KEYS[2],依此类推。
  • nil 值截断表:nil 值终止 Lua 中的表。这样 [ 1, 2, nil, 3 ] 会自动成为 [1, 2]。不要在表中使用 nil 值。
  • 错误处理redis.call 遇到错误时会抛出 Lua 错误,同时 redis.pcall 会自动捕获所有错误并将其作为可检查的表返回。
  • 数字转换:Lua 数字在发送给 Redis 时会转换为整数——小数点后的所有内容都会丢失。返回之前,将所有浮点数转换为字符串。
  • 声明 Keys:请确保在表中指定您在 Lua 脚本中使用的所有键 KEYS,否则您的脚本可能会在 Redis 的未来版本中损坏(特别是在集群模式下)。
  • 原子性与阻塞:Lua 脚本与 Redis 中的任何其他操作一样:执行它们时,其他任何程序都不会运行。将脚本视为扩展 Redis 服务器词汇的一种方法——使它们简短明了。

进一步阅读

在线上有很多关于 Lua 和 Redis 的有用资源,以下是我使用的一些资源:

说明

本文内容基于 Redis 3.x 至 6.x 版本的 Lua scripting 特性。Redis 7.0 引入了新的 FUNCTION 命令用于管理 Lua 脚本库,但基本的 EVALEVALSHA 命令仍然兼容且广泛使用。