Redis Lua 脚本功能的最新改进
Lua 脚本大概是 Redis 在已经相当流行之后引入的功能中最成功的一个:毫不意外,用户最想要的一些东西都与脚本有关。以下两个功能在过去两年里被多次提出,而且在几周前的 Redis 开发者会议上,许多人试图把我的注意力引向其中一个或另一个。
- 一个真正好用的 Redis Lua 脚本调试器。
- 复制与 AOF 存储:把 Lua 脚本复制为一组体现脚本*效果*的写命令,而不是像通常那样复制脚本本身。
第二个功能不仅仅关乎脚本如何复制,还会影响到你能用 Lua 脚本做什么,我们稍后会看到。
从伦敦回来后,我实现了这两个功能。这篇博文将对两者进行介绍,并给出一些设计和实现方面的提示,或许对读者会有所帮助。
一个真正好用的 Lua 调试器
Lua 脚本最初的设计目的,是编写非常简单的脚本。比如:如果某个键存在就做某事。只需几行代码,就能避免 Redis 因各种可能的命令变体而变得臃肿。当然,用户用它做了远超预期的事情,开始编写复杂的脚本:从四叉树实现到具有非平凡语义的完整消息系统。Lua 脚本让 Redis 变得可编程,而程序员通常无法抗拒可编程的东西。更妙的是,所有 Lua 脚本都使用同一个解释器运行并被缓存,因此非常快。大多数时候,借助 Lua 脚本,无论是功能上还是每秒操作数上,都可以用一个 Redis 实例做更多的事情。所以复杂脚本在今天完全有其用武之地。短短几年间,我们从对这个脚本功能的冷遇(把一个动态的脚本发给数据库?!),到大规模使用,再到编写复杂脚本。
然而,编写简单脚本和编写复杂脚本完全是两码事。更大的程序会呈指数级地变得更复杂,即使只是从 10 行代码增加到 200 行,你也能感受到这一点。对于简单的脚本,你完全可以通过暴力方式调试:尝试几个变体并观察对数据集的影响,或者在中间插入几条日志指令;但对于复杂脚本,没有调试器你会非常难受。
我的同事 Itamar Haber(伊塔马尔·哈贝尔)最近花了大量时间编写复杂脚本。他一度还利用 Lua 调试库为 Redis Lua 脚本编写了某种调试器。但这个调试器已经无法使用了,因为出于沙箱安全的考虑,调试库不再暴露给脚本。而且总的来说,Redis 调试器应该是交互式的远程调试器,配备一个能与服务器协同工作的合适客户端,以提供良好的调试体验。调试本身已经是很繁重的工作,拥有可靠的工具实属必要。实现这一目标的唯一途径,就是在 Redis 内部加入完善的调试支持。
于是,从伦敦回来后,我和 Itamar 开始讨论调试器应该向用户导出哪些功能才能算得上实用,并真正超越以往。我们也讨论过直接为 Redis 生态之外已有的 Lua 调试器添加支持。然而我坚信,当一切都专门为配合 Redis 而设计时,用户体验会更好,所以最终我决定从头编写这个调试器。有几件事是确定的:我们需要一个远程调试器,可以连接到 Redis,开启一个调试会话,并很好地观察脚本对 Redis 数据集做了什么。我个人特别在意的一点是输出要有颜色,当然啦 ;-) 我想让调试成为一种有趣的体验,并拥有非常平缓的学习曲线,而这两者是相关的。
现在,要通过写博文来展示调试器如何工作当然是可能的,但即便是像我这样坚持用等宽字体写文章的纯粹主义者,也会时不时 resort 到视频。所以这里有一段较长的视频,展示了 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% 的好处。这个奇特的方案最终是这样的:
- 调试会话开始时,对 Redis 执行 fork()。
- 捕获客户端的文件描述符,在调试会话期间直接进行阻塞式 I/O。
- 使用 Redis 协议,但只用一个几行代码就能实现的极简子集,这样我们完全不用重新进入 Redis 事件循环。I/O 成为调试器的一部分。
写了 400 行代码之后,基本功能就都跑起来了,剩下的只是添加功能、修复 bug 和处理边界情况。
这带来了我们需要的一切:服务器不会被阻塞,因为每个调试会话都是一个独立的进程。我们不需要从 EVAL 调用的中途重新进入事件循环,而且回滚功能是免费的。
不过,还有一种同步模式可用,它会阻塞服务器,适用于你确实需要调试某些东西并保留对数据集更改的情况。我感觉这种模式几乎不会被用到,但由于实现它只是不 fork、并在结束时处理客户端清理而已,所以我也把它加上了。
在此之上,其余功能都可以通过 Lua 的“行”钩子(line hook)来实现,用于支持单步执行和断点。由于调试器集成在 Redis 内部,捕获所有 Redis 调用并向用户展示正在发生的事情就变得轻而易举。I/O 模型也很简单:我们只是读取用户输入,并把输出追加到一个缓冲区。每当调试器在某个位置停下时,输出就作为一组“status 回复”数组刷给客户端。每行的前缀会提示 redis-cli 应提供何种着色。
得益于这个设计,调试器在 2 天后就能工作,4 天后便告完成。而且这个设计让我能写出完全自包含的代码,调试器与 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
随机一篇博客