Redis 2.6 脚本功能(scripting)简介

导读

对 Lua 脚本的支持无疑是 Redis 2.6 版本的最大亮点,本文通过一个 LPOPRPUSH 操作为例子,介绍如何使用 EVAL 命令对 Lua 脚本进行求值,以及如何在 Lua 脚本中执行 Redis 命令,并说明这些新特性又是怎样漂亮地解决一些 Redis 在 2.6 版本前很难解决的问题的。

LPOPRPUSH

Redis 的 RPOPLPUSH 命令有一个特点:它是 Redis 列表结构的众多命令里,唯一一个左右不对称的命令 —— 在 Redis 里,只有 RPOPLPUSH ,却没有 LPOPRPUSH ,而其他列表结构的命令,比如 LPUSHRPUSHLPUSHXRPUSHX ,全都是左右对称的。

如果仅仅将 Redis 的列表结构用作单向列表,只在列表的其中一边对元素进行处理的话,那么有没有 LPOPRPUSH 是无所谓的:因为你总可以将新元素添加到列表的最左边,然后用 RPOPLPUSH 来对列表进行处理 —— 常见的消息队列和事件处理在 Redis 中通常都是这样实现的。

但是,缺少 LPOPRPUSH 在一些情况下也会造成不便,其中一个例子就是,没有 LPOPRPUSH ,你就没办法用很自然的方法实现循环列表,这种列表既可以向后旋转(LPOPRPUSH list list ,并返回当前列表的尾节点),也可以向前旋转(RPOPLPUSH list list ,并返回列表的头节点),以下是一个虚构的 LPOPRPUSH 例子:

redis> LRANGE seq 0 -1
1) "1"
2) "2"
3) "3"
4) "4"

redis> LPOPRPUSH seq seq
"1"

redis> LRANGE seq 0 -1
1) "2"
2) "3"
3) "4"
4) "1"

redis> LPOPRPUSH seq seq
"2"

redis> LRANGE seq 0 -1
1) "3"
2) "4"
3) "1"
4) "2"

redis> LPOPRPUSH seq seq
"3"

redis> LRANGE seq 0 -1
1) "4"
2) "1"
3) "2"
4) "3"

LPOPRPUSH 的简单实现

既然已经将 LPOPRPUSH 的前世今生都了解了一遍,现在,是时候亲自实现这个命令了。

可以确定的是, LPOPRPUSH 的调用方式应该和 RPOPLPUSH 一样,但是弹出元素和添加元素的位置正好相反,也就是说,执行命令 LPOPRPUSH source destination ,它应该原子性地完成以下三个操作:

  1. 弹出 source 的表头元素(为了方便起见,我们将这个元素称为 item)
  2. item 添加到 destination 的最右边,成为 destination 列表的表尾元素
  3. item 返回给客户端

并且 LPOPRPUSHsourcedestination 参数的值可以是同一个列表,从而制造一种(向后)旋转操作。

一个最简单的 LPOPRPUSH 实现可能是这样的:

-- unsafe_lpoprpush.lua

local redis = require 'redis'
local client = redis.connect('127.0.0.1', 6379)

function lpoprpush(source, destination)
    local item = client:lpop(source)
    if item ~= nil then
        client:rpush(destination, item)
        return item
    end
end

使用 MULTI 、 EXEC 和 WATCH 实现安全的 LPOPRPUSH 函数

上一节的 LPOPRPUSH 实现在通常情况下可以达到我们想要的效果,但是它并不是一个原子性操作,没有提供安全性方面的保证。

举个例子,假如客户端在执行 client:lpop(source) 之后失败,那么被弹出的 item 元素就会白白丢失,无法被 RPUSH 命令推入到 destination 列表。

为了将 LPOPRPUSH 函数修改成一个原子性操作,我们不仅需要 MULTIEXEC ,而且因为 LPOPRPUSH 是一个 CAS (check-and-set, 检查并修改)类型的操作,我们还需要用上 WATCH 来监视 sourcedestination 列表。

以下是新的 LPOPRPUSH 实现,它保证了 LPOPRPUSH 两个操作的原子性,可以在任何情况下正确地执行工作:

-- safe_lpoprpush.lua

local redis = require 'redis'
local client = redis.connect('127.0.0.1', 6379)

function lpoprpush(source, destination)
    if source == destination then
        local watch = source
    else
        local watch = {source, destination}
    end

    local item = nil
    local options = { watch = watch, cas = true, retry = 2 }
    client:transaction(options, function(t)
        item = t:lpop(source)
        if item ~= nil then
            t:multi()
            t:rpush(destination, item)
        end
    end)

    return item
end

Redis 在 2.6 版本以前的问题

将前面的第一版(不安全的) LPOPRPUSH 和第二版(安全的) LPOPRPUSH 放在一起进行对比是一个很有教益的练习,我们可以从中提炼出两个版本之间的一些共性和问题,比如说:

  1. 两个版本使用的核心命令是完全一样的(不包括事务方面的命令)
  2. 因为第一版没办法保证原子性,所以就有了第二版
  3. 第二版使用 WATCHMULTIEXEC 保证了原子性,并成功将代码量上升了一倍!

另一方面,当涉及到 WATCH 命令的使用时,又牵扯出了以下问题:

  1. 多条命令在客户端和服务器之间跑来跑去,非常浪费带宽和时间
  2. WATCH 在业务高峰时期,会对吞吐量产生很大影响,因为失败的情况可能会发生得很频繁
  3. WATCH 的使用很容易出错(因为 Lua 的 transaction 函数可以指定要监视的键,所以出错的可能比较少,但是在另外一些语言,比如 Ruby 和 Python 中,如果不小心将 WATCH 放错了地方,就会引入很隐晦的竞争条件)

需要注意的是,以上的这些问题并不是一个特例,它们是一大类 CAS 操作的共同难题,在一些(稍微)比较复杂的 Redis 模式中,这些问题并不罕见,究其原因,是因为在 2.6 版本以前, Redis 缺少一种自己的内嵌语言,因此即使像条件判断这样的简单操作,也要交由客户端去完成,而一旦命令需要在服务器和客户端两边来回处理的话,原子性又成了一个严峻的问题:在旧版 Redis 中, WATCH 和事务常常被用在很多不该用的地方,而初衷仅仅是为了保证原子性(上面的 LPOPRPUSH 实现就是一个很好的例子)。

幸运的是,随着 Redis 2.6 版本的出现,这种对 WATCH 和事务的滥用即将走向终点,因为在 Redis 2.6 版本中,新添加了对 Lua 脚本的支持,从而让我们可以将条件判断这类简单的操作和对 Redis 命令的调用都放到 Redis 服务器里完成,而这一切,仅仅需要一个 EVAL 命令。

EVAL

EVAL 是 Redis 2.6 版本新增命令的其中一个,同时也是最重要的一个,通过这个命令,可以直观、优雅且高效地解决像 LPOPRPUSH 这类 CAS 问题,文章稍后就会给出用脚本实现 LPOPRPUSH 的代码,但在此之前,不妨先来简单认识一下 EVAL 命令。

EVAL 命令的调用形式是 EVAL script numkeys key [key ...] arg [arg ..] ,它的参数分别是:

script :一段 Lua 脚本代码(Lua 5.1版本) numkeys :用于指定键名参数(key name args)的个数 key [key ...] :键名参数,在 Lua 中调用 Redis 命令时,那些被执行命令的键,可以在 Lua 脚本中通过全局数组 KEYS 来访问这些键名参数(数组下标以 1 为起始值) arg [arg ...] :附加参数,当在 Lua 中执行 Redis 命令时,用作命令的参数,可以在 Lua 脚本中通过全局数组 ARGV 来访问这些附加参数(数组下标以 1 为起始值)

script 参数和 numkeys 参数是必须的,通过给定一个脚本,并且将 numkeys 设为 0 ,我们就可以用 EVAL 命令来执行简单的 Lua 求值了:

先用 EVAL 来个传统问候吧:

redis> EVAL "return 'hello world'" 0
"hello world"

然后做做初等数学计算:

redis> EVAL "return 1 + 1" 0
(integer) 2

redis> EVAL "return 10/2" 0
(integer) 5

又或者,来点儿条件判断式:

redis> EVAL "if 1 == 1 then return 'good' else return 'bad' end" 0
"good"

当然, EVAL 的真正威力不仅仅是写写 Lua 表达式那么简单,更关键的是,你可以使用 redis.call 函数,在 Lua 环境中执行 Redis 命令。

以下是两个 SETGET 的例子:

redis> EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 message "hello, moto"
OK

redis> EVAL "return redis.call('get', KEYS[1])" 1 message
"hello, moto"

这两个脚本和以下的 Redis 命令等价:

redis> SET message "hello, moto"
OK

redis> GET message
"hello, moto"

以下是另外一个集合的例子:

redis> EVAL "return redis.call('sadd', KEYS[1], ARGV[1])" 1 animal snake
(integer) 1

redis> EVAL "return redis.call('sadd', KEYS[1], unpack(ARGV))" 1 animal wolf lion tiger
(integer) 3

它和以下这两个命令等价:

redis> SADD animal snake
(integer) 1

redis> SADD animal wolf lion tiger
(integer) 3

键名参数并不一定总是只能有一个,比如说,在执行 [RENAME](http://redis.readthedocs.org/en/latest/key/rename.html) 命令的时候,就需要两个键名参数:

redis> EVAL "return redis.call('rename', KEYS[1], KEYS[2])" 2 old_name new_name
OK

这个脚本和以下命令等价:

redis> rename old_name new_name
OK

关于 EVAL 命令的使用,最后要提醒的一点是,应该总是通过 KEYSARGV 两个变量来访问相应的键和参数,不要将键名和命令参数硬写到脚本里面,这会导致脚本无法使用 EVALSHA 命令来优化,也没有办法被 Redis 集群所执行。

这是一个典型的坏例子,不要这样做(尽管它可能暂时是可用的):

redis> EVAL "return redis.call('rpush', 'language', 'ruby', 'python', 'lua')" 0
(integer) 3

LPOPRPUSH 的脚本实现

在上一节,我们介绍了 EVAL 的两个特性:

  1. 可以使用 EVAL 命令对 Lua 脚本进行求值
  2. 可以在 Lua 脚本里面使用 redis.call 执行 Redis 命令

如果将这两个特性组合起来使用,就可以实现一些有趣的操作,比如说,我们可以写一个脚本,它只在键不存在的情况下对键执行 SET 命令,就像 Redis 的 SETNX 命令一样:

redis> EVAL "if redis.call('exists', KEYS[1]) == 0 then return redis.call('set', KEYS[1], ARGV[1]) end" 1 date 2012.4.4
OK

redis> EVAL "if redis.call('exists', KEYS[1]) == 0 then return redis.call('set', KEYS[1], ARGV[1]) end" 1 date 2012.4.4
(nil)

上面执行的脚本也是一个典型的 CAS 操作,它先检查一个键是否存在,然后根据反馈决定该怎么处理给定的键。如果在客户端执行 EVAL 里面的那一段代码,那可以肯定它是不是原子性操作,但是,在 EVAL 命令中,这个脚本并不会产生安全问题 —— 这是因为, EVAL 的执行是原子性的,这也是你需要知道的,关于 EVAL 的,第三个特性:

  1. Redis 保证被 EVAL 命令所执行的脚本的原子性:当一个脚本正在运行的时候,不会有别的脚本或者别的 Redis 命令被执行。而当前所运行脚本的作用(effect)要么是不可见的(not visible),要么就是已完成的(completed)。

这样一来,在 Redis 2.6 版本以前一直困扰我们的很多 CAS 类型的问题,一下子就不复存在了:因为只要简单地将操作写成脚本,然后放到 EVAL 命令里运行,这样就再也不必担心原子性的问题了,也可以从此跟麻烦的 WATCH 命令说再见了。

作为一个演示 EVAL 真正威力的例子,我们回过头来,使用脚本实现之前讨论的 LPOPRPUSH 操作:

-- script_lpoprpush.lua

local redis = require 'redis'
local client = redis.connect('127.0.0.1', 6379)

function lpoprpush(source, destination)
    script = [[
        local item = redis.call('lpop', KEYS[1])
        if item ~= nil then
            redis.call('rpush', KEYS[2], item)
            return item
        end
    ]]

    return client:eval(script, 2, source, destination)
end

我们可以将三个版本的 LPOPRPUSH 放在一起进行对比:

第一个版本是最简单的,但是它保证不了原子性。

第二个版本通过使用事务和 WATCH 保证了操作的原子性,但是也因此引入了很多不必要的复杂性。

第三个版本既保持了第一个版本的简单性(几乎就是客户端代码到 redis.call 函数的翻译),在兼顾原子性的同时,又没有像第二版那样的不必要的复杂性,毫无疑问, LPOPRPUSH 的这个脚本实现是简单、高效而且优美的。

更重要的是,除了 LPOPRPUSH 之外,一大类 CAS 操作也可以用脚本的方式来解决,随着 Redis 2.6 版本的普及,可以预见的是,越来越多以前使用 WATCH 和事务来保证原子性的模式会逐渐被脚本实现所代替,更多有趣的新脚本模式也会陆续出现,这毫无疑问是非常让人期待的。

更多

关于 Redis 2.6 版本新增的脚本功能,这篇文章只是谈了其中的一小部分,很多有趣的特性因为篇幅关系都未能在文章里提及:比如使用 EVALSHA 优化带宽、使用 SCRIPT * 命令控制脚本缓存、脚本的沙箱和最大执行时间等等,关于这些特性,可以参考 Redis 的官方文档。

huangz
2012.4.4

留言

comments powered by Disqus