Redis Lua 脚本踩坑复盘:为什么 `GET` 后做加法,会报 boolean 错误

陆遥
陆遥
发布时间:2026-08-24| 31人浏览过
原创

前言

把阅读时长统计从 MySQL 直写改成 Redis 暂存后,性能问题缓解了,但 Lua 脚本并没有因此变得“无脑可写”。这个案例最有价值的地方,不是一个布尔值报错本身,而是它把 Redis 命令返回值在 Lua 中的类型转换规则完整暴露了出来,也顺带说明了什么时候该信任原生命令,什么时候必须先把返回值判断清楚。

文章阅读时长这类接口,看起来只是“每秒记一次数”,真正上线后却很容易把数据库写压力推高。这个案例的处理思路很典型:先把阅读时间戳和阅读时长写入 Redis,再由定时任务批量回刷 MySQL;但当脚本从固定自增改成按参数累加时,一个不显眼的类型转换细节就把逻辑绊住了。

下面按问题出现的顺序拆开看:先还原最初可用的脚本,再解释为什么新增 `seconds` 参数后会报错,最后给出更稳妥也更简洁的 Lua 写法,以及这个坑背后真正该记住的判断规则。

为什么要先用 Redis + Lua 记录阅读时长

场景是一个记录文章阅读时长的接口,前端每秒轮询一次,后端根据阅读记录 ID 更新阅读时长,阅读记录在用户打开文章时创建。

阅读时长记录链路示意图,展示前端轮询、Redis 暂存、定时任务扫描和 MySQL 批量回写的关系
阅读时长统计的缓存回写链路先写 Redis、再批量回刷 MySQL,是这个案例的性能优化前提,也是后续 Lua。

一开始,后端收到参数后直接写入 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

为了验证问题,原文先把逻辑拆开写成显式判断:

Redis Lua 类型转换导致布尔值算术报错的示意图,突出 GET 取空值后参与加法的问题
布尔值报错是怎么来的表面上是加法报错,实质上是 Redis 返回值进入 Lua 后发生了类型转换。
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 的数据类型转换规则。原文提到的文档入口是:

redis.io/docs/manual…

可在文档中搜索 Data type conversion 查看对应说明。

最后的脚本该怎么写得更简洁

弄清楚转换规则后,脚本其实可以进一步简化,不必显式判断 `nil` 或 `boolean`,直接按 Lua 的假值写法处理即可:

Lua 脚本修正前后对比图,展示显式类型判断与 not oldDuration 简化写法的区别
脚本修正方案前后对比理解返回值规则后,脚本可以从“硬判断类型”收敛到更贴近 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`、列表操作、哈希读取这类命令,写判断前最好先对照官方文档确认转换规则。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。