深刻分析 Redis Lua 脚本运行原理

Redis 提供了很是丰富的指令集,可是用户依然不知足,但愿能够自定义扩充若干指令来完成一些特定领域的问题。Redis 为这样的用户场景提供了 lua 脚本支持,用户能够向服务器发送 lua 脚原本执行自定义动做,获取脚本的响应数据。Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程当中不会被任意其它请求打断。redis

好比在分布式锁小节,咱们提到了 del_if_equals 伪指令,它能够将匹配 key 和删除 key 合并在一块儿原子性执行,Redis 原生没有提供这样功能的指令,它可使用 lua 脚原本完成。算法

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
复制代码

那上面这个脚本如何执行呢?使用 EVAL 指令数组

127.0.0.1:6379> set foo bar
OK
127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 foo bar
(integer) 1
127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 foo bar
(integer) 0
复制代码

EVAL 指令的第一个参数是脚本内容字符串,上面的例子咱们将 lua 脚本压缩成一行以单引号围起来是为了方便命令行执行。而后是 key 的数量以及每一个 key 串,最后是一系列附加参数字符串。附加参数的数量不须要和 key 保持一致,能够彻底没有附加参数。缓存

EVAL SCRIPT KEY_NUM KEY1 KEY2 ... KEYN ARG1 ARG2 ....
复制代码

上面的例子中只有 1 个 key,它就是 foo,紧接着 bar 是惟一的附加参数。在 lua 脚本中,数组下标是从 1 开始,因此经过 KEYS[1] 就能够获得 第一个 key,经过 ARGV[1] 就能够获得第一个附加参数。redis.call 函数可让咱们调用 Redis 的原生指令,上面的代码分别调用了 get 指令和 del 指令。return 返回的结果将会返回给客户端。bash

SCRIPT LOAD 和 EVALSHA 指令

在上面的例子中,脚本的内容很短。若是脚本的内容很长,并且客户端须要频繁执行,那么每次都须要传递冗长的脚本内容势必比较浪费网络流量。因此 Redis 还提供了 SCRIPT LOAD 和 EVALSHA 指令来解决这个问题。服务器

SCRIPT LOAD 指令用于将客户端提供的 lua 脚本传递到服务器而不执行,可是会获得脚本的惟一 ID,这个惟一 ID 是用来惟一标识服务器缓存的这段 lua 脚本,它是由 Redis 使用 sha1 算法揉捏脚本内容而获得的一个很长的字符串。有了这个惟一 ID,后面客户端就能够经过 EVALSHA 指令反复执行这个脚本了。 咱们知道 Redis 有 incrby 指令能够完成整数的自增操做,可是没有提供自乘这样的指令。

incrby key value  ==> $key = $key + value
mulby key value ==> $key = $key * value
复制代码

下面咱们使用 SCRIPT LOAD 和 EVALSHA 指令来完成自乘运算。微信

local curVal = redis.call("get", KEYS[1])
if curVal == false then
  curVal = 0
else
  curVal = tonumber(curVal)
end
curVal = curVal * tonumber(ARGV[1])
redis.call("set", KEYS[1], curVal)
return curVal
复制代码

先将上面的脚本单行化,语句之间使用分号隔开网络

local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal
复制代码

加载脚本分布式

127.0.0.1:6379> script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'
"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"
复制代码

命令行输出了很长的字符串,它就是脚本的惟一标识,下面咱们使用这个惟一标识来执行指令函数

127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 notexistskey 5
(integer) 0
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 notexistskey 5
(integer) 0
127.0.0.1:6379> set foo 1
OK
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo 5
(integer) 5
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo 5
(integer) 25
复制代码

错误处理

上面的脚本参数要求传入的附加参数必须是整数,若是没有传递整数会怎样呢?

127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo bar
(error) ERR Error running script (call to f_be4f93d8a5379e5e5b768a74e77c8a4eb0434441): @user_script:1: user_script:1: attempt to perform arithmetic on a nil value
复制代码

能够看到客户端输出了服务器返回的通用错误消息,注意这是一个动态抛出的异常,Redis 会保护主线程不会由于脚本的错误而致使服务器崩溃,近似于在脚本的外围有一个很大的 try catch 语句包裹。在 lua 脚本执行的过程当中遇到了错误,同 redis 的事务同样,那些经过 redis.call 函数已经执行过的指令对服务器状态产生影响是没法撤销的,在编写 lua 代码时必定要当心,避免没有考虑到的判断条件致使脚本没有彻底执行。

若是读者对 lua 语言有所了解就知道 lua 原生没有提供 try catch 语句,那上面提到的异常包裹语句到底是用什么来实现的呢?lua 的替代方案是内置了 pcall(f) 函数调用。pcall 的意思是 protected call,它会让 f 函数运行在保护模式下,f 若是出现了错误,pcall 调用会返回 false 和错误信息。而普通的 call(f) 调用在遇到错误时只会向上抛出异常。在 Redis 的源码中能够看到 lua 脚本的执行被包裹在 pcall 函数调用中。

// 编译期
int luaCreateFunction(client *c, lua_State *lua, char *funcname, robj *body) {
  ...
  if (lua_pcall(lua,0,0,0)) {
    addReplyErrorFormat(c,"Error running script (new function): %s\n",
            lua_tostring(lua,-1));
    lua_pop(lua,1);
    return C_ERR;
  }
  ...
}

// 运行期
void evalGenericCommand(client *c, int evalsha) {
  ...
  err = lua_pcall(lua,0,1,-2);
  ...
}
复制代码

Redis 在 lua 脚本中除了提供了 redis.call 函数外,一样也提供了 redis.pcall 函数。前者遇到错误向上抛出异常,后者会返回错误信息。使用时必定要注意 call 函数出错时会中断脚本的执行,为了保证脚本的原子性,要谨慎使用。

错误传递

redis.call 函数调用会产生错误,脚本遇到这种错误会返回怎样的信息呢?咱们再看个例子

127.0.0.1:6379> hset foo x 1 y 2
(integer) 2
127.0.0.1:6379> eval 'return redis.call("incr", "foo")' 0
(error) ERR Error running script (call to f_8727c9c34a61783916ca488b366c475cb3a446cc): @user_script:1: WRONGTYPE Operation against a key holding the wrong kind of value
复制代码

客户端输出的依然是一个通用的错误消息,而不是 incr 调用本应该返回的 WRONGTYPE 类型的错误消息。Redis 内部在处理 redis.call 遇到错误时是向上抛出异常,外围的用户看不见的 pcall调用捕获到脚本异常时会向客户端回复通用的错误信息。若是咱们将上面的 call 改为 pcall,结果就会不同,它能够将内部指令返回的特定错误向上传递。

127.0.0.1:6379> eval 'return redis.pcall("incr", "foo")' 0
(error) WRONGTYPE Operation against a key holding the wrong kind of value
复制代码

脚本死循环怎么办?

Redis 的指令执行是个单线程,这个单线程还要执行来自客户端的 lua 脚本。若是 lua 脚本中来一个死循环,是否是 Redis 就完蛋了?Redis 为了解决这个问题,它提供了 script kill 指令用于动态杀死一个执行时间超时的 lua 脚本。不过 script kill 的执行有一个重要的前提,那就是当前正在执行的脚本没有对 Redis 的内部数据状态进行修改,由于 Redis 不容许 script kill 破坏脚本执行的原子性。好比脚本内部使用了 redis.call("set", key, value) 修改了内部的数据,那么 script kill 执行时服务器会返回错误。下面咱们来尝试如下 script kill 指令。

127.0.0.1:6379> eval 'while(true) do print("hello") end' 0
复制代码

eval 指令执行后,能够明显看出来 redis 卡死了,死活没有任何响应,若是去观察 Redis 服务器日志能够看到日志在疯狂输出 hello 字符串。这时候就必须从新开启一个 redis-cli 来执行 script kill 指令。

127.0.0.1:6379> script kill
OK
(2.58s)
复制代码

再回过头看 eval 指令的输出

127.0.0.1:6379> eval 'while(true) do print("hello") end' 0
(error) ERR Error running script (call to f_d395649372f578b1a0d3a1dc1b2389717cadf403): @user_script:1: Script killed by user with SCRIPT KILL...
(6.99s)
复制代码

看到这里细心的同窗会注意到两个疑点,第一个是 script kill 指令为何执行了 2.58 秒,第二个是脚本都卡死了,Redis 哪里来的闲功夫接受 script kill 指令。若是你本身尝试了在第二个窗口执行 redis-cli 去链接服务器,你还会发现第三个疑点,redis-cli 创建链接有点慢,大约顿了有 1 秒左右。

Script Kill 的原理

下面我就要开始揭秘 kill 的原理了,lua 脚本引擎功能太强大了,它提供了各式各样的钩子函数,它容许在内部虚拟机执行指令时运行钩子代码。好比每执行 N 条指令执行一次某个钩子函数,Redis 正是使用了这个钩子函数。

void evalGenericCommand(client *c, int evalsha) {
  ...
  // lua引擎每执行10w条指令,执行一次钩子函数 luaMaskCountHook
  lua_sethook(lua,luaMaskCountHook,LUA_MASKCOUNT,100000);
  ...
}
复制代码

Redis 在钩子函数里会忙里偷闲去处理客户端的请求,而且只有在发现 lua 脚本执行超时以后才会去处理请求,这个超时时间默认是 5 秒。因而上面提出的三个疑点也就烟消云散了。

思考题

在延时队列小节,咱们使用 zrangebyscore 和 zdel 两条指令来争抢延时队列中的任务,经过 zdel 的返回值来决定是哪一个客户端抢到了任务,这意味着那些没有抢到任务的客户端会有这样一种感觉 —— 到了嘴边的肉(任务)最后还被别人抢走了,会很不爽。若是可使用 lua 脚原本实现争抢逻辑,将 zrangebyscore 和 zdel 指令原子性执行就不会存在这种问题,读者能够尝试一下。

注:若是读者不熟悉 lua,建议先学习 lua 语言,lua 语言简单易学,可是也不是几分钟就能够学会的事,须要再来一本小册的内容。本小册专一 Redis,因此就不开大篇内容来细讲 lua 语言了,有须要的朋友能够搜索相关在线教程。

阅读更多深度技术文章,扫一扫上面的二维码关注微信公众号「码洞」

相关文章
相关标签/搜索