1. 为什么Redis里的Lua脚本这么难调试?先从一个超卖事故说起
上个月我接手一个秒杀后端接口的紧急工单,库存100件,订单却生成了136条。代码里没有锁,也没有原子扣减,多线程同时按到同一个库存数,SQL层的UPDATE写得再快也无济于事。我第一反应就是上Redis,用DECR单命令扣减,简洁、原子、性能好。但需求里又加了“扣减前判断库存是否大于0”和“订单编号必须落库”两个动作,单条命令做不到,于是很自然地写了一个带Lua脚本的原子流程。
脚本上线当天看起来一切正常,第二天半夜开始出现零星的ERR Error running script (call to f_xxxx): @user_script:1。客户端日志里那串报错极其有限,脚本行号指向第一行,也不知道是数据问题还是命令调错。调了整整一个下午,才定位到是一个key在特殊时间点被设置成了空字符串,脚本里tonumber()直接翻车。
这套经历让我意识到一个事实:Redis + Lua的组合虽然能解决分布式锁、限流、缓存穿透这些硬需求,但脚本是跑在Redis进程内部的黑盒逻辑,普通代码里那套打断点、看堆栈、热部署的方法在这里全部失灵。这文章就把我这一路踩坑摸出来的“Redis-Lua脚本调试”方法整理清楚,适合正在写分布式锁、在Redis里跑原子脚本、或者被一段诡异的脚本报错卡住的人参考。
1.1 故障现场:一段看起来毫无问题的扣库存脚本
先还原一下当时那个最经典的脚本,逻辑很简单,凡是做过秒杀的人基本都会写成这样:
local stock = redis.call('GET', KEYS[1]) if tonumber(stock) > 0 then redis.call('DECR', KEYS[1]) return true end return false本地测试没问题,EVAL跑一次扣一次,反复执行也正确。问题在于真实环境里Redis的key不会一直规规矩矩。某次运营手动把价格key和库存key搞混,往库存key里写了一个带末尾空格的字符串;还有一次缓存预热程序在key不存在时先写入空串,后一秒才写数字。这些脏数据一进脚本,tonumber()直接报正在尝试对nil或非数字内容做算术运算,整个脚本抛异常,客户端就收到那串莫名其妙的@user_script:1错误。
这里先说结论:调试Redis Lua脚本,第一要务不是去学高级调试工具,而是把脚本执行过程从“黑盒”变成“白盒”——你能看到脚本内部的每个中间变量,问题就解决一大半。后文的日志法、模拟法、最小复现法,本质上都是在干这件事。
1.2 Lua脚本报错为什么这么劝退
我认真复盘过,Lua脚本在Redis里难调试,核心原因有五个,缺一个都不至于这么头疼:
第一,执行环境封闭。脚本不是独立进程,它运行在Redis主进程内,你没法挂一个gdb或者用Visual Studio Code的调试器直接附加进去看变量。第二,执行期间Redis会阻塞其他命令。脚本一旦开始运行,之后进来的命令全部排队等待,意味着你不能一边跑脚本一边开个redis-cli查这个key那个key。第三,报错信息不带上下文。常见错误只给脚本行号,不给当时的key名、参数、数据类型,你根本不知道是哪条命令打在了哪种数据上。第四,数据是动态的。同样的脚本,key存在和不存在返回的结果完全不同,分布在不同节点上的表现又不一样,偶发问题特别难复现。第五,很多框架封装了脚本调用过程,比如Spring Data Redis把EVAL包了一层,你连原始错误信息都未必看得到。
这五个因素叠加,新手很容易陷入“改一行、上线、看日志、再改一行”的死循环。我后面讲的调试流程,就是针对这五个原因逐一击破的。
1.3 调试前必须先建立的两个底层认知
在动手之前,有两个关于Redis脚本的底层认知必须建立,否则后面所有调试手段都会用错方向。
第一个认知:Redis里的Lua脚本执行是原子的。这不是优点描述,而是约束条件。从脚本第一条命令开始,到最后一条命令结束,中间不会穿插任何其他客户端的命令。这意味着脚本里不能做耗时很长的循环。Redis有个配置项lua-time-limit,默认5秒,脚本执行超过这个时间Redis会记录一条slowlog,但不会自动终止脚本。它只是开始接受其他客户端的SCRIPT KILL请求。如果脚本已经执行了写操作,SCRIPT KILL不能杀,只能SHUTDOWN NOSAVE重启,数据丢失和长时间阻塞都非常痛苦。所以调试任何脚本,第一件事就是检查循环边界,确认它不会在极端数据下跑成死循环。我后来在这个问题上吃过一次大亏,后面实操部分细说。
第二个认知:Redis里的Lua是裁剪过的运行环境,没有标准Lua库里的文件操作、网络操作、进程操作。你只能调用Redis命令,不能发起HTTP请求,不能读本地文件。所有外部数据都得通过ARGV参数传进来。理解这一点之后,调试的手段就很清晰了:你不能像调试普通服务一样指望脚本自己输出到某个文件,只能依靠Redis提供的有限几个“窗口”——redis.log打日志、redis.call返回值、以及少数几个调试辅助命令。
把这两个认知想清楚,再看后面的技术方案就不会觉得绕。
2. 调试基本功:EVAL怎么跑,call和pcall怎么选,返回值怎么判断
在学任何高级技巧之前,先把最基础的执行方法揉碎。很多人调试脚本翻车,不是逻辑不对,而是根本没把脚本在正确的方式下跑起来,参数传错、命令格式拧了,错误信息自然离谱。
2.1 从EVAL到EVALSHA,脚本的运行基础
Redis执行Lua脚本有两条路:EVAL直接传脚本内容,EVALSHA传脚本的SHA1摘要。首次执行时Redis会把脚本缓存起来,后续用EVALSHA省带宽、减少网络传输。但有个细节很多人忽略:脚本缓存是跟着Redis运行状态走的,重启后如果没开启持久化或者AOF里没有完整脚本,缓存可能丢失,这时EVALSHA会报NOSCRIPT No matching script。所以线上调度一般都有个“先用EVAL执行、失败再用EVALSHA”的重试逻辑,这个和调试关系不大,只需要知道。
在调试阶段,直接用EVAL最直观。命令行敲法:
redis-cli EVAL " local stock = redis.call('GET', KEYS[1]) if tonumber(stock) and tonumber(stock) > 0 then redis.call('DECR', KEYS[1]) return 1 end return 0 " 1 stock:10086注意EVAL后面的“1”表示KEYS数组长度,后面跟一个key;如果有参数,再依次写ARGV里的值:
redis-cli EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 my:key hello脚本比较长的时候,命令行写起来很痛苦,我的做法是先用脚本文件调试:
redis-cli --eval deduct_stock.lua stock:10086 , 5--eval的格式有个关键点:key和参数之间用逗号分隔,逗号两边有空格,空格容易被忽略导致参数被当成key。我在第一次用--eval时就被这个坑过一次,传参顺序整个错乱,报错信息显示“key不存在”,实际上参数全塞进了KEYS。所以这里特意标出来:逗号前后的空格不能省。
先用单条命令验证脚本能否执行,再往上加业务逻辑,这是我调试任何脚本的第一个动作。比如先跑redis-cli EVAL "return redis.call('PING')" 0,返回PONG,说明Redis环境本身没问题;再跑一个只有GET的脚本,确认key能读到;最后才放完整业务脚本。这样一层层往上搭,出问题时范围非常小。
2.2 redis.call和redis.pcall,选错会让你多排查两小时
这是脚本里最基础、也最容易埋雷的分叉点。redis.call和redis.pcall的区别只有一条:当调用的Redis命令出错时,redis.call会直接抛出Lua错误,脚本终止;redis.pcall则会捕获错误,返回一个包含错误信息的Lua表,脚本可以继续往下走。
看一个实际对比。假设KEYS[1]对应的value是字符串“abc”,你执行INCR:
-- 用redis.call return redis.call('INCR', KEYS[1]) -- 结果:脚本报错退出,客户端收到 ERR Error running script ... WRONGTYPE-- 用redis.pcall local ok, res = pcall(redis.call, 'INCR', KEYS[1]) if not ok then return {error = tostring(res)} end return res -- 结果:脚本不挂,返回一个带error字段的表,客户端能看到具体错误注意我上面这行pcall(redis.call, 'INCR', KEYS[1])的写法,直接传redis.call函数引用是OK的,但要在外面套一层pcall才能拿到ok, res。如果想更稳妥一点,也可以写成闭包:
local ok, res = pcall(function() return redis.call('INCR', KEYS[1]) end)那调试时到底用哪个?我个人的实践是:调试阶段一律用redis.call,让错误第一时间暴露出来,报错信息越原始越方便定位;上线前再根据是否需要容错来决定是否切换到redis.pcall。反过来,如果线上脚本已经用pcall把所有错误吞掉了,排查时先暂时改成call,把真实错误“逼”出来,这是最快的路径。
2.3 KEYS和ARGV的规矩,集群模式下的隐性坑
Redis官方文档里有一条硬性要求:脚本中要访问的key必须通过KEYS数组传入,业务参数通过ARGV传入。很多人不以为然,直接在脚本里写死key名:redis.call('GET', 'user:' .. ARGV[1])。
在单机模式下,这么写能跑通,但在Redis Cluster环境下就是重灾区。集群根据key做哈希槽路由,脚本要被派发到正确的节点执行,如果脚本里的key没有在KEYS中显式声明,服务器无法预判脚本会访问哪些槽位。Redis 7.0以后,这种写法会直接报错No matching key in keys array or arguments。生产环境一旦上了集群,写死key名基本等于自杀。
调试时如果看到这个错误,先检查脚本里有没有硬编码的key,统一改成从KEYS读取。另外还要注意,集群模式下要求同一个脚本访问的所有key必须落在同一个哈希槽,也就是key最好带上相同的hash tag,例如{mylock}:user1和{mylock}:user2。这个规则经常是分布式锁脚本在集群里“时灵时不灵”的幕后黑手。
2.4 返回值与类型转换,最容易看走眼的地方
Redis的客户端拿到的最终返回值,并不是Lua原生类型直接返回,而是经过RESP协议序列化再反序列化的结果。这里面有几个容易踩的坑。
第一,Lua的true会被转成整数1,false会被转成nil。这意味着如果你写return true,Java客户端收到的是Long 1而不是布尔值;你写return false,客户端可能收到null,导致判空逻辑错乱。第二个坑与之相关:nil值在RESP协议里只能表示“不存在”,有些客户端框架会把nil直接抛成异常。第三个坑是精度:Redis返回的整数可能是64位,而Lua内部number是双精度浮点,超过2^53的整数在脚本里一旦参与运算,精度就会丢失。这后面我会用一个真实案例展开。
所以我的调试经验是:脚本的返回值尽量规整成“数字”或者“字符串”,少用布尔值和Lua原生table。比如分布式锁的加锁脚本,常写成return 1和return 0,而不是return true和return false。这不只是为了统一语义,更是为了让客户端处理逻辑简单直接。
3. 四步实战调试法:日志、模拟、最小复现、官方调试器
下面这部分是全文的核心,我按照实际排查顺序整理了一套四步调试流程。大多数问题不需要走到第四步,前三步已经能解决90%的线上报错。
3.1 第一步:日志埋点,用redis.log把中间态打出来
调试普通代码时,console.log是第一反应。Redis的Lua环境里对应的是redis.log函数,它会把日志写到Redis服务器的日志文件中。
用法非常简单:
local stock = redis.call('GET', KEYS[1]) redis.log(redis.LOG_NOTICE, string.format("[lua-debug] key=%s stock=%s", KEYS[1], tostring(stock))) if tonumber(stock) and tonumber(stock) > 0 then redis.call('DECR', KEYS[1]) return 1 end return 0执行后去Redis日志文件里找[lua-debug]字样,就能看到脚本跑的时候stock到底是什么值、key长什么样。你可以打多个埋点,把每个分支的入口和出口都打出来,脚本执行的完整路径基本就还原了。
但是有一个很坑的限制:redis.log的输出受Redis日志级别控制。默认日志级别是notice,能用;如果运维把loglevel调成了warning,redis.log(redis.LOG_NOTICE, ...)打出来的内容会被直接丢弃。遇到过这种环境时,记得先看配置或者直接跑到服务器上确认一下日志有没有写进去。另外,生产环境用完的调试日志一定要删掉,否则每个key的每次访问都往磁盘写日志,日志量会涨得飞快。
redis.log是我日常使用频率最高的调试手段,它的最大价值在于,脚本报错时你至少能知道“那一刻脚本执行到了哪一步”。没有这个信息,你面对错误信息里的@user_script:1真的就是两眼一抹黑。
3.2 第二步:本地模拟,在没有Redis环境的时候也能验证逻辑
有些时候问题根本不涉及Redis数据,纯粹是Lua语法错误、分支条件写反、table结构拼错。这种情况每次都跑到服务器上执行脚本太慢了,我的做法是在本地用纯Lua环境模拟跑一遍。
原理很简单:脚本里用到的redis全局对象,本质上就那两三个方法,我可以在本地自己mock一份:
-- mock.lua local data = {} redis = { call = function(cmd, key, val) print("[mock redis.call] " .. cmd .. " " .. key .. " " .. tostring(val)) if cmd == "GET" then return data[key] end if cmd == "SET" then data[key] = val; return "OK" end if cmd == "DECR" then data[key] = tonumber(data[key]) - 1; return data[key] end if cmd == "PEXPIRE" then return 1 end return "OK" end, log = function(level, msg) print("[mock redis.log] " .. msg) end, LOG_NOTICE = "notice" } -- 然后执行目标脚本 assert(loadfile("target_script.lua"))()这样不需要装Redis,只要本地有Lua解释器就能跑。脚本里的语法错误、参数错位、逻辑分支顺序,这些都能在mock环境里直接暴露。我说清楚mock的局限:它不能模拟真正的Redis数据类型行为,比如SADD、LPUSH、过期策略、持久化,这些操作mock出来跟真实环境差别很大。所以我只拿它来排除“脚本本身的逻辑错误”,一旦涉及Redis命令的真实交互,还是得到真实Redis上验证。
这个步骤的实际价值在于,把“代码问题”和“环境问题”做一个快速切分。我调试任何一段脚本,都会先在本地mock跑通一次,再上真实Redis跑。如果mock通过但线上报错,那我就知道问题大概率出在数据状态、key分布或网络参数上,不需要再看脚本逻辑。
3.3 第三步:最小复现,把问题脚本裁剪到最短
线上脚本往往是几十行甚至上百行的大块逻辑,里面可能同时做了库存扣减、日志记录、排行榜更新、返回值组装。一旦报错,最忌讳的就是直接在大脚本里加日志试来试去。正确做法是复刻一个最小复现脚本,只保留出问题的那个分支。
比如我去年排查过一个脚本,线上偶尔报错,但测试环境怎么也复现不了。那段脚本功能是“扣减库存,如果扣减成功则往另一个key里写入订单号,最后返回订单号和剩余库存的table”。初看哪哪都对,但就是偶尔挂。后来我把脚本从60行裁剪到只剩10行:
local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock <= 0 then return { err = "no stock" } end redis.call('DECR', KEYS[1]) return { stock = redis.call('GET', KEYS[1]) }再配合固定参数反复执行,问题就暴露了:当DECR后库存变成负数时,返回给客户端的table里stock字段的值是字符串负数,客户端解析时把它当成无符号数字,导致后续逻辑爆炸。裁剪脚本的过程,本身就是把问题范围一层层缩小的过程。
这里有一个重要的行动纪律:每次只改动一个变量。如果你想同时验证“key是否存在”和“value是否为脏数据”两个因素,那就先固定一个,只切换另一个。一次改三个地方,最后跑通了也不知道是哪一处修好的,这是排查工作里最浪费时间的错误。
3.4 第四步:交互式单步调试,redis-cli --ldb的用法和限制
Redis从3.2版本开始提供了一个内置的Lua调试器,可以通过redis-cli --ldb进入。说实话,这个调试器在真实项目里用得不多,但它的确能解决某些很刁钻的逻辑问题,值得了解。
用法是:
redis-cli --ldb --eval target_script.lua key1 key2 , arg1进入调试模式后,可以用n执行下一行,用s跳到函数内部,用p 变量名打印变量值,用c继续执行到下一个断点。在脚本里还可以调redis.breakpoint()主动设置断点,以及redis.debug()输出变量。
我第一次用的时候,感觉它就像一个古老的命令行调试器,功能简陋、交互别扭。它有一个很明显的限制:调试过程中Redis处于单客户端阻塞式交互状态,没法模拟多客户端并发场景;而且在集群模式下使用还会因为节点路由问题变得很不可靠。所以我只会在本地单机、脚本逻辑复杂到日志法无法覆盖时,才掏出这个调试器。对于大多数人,前两步日志法和mock法已经完全够用。
为什么这个调试器出场率低?很大原因是生产环境根本不会给你开--ldb的机会,线上Redis实例上跑着大量业务,不可能为调试一个脚本把实例切到调试模式。所以我把它定位成“本地研究工具”,而不是“线上排查工具”。
4. 高频报错速查表与两个真实踩坑记录
脚本日志打得再好,也得知道错误信息到底在说什么。我把实际工作中遇到的高频错误整理成了一张速查表,并附上两个典型的踩坑案例。这张表建议截图保存,比你临时搜搜索引擎快得多。
4.1 错误信息与排查思路对应表
| 报错信息特征 | 出现场景 | 根本原因 | 排查与处理方法 |
|---|---|---|---|
ERR Error running script ... WRONGTYPE | 脚本里对一个key执行了与其类型不符的命令,比如对list执行GET | key的实际数据类型不是预期值 | 先用TYPE key确认类型;在脚本里加类型判断,或统一数据写入规范 |
ERR wrong number of arguments | 脚本内调用某个Redis命令时参数数量不对 | 命令调用少传了参数 | 逐条核对脚本里的redis.call,对照Redis命令文档检查参数个数 |
No matching key in keys array or arguments | Redis 7.x执行含静态key名的脚本 | 脚本里写了硬编码的key,未通过KEYS数组声明 | 把所有key改为从KEYS读取,集群场景下还需要关注hash tag |
attempt to perform arithmetic on a nil value | 脚本对未命中的GET结果做了运算 | key不存在时返回nil,未处理空值 | 先判断返回值是否为nil,再用tonumber转换 |
attempt to compare boolean with number | 把redis.call('GET')的结果直接与数字比较 | GET结果可能是false/nil | 先用tonumber()转换,再参与比较运算 |
客户端报io.lettuce.core.RedisCommandTimeoutException | 脚本执行耗时过长,阻塞Redis进程 | 脚本里有死循环或高复杂度逻辑,超过lua-time-limit | 看服务器slowlog定位坏脚本;未写入数据时用SCRIPT KILL,已写入则SHUTDOWN NOSAVE后优化脚本 |
NOSCRIPT No matching script | 使用EVALSHA时脚本缓存丢失 | Redis重启或脚本缓存被清理 | 改用EVAL执行,或增加“EVALSHA失败后回退EVAL”的重试逻辑 |
这张表里最让我印象深刻的,是那张RedisCommandTimeoutException。表面上看是网络超时,实际上是某个同事写了一个遍历大量key的Lua脚本,一次执行了3秒多,Redis在这期间把所有命令都挡在外面,Lettuce客户端那边直接超时。排查时我一度在调网络参数,后来打开slowlog,看到脚本执行时间记录,才知道问题根本不在网络。
4.2 踩过的坑:返回false引发的连锁问题
这是一个分布式锁解锁脚本的经典案例。当时业务用的是自研的Redis锁,解锁逻辑要求“先判断持有者标识,匹配才删除key”,核心脚本是:
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return false看起来没毛病,但运行一段时间后,线上反馈解锁偶尔会抛异常。一开始怀疑是DEL命令权限问题,后来抓日志看到,客户端Java代码里是这样处理的:
if (unlockResult != null && unlockResult > 0) { // 解锁成功 }问题就在返回值上。当GET结果不等于ARGV[1]时,脚本返回false,经过RESP协议转换后Java端拿到的是null。unlockResult != null判断直接短路,走“解锁失败”分支,然后业务方误以为锁被别人抢走,触发告警,实际锁根本没丢。真正的问题出在“脚本返回false被客户端当成null”的这个转换上,而不是锁逻辑本身。
修复方式很简单,把return false改成return 0,同时在客户端判断整数。这个案例提醒我:在写Lua脚本时,返回类型一定要在脚本端就统一,布尔值不要跨协议传输,统一用数字0和1表达成功失败。
4.3 踩过的坑:数字精度和类型陷阱
另一个坑藏得更深。有一个计数器脚本,作用是把某个累计值INCR后直接返回给业务方:
local cnt = redis.call('INCR', KEYS[1]) return cnt单看没问题,直到计数超过9007199254740991,也就是2^53,业务方上报数值对不上账。原因是Redis返回的64位整数在Lua内部被转成双精度浮点,超过2^53后精度开始丢失,再返回到客户端时已经不是原始值了。
排查了很久才意识到这个边界。解决办法也简单,脚本里不直接返回数字,而是转成字符串再返回:
local cnt = redis.call('INCR', KEYS[1]) return tostring(cnt)客户端收到字符串后按Long解析,精度保住了。这个教训让我养成一个习惯:脚本里一旦涉及大整数,绝不直接裸返回数字,统一转字符串。类似的坑还出现在对时间戳PEXPIRE结果做运算时——如果时间戳超过2^53,运算结果也会漂。
5. 案例复盘:一个分布式锁续期脚本从故障到稳定
纸上谈兵说了这么多,最后用一个完整案例把前面的调试流程串一遍。这是在Docker部署的Redis主从环境里排查分布式锁续期问题的全过程。
5.1 脚本需求与初版实现
分布式锁在持锁时间超过过期时间时,如果持有者还活着,就需要“看门狗”机制自动续期。续期不是无条件EXPIRE,必须确认“当前持锁者还是我自己”,否则帮别人续了命。于是写了这个续期脚本:
if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end return 0KEYS[1]是锁key,ARGV[1]是持锁者标识,ARGV[2]是续期毫秒数。测试环境跑了好几天没问题,功能看起来完全正常。但线上主从架构下,偶尔出现续期失败告警,日志里脚本返回0。
5.2 排查步骤全过程还原
我没有直接怀疑网络或分布式环境,而是先做第一步:手动执行脚本,验证基础逻辑。在redis-cli里设置一个锁key和value,然后手动执行续期脚本,返回1,续期成功。脚本没问题。
第二步,加日志埋点。在脚本里插入redis.log打印GET的原始返回值和ARGV[1]:
local current = redis.call('GET', KEYS[1]) redis.log(redis.LOG_NOTICE, string.format("current=%s expect=%s len=%d %d", tostring(current), tostring(ARGV[1]), string.len(tostring(current)), string.len(tostring(ARGV[1])))) if current == ARGV[1] then redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end return 0日志打出来之后,问题立刻浮出水面:current和expect是人眼看起来一模一样的UUID,但string.len一个返回36,一个返回37。差了一个字符——客户端在拼接锁值时,末尾多带了一个换行或者空格。我的判断没有错,脚本的核心逻辑是好的,问题出在调用方对ARGV[1]的清洗上。
第三步,修复客户端入参。把所有生成锁值的入口过滤掉不可见字符,重新上线,续期成功率达到100%。
第四步,处理主从切换的另一类问题。排查过程中还发现,在Redis主从架构下,如果脚本里的GET请求被路由到从节点,而主节点的锁刚刚写入还没来得及同步,脚本会读到空值,误判“锁不属于我”。这个问题的处理方式有几种:一是让锁相关操作强制走主节点;二是读不到锁时不要立刻返回0,而是加一次简单的重试或短暂等待;三是在业务层面对锁的读写做好路由隔离。我们最终选了第一种,最直接。
5.3 修复后的最终版本
最终稳定版脚本如下,也成了我们团队内部的标准模板:
local current = redis.call('GET', KEYS[1]) if current == false then return 0 end if current == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0相比初版,改动点有三个:先判断current是否为false,避免类型比较;续期结果直接透传PEXPIRE的返回值,而不是固定返回1,这样客户端能拿到真实执行结果;统一返回值类型为数字。
这段案例如果要提炼一句话,那就是:脚本调试很多时候拼的不是技巧,而是把“数据形态”看清楚。每次报错都先去确认那一刻Redis里到底存了什么,比反复猜脚本逻辑高效得多。
6. 调试心态和一些不上台面的小技巧
技术方法讲完,再说几个调试心态上的体会,这些东西在文档里基本找不到。
6.1 不迷信工具,先理清逻辑
Lua脚本普遍不长,绝大多数长度在一百行以内。遇到问题,最可靠的工具其实是纸和笔。把脚本抄下来,逐行走一遍,标出每个分支的条件、每个redis.call的返回值可能是什么类型。这个“人工trace”的过程看起来笨拙,但往往比上调试器更快发现真相。因为脚本的报错信息通常指向某一行,但你真正的问题是“某行执行时数据到底是什么样”,这一层只能靠你自己推演。
我自己的排查习惯是:先在脚本头部加一条日志,打印当前时间和所有参数;然后在每个条件分支的入口加一条日志;最后在返回值前加一条日志。加起来不超过五条,但执行路径完全暴露。如果加了日志还是找不到问题,那大概率不是脚本问题,而是调用方传参问题,回头查客户端的序列化配置和参数清洗逻辑。
6.2 关于Redisson等封装的一点体会
现在很多项目直接用现成的分布式锁框架,比如Redisson,框架内部帮你管理和执行Lua脚本。遇到问题时的排查方式有些不一样,切记不要把整个框架当成黑盒。
Redisson的锁脚本在它的源码和文档里都能找到,你可以把那段脚本抽出来,在redis-cli里用自己的参数手动执行一遍,理解它在做什么。我遇到过一个问题:用Redisson加锁,偶尔出现重入锁计数不准的情况。直接在业务代码里找答案非常痛苦,于是我把Redisson的加锁脚本提取出来,配合日志输出,发现是业务方从外部传入了自定义的lockWatchdogTimeout值,导致续期周期设置得不合理,锁在业务还没跑完时就被释放。这不是脚本本身的bug,而是配置参数和脚本语义没对齐。
所以在用任何框架的Lua功能时,我都建议至少把框架里那几个核心脚本读一遍。这不只是为了调试方便,更是为了理解框架的边界和参数语义。用好Redis和Lua,不是背命令、抄脚本,而是知道每个返回值在那个数据形态下意味着什么。这套调试方法你多跑几个真实场景,就会慢慢变成肌肉记忆。