返回首页 Redis 3.0 中文版

从入门到精通(中)

Redis 列表(Lists)

为了解释列表类型,最好先开始来点理论,因为列表这个术语在信息技术领域常常使用不当。例如,”Python Lists”,并不是字面意思(链表),实际是表示数组 (和 Ruby 中的 Array 是同一种类型)。

通常列表表示有序元素的序列:10,20,1,2,3 是一个列表。但是数组实现的列表和链表实现的列表,他们的属性非常不同。

Redis 的列表是使用链表实现的。这意味着,及时你的列表中有上百万个元素,增加一个元素到列表的头部或者尾部的操作都是在常量时间完成。使用 LPUSH 命令增加一个新元素到拥有 10 个元素的列表的头部的速度,与增加到拥有 1000 万个元素的列表的头部是一样的。

缺点又是什么呢?使用索引下标来访问一个数组实现的列表非常快(常量时间),但是访问链表实现列表就没那么快了(与元素索引下标成正比的大量工作)。

Redis 采用链表来实现列表是因为,对于数据库系统来说,快速插入一个元素到一个很长的列表非常重要。另外一个即将描述的优势是,Redis 列表能在常数时间内获得常数长度。

如果需要快速访问一个拥有大量元素的集合的中间数据,可以用另一个称为有序集合的数据结构。稍后将会介绍有序集合。

Redis 列表起步

LPUSH 命令从左边 (头部) 添加一个元素到列表,RPUSH 命令从右边(尾部)添加一个元素的列表。LRANGE 命令从列表中提取一个范围内的元素。

> rpush mylist A  
(integer) 1  
> rpush mylist B  
(integer) 2  
> lpush mylist first  
(integer) 3  
> lrange mylist 0 -1  
1) "first"  
2) "A"  
3) "B"  

注意 LRANGE 命令使用两个索引下标,分别是返回的范围的开始和结束元素。两个索引坐标可以是负数,表示从后往前数,所以 - 1 表示最后一个元素,-2 表示倒数第二个元素,等等。

如你所见,RPUSH 添加元素到列表的右边,LPUSH 添加元素到列表的左边。

两个命令都是可变参数命令,也就是说,你可以在一个命令调用中自由的添加多个元素到列表中:

> rpush mylist 1 2 3 4 5 "foo bar"  
(integer) 9  
> lrange mylist 0 -1  
1) "first"  
2) "A"  
3) "B"  
4) "1"  
5) "2"  
6) "3"  
7) "4"  
8) "5"  
9) "foo bar"  

定义在 Redis 列表上的一个重要操作是弹出元素。弹出元素指的是从列表中检索元素,并同时将其从列表中清楚的操作。你可以从左边或者右边弹出元素,类似于你可以从列表的两端添加元素。

> rpush mylist a b c  
(integer) 3  
> rpop mylist  
"c"  
> rpop mylist  
"b"  
> rpop mylist  
"a"  

我们添加了三个元素并且又弹出了三个元素,所以这一串命令执行完以后列表是空的,没有元素可以弹出了。如果我们试图再弹出一个元素,就会得到如下结果:

> rpop mylist  
(nil)  

Redis 返回一个 NULL 值来表明列表中没有元素了。

列表的通用场景(Common use cases)

列表可以完成很多任务,两个有代表性的场景如下:

  • 记住社交网络中用户最近提交的更新。
  • 使用生产者消费者模式来进程间通信,生产者添加项(item)到列表,消费者(通常是 worker)消费项并执行任务。Redis 有专门的列表命令更加可靠和高效的解决这种问题。

例如,两种流行的 Ruby 库 resque 和 sidekiq,都是使用 Redis 列表作为钩子,来实现后台作业 (background jobs)。

流行的 Twitter 社交网络,使用 Redis 列表来存储用户最新的微博 (tweets)。

为了一步一步的描述通用场景,假设你想加速展现照片共享社交网络主页的最近发布的图片列表。

每次用户提交一张新的照片,我们使用 LPUSH 将其 ID 添加到列表。

当用户访问主页时,我们使用 LRANGE 0 9 获取最新的 10 张照片。

上限列表(Capped)

很多时候我们只是想用列表存储最近的项,随便这些项是什么:社交网络更新,日志或者任何其他东西。

Redis 允许使用列表作为一个上限集合,使用 LTRIM 命令仅仅只记住最新的 N 项,丢弃掉所有老的项。

LTRIM 命令类似于 LRANGE,但是不同于展示指定范围的元素,而是将其作为列表新值存储。所有范围外的元素都将被删除。

举个例子你就更清楚了:

> rpush mylist 1 2 3 4 5  
(integer) 5  
> ltrim mylist 0 2  
OK  
> lrange mylist 0 -1  
1) "1"  
2) "2"  
3) "3"  

上面 LTRIM 命令告诉 Redis 仅仅保存第 0 到 2 个元素,其他的都被抛弃。这可以让你实现一个简单而又有用的模式,一个添加操作和一个修剪操作一起,实现新增一个元素抛弃超出元素。

LPUSH mylist <some element>  
LTRIM mylist 0 999  

上面的组合增加一个元素到列表中,同时只持有最新的 1000 个元素。使用 LRANGE 命令你可以访问前几个元素而不用记录非常老的数据。

注意:尽管 LRANGE 是一个O(N)时间复杂度的命令,访问列表头尾附近的小范围是常量时间的操作。

列表的阻塞操作 (blocking)

列表有一个特别的特性使得其适合实现队列,通常作为进程间通信系统的积木:阻塞操作。

假设你想往一个进程的列表中添加项,用另一个进程来处理这些项。这就是通常的生产者消费者模式,可以使用以下简单方式实现:

  • 生产者调用 LPUSH 添加项到列表中。
  • 消费者调用 RPOP 从列表提取 / 处理项。

然而有时候列表是空的,没有需要处理的,RPOP 就返回 NULL。所以消费者被强制等待一段时间并重试 RPOP 命令。这称为轮询(polling),由于其具有一些缺点,所以不合适在这种情况下:

  1. 强制 Redis 和客户端处理无用的命令 (当列表为空时的所有请求都没有执行实际的工作,只会返回 NULL)。
  2. 由于工作者受到一个 NULL 后会等待一段时间,这会延迟对项的处理。

于是 Redis 实现了 BRPOP 和 BLPOP 两个命令,它们是当列表为空时 RPOP 和 LPOP 的会阻塞版本:仅当一个新元素被添加到列表时,或者到达了用户的指定超时时间,才返回给调用者。 这个是我们在工作者中调用 BRPOP 的例子:

> brpop tasks 5  
1) "tasks"  
2) "do_something"  

上面的意思是” 等待 tasks 列表中的元素,如果 5 秒后还没有可用元素就返回”。

注意,你可以使用 0 作为超时让其一直等待元素,你也可以指定多个列表而不仅仅只是一个,同时等待多个列表,当第一个列表收到元素后就能得到通知。

关于 BRPOP 的一些注意事项。

  1. 客户端按顺序服务:第一个被阻塞等待列表的客户端,将第一个收到其他客户端添加的元素,等等。
  2. 与 RPOP 的返回值不同:返回的是一个数组,其中包括键的名字,因为 BRPOP 和 BLPOP 可以阻塞等待多个列表的元素。
  3. 如果超时时间到达,返回 NULL。

还有更多你需要知道的关于列表和阻塞选项,建议你阅读下面的页面:

  • 使用 RPOLPUSH 构建更安全的队列和旋转队列。
  • BRPOPLPUSH 命令是其阻塞变种命令。

自动创建和删除键

到目前为止的例子中,我们还没有在添加元素前创建一个空的列表,也没有删除一个没有元素的空列表。要注意,当列表为空时 Redis 将删除该键,当向一个不存在的列表键(如使用 LPUSH)添加一个元素时,将创建一个空的列表。

这并不只是针对列表,适用于所有 Redis 多元素组成的数据类型,因此适用于集合,有序集合和哈希。

基本上我们可以概括为三条规则:

  1. 当我们向聚合(aggregate)数据类型添加一个元素,如果目标键不存在,添加元素前将创建一个空的聚合数据类型。
  2. 当我们从聚合数据类型删除一个元素,如果值为空,则键也会被销毁。
  3. 调用一个像 LLEN 的只读命令(返回列表的长度),或者一个写命令从空键删除元素,总是产生和操作一个持有空聚合类型值的键一样的结果。

规则 1 的例子:

> del mylist  
(integer) 1  
> lpush mylist 1 2 3  
(integer) 3  

然而,我们不能执行一个错误键类型的操作:

> set foo bar  
OK  
> lpush foo 1 2 3  
(error) WRONGTYPE Operation against a key holding the wrong kind of value  
> type foo  
string  

规则 2 的例子:

> lpush mylist 1 2 3  
(integer) 3  
> exists mylist  
(integer) 1  
> lpop mylist  
"3"  
> lpop mylist  
"2"  
> lpop mylist  
"1"  
> exists mylist  
(integer) 0  

当所有元素弹出后,键就不存在了。

规则 3 的例子:

> del mylist  
(integer) 0  
> llen mylist  
(integer) 0  
> lpop mylist  
(nil)  

Redis 哈希/散列 (Hashes)

Redis 哈希看起来正如你期待的那样:

> hmset user:1000 username antirez birthyear 1977 verified 1  
OK  
> hget user:1000 username  
"antirez"  
> hget user:1000 birthyear  
"1977"  
> hgetall user:1000  
1) "username"  
2) "antirez"  
3) "birthyear"  
4) "1977"  
5) "verified"  
6) "1"  

哈希就是字段值对(fields-values pairs)的集合。由于哈希容易表示对象,事实上哈希中的字段的数量并没有限制,所以你可以在你的应用程序以不同的方式来使用哈希。

HMSET 命令为哈希设置多个字段,HGET 检索一个单独的字段。HMGET 类似于 HGET,但是返回值的数组:

> hmget user:1000 username birthyear no-such-field  
1) "antirez"  
2) "1977"  
3) (nil)  

也有一些命令可以针对单个字段执行操作,例如 HINCRBY:

> hincrby user:1000 birthyear 10  
(integer) 1987  
> hincrby user:1000 birthyear 10  
(integer) 1997  

你可以从命令页找到全部哈希命令列表。

值得注意的是,小的哈希 (少量元素,不太大的值) 在内存中以一种特殊的方式编码以高效利用内存。

Redis 集合 (Sets)

Redis 集合是无序的字符串集合 (collections)。SADD 命令添加元素到集合。还可以对集合执行很多其他的操作,例如,测试元素是否存在,对多个集合执行交集、并集和差集,等等。

> sadd myset 1 2 3  
(integer) 3  
> smembers myset  
1. 3  
2. 1  
3. 2  

我们向集合总添加了 3 个元素,然后告诉 Redis 返回所有元素。如你所见,他们没有排序,Redis 在每次调用时按随意顺序返回元素,因为没有与用户有任何元素排序协议。

我们有测试成员关系的命令。一个指定的元素存在吗?

> sismember myset 3  
(integer) 1  
> sismember myset 30  
(integer) 0  

“3” 是集合中的成员,”30” 则不是。

集合适用于表达对象间关系。例如,我们可以很容易的实现标签。对这个问题的最简单建模,就是有一个为每个需要标记的对象的集合。集合中保存着与对象相关的标记的 ID。

假设,我们想标记新闻。如果我们的 ID 为 1000 的新闻,被标签 1,2,5 和 77 标记,我们可以有一个这篇新闻被关联标记 ID 的集合:

> sadd news:1000:tags 1 2 5 77  
(integer) 4  

然而有时候我们也想要一些反向的关系:被某个标签标记的所有文章:

> sadd tag:1:news 1000  
(integer) 1  
> sadd tag:2:news 1000  
(integer) 1  
> sadd tag:5:news 1000  
(integer) 1  
> sadd tag:77:news 1000  
(integer) 1  

获取指定对象的标签很简单:

> smembers news:1000:tags  
1. 5  
2. 1  
3. 77  
4. 2  

注意:在这个例子中,我们假设你有另外一个数据结构,例如,一个 Redis 哈希,存储标签 ID 到标签名的映射。

还有一些使用正确的 Redis 命令就很容实现的操作。例如,我们想获取所有被标签 1,2,10 和 27 同时标记的对象列表。我们可以使用 SINTER 命令实现这个,也就是对不同的集合执行交集。我们只需要:

> sinter tag:1:news tag:2:news tag:10:news tag:27:news  
... results here ...  

并不仅仅是交集操作,你也可以执行并集,差集,随机抽取元素操作等等。

抽取一个元素的命令是 SPOP,就方便为很多问题建模。例如,为了实现一个基于 web 的扑克游戏,你可以将你的一副牌表示为集合。假设我们使用一个字符前缀表示(C)lubs 梅花, (D)iamonds 方块,(H)earts 红心,(S)pades 黑桃。

>  sadd deck C1 C2 C3 C4 C5 C6 C7 C8 C9 C10 CJ CQ CK  
   D1 D2 D3 D4 D5 D6 D7 D8 D9 D10 DJ DQ DK H1 H2 H3  
   H4 H5 H6 H7 H8 H9 H10 HJ HQ HK S1 S2 S3 S4 S5 S6  
   S7 S8 S9 S10 SJ SQ SK  
   (integer) 52  

现在我们为每位选手提供 5 张牌。SPOP 命令删除一个随机元素,返回给客户端,是这个场景下的最佳操作。

然而,如果我们直接对这副牌调用,下一局我们需要再填充一副牌,这个可能不太理想。所以我们一开始要复制一下 deck 键的集合到 game:1:deck 键。

这是通过使用 SUNIONSTORE 命令完成的,这个命令通常对多个集合执行交集,然后把结果存储在另一个集合中。而对单个集合求交集就是其自身,于是我可以这样拷贝我的这副牌:

> sunionstore game:1:deck deck  
(integer) 52  

现在我们准备好为第一个选手提供 5 张牌:

> spop game:1:deck  
"C6"  
> spop game:1:deck  
"CQ"  
> spop game:1:deck  
"D1"  
> spop game:1:deck  
"CJ"  
> spop game:1:deck  
"SJ"  

只有一对 jack,不太理想……

现在是时候介绍提供集合中元素数量的命令。这个在集合理论中称为集合的基数(cardinality,也称集合的势),所以相应的 Redis 命令称为 SCARD。

> scard game:1:deck  
(integer) 47  

数学计算式为:52 - 5 = 47。

当你只需要获得随机元素而不需要从集合中删除,SRANDMEMBER 命令则适合你完成任务。它具有返回重复的和非重复的元素的能力。