Redis之Lua踩坑记

1,633 阅读4分钟

本文已参加「新人创作礼」活动,一起开启掘金创作之路。

Round 1

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

一开始,后端接收到参数后直接存入MySQL。当阅读文章的用户较多时,就会给数据库造成压力。于是改为先将阅读时间戳和阅读时长存入Redis,使用一个定时任务来周期性扫描,如果发现用户一定时间没有读文章(根据阅读时间戳和当前时间的间隔来判断),视为已经结束阅读,将阅读时长批量存入MySQL。

由于需要同时操作两个key(阅读时间戳和阅读时长),使用Lua脚本来确保事务。此时的脚本非常简单:

--- 阅读时间戳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

Round 2

上面的脚本上线后运行良好。但是后来出现了新的场景,对于公众号文章,前端可以拿到总的阅读时长。同样的接口,但是现在增加了阅读时长参数,上面的脚本显然不适用了,因为阅读时长的增长是固定的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

关键信息在最后,说我试图对一个布尔值进行算数运算。布尔值?

看看新脚本,涉及到运算的只有redis.call("get", durationKey) + seconds,seconds不可能是布尔值,那就只能是redis.call("get", durationKey)了。但是我理解即便durationKey不存在,这里也应该返回一个nil呀,怎么会是布尔值呢?会不会是Spring把Lua的nil视作布尔值的false了呢?

为了验证我的猜想,我把“阅读时长增加seconds秒”改成了这样:

local oldDuration = redis.call("get", durationKey)
if oldDuration == nil then
    redis.call("set", durationKey, second)
else
    redis.call("set", durationKey, oldDuration + second)
end

如果没有旧阅读时长,直接set新阅读时长;如果有旧阅读时长,则set新阅读时长与旧阅读时长的和。简单明了,不可能出错。然而测试结果打破了我的自信。

不会吧不会吧,不会真的get到一个布尔值吧?

抱着姑且试试的想法,我给上面的程序加了个判断:

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

程序好了。我TM……

尽管程序好了,但是技术人的执着驱使我必须弄懂为什么。最终我在官方文档上找到这样的一段话:

image.png

简而言之就是,我之前的猜想很接近,的确是存在类型转换,但是并不是Spring把Lua的nil当作了false,而是Lua把Redis的nil当作了false。也就是说,锅是Lua的。详情见官方文档: redis.io/docs/manual…

需要往下翻一翻,或者可以直接搜索Data type conversion

get到这个知识点之后,我发现自己的脚本写的啰嗦了。于是我又改了一下,最后变成了这样:

--- 阅读时间戳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

这里需要注意的一点是,Lua的取反符号是not,而不是Java的!,也不是~。