最近在使用 Redis Lua 脚本做登录失败次数限制时,遇到了一个比较容易忽略的问题。
Lua 脚本里明明写了:
local maxCount = tonumber(ARGV[1])
但是运行时报错:
attempt to compare nil with number
对应代码类似这样:
local currentCount = tonumber(redis.call('GET', KEYS[1]) or '0') if currentCount >= tonumber(ARGV[1]) then return -1 end乍一看会觉得奇怪:ARGV[1]明明传的是登录失败次数上限,比如5,为什么tonumber(ARGV[1])会得到nil?
问题原因
问题的关键不在 Lua 脚本本身,而在 Java 侧执行 Lua 脚本时使用的 RedisTemplate 序列化方式。
项目里常见的 RedisTemplate 配置是:
RedisTemplate<String, Object>
并且 valueSerializer 可能配置为:
GenericJackson2JsonRedisSerializer
这个配置用于存储对象、JSON 数据时很方便,但在执行 Lua 脚本时需要注意:
传给 Lua 的 ARGV 参数也可能会经过 RedisTemplate 的序列化。
比如 Java 里传的是:
String.valueOf(5)
你以为 Lua 收到的是:
5
但如果经过 JSON 序列化,Lua 实际收到的可能是:
"5"
这两个在 Lua 里是不一样的:
tonumber("5") -- 结果是 5 tonumber("\"5\"") -- 结果是 nil所以最终就会出现:
currentCount >= nil
然后报错:
attempt to compare nil with number
简单来说:
不是参数没传,也不是 Lua 写错了,而是参数被 RedisTemplate 序列化成了 Lua 不好直接识别的格式。
方案一:使用 StringRedisTemplate,参数传 String.valueOf(...)
这是比较推荐的方式。
适合场景:
验证码发送限制;
登录失败次数限制;
限流;
计数器;
Lua 脚本参数都是字符串或数字。
Java 侧使用StringRedisTemplate:
Long result = stringRedisTemplate.execute( checkAndIncrementLoginFailScript, Collections.singletonList(failCountKey), String.valueOf(LOGIN_FAIL_MAX_COUNT), String.valueOf(LOGIN_LOCK_MINUTES * 60) );
Lua 侧统一使用tonumber():
-- 原子性地检查登录失败次数并累加 -- 如果失败次数已达上限,返回 -1; -- 如果未达上限,累加失败次数,并返回累加后的值 -- -- KEYS[1]: 登录失败计数的 Redis Key(如 login_fail_count:13800138001) -- ARGV[1]: 登录失败次数上限(如 5) -- ARGV[2]: 锁定时间,单位秒(如 1800,即 30 分钟) local currentCount = tonumber(redis.call('GET', KEYS[1]) or '0') -- 如果失败次数已达上限,直接返回 -1,不累加 if currentCount >= tonumber(ARGV[1]) then return -1 end -- 累加失败次数 local newCount = redis.call('INCR', KEYS[1]) -- 首次失败时,设置过期时间(锁定窗口) if newCount == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end return newCount这种方式的好处是职责清晰:
RedisTemplate<String, Object> // 用来存对象、JSON StringRedisTemplate // 用来处理字符串、数字、Lua、计数器
对于 Lua 脚本来说,ARGV本身就是字符串参数,所以 Java 端用:
String.valueOf(LOGIN_FAIL_MAX_COUNT)
Lua 端再用:
tonumber(ARGV[1])
这个约定比较清楚,也比较稳定。
方案二:使用 RedisTemplate,参数直接传数字
也可以继续使用原来的RedisTemplate<String, Object>,并且参数直接传数字:
Long result = redisTemplate.execute( checkAndIncrementLoginFailScript, Collections.singletonList(failCountKey), LOGIN_FAIL_MAX_COUNT, LOGIN_LOCK_MINUTES * 60 );
Lua 侧仍然要使用tonumber():
-- 原子性地检查登录失败次数并累加 -- 如果失败次数已达上限,返回 -1; -- 如果未达上限,累加失败次数,并返回累加后的值 -- -- KEYS[1]: 登录失败计数的 Redis Key(如 login_fail_count:13800138001) -- ARGV[1]: 登录失败次数上限(如 5) -- ARGV[2]: 锁定时间,单位秒(如 1800,即 30 分钟) local currentCount = tonumber(redis.call('GET', KEYS[1]) or '0') -- 如果失败次数已达上限,直接返回 -1,不累加 if currentCount >= tonumber(ARGV[1]) then return -1 end -- 累加失败次数 local newCount = redis.call('INCR', KEYS[1]) -- 首次失败时,设置过期时间(锁定窗口) if newCount == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end return newCount为什么这种方式也可以?
因为如果传的是数字,例如:
5
经过 JSON 序列化后,通常还是:
5
Lua 中:
tonumber("5")可以正常转成数字。
但是如果传的是字符串:
String.valueOf(5)
在某些 JSON 序列化配置下,可能会变成:
"5"
这时:
tonumber("\"5\"")就会得到nil。
所以如果继续使用RedisTemplate<String, Object>,需要注意:
不要把数字参数先转成 String 再交给 JSON 序列化器处理,否则可能被序列化成带引号的 JSON 字符串。
两种方案怎么选?
更推荐第一种:
StringRedisTemplate + String.valueOf(...) + Lua tonumber(...)
因为它的类型约定最清楚:
Java 端明确传字符串形式的数字;
Redis 使用字符串序列化;
Lua 端统一用
tonumber()转数字。
第二种:
RedisTemplate + 直接传数字 + Lua tonumber(...)
也可以用,但前提是你清楚当前 RedisTemplate 的序列化行为。
如果当前 RedisTemplate 主要是用来存对象、JSON,那么它的 valueSerializer 可能会影响 Lua 参数的最终格式,这类问题后续还可能再次出现。
总结
这次问题可以概括为:
RedisTemplate 使用 JSON 序列化时,Lua 脚本的 ARGV 参数可能被序列化成非预期格式,导致 tonumber(ARGV[x]) 得到 nil。
最终建议:
如果是 Lua、限流、计数器、验证码、登录失败次数这类场景,优先使用
StringRedisTemplate;使用
StringRedisTemplate时,Java 端参数可以传String.valueOf(xxx);Lua 脚本里参与数字计算和比较的参数,统一使用
tonumber(ARGV[x]);如果继续使用
RedisTemplate<String, Object>,可以直接传数字参数,不要先转成字符串再让 JSON 序列化器处理。
一句话总结:
Lua 脚本问题不一定出在 Lua,很多时候是 Java 侧 RedisTemplate 的序列化方式影响了 ARGV 的实际值。