浅析 redis lua 实现
关于 redis lua 的使用大家都不陌生,应用场景需要把复杂逻辑的原子性,比如计数器,分布式锁。见过没用 lua 实现的锁,不出 bug 也算是神奇
好奇实现的细节,阅读了几个版本,本文源码展示为 3.2 版本, 7.0 重构比较多,看着干净一些
一致性
1 |
redis> EVAL "return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }" 2 key1 key2 arg1 arg2 arg3 |
上面是简单的测试用例,其中 2 表示紧随其后的两个参数是 key, 我们通过
KEYS[i]
来获取,后面的是参数,通过
ARGV[i]
获取。我司历史上遇到过一次 redis 主从数据不一致的情况,原因比较简单:
Lua 脚本需要
hgetall
拿到所有数据,但是依赖顺序,恰好此时底层结构 master 己经变成了
hashtable
, 但是 slave 还是
ziplist
, 获取到的第一个数据当成 key 去做其它逻辑,导致主从不一致发生
引出使用 redis lua 最佳实践之一: 无论单机还是集群模式,对于 key 的操作必须通过参数列表,显示的传进去,而不能依赖脚本或是随机逻辑
结论其实显而易见,会有数据不一致的风险,同时对于 cluster 模式,要求所有 keys 所在的 slot 必须在同一个 shard 内,这个检测是在 smart client 或者是 cluster proxy 端
导致问题的原因在于,redis 旧版本同步时,本质上还是直接执行的 lua 脚本,这种模式叫做
verbatim replication
. 如果只同步 lua 脚本修改的内容可以避免这类 issue, 类似于 mysql binlog 的 SQL 模式和 ROW 模式的区别(也不完全一样)
实际上 redis 也是这么做的,3.2 版本引入
redis.replicate_commands()
, 只同步变更的内容,称为
effects replication
模式。5.0 lua 默认为该模式,在 7.0 中移除了旧版本的
verbatim replication
的支持
1 |
int luaRedisGenericCommand(lua_State *lua, int raise_error) { |
3.2 版本中,当 lua 虚拟机执行 redis.call 或者 redis.pcall 时调用
luaRedisGenericCommand
, 如果开启了
lua_replicate_commands
选项,那么生成一个
multi
事务命令用于复制
同时
call
去真正执行命令时,call_flags 打上
CMD_CALL_PROPAGATE_AOF
与
CMD_CALL_PROPAGATE_REPL
标签,执行命令时生成同步命令
1 |
void evalGenericCommand(client *c, int evalsha) { |
略去无关代码,
evalGenericCommand
函数最后判断,如果处于
effects replication
模式,那么只通过事务去执行产生的命令,而不是同步 lua 脚本,生成一个
exec
命令
另外为了保证
deterministic
确定性,redis lua 做了以下事情:
math.random
, 使用同一个种子,使得每次获取得到随机序列是一样的(除非指定了 math.randomseed)
SMEMBERS
, 4.0 版本 redids lua 会额外的做一次排序再返回。但是 5.0 后去掉了这个排序,因为前面提到的
effects replication
避免了这个问题,但是使用时不要假设有任何排序,是否排序要看普通命令的文档说明
RANDOMKEY
,
SRANDMEMBER
,
TIME
随机命令后,尝试去修改数据库,会报错。但是只读的 lua 脚本可以调用这些 non-deterinistic 命令
缓存
一般我们用
eval
命令执行 lua 脚本内容,但是对于高频执行的脚本,每次都要从文本中解析生成 function 开销会很高,所以引入了
evalsha
命令
1 |
> script load "redis.call('incr', KEYS[1])" |
先调用
script load
生成对应脚本的 hash 值,每次执行时只需要传入 hash 值即可
1 |
EVALSHA da0bf4095ef4b6f337f03ba9dcd326dbc5fc8ace 1 testkey |
对于 failover, 或第一次执行时 redis 不存在该 lua 函数则报错
1 |
> EVALSHA da0bf4095ef4b6f337f03ba9dcd326dbc5fc8aca 1 testkey |
所以,我们在封装 redis client 时要处理异常情况
evalsha
调用脚本
NOSCRIPT
, 再调用
SCRIPT LOAD
创建 lua 函数,Client 再正常调用
evalsha
1 |
void scriptCommand(client *c) { |
命令入口函数
scriptCommand
,
LOAD
名字很不直观,以为是个只读命令,但实际上做了很多事情:
luaCreateFunction
创建运行时函数
forceCommandPropagation
设置 flag 参数用于复制到从库或者 AOF
Lua 源码走读
初始化
1 |
void scriptingInit(int setup) { |
这里面涉及 c 如何与 lua 语言交互,如何互相调用的问题,不用深究用到了再学即可
lua_newtable(lua);
创建 lua table 并入栈,此时位置是 -1
lua_pushstring(lua,"call");
入栈字符串
call
lua_pushcfunction(lua,luaRedisCallCommand);
入栈函数
luaRedisCallCommand
lua_settable(lua,-3);
生成命令表,此时 table 位置是 -3,然后一次从栈中弹出,即伪代码为
table["call"] = luaRedisCallCommand
1 |
eval "redis.call('incr', KEYS[1])" 1 testkey |
这也就是为什么我们的 lua 脚本可以执行 redis 命令的原因,函数查表去执行。其它命令也同理
1 |
/* Replace math.random and math.randomseed with our implementations. */ |
这里也看到同时修改了 random 函数行为
执行命令
eval
函数总入口是
evalCommand
, 这里参考 3.2 源码,非 debug 模式下执行调用
evalGenericCommand
, 函数比较长,主要分三大块
1 |
void evalGenericCommand(client *c, int evalsha) { |
命令执行前的检查阶段,设置随机种子,设置一些 flag, 并检查 keys 个数是否正确
1 |
/* We obtain the script SHA1, then check if this function is already |
lua 中保存脚本 funcname 格式是
f_{evalsha hash}
, 如果每一次执行,调用
luaCreateFunction
让 lua 虚拟机加载 user_script 脚本
1 |
/* Populate the argv and keys table accordingly to the arguments that |
luaSetGlobalArray
将
KEYS
,
ARGS
以参数形式入栈,设置一堆 debug/slow call 相关的参数,最后
lua_pcall
执行用户脚本,lua 虚拟机执行脚本时,如果遇到
redis.call
就会回调 redis 函数
luaRedisCallCommand
, 对应的
redis.pcall
执行
luaRedisPCallCommand
函数
1 |
if (err) { |
代码有点长,总体就是执行超时处理,生成
exec
用于复制,最后如果 replication 从库没有执行过这个
evlsha
脚本,并且当前模式不是 lua_always_replicate_commands 要把脚本真实内容也先同步到 replication
这里还有最重要的是
luaReplyToRedisReply(c,lua);
将 lua 返回值,转换成 redis RESP 格式
再来看一下
luaRedisGenericCommand
是如何调用 redis 函数
1 |
int luaRedisGenericCommand(lua_State *lua, int raise_error) { |
这里的功能,主要是从 lua 虚拟机中获取 eval 脚本的参数,赋值给 redisClient, 为以后执行命令做准备
1 |
/* Command lookup */ |
lookupCommand
查表,找到要执行的 redis 命令
1 |
/* There are commands that are not allowed inside scripts. */ |
如果是不允许在 lua 中执行的命令,报错退出
1 |
/* Write commands are forbidden against read-only slaves, or if a |
设置 cmd->flags
1 |
/* If this is a Redis Cluster node, we need to make sure Lua is not |
如果是 cluster 模式,要保证 lua 的 keys 所在的 slots 必须在本地 shard
1 |
/* If we are using single commands replication, we need to wrap what |
如果是
effect replication
模式,生成
multi
事务命令用于复制
1 |
/* Run the command */ |
这里才去真正的执行命令,
call_flags
参数用于控制是否复制,是否生成 AOF 等等
1 |
/* Convert the result of the Redis command into a suitable Lua type. |
redisProtocolToLuaType
把 redis 结果转换成 lua 类型返回给 lua 虚拟机