文章阅读时长这类接口,看起来只是“每秒记一次数”,真正上线后却很容易把数据库写压力推高。这个案例的处理思路很典型:先把阅读时间戳和阅读时长写入 Redis,再由定时任务批量回刷 MySQL;但当脚本从固定自增改成按参数累加时,一个不显眼的类型转换细节就把逻辑绊住了。
下面按问题出现的顺序拆开看:先还原最初可用的脚本,再解释为什么新增 `seconds` 参数后会报错,最后给出更稳妥也更简洁的 Lua 写法,以及这个坑背后真正该记住的判断规则。
为什么要先用 Redis + Lua 记录阅读时长
场景是一个记录文章阅读时长的接口,前端每秒轮询一次,后端根据阅读记录 ID 更新阅读时长,阅读记录在用户打开文章时创建。

一开始,后端收到参数后直接写入 MySQL。用户一多,数据库压力就会上来。后来的优化方案是:
- 先把“最近一次阅读时间戳”和“累计阅读时长”写进 Redis;
- 再通过定时任务周期性扫描;
- 如果发现用户一段时间没有继续阅读,就认为本次阅读结束;
- 最后把阅读时长批量写入 MySQL。
这里会同时操作两个 key:一个是阅读时间戳,一个是阅读时长。为了保证这两个操作在一次请求内原子执行,使用 Lua 脚本是很自然的选择。
Round 1:固定每次加 1 秒时,脚本为什么没问题
第一版脚本很简单:更新时间戳,把阅读时长 `incr` 一次,再分别设置过期时间。
--- 阅读时间戳key
local timeKey = KEYS[1]
--- 阅读时长key
local durationKey = KEYS[2]
--- 当前时间戳
local current = ARGV[1]
--- 过期时间
local expire = ARGV[2]
--- 阅读时间戳更新为当前时间戳
redis.call("set", timeKey, current)
--- 阅读时长增加1秒
redis.call("incr", durationKey)
--- 设置过期时间
redis.call("expire", timeKey, expire)
redis.call("expire", durationKey, expire)
return true
这一版能稳定运行,关键在于 `INCR` 已经把“取值、转整数、自增、写回”这件事封装好了。即使 `durationKey` 一开始不存在,Redis 也能按它自己的规则完成处理,不需要在 Lua 里自己做空值判断和算术运算。
Round 2:改成按参数累加后,报错出在哪里
后来又出现了一个新场景:对于公众号文章,前端能直接拿到总阅读时长。这样一来,后端接口除了时间戳和过期时间,还要额外接收一个阅读时长参数,固定 `incr` 1 秒显然不够用了。
于是脚本被改成了下面这样:
--- 阅读时间戳key
local timeKey = KEYS[1]
--- 阅读时长key
local durationKey = KEYS[2]
--- 当前时间戳
local current = ARGV[1]
--- 过期时间
local expire = ARGV[2]
--- 阅读时长秒数
local seconds = ARGV[3]
--- 阅读时间戳更新为当前时间戳
redis.call("set", timeKey, current)
--- 阅读时长增加seconds秒
redis.call("set", durationKey, redis.call("get", durationKey) + seconds)
--- 设置过期时间
redis.call("expire", timeKey, expire)
redis.call("expire", durationKey, expire)
return true
测试时报错如下:
org.springframework.dao.InvalidDataAccessApiUsageException: ERR Error running script (call to f_f6ea343b2df0bb427a068c67e15fa9a65030e89e): @user_script:13: user_script:13: attempt to perform arithmetic on a boolean value
报错重点是最后一句:attempt to perform arithmetic on a boolean value,也就是“试图对一个布尔值做算术运算”。问题位置基本可以锁定在这一段:
redis.call("get", durationKey) + seconds
`seconds` 是参数,问题更可能出在 `redis.call("get", durationKey)` 的返回值上。直觉上很多人会认为,key 不存在时应该拿到 `nil`,但这里实际遇到的却不是普通的 `nil` 判断问题。
真正的坑:Redis Lua 的空值会映射成 Lua 的 false
为了验证问题,原文先把逻辑拆开写成显式判断:

local oldDuration = redis.call("get", durationKey)
if oldDuration == nil then
redis.call("set", durationKey, second)
else
redis.call("set", durationKey, oldDuration + second)
end
结果仍然不符合预期。继续加判断后,程序才恢复正常:
local oldDuration = redis.call("get", durationKey)
if oldDuration == nil or type(oldDuration) == 'boolean' then
redis.call("set", durationKey, second)
else
redis.call("set", durationKey, oldDuration + second)
end
问题根源最终在官方文档里能找到:Redis 命令返回值在 Lua 环境里会发生类型转换。简单说,Redis 的空结果并不是按“你以为的 Lua `nil`”那样参与后续逻辑,而是会映射成 Lua 可判断的假值语义;在这个案例里,`GET` 取不到值时实际落到了布尔值处理分支上,导致后续直接参与加法时报错。
因此,这个错误的责任不在 Spring,而在 Redis Lua 的数据类型转换规则。原文提到的文档入口是:
可在文档中搜索 Data type conversion 查看对应说明。
最后的脚本该怎么写得更简洁
弄清楚转换规则后,脚本其实可以进一步简化,不必显式判断 `nil` 或 `boolean`,直接按 Lua 的假值写法处理即可:

--- 阅读时间戳key
local timeKey = KEYS[1]
--- 阅读时长key
local durationKey = KEYS[2]
--- 当前时间戳
local current = ARGV[1]
--- 过期时间
local expire = ARGV[2]
--- 阅读时长秒数
local seconds = ARGV[3]
--- 阅读时间戳更新为当前时间戳
redis.call("set", timeKey, current)
--- 阅读时长增加seconds秒
local oldDuration = redis.call("get", durationKey)
if not oldDuration then
redis.call("set", durationKey, second)
else
redis.call("set", durationKey, oldDuration + second)
end
--- 设置过期时间
redis.call("expire", timeKey, expire)
redis.call("expire", durationKey, expire)
return true
这里的关键点是 `if not oldDuration then`。既然这个场景下取不到值会落到假值判断,直接用 `not` 比手动比对类型更贴近 Lua 的写法。
同时还要注意一个语法细节:Lua 的取反运算符是 not,不是 Java 里的 !,也不是 ~。
这个案例里最值得记住的三件事
1. 需要同时更新多个 key 时,Lua 的价值不只是“能写脚本”
这个场景里,阅读时间戳和阅读时长必须一起更新,还要一起续期。Lua 脚本的意义是把这些操作收进一次原子执行中,避免并发下数据不一致。
2. 能用 Redis 原生命令完成的累加,优先不要自己拼算术
第一版之所以稳定,很大程度上是因为 `INCR` 避开了脚本层的取值与类型处理。只要业务允许固定增量,原生命令往往比自己 `GET` 再 `SET` 更省事,也更不容易踩类型坑。
3. 在 Redis Lua 里写判断前,先确认返回值的真实类型
很多脚本问题不是业务逻辑错了,而是“你以为返回的是一种类型,实际却是另一种”。尤其是 `GET`、列表操作、哈希读取这类命令,写判断前最好先对照官方文档确认转换规则。













