dot Redis 8 来了——而且是开源的

了解更多

RedisPy 中 Lua 脚本的防错处理

Lua 脚本是 Redis 的一个非常强大的功能。但与此同时,Lua 脚本可能很难“正确使用”。

有很多方法可以运行一个 大部分时间都有效的脚本,也可以理解为一个 有时会失败的脚本。让我们看看 Python 中 RedisPy 的一个 Lua 脚本的极端情况,即使您的脚本看起来是正确的,也可能会失败。当然,最后,我们将向您展示在您需要 始终有效时,如何进行防错处理。

Lua 脚本处理基础知识

我们需要回顾 Redis 的 Lua 脚本引擎以及您如何运行脚本。首先也是最基本的是 EVAL。此命令接受 完整的 Lua 源代码,然后是键的数量、键,最后是传递到脚本中的任何参数。一遍又一遍地发送源代码是带宽的浪费,因此 SCRIPT LOAD 允许您发送一次 Lua 源代码,并接收一个 SHA-1 摘要,您可以使用该摘要来识别和运行此脚本 EVALSHA。此命令的功能与 EVAL 完全一样,但指向 SHA-1 摘要。Redis 为脚本使用完全独立的、无键空间的缓存:EVAL 和 SCRIPT LOAD 都采用源代码,对其进行编译,并将字节码表示形式存储在缓存中,但 EVAL 首先检查脚本缓存,因此如果脚本已存储在脚本缓存中,则不会触发脚本的重新编译。

EVALSHA 的问题是,如果您尝试运行脚本缓存中不存在的脚本,您将收到以下错误

(error) NOSCRIPT No matching script. Please use EVAL.

如果您不想立即运行脚本,您可以使用 SCRIPT EXISTS 命令来查看给定的 SHA-1 摘要是否表示缓存的脚本。实际上,在 Redis 中使用 Lua 脚本的应用程序始终需要准备好随时向 Redis 提供完整的 Lua 源代码,并且无法确保给定的脚本永远不会从脚本缓存中驱逐。

Lua RedisPy 的一部分

RedisPy 是一个功能齐全的客户端库,可以提高 Redis 中 Lua 的人体工程学,让我们看看它如何使您的生活更轻松。首先,在大多数情况下,您不必自己管理 SHA-1 哈希或加载脚本,RedisPy 以简洁的 Pythonic 风格将其抽象出来。让我们从 Python REPL 来看一下

>> import redis
>>> r = redis.Redis()
>>> mylua = """
... return "hello world"
... """
>>> hello = r.register_script(mylua)
>>> hello()
b'hello world'

从 Redis 的角度来看,这里发生了什么?如果您在逐行输入此脚本时运行 MONITOR,您只会调用 hello() 时才会看到活动。输出应该如下所示(如果您的脚本缓存为空):

"EVALSHA" "0a4e337ee79a86930eb054981e3acc8a22d0674d" "0"
"SCRIPT" "LOAD" "nreturn "hello world"n"
"EVALSHA" "0a4e337ee79a86930eb054981e3acc8a22d0674d" "0"

这显示了 RedisPy 正在做的事情:尝试运行脚本,获得 NOSCRIPT 错误,加载脚本,然后再次运行 EVALSHA。如果您再次运行 hello(),它将仅显示一个 EVALSHA。因此,您可以看到此抽象正在为您节省一些代码,并使您的代码更具可读性。

RedisPy 还为您提供围绕流水线和事务的抽象。您创建一个流水线对象,然后像往常一样执行您的操作。为了演示,首先,我将从 redis-cli 运行 SCRIPT FLUSH(它会清空整个脚本缓存),然后再回到 REPL:

>> import redis
>>> mylua = """
... return "hello world"
... """
>>> r = redis.Redis()
>>> mypipeline = r.pipeline()
>>> mypipeline.get('foo')
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> hello = mypipeline.register_script(mylua)
>>> hello()
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> mypipeline.execute()
[None, b'hello world']

一个可能的困惑点是,流水线和事务是两个非常不同的东西,但 RedisPy 对两者使用相同的构造:尽管有“流水线”一词,但脚本中的命令将作为 MULTI/EXEC 事务运行。如果您要将 r.pipeline() 更改为 r.pipeline(transaction=False),您将拥有一个流水线。(这不是我会选择这样做的方式,但无数行的 Python 已经走过这条路。)

那么,在 Redis 端发生了什么?当您在 mypipeline.execute()MONITOR 将显示以下内容:

"SCRIPT" "EXISTS" "0a4e337ee79a86930eb054981e3acc8a22d0674d"
"SCRIPT" "LOAD" "nreturn "hello world"n"
"MULTI"
"GET" "foo"
"EVALSHA" "0a4e337ee79a86930eb054981e3acc8a22d0674d" "0"
"EXEC"

这里发生的事情比我们之前的例子更复杂。实际上,如果您查看 RedisPy 的源代码,它正在做一些非常聪明的事情。因为在流水线或事务中,您在所有操作完成之前无法获得回复,RedisPy 正在检查将要执行的脚本的 存在性 (SCRIPT EXISTS) 在它开始事务 之前。如果该脚本尚未存在于缓存中,则 RedisPy 使用 SCRIPT LOAD 来缓存该脚本以供以后执行。只有在此过程完成后,它才会开始事务。在事务中间,我们使用 EVALSHA 来调用脚本。

偷偷摸摸的并发

现在,我们都知道许多客户端可以连接到单个 Redis 服务器。虽然 Redis 在很大程度上是单线程的,但它使用事件循环,因此它一次只做一件事,但这件事可能不是 您的 一件事。在 MULTI/EXEC 事务或 Lua 脚本 内部的命令之外,无法保证您的应用程序快速发送的命令将由 Redis 原子地执行。其他连接的客户端可能会在您的应用程序发送的内容 之间插入命令。

让我们看看 RedisPy 中脚本检查/加载序列的真正作用:

  1. Python 发送“SCRIPT EXISTS”和参数
  2. Redis 执行该命令并向 Python 返回 0
  3. 当 Python 正在评估返回值时,Redis 执行其他操作
  4. Python 发送“SCRIPT LOAD”和参数
  5. Redis 执行该命令并返回 SHA-1 哈希
  6. 当 Python 评估返回值并准备发送 MULTI/EXEC 块时,Redis 执行其他操作
  7. Python 发送 MULTI/EXEC 块
  8. Redis 执行该块并返回 MULTI/EXEC 块的结果

请注意,在步骤 3 和 6 中,Redis 正在“执行其他操作”。这可能只是一个空闲循环,也可能是为其他应用程序提供其他命令。Redis 无法执行任何其他操作的地方是在步骤 8 中。现在,步骤 3 和 6 通常消耗非常短的时间(微秒或毫秒),但仍然是非零值。

由于 Redis 正在执行其他操作,因此在此过程中可能会发生其他“事情”。

例如,假设您正在运行一个连接到同一 Redis 服务器的混乱脚本。 由于某些未知原因,此脚本正在运行 SCRIPT FLUSH 命令的紧密循环。 因此,在我们上面的逐步示例中,步骤 6 中提到的“其他东西”可能是 SCRIPT FLUSH。 尽管刚刚进行了 SCRIPT LOAD,但 MULTI/EXEC 块可能会在脚本丢失的情况下开始运行。

一个混乱的 SCRIPT FLUSH 脚本并不是唯一可能出现此问题的场景。 假设您正在运行大量 Lua 脚本(通常不是最佳实践,但确实会发生),并且脚本缓存中的空间很紧张。 然后,另一个应用程序出现并执行类似的过程,并 SCRIPT LOAD 其脚本,并在您的 MULTI/EXEC 运行之前从缓存中逐出您的脚本。 您的情况与紧密循环 SCRIPT FLUSH 相同。

重要的是要注意 RedisPy 在这里没有做错任何事情。 没有机制可以在 MULTI/EXEC 事务中使用 EVALSHA,并确保脚本在开始之前存在。

尽管如此,所有这些场景的最终结果是,您得到一个有时有效,有时失败的 Lua 脚本,这非常可怕。 但是,这并不意味着您永远不应该在事务中运行 Lua。 有一种万无一失的方法可以做到这一点——承担带宽损失,并在事务内部运行带有完整 Lua 源代码的 EVAL。

让我们看看这种方法在 Python 中是如何实现的

>> mypipeline = r.pipeline()
>>> mypipeline.get('foo')
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> mypipeline.eval("""
... return "hello world"
... """, 0)
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> mypipeline.execute()
[None, b'hello world']

注意:如果您在单个事务中多次运行相同的 Lua 脚本,则可以安全地在 MULTI/EXEC 块中对同一脚本的后续调用使用 EVALSHA。 请记住,EVAL 仍然会缓存脚本。 这将节省您一些带宽,这肯定是一种非常罕见的情况。

到目前为止,我们一直在讨论 MULTI/EXEC 事务——那么 pipeline 中的 Lua 脚本呢? 在这种情况下,风险可能更加明显。 请记住,pipelines 不提供任何原子性保证,因此有更多机会发生混乱的 SCRIPT FLUSH 或缓存填充和驱逐。

RedisPy/Lua 脚本的结论是什么?

那么,您应该避免在事务或 pipelines 中使用 RedisPy 的符合人体工程学的 register_script 函数吗?

我不会走那么远。 真正的答案,虽然令人不满意,但取决于具体情况。

如果您的 Lua 脚本正在做一些关键任务,那么是的,咬紧牙关,接受带宽损失,并坚持使用旧的 EVAL。 但是,如果脚本正在做一些您可以通过查看事务或 pipeline 响应来稍后说明的事情,那就去做吧。 请记住,在这些 pipeline 和事务场景之外,register_script 以一种并非真正冒险的方式为您的代码提供了大量价值。