脚本调试

在早期支持Lua脚本功能的Redis版本中,用户为了对脚本进行调试, 通常需要重复执行同一个脚本多次,并通过查看返回值的方式来验证 计算结果,这给脚本的编写带来了很大的麻烦,也制约了用户使用Lua 脚本功能编写大型脚本的能力。

为了解决上述问题,Redis从3.2版本开始新引入了一个Lua调试器,这 个调试器被称为Redis Lua调试器,简称LDB,用户可以通过LDB实现单 步调试、添加断点、返回日志、打印调用链、重载脚本等多种功能, 本节接下来的内容就会对这些功能进行详细的介绍。

一个简单的调试示例

让我们来看一个具体的脚本调试示例。首先,假设现在我们要调试一 个名为debug.lua的脚本,代码清单14-4展示了它的具体定义。

代码清单14-4 待调试的脚本:/script/debug.lua
local ping_result = redis.call("PING")
local set_result = redis.call("SET", KEYS[1], ARGV[1])
return {ping_result, set_result}

为了创建一个新的调试会话,我们需要将—​ldb选项、--eval选项、脚 本文件名debug.lua、键名"msg"以及附加参数"hello world"全部传递 给redis-cli客户端:

$ redis-cli --ldb --eval debug.lua "msg" , "hello world"
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 ping_result = redis.call("PING")

注意,在键名和附加参数之间需要使用一个逗号进行分隔。

客户端首先向我们展示了3个可用的调试器操作命令,分别是:

·quit——退出调试会话并关闭客户端。 ·restart——重新启动调试会话。 ·help——列出可用的调试命令。

在此之后,调试器向我们展示了当前的调试状态:

* Stopped at 1, stop reason = step over
-> 1 local ping_result = redis.call("PING")

因为调试器目前正处于单步调试模式,所以它在程序的第一行(同时 也是程序第一个有实际作用的代码行)前面停了下来,等待我们的调 试命令。

这时,可以通过输入调试命令step或者next,让调试器运行当前的代码行:

lua debugger> next
<redis> PING
<reply> "+PONG"
* Stopped at 2, stop reason = step over
-> 2 local set_result = redis.call("SET", KEYS[1], ARGV[1])

next命令返回了4行结果:

·第1行<redis>PING展示了Redis服务器执行的命令。 ·第2行<reply>"+PONG"是服务器在执行命令之后返回的结果。 ·第3行*Stopped at 2,stop reason=step over说明调试器因为处于单步调试模式,所以停在了程序第2行的前面,等待用户的下一个指示。 ·第4行→2local set_result=redis.call("SET",KEYS[1],ARGV[1])打印出了程序第2行的具体代码,即调试器下一次单步执行将要执行的代码。

这时,我们可以通过输入调试命令print来查看程序当前已有的局部变量以及它们的值:

lua debugger> print
<value> ping_result = {["ok"]="PONG"}

从这个结果可知,局部变量 ping_result 当前的值是一个包含 ok 字段的 Lua 表格,该字段的值为 "PONG"。

现在,再次执行next命令,调试器将执行程序的第2行代码:

lua debugger> next
<redis> SET msg hello world
<reply> "+OK"
* Stopped at 3, stop reason = step over
-> 3 return {ping_result, set_result}

根据结果可知,服务器这次执行了一个SET命令,返回了结果"+OK", 并且单步调试也执行到了程序的第3行代码。

这时如果我们再次执行print命令,可以看到新增的局部变量 set_result以及它的值:

lua debugger> print
<value> ping_result = {["ok"]="PONG"}
<value> set_result = {["ok"]="OK"}

现在,再次执行next命令,将看到以下结果:

lua debugger> next
1) PONG
2) OK
(Lua debugging session ended -- dataset changes rolled back)
redis>

其中PONG以及OK为脚本语句return{ping_result,set_result}返回的 值,而之后显示的(Lua debugging session ended—​dataset changes rolled back)则是调试器打印的提示信息,它告诉我们Lua 调试会话已经结束。此外,因为在调试完成之后,客户端将退出调试 模式并重新回到普通的Redis客户端模式,所以我们在最后看到了熟悉 的redis>提示符。

调试命令

除了前面展示过的next命令和print命令之外,Lua脚本调试器还支持 很多不同的调试命令,这些命令可以通过在调试客户端中执行help命 令打印出来,表14-3展示了这些命令的用法以及作用。

image 2025 01 05 21 49 04 409
Figure 1. 表14-3 调试器命令
image 2025 01 05 21 49 21 714

因为调试程序通常需要重复执行多次相同的调试命令,为了让枯燥的 调试过程变得稍微愉快和容易一些,Redis为每个调试命令都设置了一 个缩写,即执行命令的快捷方式,这些缩写就是命令开头的首个字 母,用户只需要输入这些缩写,就可以执行相应的调试命令。比如, 输入n即可执行next命令,输入p即可执行print命令,诸如此类。

接下来将会对主要的调试命令进行介绍。

断点

在一般情况下,我们将以单步执行的方式对脚本进行调试,也就是 说,使用next命令执行一个代码行,观察一下执行的结果,在确认没有问题之后,继续使用next命令执行下一个代码行,以此类推,直到整个脚本都被执行完毕为止。

但是在有需要的情况下,也可以通过break命令给脚本增加断点,然后使用continue命令执行代码,直到遇见下一个断点为止。

比如,对于代码清单14-5所示的程序:

代码清单14-5 等待添加断点的脚本:/script/breakpoint.lua
redis.call("echo", "line 1")
redis.call("echo", "line 2")
redis.call("echo", "line 3")
redis.call("echo", "line 4")
redis.call("echo", "line 5")

我们可以通过执行命令break 35,分别在脚本的第3行和第5行添加断点:

lua debugger> break 3 5
-> 1 redis.call("echo", "line 1")
2 redis.call("echo", "line 2")
#3 redis.call("echo", "line 3")
4 redis.call("echo", "line 4")
#5 redis.call("echo", "line 5")

在break命令返回的结果中,符号→用于标识当前行,而符号#则用于标识添加了断点的行。

如果我们现在执行命令continue,那么调试器将执行脚本的第1行和第 2行,然后在脚本的第1个断点(第3个代码行)前面暂停:

lua debugger> continue
* Stopped at 3, stop reason = break point
->#3 redis.call("echo", "line 3")

之后,再次执行命令continue,这次调试器将执行脚本的第3行和第4行,然后在脚本的第2个断点(第5个代码行)前面暂停:

lua debugger> continue
* Stopped at 5, stop reason = break point
->#5 redis.call("echo", "line 5")

最后,再次执行continue命令,这次调试器将执行至脚本的末尾并退出:

lua debugger> continue
(nil)
(Lua debugging session ended -- dataset changes rolled back)

break命令除了可以用于添加断点之外,还可用于显示已有断点以及移除断点,以下是一个简单的示例:

$ redis-cli --ldb --eval breakpoint.lua
* Stopped at 1, stop reason = step over
-> 1 redis.call("echo", "line 1")
lua debugger> break 3 5 -- 添加断点
-> 1 redis.call("echo", "line 1")
2 redis.call("echo", "line 2")
#3 redis.call("echo", "line 3")
4 redis.call("echo", "line 4")
#5 redis.call("echo", "line 5")
lua debugger> break -- 显示已有断点
2 breakpoints set:
#3 redis.call("echo", "line 3")
#5 redis.call("echo", "line 5")
lua debugger> break -3 -- 移除第3个代码行的断点
Breakpoint removed.
lua debugger> break -- 结果显示第3个代码行的断点已被移除
1 breakpoints set:
#5 redis.call("echo", "line 5")
lua debugger> break 0 -- 移除所有断点
All breakpoints removed.
lua debugger> break -- 目前没有设置任何断点
No breakpoints set. Use 'b <line>' to add one.

动态断点

除了可以使用break命令在调试脚本时手动添加断点之外,Redis还允 许用户在脚本中通过调用redis.breakpoint()函数来添加动态断 点,当调试器执行至redis.breakpoint()调用所在的行时,调试器 就会暂停执行过程并等待用户的指示。

动态断点对于调试条件语句以及循环语句非常有用,比如,我们可以在变量只为真的情况下添加断点:

if condition == true then
    redis.breakpoint()
    -- ...
end

或者在计数器达到某个指定值时添加断点:

if counter > n then
    redis.breakpoint()
    -- ...
end

比如,代码清单14-6所示的脚本将在计数器的值大于ARGV[1]的值时添加断点。

代码清单14-6 等待添加动态断点的脚本:/script/dynamic_breakpoint.lua
local i = 1
local target = tonumber(ARGV[1])
while true do
    if i > target then
        redis.breakpoint()
        return "bye bye"
    end
    i = i+1
end

以下是一个将ARGV[1]设置为50并使用调试器调试该脚本的例子:

$ redis-cli --ldb --eval dynamic_breakpoint.lua , 50
* Stopped at 1, stop reason = step over
-> 1 local i = 1
lua debugger> continue
* Stopped at 6, stop reason = redis.breakpoint() called
-> 6 return "bye bye"
lua debugger> print
<value> i = 51
<value> target = 50

由print命令的执行结果可知,在第一次执行continue命令之后,调试器将在i的值为51时添加断点。

需要注意的是,redis.breakpoint()调用只会在调试模式下产生效 果,处于普通模式下的Lua解释器将自动忽略该调用。比如,如果我们 直接使用EVAL命令去执行dynamic_breakpoint.lua脚本,那么脚本将 不会产生任何断点,而是会直接返回脚本的执行结果:

$ redis-cli --eval dynamic_breakpoint.lua , 50
"bye bye"

输出调试日志

虽然我们可以通过添加动态断点并使用print命令打印出脚本在某个特 定时期的状态,但这种做法有时还是不够动态,如果能够直接在脚本 中把特定时期的状态打印出来,那么调试高度动态的程序时就会非常 方便。

为了做到这一点,我们可以使用Lua环境内置的redis.debug()函 数,这个函数能够直接把给定的值输出到调试客户端,使得用户可以 方便地得知给定变量或者表达式的值。

作为例子,代码清单14-7展示了一个计算斐波那契数的Lua脚本,这个 脚本在每次计算出新的斐波那契数时,都会使用redis.debug()函数 将这个值输出到调试客户端,使得我们可以直观地看到整个斐波那契 数的计算过程。这种做法比在每个循环中动态添加断点,然后使用 print命令打印斐波那契数的做法要方便得多。

代码清单14-7 计算斐波那契数的Lua脚本:/script/fibonacci.lua
local n = tonumber(ARGV[1])

-- F(0) = 0 , F(1) = 1
local i = 0
local j = 1

-- F(n) = F(n-1)+F(n-2)
while n ~= 0 do
    i, j = j, i+j
    n = n-1
    redis.debug(i)
end

return i

以下是使用调试器调试斐波那契数计算脚本的过程,可以看到,在每 次循环时,redis.debug()函数都会把斐波那契数的当前值打印出来:

$ redis-cli --ldb --eval fibonacci.lua , 10
* Stopped at 1, stop reason = step over
-> 1 local n = tonumber(ARGV[1])
lua debugger> continue
<debug> line 11: 1
<debug> line 11: 1
<debug> line 11: 2
<debug> line 11: 3
<debug> line 11: 5
<debug> line 11: 8
<debug> line 11: 13
<debug> line 11: 21
<debug> line 11: 34
<debug> line 11: 55
(integer) 55
(Lua debugging session ended -- dataset changes rolled back)

执行指定的代码或命令

Lua调试器提供了eval和redis这两个调试命令,用户可以使用前者来 执行指定的Lua代码,并使用后者来执行指定的Redis命令,也可以通 过这两个调试命令来快速地验证一些想法以及结果,这会给程序的调 试带来很多好处。

比如,如果我们在调试某个脚本时,需要知道某个字符串对应的SHA1 校验和,那么只需要使用eval命令调用Lua环境内置的 redis.sha1hex()函数即可:

lua debugger> eval redis.sha1hex('hello world')
<retval> "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"

又比如,如果我们在调试某个会对数据库进行操作的脚本时,想要知 道某个键的当前值,那么只需要使用redis命令执行相应的数据获取命 令即可:

lua debugger> redis GET msg
<redis> GET msg
<reply> "hello world"

显示调用链

trace调试命令可以打印出脚本的调用链信息,这些信息在研究脚本的 调用路径时会非常有帮助。

比如,对于代码清单14-8所示的脚本:

代码清单14-8 带有复杂调用链的Lua脚本:/script/trace.lua
local f1 = function()
    local f2 = function()
        local f3 = function()
            redis.breakpoint()
        end
        f3()
    end
    f2()
end

f1()

trace命令将产生以下信息:

$ redis-cli --ldb --eval trace.lua
* Stopped at 9, stop reason = step over
-> 9 end
lua debugger> continue
* Stopped at 5, stop reason = redis.breakpoint() called
-> 5 end
lua debugger> list
1 local f1 = function()
2 local f2 = function()
3 local f3 = function()
4 redis.breakpoint()
-> 5 end
6 f3()
7 end
8 f2()
9 end
10
lua debugger> trace
In f3:
-> 5 end
From f2:
6 f3()
From f1:
8 f2()
From top level:
11 f1()

在trace命令返回的结果中:

In f3:
-> 5  end

表示调试器停在了脚本第5行,该行位于函数f3()当中;而:

From f2:
6   f3()

则说明了函数f3()位于脚本的第6行,由函数f2()调用;与此类似,之后的:

From f1:
8    f2()

则说明了函数f2()位于脚本的第8行,由函数f1()调用;至于最后的:

From top level:
11   f1()

则说明了函数f1()位于脚本的第11行,由解释器顶层(top level)调用。

重载脚本

restart是一个非常重要的调试器操作命令,它可以让调试客户端重新 载入被调试的脚本,并开启一个新的调试会话。

一般来说,用户在调试脚本的时候,通常需要重复执行以下几个步 骤,直至排除所有问题为止: 1)调试脚本。 2)根据调试结果修改脚本。 3)使用restart命令重新载入修改后的脚本,然后继续调试。

作为例子,假设我们现在需要对代码清单14-9所示的脚本进行调试, 这个脚本的第3行和第5行分别将PING错写成了P1NG(数字1)和PONG。

代码清单14-9 一个包含错别字的脚本:/script/typo.lua
redis.call('PING')
redis.call('PING')
redis.call('P1NG')  -- 错别字
redis.call('PING')
redis.call('PONG')  -- 错别字

在将这个脚本载入调试器并运行至第3行时,脚本出现了错误,而客户 端也由于这个错误从调试状态退回到了普通的客户端状态:

$ redis-cli --ldb --eval typo.lua
* Stopped at 1, stop reason = step over
-> 1 redis.call('PING')
lua debugger> continue
(error) ERR Error running script (call to f_cb3ff5da49083d9b7765f3c62b6bfce3b07cbdcb): @user_scr
ipt:3: @user_script: 3: Unknown Redis command called from Lua script
(Lua debugging session ended -- dataset changes rolled back)
redis>

根据调试结果可知,脚本第3行的命令调用出现了错误。于是我们修改 文件,将调用中的P1NG修改为PING,然后在客户端中输入restart,重 新开始调试:

redis> restart
* Stopped at 1, stop reason = step over
-> 1 redis.call('PING')
lua debugger>

之后,调试器继续执行脚本,并在脚本的第5行停了下来:

lua debugger> continue
(error) ERR Error running script (call to f_6f077220cdd710a5592c23dd0eedab9dee363854): @user_script:5: @user_script: 5: Unknown Redis command called from Lua script
(Lua debugging session ended -- dataset changes rolled back)

根据这次的调试结果,我们得知脚本第5行调用的命令出错了,于是修 改文件,将调用中的PONG修改为PING,然后再次在客户端中输入 restart并重新开始调试:

redis> restart
* Stopped at 1, stop reason = step over
-> 1 redis.call('PING')
lua debugger>

经历了两次修改之后,脚本终于可以顺利地执行了:

lua debugger> continue
(nil)
(Lua debugging session ended -- dataset changes rolled back)
redis>

调试模式

Redis的Lua调试器支持两种不同的调试模式,一种是异步调试,另一 种则是同步调试。当用户以ldb选项启动调试会话时,Redis服务器将 以异步方式调试脚本:

redis-cli --ldb --eval script.lua

运行在异步调试模式下的Redis服务器会为每个调试会话分别创建新的 子进程,并将其用作调试进程:

  • 因为Redis服务器可以创建出任意多个子进程作为调试进程,所以异 步调试允许多个调试会话同时存在,换句话说,异步调试模式允许多 个用户同时进行调试。

  • 因为异步调试是在子进程而不是服务器进程上进行,它不会阻塞服 务器进程,所以在异步调试的过程中,其他客户端可以继续访问Redis 服务器。

  • 因为异步调试期间执行的所有Lua代码以及Redis命令都是在子进程 上完成的,所以在调试完成之后,调试期间产生的所有数据修改也会 随着子进程的终结而消失,它们不会对Redis服务器的数据库产生任何 影响。

当用户以ldb-sync-mode选项启动调试会话时,Redis服务器将以同步 方式调试脚本:

redis-cli --ldb-sync-mode --eval script.lua

运行在同步调试模式下的Redis服务器将直接使用服务器进程作为调试进程:

  • 因为同步调试不会创建任何子进程,而是直接使用服务器进程作为 调试进程,所以同一时间内只能有一个调试会话存在。换句话说,同 步调试模式只允许单个用户进行调试。

  • 因为同步调试直接在服务器进程上进行,它需要独占整个服务器, 所以在整个同步调试过程中,其他客户端对服务器的访问都会被阻塞。

  • 因为在同步调试期间,所有Lua代码以及Redis命令都是直接在服务 器进程上执行的,所以调试期间产生的数据修改将保留在服务器的数 据库中。

简单来说,虽然异步调试和同步调试都能够调试Lua脚本,但它们完成 调试工作的方式却是完全相反的。

举个例子,如果我们在一个拥有空白数据库的Redis服务器上进行异步 调试,并使用redis调试命令执行一个设置操作:

$ redis-cli --ldb --eval typo.lua
* Stopped at 1, stop reason = step over
-> 1 redis.call('PING')
lua debugger> redis SET msg 'hello world'
<redis> SET msg hello world
<reply> "+OK"
lua debugger> quit

那么在调试完毕之后,msg键将不会在数据库中出现:

$ redis-cli
redis> KEYS *
(empty list or set)

与此相反,如果我们在相同的Redis服务器上进行同步调试,并执行相同的设置操作:

$ redis-cli --ldb-sync-mode --eval typo.lua
* Stopped at 1, stop reason = step over
-> 1 redis.call('PING')
lua debugger> redis SET msg 'hello world'
<redis> SET msg hello world
<reply> "+OK"
lua debugger> quit

那么在调试完毕之后,msg键将继续保留在服务器的数据库中:

$ redis-cli
redis> KEYS *
1) "msg"

终止调试会话

在调试Lua脚本时,用户有3种方法可以退出调试会话:

  • 当脚本执行完毕时,调试会话将自然终止,客户端也会从调试状态 退回到普通状态。

  • 当用户在调试器中按下Ctrl+C键时,调试器将在执行完整个脚本之 后终止调试会话。

  • 当用户在调试器中执行abort命令时,调试器将不再执行任何代码, 直接终止调试会话。

我们需要特别注意方法2和方法3之间的区别,因为对于一些脚本来 说,使用这两种退出方法可能会产生不一样的结果。

举个例子,对于代码清单14-10所示的脚本:

代码清单14-10 对数据库进行设置的脚本:/script/set_strings.lua
redis.call("SET", "msg", "hello world")
redis.call("SET", "database", "redis")
redis.call("SET", "number", 10086)

如果我们使用同步模式调试这个脚本,并在执行脚本的第一行代码之 后按Ctrl+C键退出调试,那么调试器将在执行完整个脚本之后退出调 试会话:

$ redis-cli --ldb-sync-mode --eval set_strings.lua
* Stopped at 1, stop reason = step over
-> 1 redis.call("SET", "msg", "hello world")
lua debugger> next
<redis> SET msg hello world
<reply> "+OK"
* Stopped at 2, stop reason = step over
-> 2 redis.call("SET", "database", "redis")
lua debugger>
$

通过访问数据库可以看到,脚本设置的3个键都出现在了数据库中,这 说明脚本包含的3个SET命令都被执行了:

redis> KEYS *
1) "number"
2) "database"
3) "msg"

此外,如果我们使用相同的模式调试相同的脚本,但是在执行脚本的 第1行之后使用abort命令退出调试:

$ redis-cli --ldb-sync-mode --eval set_strings.lua
* Stopped at 1, stop reason = step over
-> 1 redis.call("SET", "msg", "hello world")
lua debugger> next
<redis> SET msg hello world
<reply> "+OK"
* Stopped at 2, stop reason = step over
-> 2 redis.call("SET", "database", "redis")
lua debugger> abort
(error) ERR Error running script (call to f_4a3b211335f38c87bc0465bb0b6b0c9780-
f4be41): @user_script:2: script aborted for user request
(Lua debugging session ended)

那么数据库将只会包含脚本第1行代码设置的msg键:

redis> KEYS *
1) "msg"

为了避免出现类似问题,我们在进行调试,特别是在同步模式下进行 调试时,如果要中途停止调试,最好还是使用abort命令退出调试会 话,从而尽可能地避免意料之外的情况发生。