Redis Lua 脚本的最新改进
原文由 Salvatore Sanfilippo 于 发布,订阅该博客
Lua 脚本可能是 Redis 最成功的功能之一,就那些在 Redis 已经相当流行之后才引入的功能而言:用户最想要的几项功能与脚本有关也就不足为奇了。过去两年里,下面这两项功能被多次提及,几周前的 Redis 开发者会议上,也有不少人试图让我把注意力集中在这其中某一项上。
- 为 Redis Lua 脚本提供一个真正可用的调试器。
- 将 Lua 脚本以一组体现脚本*效果*的写命令的形式进行复制并写入 AOF,而不是像通常那样直接复制脚本本身。
第二项功能不仅仅关乎脚本如何被复制,正如后文将看到的,它还会影响你能用 Lua 脚本做什么。
从伦敦回来后,我把这两项功能都实现了。这篇博文将对二者进行介绍,并就一些读者可能会感兴趣的设计与实现细节给出几点提示。
真正可用的 Lua 调试器
Lua 脚本最初的设计初衷是用来编写非常简单的脚本。比如:如果键存在就执行某个操作。就那么几行代码,目的是避免为了覆盖各种命令变体而让 Redis 变得臃肿。当然,用户用它做了远不止这些,开始编写复杂的脚本:从四叉树的实现,到具备复杂语义的完整消息系统。Lua 脚本让 Redis 变得可编程,而程序员通常无法抗拒可编程的东西。再加上所有 Lua 脚本都使用同一个解释器运行并会被缓存,执行速度非常快。在大多数情况下,借助 Lua 脚本,单个 Redis 实例无论在功能上还是在每秒操作数上都能做更多事情。因此,如今复杂脚本完全有其存在的价值。短短几年内,我们对脚本功能的态度就经历了从最初的冷淡(把如此动态的脚本发给数据库!),到广泛使用,再到编写复杂脚本的转变。
然而,编写简单脚本和编写复杂脚本完全是两回事。程序越大,复杂度就呈指数级增长,即使只是从 10 行增加到 200 行,你也能明显感受到这一点。对于简单的脚本,你完全可以靠笨办法调试——多试几个版本、观察对数据集的影响,或在中间加几行日志就行;但面对复杂的脚本,没有调试器会让你寸步难行。
我的同事 Itamar Haber 最近花了大量时间编写复杂的脚本。他曾一度还利用 Lua 的 debug 库为 Redis Lua 脚本写过一个调试器。由于出于沙箱安全的考虑,debug 库现在已不再向脚本开放,这个调试器已经无法使用。而且一般来说,你在 Redis 调试器中想要的是一个交互式的远程调试器,配有一个能与服务端协同工作的、像样的客户端,以提供良好的调试体验。调试本身就已经足够艰辛,拥有可靠的工具更是必不可少。要实现这一目标,唯一的办法就是在 Redis 内部直接加入完善的调试支持。
所以从伦敦回来后,我和 Itamar 开始讨论调试器应该向用户暴露哪些功能,才能算得上真正有用、相比过去有实质提升。我们也讨论过直接为 Redis 生态之外已有的 Lua 调试器添加支持。不过我坚信,当一切都为与 Redis 良好协作而专门设计时,用户体验会更好,所以最终我决定从零开始编写这个调试器。有几点是确定的:我们需要一个远程调试器,可以连接到 Redis、开启调试会话,并能清晰地观察脚本对 Redis 数据集做了什么。当然,我还特别在意要有彩色输出 ;-) 我想让调试变得有趣,并且上手极快——这两点是相辅相成的。
要通过写博文来展示调试器如何工作,当然是可行的,但即使是像我这样坚持用等宽字体写文章的纯粹主义者,偶尔也会借助视频。所以下面这段稍长的视频展示了 Redis 调试器的主要功能。开头我的语气听起来有点低落,因为那是一大早录的,不过几分钟后咖啡起了作用,你会看到我变得精神多了。
(提示:请全屏观看视频,以获得交互会话中合适的字体大小。视频清晰度足够高,可以看清文字)
如果你不想看视频,Lua 调试器帮助界面本身就简要概括了它能做什么:
$ ./redis-cli --ldb --eval /tmp/script.lua
Lua debugging session started, please use:
quit -- End the session.
restart -- Restart the script in debug mode again.
help -- Show Lua script debugging commands.
* Stopped at 1, stop reason = step over
-> 1 local src = KEYS[1]
lua debugger> help
Redis Lua debugger help:
[h]elp Show this help.
[s]tep Run current line and stop again.
[n]ext Alias for step.
[c]continue Run till next breakpoint.
[l]list List source code around current line.
[l]list [line] List source code around [line].
line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
to show before/after [line].
[w]hole List all source code. Alias for 'list 1 1000000'.
[p]rint Show all the local variables.
[p]rint <var> Show the value of the specified variable.
Can also show global vars KEYS and ARGV.
[b]reak Show all breakpoints.
[b]reak <line> Add a breakpoint to the specified line.
[b]reak -<line> Remove breakpoint from the specified line.
[b]reak 0 Remove all breakpoints.
[t]race Show a backtrace.
[e]eval <code> Execute some Lua code (in a different callframe).
[r]edis <cmd> Execute a Redis command.
[m]axlen [len] Trim logged Redis replies and Lua var dumps to len.
Specifying zero as <len> means unlimited.
[a]abort Stop the execution of the script. In sync
mode dataset changes will be retained.
Debugger functions you can call from Lua scripts:
redis.debug() Produce logs in the debugger console.
redis.breakpoint() Stop execution like if there was a breakpoing.
in the next line of code.
lua debugger>它是如何工作的?
整个调试器基本上是一块自包含的代码,共约 1300 行,主要位于 scripting.c 中,少部分在 redis-cli.c 里,用于实现 CLI 中作为调试器客户端的特殊模式。如前所述,这是一个服务端-客户端架构的远程调试器。
Lua C API 提供了一个相当有用的调试接口。它本身不是调试器,但提供了编写调试器所需的原语。不过在 Redis 的上下文中编写调试器,要比编写一个独立的 Lua 调试器稍微复杂一些。为了调试脚本,你需要在脚本运行时执行回调。但当 Redis 正在运行脚本时,我们正处于执行客户端命令 EVAL 的上下文中。如果在这里被阻塞了,该如何进行 I/O 呢?Redis 服务器又会怎样?即便调试本应在开发环境而非生产环境中进行,完全挂起整个实例也未必是个好主意。也许其他开发者也想使用这个实例,或者正在调试脚本的这位开发者想另开一个并行的调试会话。最后,如何回滚变更,以便无论调试过程中脚本做了什么修改,都能用同一份 Redis 数据集反复测试同一个脚本?在调试的场景下,确定性是无价的。
看起来,我需要一个复杂的实现。或者,我可以大幅取巧,找到一个代码量和复杂度都少得多、却能带来 90% 预期收益的奇特方案。而这个奇特的方案就是:
- 调试会话开始时,fork() Redis。
- 捕获客户端的文件描述符,在调试会话期间直接进行阻塞式 I/O。
- 使用 Redis 协议,但只用一个极其简单的子集,几行代码就能实现,因此完全不需要重新进入 Redis 的事件循环。I/O 本身就是调试器的一部分。
写了 400 行代码后,所有基础功能就都跑通了,剩下的只是添加功能、修复缺陷和处理边界情况。
这为我们提供了一切所需:由于每个调试会话都是一个独立的进程,服务器不会被阻塞。我们无需在 EVAL 调用中途重新进入事件循环,还能免费获得回滚能力。
不过,也提供了一种会阻塞服务器的同步模式,以防你确实需要在保留对数据集修改的情况下进行调试。我感觉这种模式不会被经常使用,但实现它只需做到不 fork 并在结束时处理好客户端的清理工作,所以我也一并加上了。
在此基础上,利用 Lua 的“行”钩子来实现单步执行和断点就成为可能,其他功能也都能加上。由于调试器直接集成在 Redis 内部,捕获所有 Redis 调用并向用户展示发生了什么就变得轻而易举。I/O 模型也非常简单,我们只需读取用户输入,并将输出追加到缓冲区。每当调试器在某处暂停时,输出就会作为一组“状态回复”刷新给客户端。每行的前缀会提示 redis-cli 应该使用何种颜色。
得益于这种设计,调试器在两天内就跑了起来,四天内就已完成。此外,这种设计让我得以编写完全自包含的代码,调试器与 Redis 的其余部分几乎没有任何交互。这使得它能够随 Redis 3.2 在 12 月一起发布成为可能。
一个几乎零成本却能让调试器强大得多的方法,是新增两个 Redis Lua 调用:redis.breakpoint() 和 redis.debug(),它们分别可以在调试器中模拟一个断点(在下一行代码处暂停),或在调试器控制台中打印 Lua 对象。这样,你就可以添加仅在发生特定情况时才触发的断点:
if some_odd_condition then redis.breakpoint() end这实际上替代了你可能会为调试器添加的许多复杂功能。不过,我们在调试器内部也直接提供了所有常规功能,比如静态断点、观察状态的能力等等。
我非常期待编写复杂脚本的用户会如何评价它!拭目以待。
脚本效果复制
在理解为何仅复制脚本的*效果*很有意义之前,最好先弄清楚为什么默认直接复制脚本本身、让从节点重新执行,会被认为是最佳选择——而且目前也仍是默认行为。核心问题在于:主从之间的带宽,以及从节点能否“跟上”主节点(保持同步而不产生过大延迟)、不掉队。
考虑这样一个小型的 Redis Lua 脚本:
local i;
for i=0,1000000 do
redis.call(“lpush”,KEYS[1],ARGV[1])
end它向指定列表追加 100 万个元素,在我的测试环境中运行耗时 0.75 秒。这个脚本本身只有几个字节,并在服务器内部运行,因此将其作为脚本来复制,而不是作为执行后产生的 100 万条命令来复制,是非常合理的。
也有完全相反的脚本。在另一端,有这样一个脚本:它计算存储在一个列表中的 100 万个整数元素的平均值,并用 SET 将结果写入某个键。
脚本的效果可能仅仅是:SET somekey 94.29
但实际执行却可能需要 2 秒的计算时间。将这个脚本以其结果命令的形式进行复制要好得多。不过,复制脚本与复制效果之间存在一个区别:两者在不同用例下各有优劣,但复制脚本始终是可行的,即使效率不高。它永远不会导致复制链路需要传输海量数据,也不会让从节点比主节点做更多的工作。这就是为什么迄今为止 Redis 总是原样复制脚本的原因。
然而,也许最有意思的一点在于,这不仅仅是效率问题。当复制脚本时,我们要求每个脚本都是*纯函数*。使用相同初始数据集执行的脚本,必须始终产生相同的结果。这一要求使得用户无法编写例如使用 TIME 命令或 SRANDMEMBER 的脚本。Redis 会检测到这种危险情况,并在即将调用第一个写命令时中止脚本。
然而,使用当前时间、随机数或随机元素的脚本却有很多实际用例。复制脚本的效果也能克服这一限制。
因此,最终——也得益于过去几个月在 Redis 内部进行的重构——得以实现对脚本效果复制的可选支持。用法非常简单,只需在脚本开头调用如下命令:
redis.replicate_commands()
… do something with the script …该脚本将仅作为一组写命令被复制。实际上,并不需要将 replicate_commands() 作为第一条命令来调用。只要在任何写操作之前调用它就足够了,因此 Lua 脚本甚至可以先检查要做的工作,再选择合适的复制模式。如果在调用 replicate_commands() 时已经执行过写操作,它只会返回 false,并采用常规的完整脚本复制方式,因此即使误用,该命令也永远不会阻止脚本运行。
不过,我们还是没能抵住做更高级、也可能更危险的事情的诱惑。我与来自 Redis Labs 的同事 Yossi Gottleib 一起设计了这一功能,他提出了一个非常有说服力的用例,需要一个能将选定命令排除在复制流之外的危险功能。
设想你的脚本可能会做这样的事:
- 调用某些写入临时值的命令。可以用集合求交集来建立一个直观的模型。
- 执行某种聚合操作。
- 将一个较小的结果作为脚本的效果存储起来。
- 丢弃临时值。
上述模式有几个合理的用例,而你肯定不希望将那些临时的写入复制到 AOF 和从节点。你只想复制第“3”步。因此最终我们决定,在启用脚本效果复制的情况下,勇敢的用户可以通过以下 API 来选择复制什么、不复制什么:
redis.set_repl(redis.REPL_ALL); -- The default
redis.set_repl(redis.REPL_NONE); -- No replication at all
redis.set_repl(redis.REPL_AOF); -- Just AOF replication
redis.set_repl(redis.REPL_SLAVE); -- Just slaves replication这留下了很大的误用空间,但非专业用户几乎不可能去碰这个功能,而懂得如何使用强大工具的人则能从中受益。
预计发布时间
这两项功能都将在 Redis 3.2 中提供,该版本将于 2015 年 12 月中旬发布 RC 版。Redis 3.2 将在面向用户的 API 层面带来许多有趣的新功能。在一段更专注于运维和内部成熟度的时期之后,很大一部分用户群体都期待着这些更新。
如果你想了解更多或有任何疑问,欢迎在评论区提问。
Hacker News 讨论帖在此:https://news.ycombinator.com/item?id=10594236
随机一篇博客
评论
登录后参与讨论