12_Redis Lua 脚本踩坑:为什么 tonumber(ARGV[x]) 会得到 nil?

最近在使用 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。

最终建议:

  1. 如果是 Lua、限流、计数器、验证码、登录失败次数这类场景,优先使用 StringRedisTemplate;

  2. 使用 StringRedisTemplate 时,Java 端参数可以传 String.valueOf(xxx);

  3. Lua 脚本里参与数字计算和比较的参数,统一使用 tonumber(ARGV[x]);

  4. 如果继续使用 RedisTemplate<String, Object>,可以直接传数字参数,不要先转成字符串再让 JSON 序列化器处理。

一句话总结:

Lua 脚本问题不一定出在 Lua,很多时候是 Java 侧 RedisTemplate 的序列化方式影响了 ARGV 的实际值。

相关推荐
.Hypocritical.1 分钟前
Redis从入门到实战:核心原理、场景落地与避坑指南
数据库·redis·缓存
用户6222884048195 分钟前
Redis 分布式锁:从 2.8 之前到 2.8+,一篇讲透
redis
anew___1 小时前
《从零手写操作系统 (20):ELF加载器——让OS读懂现代编译器》
java·服务器·前端
code2cat1 小时前
【随笔】MCP资源更新订阅:通知到达以后,Agent怎样刷新旧资料
java·后端·开发工具·ai agent·mcp
一只旭宝2 小时前
C++ 后端开发面试题整理
java·c++·面试
sunshine22 girl2 小时前
Java学习五 面向对象高级5 内部类3-静态内部类和局部内部类(了解)
java·学习
en.en..2 小时前
Linux 进程退出exit(函数)
java·开发语言·算法
李游Leo2 小时前
HarmonyOS 7 TaskPool + Node-API + zstd:Native 批量压缩任务的主线程隔离、进度回传与资源收口【鸿蒙心迹】
java·华为·harmonyos
Light Gao2 小时前
Encoder 与 Decoder 详解:从 Embedding 到 Seq2Seq 的完整数据流
java·开发语言·embedding
程序猿乐锅2 小时前
【黑马点评 | 第十一篇】关注 Feed 流实现
java·数据库·redis·分布式·后端·缓存·maven