写这篇教程的起因,是我上周帮一个同事排查线上问题:一个用 Lua 脚本写的库存扣减接口,偶尔会出现"库存已经扣了,但业务库没记录"的现象。他打开终端第一句话是:"这个脚本我本地跑是正确的,生产上根本没法调试,你到底怎么定位的?"
说实话,这个问题我问过自己很多次。网上 Redis Lua 的资料多是"怎么用 EVAL 执行脚本""原子性的好处""和管道有什么区别",但真正到了要调一个复杂脚本、查一个隐藏 bug 的时候,资料就变得很稀碎。更麻烦的是,Redis 的 Lua 调试器和常规编程语言调试器差别很大:它不是打断点然后等在那里,而是自带了一套独立的交互式调试命令行;不同 Redis 版本下入口还不一样。这篇教程我打算把从"环境准备"到"真实业务场景排错"的完整链路捋一遍,包含我实际踩过的坑和总结出来的排查套路,适合正在写 Redis Lua 脚本、遇到逻辑 bug 不知道怎么定位的开发者参考。
1. 调试 Redis Lua 脚本前,先摸清它的运行边界
很多人在调试上走弯路,不是因为不会敲命令,而是没搞懂 Redis 里 Lua 的运行模型。我建议先把下面这两件事想清楚,再谈怎么打断点。
1.1 单线程执行:调试器其实只有"半个"断点能力
Redis 执行 Lua 脚本时,用的是自己内嵌的 Lua 5.1 解释器,脚本中的redis.call或redis.pcall会直接在当前连接对应的服务端进程中执行。也就是说,脚本是在 Redis 的主线程里跑的,同一时刻不会有第二个脚本跟你抢 CPU,这也是 Lua 脚本能保证原子性的根本原因。
但副作用很直接:如果脚本跑起来了,服务端没有"暂停脚本执行、等你去操作"的机制。常规调试器那种"运行到这个位置就停住,你慢慢看变量"的做法,放到 Redis 的 Lua 脚本上是不成立的。因为 Redis 主线程一旦被脚本占住,它没法同时处理你从客户端发来的"暂停""继续"指令——它正忙着跑脚本呢。
所以 Redis 官方提供的 Lua 调试器,并不是真正像 GDB 那样能暂停运行中的进程。它是靠**在执行前预埋调试钩子(hook)**实现的,本质上是在脚本的每一条语句之间插入回调,遇到断点或者单步指令时,由调试器控制下一步走向。这个"控制权"在你进入调试模式的那一刻就已经建立了,脚本并没有在线上真实环境里跑一半被截停。
记住这个区别,你就能理解为什么网上不少人说"redis-cli --ldb 只能在测试环境用、不能对线上脚本在线调试"——这不是命令的问题,是 Redis 的架构如此。线上排查时,我们一般用redis.log日志式调试或慢日志来定位,这个后面详细讲。
1.2 调试器锅里有啥料:靠的是钩子事件
Lua 虚拟机本身支持debug库和钩子机制。Redis 的调试器正是基于这里。你在终端里输入调试命令时,实际上是在跟一个带着调试模式标志的 Lua 解释器交互。它的能力包括:设置断点、单步执行、查看当前调用栈、打印局部变量和全局变量、修改变量值、重新执行脚本。
有个细节容易被忽略:断点是按"行号"或"源码片段字符串"来设置的,不是按函数名。所以调试前最好把脚本格式化好,保持行号稳定。实际操作时,我一般会在要停的地方写一个-- break注释,调试器会自动把它识别为断点,这一招在长脚本里非常省事。
下面我直接讲怎么实际操作。
2. 第一步:搭一个能安全折腾的调试环境
调试 Redis Lua 脚本,第一件事不是写脚本,而是确认环境。这个环境分两层:底层 Redis 版本,以及你能拿到手的测试数据。
2.1 版本检查与 redis-cli 的调试入口
redis-cli --ldb是从 Redis 5.0 开始引入的调试模式入口。如果你还在用 4.x 或更老版本,官方根本没有交互式调试器可用;这时候只能靠打印日志或者自己把脚本逻辑拆出来在本地 Lua 解释器里跑。所以先执行:
redis-cli -v如果版本号带 5.0 以上(包括 6.x、7.x),就可以用--ldb了。还有一点需要注意:--ldb是调试 Lua 脚本用的,--lua是老客户端(自 6.2 弃用之前)进入脚本交互模式的参数,这两个完全不是一回事,别搞混。
调试时不要连生产实例,理由我们在上一段已经聊过——钩子模型下,调试过程本身就在执行脚本,单步操作会长时间占住 Redis 主线程,线上稍微有点流量就会出事。我一般会在本地起一个干净实例:
redis-server --port 6380 --save "" --appendonly no--save ""和--appendonly no可以避免调试过程中不小心把测试数据写进持久化文件,防止污染本地环境。如果你的场景需要验证持久化行为,再单独开一个实例专门测试 AOF/RDB,两个实例分开,别混。
2.2 准备一份可复现的脚本与测试数据
调试效率低,有很大一部分原因是测试数据不够"刁钻"。我惯用的做法是:先把线上疑似出问题的 key 结构复制一份下来,做成一个模拟数据集。举个例子,假设问题脚本是扣库存:
-- seckill.lua -- 用 KEYS[1] 表示用户ID,KEYS[2] 表示库存key local userId = KEYS[1] local stockKey = KEYS[2] local stock = redis.call('GET', stockKey) if stock == nil then return 'no stock key' end -- 这里故意不转数字,先跑跑看 if stock <= 0 then return 'sold out' end redis.call('DECR', stockKey) return 'ok'在准备数据时,我故意把stock的值设置成"0"(字符串)而不是0(数字):
redis-cli -p 6380 SET stock:10086 0这时候跑脚本:
redis-cli -p 6380 --eval seckill.lua user_9527 , stock:10086返回结果大概率会出乎意料。为什么?因为redis.call('GET')返回的是字符串"0",Lua 里字符串"0"和数字0比较会先尝试转数字,其实这里能转成功,返回sold out。但假如库存被设置成"abc"、空字符串、或者一个前面带空格的值,就比较尴尬了。这就是我为什么强调:准备数据时要把"异常值"也带上,否则调试了半天都是"我的脚本没问题",一上线上就翻车。
3. 断点、步进、监视:Lua 调试器的完整用法
环境就绪后,正式进入调试流程。这一节我会把redis-cli --ldb的常用命令完整走一遍,并用第一节那个库存脚本为例,演示一次实际排查过程。
3.1 进入调试模式:命令格式与常见误区
进入调试模式的命令是:
redis-cli -p 6380 --ldb --eval seckill.lua user_9527 , stock:10086注意这里的参数顺序:逗号左边是 KEYS,右边是 ARGV。如果写成--eval seckill.lua user_9527 stock:10086而没有逗号,这两个参数都会进 KEYS,脚本里取的KEYS[2]就变成nil,调试器里你会看到一堆奇怪的 nil 报错。很多新手第一次用--ldb就卡在这里,以为是调试器坏了。
进入后,会看到类似这样的提示:
Lua debugging session started * Stopped at line 4, stop reason: debug hook -> 4 local stock = redis.call('GET', stockKey) lua debugger>意思是它默认停在了第一条可执行语句(行号因脚本而异)。接下来就是几个最基本的操作:
s(step):单步执行,进入下一行n(next):跳到下一行,但不进入函数内部c(continue):继续执行到下一个断点或结尾list:查看当前脚本的完整源码,并标出当前行print 变量名:打印变量值locals:列出当前作用域内的所有局部变量b 行号:在指定行设置断点del 行号:删除断点trace:查看调用栈restart:重新执行脚本abort:终止调试会话并放弃执行脚本
注意,s和n的区别在 Lua 调试器里并不像 IDE 里那么明显,原因很简单:大多数脚本不会调用其他 Lua 函数,顶多调用 redis.call。redis.call是 C 实现的,不会被单步进入,所以实操中我更常用s,它至少能让我看到每一行代码的执行情况。
3.2 完整的一次调试过程:从"表面正常"到"揪出类型问题"
回到第一节那个库存脚本。假设我在第 8 行(if stock <= 0 then)设一个断点,然后继续执行:
lua debugger> b 8 lua debugger> c -> 8 if stock <= 0 then lua debugger> print stock "0" lua debugger> print type(stock) string到这一步,问题已经暴露了:stock是字符串"0",不是数字0。接下来在 Lua 里做stock <= 0比较时,Lua 会尝试把字符串转成数字,"0"能转,所以这里没报错。但如果你在脚本里写的是stock - 1 > 0这样的运算,字符串会直接抛错。
这只是最简单的例子。在真实场景里,我遇到过stock是nil、是" "(空格)、甚至是被另一个脚本写成 JSON 字符串的情况。用debugger里的print type(stock)一看,瞬间就明白线上为啥偶发异常了。
继续执行,走到redis.call('DECR', stockKey)之前,我再打印一下stockKey是否正常。这里有个小技巧:print不仅能打印变量,还能打印表达式,比如print KEYS[1]、print #KEYS。我在调脚本时,几乎每走几步就会用print确认一边,这是最直观的。
3.3 按字符串匹配设置断点:应对脚本换行与混淆
如果脚本是别人写的、行号乱七八糟,或者你从线上拉回来的脚本带有大量注释和压缩行,按行号设置断点就不太靠谱。此时用b s:表达式按源码片段匹配更稳:
lua debugger> b s:redis.call('DECR'它会停在第一次匹配到redis.call('DECR'的地方,注意这里必须是精确的源码片段,建议复制脚本原文去查。这是我调试线上脚本(经过某种形式的打包、只有一行的脚本)时最常用的方式。
3.4 查看局部变量与调用栈:locals和trace
脚本里变量一多,一个个print太慢。用locals一次性列出当前作用域所有局部变量:
lua debugger> locals KEYS = { "user_9527", "stock:10086" } ARGV = {} userId = "user_9527" stockKey = "stock:10086" stock = "0"配合trace看调用栈:
lua debugger> trace -> seckill.lua:8脚本一般就一层,不像复杂应用有嵌套栈,但trace还是很有用——如果你用pcall包装了redis.call,异常位置找起来就靠它。
3.5 修改变量:调试器里临时"救火"
调试器支持直接给变量赋值,比如把字符串"0"改成数字0,看看后面逻辑会不会变正常:
lua debugger> stock = 0 lua debugger> print type(stock) number这在排查"是不是这个变量类型有问题"时特别高效,不用改脚本重新跑,当场验证判断。改完以后要继续走就c,脚本退出后修改会丢掉,放心折腾。
4. 日志式调试:print 无效时的另类打法
交互式调试器好用,但有个天然限制——它只能在跑脚本那一刻暂停并让你操作,对于"脚本就崩溃了""跑完就没影了"的场景,它帮不上忙。更常见的情况是:脚本已经放到生产环境,出问题的 moment 稍纵即逝,你根本来不及连上去调试。这时候日志式调试就派上用场了。
4.1 用 redis.log 把中间状态写到服务端日志
redis.log是 Lua 脚本里唯一可以把信息输出到 Redis 日志(就是redis-server的 stdout 或 logfile)的命令,语法:
redis.log(redis.LOG_NOTICE, 'userId=' .. tostring(userId) .. ' stock=' .. tostring(stock))日志级别有LOG_DEBUG、LOG_VERBOSE、LOG_NOTICE、LOG_WARNING。默认配置下,LOG_DEBUG和LOG_VERBOSE会被过滤掉,所以排查问题时我一般直接用LOG_NOTICE,日志里头能看到。
一个常见坑:redis.log的第二个参数必须是一个字符串,不能直接传数字或 nil。如果变量可能是 nil,tostring()也要小心,tostring(nil)返回的就是字符串"nil",不会报错,这是好事。但如果你直接拼'stock=' .. stock,而stock是 nil,整个脚本会当场抛错,反而不利于排查。所以调试期间,我习惯一律tostring()套一层。
线上环境加日志的注意事项是:脚本改动本身也是高危操作。如果担心污染线上数据,可以把redis.log放进一个开关里:
if DEBUG_FLAG == 1 then redis.log(redis.LOG_NOTICE, '...') end上线前把DEBUG_FLAG设成 0 即可。
4.2 巧用返回值制造"假断点"
如果你的脚本是给业务方调用的,返回值能被外面看到。那么可以临时把中间状态塞进返回值,比如:
return { code = 0, stock = stock, userId = userId, debugInfo = debugInfo }这样调用方拿到返回值就能看到内部状态。这种做法在"脚本崩了,但不知道崩在哪个分支"的时候特别有效——你把关键分支的标记都放进返回表,用 curl 或 redis-cli 调一次,直接看返回内容就知道走的是哪条路。
还有一种暴力但有效的办法:在关键分支写一个return redis.error_reply('DEBUG: ' .. tostring(...)),让脚本主动报错并携带信息。这个只适合测试环境,线上千万别这么干。
4.3 冷门技巧:把中间结果写回临时 key
最硬核的调试方式之一,是把中间变量写到一个临时 key,脚本执行完后再拿这个 key 来分析。因为 Lua 脚本是原子性执行的,直接用redis.call('SET', 'DEBUG:'..KEYS[1], ..., 'EX', 60)不会有并发干扰问题。
local debugInfo = cjson.encode({ stock = stock, userId = userId, now = redis.call('TIME', )[1] }) redis.call('SET', 'DEBUG:seckill', debugInfo, 'EX', 60)注意两点:一是cjson.encode对 nil 会直接报错,所以要先把 nil 处理掉;二是这个临时 key 写入后,要尽快清理,否则 DEBUG key 会越来越多,甚至占满内存。我习惯统一加DEBUG:前缀,并设置EX过期时间,最好再写个定时清理脚本,防止调试期结束忘了删。
5. 最容易翻车的三个逻辑坑
写 Lua 脚本调试多了,你会发现真正逼疯人的不是语法,而是一些"看起来正确、跑起来偶尔出事"的逻辑问题。这里重点讲三个,都是我实际踩过的。
5.1 nil 参与运算:脚本崩溃的经典场景
Redis Lua 里有几个"默认值"要特别小心:GET一个不存在的 key 返回 nil;HMGET某个 field 不存在时对应位置也可能是 false;KEYS[3]如果调用方没传够参数也是 nil。Lua 里 nil 不能直接参与字符串拼接、不能跟数字比较(不是报错就是得出奇葩结果)。
例如:
local score = redis.call('GET', 'user:'..userId..':score') if score > 60 then -- 业务逻辑 end如果score是 nil,nil > 60在 Lua 5.1 里会直接抛错"attempt to compare nil with number",Redis 会返回一个 error。我调试时几乎每次都会遇到这个,所以现在写脚本的规矩是:凡是从 Redis 读出来的值,先判断 nil 再决定下一步。判断要非常严格,if not stock then和if stock == nil then有细微差别,但都比直接拿来运算安全。
5.2 批量接口返回类型:表格套表格的陷阱
HMGET、MGET、ZRANGE ... WITHSCORES这类命令返回的是一个 Lua table。很多人误以为它的结构跟 Redis 客户端解析后的 JSON 一样,比如:
local info = redis.call('HMGET', 'user:'..userId, 'name', 'age') return cjson.encode(info)实际返回的是{ "某用户", "28" },不是{ name = "某用户", age = "28" }。你在调试器里print info[1]能看到值,print info.name则是 nil。定位这类问题,最好的办法就是在--ldb里执行到拿到 table 的那一行后用print info,你会发现info的内容是个从 1 开始的数组,而不是带 key 的表。如果脚本里后续用到info.name,那必然出问题。
5.3 集群模式下的 key 散列:单机调好了,上集群就崩
本地调试一切正常,一旦部署到 Redis Cluster 就报错:
Lua script attempted to access a non local key in a cluster node这跟调试器无关,而是Redis Cluster 要求一个脚本内所有涉及操作的 key 必须分布在同一个哈希槽。假设你的脚本同时操作stock:10086和user:9527:record,这两个 key 的哈希槽不同,脚本在集群里就跑不了。
调试时很难直接模拟集群(除非你真开一个集群环境),所以我的经验是:写脚本时就把所有 key 都设计成带同一个哈希标签,比如:
redis.call('GET', 'goods:{10086}:stock') redis.call('GET', 'user:{10086}:bought'){10086}是 Redis Cluster 的哈希标签语法,这两个 key 会被路由到同一个槽。上线前再用一个三主三从的本地集群验证一遍,这一步能省掉 80% 的集群问题。
6. 卡在"脚本一直在跑"时怎么定位
最后聊一个所有人都会紧张的情况:脚本上了生产,Redis 突然卡住,CPU 100%,日志显示一条 Lua 脚本长时间没有返回。这种时候,交互式调试器反而帮不上大忙,因为线上你不能真去 "continue" 它。要按下面的顺序来。
6.1 判断是死循环还是慢脚本
Redis 默认给 Lua 脚本设了一个lua-time-limit,默认 5000 毫秒(可以改,单位毫秒)。超过这个阈值,Redis 会记录日志,并允许客户端发送SCRIPT KILL来终止脚本。注意:
SCRIPT KILL只有在脚本还没有执行任何写命令时才能生效;- 如果脚本已经执行过
redis.call('SET')、DECR之类的写操作,SCRIPT KILL会报错,只能SHUTDOWN NOSAVE,这会丢数据。
所以线上事故处理时,第一反应是看日志里的关键字。如果还没执行写命令,立刻SCRIPT KILL;如果已经写了,优先确认能不能接受丢数据,不能接受就要考虑等脚本跑完——同时排查为什么会有这个脚本。
6.2 用 SLOWLOG 查看脚本执行细节
脚本跑完后,SLOWLOG GET会记录下它。你能看到执行时长、脚本源码、以及执行时间和 CPU 消耗。比如:
redis-cli SLOWLOG GET返回里会包含 Lua 脚本的完整源码。如果线上没法加日志,可以先从 SLOWLOG 里把脚本捞出来,在测试环境用--ldb逐步调试,大概率能复现问题。这比瞎猜强得多。
6.3 排查脚本里的"隐蔽长任务"
慢脚本通常不是死循环,而是某些操作本身很耗时。最常见的是:
- 在脚本里用了
KEYS或SCAN,遍历大量 key; ZRANGEBYSCORE、SMEMBERS返回了超大集合;- 在循环里多次调用
redis.call,比如一个for循环每次都 GET 一个 key,线上 key 量一大就必然超时。
调试思路很简单:在关键循环前后用redis.log记录os.clock()或redis.call('TIME')的差值。os.clock()在 Lua 脚本里可以调用,但它反映的是 Lua 解释器的 CPU 时间,不是 Redis 命令的真实耗时,所以更推荐用redis.call('TIME')[1]拿到服务端时间戳来算粗略耗时。这个字段常用于计算执行时间窗口,能帮你定位是哪一段很慢。
另外还有一个很多人忽略的点:脚本里尽量不要去遍历一个巨大的集合。Redis 内部集合结构对单个命令是优化的,但你在 Lua 里for i=1, n do redis.call('GET', key[i]) end这种循环,每一轮都是一个完整的命令调用,开销远高于你想象。我见过一个原本 20 毫秒的脚本,因为加了个遍历几百个 key 的逻辑,直接变成 1.8 秒,差点把整个实例拖死。
最后再分享一个调试习惯。写完一个 Redis Lua 脚本,我至少会在本地用--ldb全流程走一遍,并在关键分支打印type和值;确认逻辑后,再写几个"故意造意外"的测试数据(空 key、nil、字符串数字、超大集合),把这些 case 的日志留底;最后才考虑上生产。这样能挡住大部分低级 bug。如果你刚接触--ldb,不用急着背命令,拿一个真实脚本进去敲一遍,很快就能上手——它真正的价值,是让你看到那些"本该知道、却没注意"的运行时真相。