圆点 快速的未来正在进入你所在城市的某项活动。

在 Redis Released 上加入我们

在 RedisPy 中为 Lua 脚本提供保护

Lua 脚本是 Redis 功能强大的特性。同时,Lua 脚本可能很难“用对”。

有很多种方式来运行脚本,这些脚本在大多数情况下有效,也可以表达为偶尔失败的脚本。我们来看一下 Python 中的 Lua 脚本边缘案例,它使用的是带有 RedisPy 的 Redis,即使按照正确的做法执行,脚本也有可能失败。当然,最后,我们会向你展示一种万无一失的方法,这样你就可以始终使工作正常。

Lua 脚本处理基础知识

我们需要复习 Redis 的 Lua 脚本引擎以及如何运行脚本。首先也是最基本的EVAL。此命令接受完整的 Lua 源,后跟键的数量、键,最后是传给脚本的所有参数。不断发送源代码会浪费带宽,因此SCRIPT LOAD让你发送一次 Lua 源,并收到一个 SHA-1 摘要,以后你可以用它来标识此脚本并使用EVALSHA运行此脚本。此命令的功能与 EVAL 一样,但指向 SHA-1 摘要。Redis 为脚本使用完全单独的、不包含键空间的缓存:EVAL 和 SCRIPT LOAD 都获取源代码,将其编译,并将字节码表示存储在缓存中,但 EVAL 首先检查脚本缓存,所以如果脚本已经存储在脚本缓存中,它将不会触发脚本的重新编译。 

EVALSHA 的问题在于,如果你尝试运行一个在脚本缓存中不存在的脚本,你将收到以下错误:

(错误) 无匹配脚本。请使用 EVAL。

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

RedisPy 的 Lua 切片

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 还提供了流水线和事务相关的一个抽象概念。创建一个 pipeline 对象,然后正常执行你的操作。首先,我将运行 SCRIPT FLUSH(清空整个脚本缓存)从 redis-cli,然后再回到 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 的无数代码行已经走向了此 path。)

那么,Redis 侧发生了什么?当你按 mypipeline.execute() 上的 Return 键时,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 执行该命令并返回 0 给 Python
  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 加载其脚本并在你 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 事务——在 管道中使用 Lua 脚本的情况又如何呢?在这种情况下,风险可能更大。记住,管道不会提供任何原子保证,因此混乱的 SCRIPT FLUSHing 或缓存填充和驱逐会更容易发生。

RedisPy/Lua 脚本评估结果是什么?

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

我的回答不会那么极端。真正的情况不太令人满意,它取决于具体情况。

如果 Lua 脚本执行的是一项关键任务,那么是的,咬紧牙关,承受带宽费用,并坚持使用古老的 EVAL。但如果脚本执行的是一项稍后可通过查看事务或管道响应来解决的任务,那就去做吧。请记住,在这些管道和事务情形之外,register_script 会以一种不太有风险的方法为你的代码提供大量价值。