Redis 2.6 脚本功能(scripting)简介 — blog.huangz.me
source link: https://blog.huangz.me/diary/2012/redis-scripting-short-guide.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Redis 2.6 脚本功能(scripting)简介¶
导读¶
对 Lua 脚本的支持无疑是 Redis 2.6 版本的最大亮点,本文通过一个 LPOPRPUSH
操作为例子,介绍如何使用 EVAL
命令对 Lua 脚本进行求值,以及如何在 Lua 脚本中执行 Redis 命令,并说明这些新特性又是怎样漂亮地解决一些 Redis 在 2.6 版本前很难解决的问题的。
LPOPRPUSH¶
Redis 的 RPOPLPUSH
命令有一个特点:它是 Redis 列表结构的众多命令里,唯一一个左右不对称的命令 —— 在 Redis 里,只有 RPOPLPUSH
,却没有 LPOPRPUSH
,而其他列表结构的命令,比如 LPUSH
和 RPUSH
、 LPUSHX
和 RPUSHX
,全都是左右对称的。
如果仅仅将 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
,它应该原子性地完成以下三个操作:
弹出
source
的表头元素(为了方便起见,我们将这个元素称为item
)将
item
添加到destination
的最右边,成为destination
列表的表尾元素将
item
返回给客户端
并且 LPOPRPUSH
的 source
和 destination
参数的值可以是同一个列表,从而制造一种(向后)旋转操作。
一个最简单的 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
函数修改成一个原子性操作,我们不仅需要 MULTI
和 EXEC
,而且因为 LPOPRPUSH
是一个 CAS (check-and-set, 检查并修改)类型的操作,我们还需要用上 WATCH
来监视 source
和 destination
列表。
以下是新的 LPOPRPUSH
实现,它保证了 LPOP
和 RPUSH
两个操作的原子性,可以在任何情况下正确地执行工作:
-- 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
放在一起进行对比是一个很有教益的练习,我们可以从中提炼出两个版本之间的一些共性和问题,比如说:
两个版本使用的核心命令是完全一样的(不包括事务方面的命令)
因为第一版没办法保证原子性,所以就有了第二版
第二版使用
WATCH
、MULTI
和EXEC
保证了原子性,并成功将代码量上升了一倍!
另一方面,当涉及到 WATCH
命令的使用时,又牵扯出了以下问题:
多条命令在客户端和服务器之间跑来跑去,非常浪费带宽和时间
WATCH
在业务高峰时期,会对吞吐量产生很大影响,因为失败的情况可能会发生得很频繁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 命令。
以下是两个 SET
和 GET
的例子:
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
命令的使用,最后要提醒的一点是,应该总是通过 KEYS
和 ARGV
两个变量来访问相应的键和参数,不要将键名和命令参数硬写到脚本里面,这会导致脚本无法使用 EVALSHA
命令来优化,也没有办法被 Redis 集群所执行。
这是一个典型的坏例子,不要这样做(尽管它可能暂时是可用的):
redis> EVAL "return redis.call('rpush', 'language', 'ruby', 'python', 'lua')" 0 (integer) 3
LPOPRPUSH 的脚本实现¶
在上一节,我们介绍了 EVAL
的两个特性:
可以使用
EVAL
命令对 Lua 脚本进行求值可以在 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
的,第三个特性:
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 的官方文档。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK