背景介绍
tendis存储版在2.2.0版本支持了Lua,支持原生redis中的大部分lua功能。本文介绍tendis存储版项目中Lua的实现。
使用Lua的主要作用:
1.保证隔离性,也就是Lua脚本中调用的多个命令处理的多个key不会被其它的命令插入而修改。
2.可编程逻辑,减少网络开销。不用在客户端和服务器之间来回发包,一次性执行多个命令。
使用示例
示例功能:限制同一个ip在某段时间内只允许登录n次
shell下输入:
lua_code='
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire_time = ARGV[2]
local is_exists = redis.call("EXISTS", key)
if is_exists == 1 then
if redis.call("INCR", key) > limit then
return 0
else
return 1
end
else
redis.call("SET", key, 1)
redis.call("EXPIRE", key, expire_time)
return 1
end
'
限制ip=127.0.0.1在60秒以内只能登录2次:
redis-cli -p 9953 eval "$lua_code" 1 127.0.0.1 2 60
实现方案
实现的模块图如图所示

(一)客户端
客户端调用命令:eval lua_codes KEYS ARGV
(二)tendis环境的evalCommand
tendis环境下面实现evalCommand,该命令主要处理的事情如下:
1.取出用户传入的KEYS,并设置到lua环境(lua_state)的全局表_G中,即图中的_G.KEYS
2.取出用户传入的ARGV,并设置到lua环境(lua_state)的全局表_G中,即图中的_G.ARGV
3.取出用户传入的lua_codes,并把它封装成一个函数,把这个函数设置到lua环境(lua_state)的全局表_G中,即图中的_G.function
4.构造一个伪客户端fakeSess
5.对所有的KEY上X锁,从而实现隔离性
6.调用lua_codes,通过对lua环境中的栈“stack”进行压栈出栈来进行函数的调用,参数的传递,结果的取回。
7.释放所有KEY的锁
8.返回结果给客户端
相关逻辑参看代码:LuaState::evalGenericCommand()。
(三)lua环境
lua环境里面执行的lua_code代码,会通过redis.call("","",...)来调用tendis环境的普通命令,比如get命令,set命令等。
首先需要在lua环境的全局表里面定义一个全局函数 _G.redis.call(){.....},这个函数实现了调用tendis环境命令的相关逻辑,主要事情有:
1.设置fakeSess的参数
2.调用tendis环境的命令getCommand/setCommand/....
3.取回tendis命令的返回结果,并把结果压到lua环境的栈stack里面
4.对多次redis.call()调用重复上面的1-3操作。
5.把整个lua_codes的最终结果压到lua环境的栈stack里面。tendis环境的evalCommand从栈stack里面取得最终结果。
相关逻辑主要参考代码:LuaState::luaRedisGenericCommand()
(四)tendis环境的普通命令
tendis环境执行普通命令getCommand/setCommand等,与普通命令处理逻辑一致,这里不再详述。
lua_state环境管理
因为tendis存储版采用多线程实现,如果只用一个lua_state的话,无法发挥多线程的性能优势,性能会退化为单线程的性能。所以需要采用多lua_state,为每个需要运行eval命令的线程分配一个lua_state。
如果我们在WorkerPool类中为每个_threads创建一个lua_state对象,这样每个线程都使用自己的lua环境,这里维护起来比较简单。但是这样会让线程池类和lua相关逻辑强耦合,不利于代码维护和功能扩展,所以引入scriptManager来管理lua_state。
scriptManager管理着一个lua_state池子,对lua环境的调用通过它的接口进入。模块图如下:

因为lua支持math.random函数,多个线程调用random函数会使得随机出来的序列互相影响,为了实现隔离,需要为每个lua_state各分配一个RedisRandom对象。
主要调用逻辑参考代码:ScriptManager::run()
伪客户端
客户端的会话sess,保留着客户端的evalCommand命令的参数等信息,不能任意修改。
getCommand/setCommand等普通命令执行也需要一个会话sess,用来传入参数返回结果等。所以需要创建一个fakeSess伪客户端。每次调用redis.call()时需要重新设置命令参数。
对KEY上锁
因为evalCommand里面需要对所有key上X锁,而普通的getCommand/setCommand等命令会再次尝试对该key上X/S锁,从而会出现上锁冲突的问题。
通过调研分析,决定利用递归锁功能来解决这个问题。即同一个会话上下文SessionCtx可以对同一个key多次递归上锁。为了保证逻辑正确性,后面上的锁级别需要弱于前面上的锁的级别,不然上锁需要返回失败,锁的级别强弱关系:X > S > IX > IS。
evalCommand里面进行上X锁的时候,不能用客户端sess的SessionCtx进行上锁,需要对fakeSess的SessionCtx进行上锁,这样才能跟普通命令进行递归上锁。因为evalCommand里面上的是X锁,是最先上锁,且为最强级别,不会跟普通命令的上锁级别出现冲突。
递归锁逻辑详细实现参见源码SessionCtx::isLockedByMe()。
SCRIPT KILL
script kill的功能是杀掉执行超时的脚本。
为了实现该功能,需要在lua环境里面设置一个钩子函数luaMaskCountHook,lua环境对lua_code每执行一段时间都会回调该函数,在回调函数里面判断是否有客户端发送过script kill行为。有kill行为则中断执行,并返回错误码给客户端。
因为tendis存储版是多线程的原因,所以收到该命令会kill掉所有正在执行的lua脚本,另外如果脚本还没杀完时不允许新的lua脚本执行。
脚本缓存
redis社区版支持脚本缓存功能,通过SCRIPT LOAD缓存脚本,通过EVALSHA执行之前缓存的脚本。
但是脚本缓存功能只能缓存在执行SCRIPT LOAD的节点上,其他节点并不知道该脚本的存在。在cluster集群模式下,如果涉及到搬迁,因为slots跟脚本没有任何对应关系,所以无法知道哪些脚本需要搬迁。所以会带来一致性的问题。redis社区版直接忽略该问题。
因为这个原因,tendis存储版暂时不支持脚本缓存相关功能。
Lua主从复制
为了保证主从一致性,还需要对Lua进行主从复制。redis社区版支持完全复制,效果复制,选择复制,相关命令为:
redis.replicate_commands()
redis.set_repl(redis.REPL_ALL/REPL_AOF/REPL_REPLICA/REPL_SLAVE/REPL_NONE)
tendis存储版为了简化行为,统一采用效果复制,即直接复制执行的结果。主要依赖普通命令的binlog主从复制功能实现。
其他问题
实现lua还需要考虑很多其他问题,比如:
1.因为有些命令的返回值顺序具有不确定性,所以需要对结果进行排序
2.为了保护lua环境不被用户错误修改,需要对lua环境的全局变量进行保护
此类细节问题不再详细描述。
源码地址
tendis存储版开源地址:https://github.com/Tencent/Tendis
Lua相关代码地址:https://github.com/Tencent/Tendis/blob/dev-2.2/src/tendisplus/script/
Lua环境第三方库地址:https://github.com/Tencent/Tendis/tree/dev-2.2/src/thirdparty/lua




