列表(List)

Redis 的列表是一种线性的有序结构, 它可以按照元素被推入到列表中的顺序来储存元素, 这些元素既可以是文字数据, 又可以是二进制数据, 并且列表中的元素可以出现重复。

作为例子, 图 4-1 展示了一个包含多个字符串的列表, 这个列表按照从左到右的方式, 依次储存了 "one""two""three""four" 四个元素。


图 4-1 横向表示的列表

_images/IMAGE_GRAPH_LIST.jpeg

为了展示方便, 本书给出的列表图片一般都会像图 4-1 那样只展示列表本身而忽略列表的键名, 但是在有需要的时候, 本书也会像图 4-2 那样, 将列表及其键名一并给出。


图 4-2 完整的列表键视图

_images/IMAGE_LIST_WITH_KEY.jpeg

Redis 为列表提供了丰富的操作命令, 通过这些命令, 用户可以:

  • 将新元素推入到列表的左端或者右端;

  • 移除位于列表最左端或者最右端的元素;

  • 移除列表最右端的元素, 然后把被移除的元素推入到另一个列表的左端;

  • 获取列表包含的元素数量;

  • 获取列表在指定索引上的单个元素, 又或者获取列表在指定索引范围内的多个元素;

  • 为列表的指定索引设置新元素, 或者把新元素添加到某个指定元素的前面或者后面;

  • 对列表进行修剪, 只保留指定索引范围内的元素;

  • 从列表里面移除指定元素;

  • 执行能够阻塞客户端的推入和移除操作;

本章接下来将对以上提到的各个列表操作命令进行介绍, 并说明如何使用这些命令去构建各种实用的程序。

LPUSH:将元素推入到列表左端

用户可以通过 LPUSH 命令, 将一个或多个元素推入到给定列表的左端:

LPUSH list item [item item ...]

在推入操作执行完毕之后, LPUSH 命令会返回列表当前包含的元素数量作为返回值。

比如以下代码就展示了如何通过 LPUSH 命令, 将 "buy some milk""watch tv""finish homework" 等元素依次推入到 todo 列表的左端:

redis> LPUSH todo "buy some milk"
(integer) 1    -- 列表现在包含一个元素

redis> LPUSH todo "watch tv"
(integer) 2    -- 列表现在包含两个元素

redis> LPUSH todo "finish homework"
(integer) 3    -- 列表现在包含三个元素

图 4-3 展示了以上三个 LPUSH 命令的执行过程:

  1. 首先,在操作执行之前, todo 列表为空,也即是不存在于数据库中;

  2. 执行第一个 LPUSH 命令,将元素 "buy some milk" 推入到列表左端;

  3. 执行完第一个 LPUSH 命令的列表现在包含一个元素;

  4. 执行第二个 LPUSH 命令,将元素 "watch tv" 推入到列表左端;

  5. 执行完第二个 LPUSH 命令的列表现在包含两个元素;

  6. 执行第三个 LPUSH 命令,将元素 "finish homework" 推入到列表左端;

  7. 执行完第三个 LPUSH 命令的 todo 列表现在包含三个元素。


图 4-3 LPUSH 命令执行过程

_images/IMAGE_LPUSH_EXAMPLE.jpeg

一次推入多个元素

LPUSH 命令允许用户一次将多个元素推入到列表左端: 如果用户在执行 LPUSH 命令时给定了多个元素, 那么 LPUSH 命令将按照元素给定的顺序, 从左到右依次地将所有给定元素推入到列表左端。

举个例子, 如果用户执行以下命令:

redis> LPUSH another-todo "buy some milk" "watch tv" "finish homework"
(integer) 3

那么 LPUSH 命令将按照图 4-4 所示的顺序, 将三个给定元素依次推入到 another-todo 列表的左端。


图 4-4 一次推入多个元素

_images/IMAGE_MULTI_LPUSH.jpeg

最终, 这条 LPUSH 命令将产生图 4-5 所示的列表, 它和上一小节使用三条 LPUSH 命令构建出的列表完全相同。


图 4-5 another-todo 列表及其包含的元素

_images/IMAGE_ANOTHER_TODO.jpeg

其他信息

属性

复杂度

O(N),其中 N 为被推入到列表的元素数量。

版本要求

LPUSH 命令从 Redis 1.0.0 版本开始可用, 但是只有 Redis 2.4.0 或以上版本的 LPUSH 命令可以一次推入多个元素, Redis 2.4.0 以下版本的 LPUSH 命令每次只能推入一个元素。

RPUSH:将元素推入到列表右端

RPUSH 命令和 LPUSH 命令类似, 这两个命令执行的都是元素推入操作, 它们之间唯一的区别就在于 LPUSH 命令会将元素推入列表左端, 而 RPUSH 命令则会将元素推入列表右端:

RPUSH list item [item item ...]

在推入操作执行完毕之后, RPUSH 命令会返回列表当前包含的元素数量作为返回值。

举个例子, 以下代码展示了如何通过 RPUSH 命令, 将 "buy some milk""watch tv""finish homework" 等元素依次推入到 todo 列表的右端:

redis> RPUSH todo "buy some milk"
(integer) 1    -- 列表现在包含一个元素

redis> RPUSH todo "watch tv"
(integer) 2    -- 列表现在包含两个元素

redis> RPUSH todo "finish homework"
(integer) 3    -- 列表现在包含三个元素

图 4-6 展示了以上三个 RPUSH 命令的执行过程:

  1. 首先,在操作执行之前, todo 列表为空,也即是不存在于数据库中;

  2. 执行第一个 RPUSH 命令, 将元素 "buy some milk" 推入到列表右端;

  3. 执行完第一个 RPUSH 命令的列表现在包含一个元素;

  4. 执行第二个 RPUSH 命令, 将元素 "watch tv" 推入到列表右端;

  5. 执行完第二个 RPUSH 命令的列表现在包含两个元素;

  6. 执行第三个 RPUSH 命令, 将元素 "finish homework" 推入到列表右端;

  7. 执行完第三个 RPUSH 命令的 todo 列表现在包含三个元素。


图 4-6 RPUSH 命令执行过程

_images/IMAGE_RPUSH_EXAMPLE.jpeg

一次推入多个元素

LPUSH 命令一样, RPUSH 命令也允许用户一次推入多个元素: 如果用户在执行 RPUSH 命令时给定了多个元素, 那么 RPUSH 命令将按照元素给定的顺序, 从左到右依次地将所有给定元素推入到列表右端。

举个例子, 如果用户执行以下命令:

redis> RPUSH another-todo "buy some milk" "watch tv" "finish homework"
(integer) 3

那么 RPUSH 命令将按照图 4-7 展示的顺序, 将三个给定元素依次推入到 another-todo 列表的右端。


图 4-7 一次推入多个元素

_images/IMAGE_MULTI_RPUSH.jpeg

最终, 这条 RPUSH 命令将产生图 4-8 所示的列表, 这个列表和上一小节使用三条 RPUSH 命令构建出的列表完全相同。


图 4-8 another-todo 列表及其包含的元素

_images/IMAGE_TODO_AGAIN.jpeg

其他信息

属性

复杂度

O(N),其中 N 为被推入到列表的元素数量。

版本要求

RPUSH 命令从 Redis 1.0.0 版本开始可用, 但是只有 Redis 2.4.0 或以上版本的 RPUSH 命令可以一次推入多个元素, Redis 2.4.0 以下版本的 RPUSH 命令每次只能推入一个元素。

LPUSHX、RPUSHX:只对已存在的列表执行推入操作

当用户调用 LPUSH 命令或是 RPUSH 命令尝试将元素推入到列表的时候, 如果给定的列表并不存在, 那么命令将自动创建一个空列表, 并将元素推入到刚刚创建的列表里面。

比如对于空列表 list1list2 来说, 执行以下命令将创建图 4-9 所示的两个列表:

redis> LPUSH list1 "item1"
(integer) 1

redis> RPUSH list2 "item1"
(integer) 1

图 4-9 两个只包含单个元素的列表

_images/IMAGE_LIST1_LIST2.jpeg

除了 LPUSH 命令和 RPUSH 命令之外, Redis 还提供了 LPUSHX 命令和 RPUSHX 命令:

LPUSHX list item

RPUSHX list item

这两个命令对待空列表的方式跟 LPUSH 命令和 RPUSH 命令正好相反:

  • LPUSHX 命令只会在列表已经存在的情况下, 将元素推入列表左端;

  • RPUSHX 命令只会在列表已经存在的情况下, 将元素推入列表右端;

如果给定列表并不存在, 那么 LPUSHX 命令和 RPUSHX 命令将放弃执行推入操作。

LPUSHX 命令和 RPUSHX 命令在成功执行推入操作之后, 将返回列表当前的长度作为返回值; 如果推入操作未能成功执行, 那么命令将返回 0 作为结果。

举个例子, 如果我们对不存在的列表 list3 执行以下 LPUSHX 命令和 RPUSHX 命令, 那么这两个推入操作都将被拒绝:

redis> LPUSHX list3 "item-x"
(integer) 0    -- 没有推入任何元素

redis> RPUSHX list3 "item-y"
(integer) 0    -- 没有推入任何元素

另一方面, 如果我们先使用 LPUSH 命令, 将一个元素推入到 list3 列表里面, 使得 list3 变成非空列表, 那么 LPUSHX 命令和 RPUSHX 命令就可以成功地执行推入操作:

redis> LPUSH list3 "item1"
(integer) 1    -- 推入一个元素,使列表变为非空

redis> LPUSHX list3 "item-x"
(integer) 2    -- 执行推入操作之后,列表包含两个元素

redis> RPUSHX list3 "item-y"
(integer) 3    -- 执行推入操作之后,列表包含三个元素

图 4-10 展示了列表 list3 的整个变化过程:

  1. 在最初的 LPUSHX 命令和 RPUSHX 命令执行之后, list3 仍然是一个空列表。

  2. 执行 LPUSH 命令, 将元素 "item1" 推入到列表里面, 使之变为非空。

  3. 执行 LPUSHX 命令, 将元素 "item-x" 推入列表, 使得列表包含两个元素。

  4. 执行 RPUSHX 命令, 将元素 "item-y" 推入列表, 使得列表包含三个元素。


图 4-10 LPUSHX 命令和 RPUSHX 命令的执行过程

_images/IMAGE_LPUSHX_RPUSHX.jpeg

每次只能推入单个元素

LPUSH 命令和 RPUSH 命令不一样, LPUSHX 命令和 RPUSHX 命令每次只能推入一个元素, 尝试向 LPUSHX 命令或 RPUSHX 命令给定多个元素将引发错误:

redis> LPUSHX list "item1" "item2" "item3"
(error) ERR wrong number of arguments for 'lpushx' command

redis> RPUSHX list "item1" "item2" "item3"
(error) ERR wrong number of arguments for 'rpushx' command

其他信息

属性

复杂度

O(1)

版本要求

LPUSHX 命令和 RPUSHX 命令从 Redis 2.2.0 版本开始可用。

LPOP:弹出列表最左端的元素

用户可以通过 LPOP 命令移除位于列表最左端的元素, 并将被移除的元素返回给用户:

LPOP list

比如说, 以下代码就展示了如何使用 LPOP 命令去弹出 todo 列表的最左端元素:

redis> LPOP todo
"finish homework"

redis> LPOP todo
"watch tv"

redis> LPOP todo
"buy some milk"

图 4-11 展示了 todo 列表在 LPOP 命令执行时的整个变化过程:

  1. LPOP 命令执行之前, todo 列表包含三个元素;

  2. 执行第一个 LPOP 命令, 从列表中弹出 "finish homework" 元素;

  3. 执行第二个 LPOP 命令, 从列表中弹出 "watch tv" 元素;

  4. 执行第三个 LPOP 命令, 从列表中弹出 "buy some milk" 元素, 并使得 todo 列表变为空。


图 4-11 LPOP 命令的执行过程

_images/IMAGE_LPOP_EXAMPLE.jpeg

另一方面, 如果用户给定的列表并不存在, 那么 LPOP 命令将返回一个空值, 表示列表为空, 没有元素可供弹出:

redis> LPOP empty-list
(nil)

其他信息

属性

复杂度

O(1)

版本要求

LPOP 命令从 Redis 1.0.0 版本开始可用。

RPOP:弹出列表最右端的元素

用户可以通过 RPOP 命令移除位于列表最右端的元素, 并将被移除的元素返回给用户:

RPOP list

比如说, 以下代码就展示了如何使用 RPOP 命令去弹出 todo 列表最右端的元素:

redis> RPOP todo
"finish homework"

redis> RPOP todo
"watch tv"

redis> RPOP todo
"buy some milk"

图 4-12 展示了 todo 列表在 RPOP 命令执行时的整个变化过程:

  1. RPOP 命令执行之前, todo 列表包含三个元素;

  2. 执行第一个 RPOP 命令, 从列表中弹出 "finish homework" 元素;

  3. 执行第二个 RPOP 命令, 从列表中弹出 "watch tv" 元素;

  4. 执行第三个 RPOP 命令, 从列表中弹出 "buy some milk" 元素, 并使得 todo 列表变为空。


图 4-12 RPOP 命令的执行过程

_images/IMAGE_RPOP_EXAMPLE.jpeg

LPOP 命令一样, 如果用户给定的列表并不存在, 那么 RPOP 命令将返回一个空值, 表示列表为空, 没有元素可供弹出:

redis> RPOP empty-list
(nil)

其他信息

属性

复杂度

O(1)

版本要求

RPOP 命令从 Redis 1.0.0 版本开始可用。

RPOPLPUSH:将右端弹出的元素推入到左端

RPOPLPUSH 命令的行为和它的名字一样, 它首先使用 RPOP 命令将源列表最右端的元素弹出, 然后使用 LPUSH 命令将被弹出的元素推入到目标列表左端, 使之成为目标列表的最左端元素:

RPOPLPUSH source target

RPOPLPUSH 命令会返回被弹出的元素作为结果。

作为例子, 以下代码展示了如何使用 RPOPLPUSH 命令, 将列表 list1 的最右端元素弹出, 然后将其推入至列表 list2 的左端:

redis> RPUSH list1 "a" "b" "c"    -- 创建两个示例列表 list1  list2
(integer) 3

redis> RPUSH list2 "d" "e" "f"
(integer) 3

redis> RPOPLPUSH list1 list2
"c"

redis> RPOPLPUSH list1 list2
"b"

redis> RPOPLPUSH list1 list2
"a"

图 4-13 展示了列表 list1list2 在执行以上 RPOPLPUSH 命令时的变化过程:

  1. RPOPLPUSH 命令执行之前, list1list2 都包含三个元素;

  2. 执行第一个 RPOPLPUSH 命令, 弹出 list1 的最右端元素 "c" , 并将其推入至 list2 的左端;

  3. 执行第二个 RPOPLPUSH 命令, 弹出 list1 的最右端元素 "b" , 并将其推入至 list2 的左端;

  4. 执行第三个 RPOPLPUSH 命令, 弹出 list1 的最右端元素 "a" , 并将其推入至 list2 的左端;

  5. 在以上三个 RPOPLPUSH 命令执行完毕之后, list1 将变为空列表, 而 list2 则会包含六个元素。


图 4-13 RPOPLPUSH 命令的执行过程

_images/IMAGE_RL.jpeg

源列表和目标列表相同

RPOPLPUSH 命令允许用户将源列表和目标列表设置为同一个列表: 在这种情况下, RPOPLPUSH 命令的效果就相当于将列表最右端的元素变成列表最左端的元素。

比如以下代码就展示了如何通过 RPOPLPUSH 命令, 将 rotate-list 列表的最右端元素变成列表的最左端元素:

redis> RPUSH rotate-list "a" "b" "c"    -- 创建一个示例列表
(integer) 3

redis> RPOPLPUSH rotate-list rotate-list
"c"

redis> RPOPLPUSH rotate-list rotate-list
"b"

redis> RPOPLPUSH rotate-list rotate-list
"a"

图 4-14 展示了以上三个 RPOPLPUSH 命令在执行时, rotate-list 列表的整个变化过程:

  1. RPOPLPUSH 命令执行之前,列表包含 "a""b""c" 三个元素。

  2. 执行第一个 RPOPLPUSH 命令,将最右端元素 "c" 变为最左端元素。

  3. 执行第二个 RPOPLPUSH 命令,将最右端元素 "b" 变为最左端元素。

  4. 执行第三个 RPOPLPUSH 命令,将最右端元素 "a" 变为最左端元素。

  5. 在以上三个 RPOPLPUSH 命令执行完毕之后,列表又重新回到了原样。


图 4-14 使用 RPOPLPUSH 对列表元素进行轮换

_images/IMAGE_ROTATE.jpeg

正如上面展示的例子所示, 通过对同一个列表重复执行 RPOPLPUSH 命令, 我们可以创建出一个对元素进行轮换的列表; 并且当我们对一个包含了 N 个元素的列表重复执行 N 次 RPOPLPUSH 命令之后, 列表元素的排列顺序将变回原来的样子。

处理空列表

如果用户传给 RPOPLPUSH 命令的源列表并不存在, 那么 RPOPLPUSH 命令将放弃执行弹出和推入操作, 只返回一个空值表示命令执行失败:

redis> RPOPLPUSH list-x list-y
(nil)

另一方面, 如果源列表非空, 但是目标列表为空, 那么 RPOPLPUSH 命令将正常执行弹出操作和推入操作:

redis> RPUSH list-x "a" "b" "c"    --  list-x 变为非空列表
(integer) 3

redis> RPOPLPUSH list-x list-y
"c"

图 4-15 展示了这条 RPOPLPUSH 命令执行之前和执行之后, list-xlist-y 的变化:

  1. 在执行 RPOPLPUSH 命令之前, list-x 包含三个元素, 而 list-y 为空。

  2. 执行 RPOPLPUSH 命令, 将 list-x 的最右端元素 "c" 弹出, 并将其推入至 list-y 的左端。

  3. RPOPLPUSH 命令执行完毕之后, list-x 将包含两个元素, 而 list-y 则包含一个元素。


图 4-15 RPOPLPUSH 命令处理目标列表为空的例子

_images/IMAGE_EMPTY_RL.jpeg

其他信息

属性

复杂度

O(1)

版本要求

RPOPLPUSH 命令从 Redis 1.2.0 版本开始可用。

示例:先进先出队列

先进先出队列(first in first out queue)是一种非常常见的数据结构, 一般都会包含入队(enqueue)和出队(dequeue)这两个操作, 其中入队操作会将一个元素放入到队列中, 而出队操作则会从队列中移除最先被入队的元素。

先进先出队列的应用非常广泛, 它见诸于各式各样的应用程序当中。 举个例子, 很多电商网站都会在节日时推出一些秒杀活动, 这些活动会放出数量有限的商品供用户抢购, 秒杀系统的一个特点就是在短时间内会有大量用户同时进行相同的购买操作, 如果使用事务或者锁去实现秒杀程序, 那么就会因为锁和事务的重试特性而导致性能低下, 并且由于重试的存在, 成功购买商品的用户可能并不是最早执行购买操作的用户, 因此这种秒杀系统实际上是不公平的。

解决上述问题的其中一个方法就是把用户的购买操作都放入到先进先出队列里面, 然后以队列方式处理用户的购买操作, 这样程序就可以在不使用锁或者事务的情况下实现秒杀系统, 并且得益于先进先出队列的特性, 这种秒杀系统可以按照用户执行购买操作的顺序来判断哪些用户可以成功执行购买操作, 因此它是公平的。

代码清单 4-1 展示了一个使用 Redis 列表实现先进先出队列的方法。


代码清单 4-1 使用列表实现的先进先出队列:/list/fifo_queue.py

class FIFOqueue:

    def __init__(self, client, key):
        self.client = client
        self.key = key

    def enqueue(self, item):
        """
        将给定元素放入队列,然后返回队列当前包含的元素数量作为结果。
        """
        return self.client.rpush(self.key, item)

    def dequeue(self):
        """
        移除并返回队列目前入队时间最长的元素。
        """
        return self.client.lpop(self.key)

作为例子, 我们可以通过执行以下代码, 载入并创建一个先进先出队列:

>>> from redis import Redis
>>> from fifo_queue import FIFOqueue
>>> client = Redis(decode_responses=True)
>>> q = FIFOqueue(client, "buy-request")

然后通过执行以下代码, 将三个用户的购买请求依次放入到队列里面:

>>> q.enqueue("peter-buy-milk")
1
>>> q.enqueue("john-buy-rice")
2
>>> q.enqueue("david-buy-keyboard")
3

最后, 按照先进先出顺序, 依次从队列中弹出相应的购买请求:

>>> q.dequeue()
'peter-buy-milk'
>>> q.dequeue()
'john-buy-rice'
>>> q.dequeue()
'david-buy-keyboard'

可以看到, 队列弹出元素的顺序跟元素入队时的顺序是完全相同的: 最先是 "peter-buy-milk" 元素, 接着是 "john-buy-rice" 元素, 最后是 "david-buy-keyboard" 元素。

LLEN:获取列表的长度

用户可以通过执行 LLEN 命令来获取列表的长度, 也即是列表包含的元素数量:

LLEN list

图 4-16 几个不同长度的列表

_images/IMAGE_LLEN.jpeg

比如对于图 4-16 所示的几个列表来说, 对它们执行 LLEN 命令将获得以下结果:

redis> LLEN todo
(integer) 3

redis> LLEN alphabets
(integer) 8

redis> LLEN msg-queue
(integer) 4

另一方面, 对于不存在的列表, LLEN 命令将返回 0 作为结果:

redis> LLEN not-exists-list
(integer) 0

其他信息

属性

复杂度

O(1)

版本要求

LLEN 命令从 Redis 1.0.0 版本开始可用。

LINDEX:获取指定索引上的元素

Redis 列表包含的每个元素都有与之相对应的正数索引和负数索引:

  • 正数索引从列表的左端开始计算, 依次向右端递增: 最左端元素的索引为 0 , 左端排行第二的元素索引为 1 , 左端排行第三的元素索引为 2 , 以此类推。 最大的正数索引为列表长度减一, 也即是 N-1

  • 负数索引从列表的右端开始计算, 依次向左端递减: 最右端元素的索引为 -1 , 右端排行第二的元素索引为 -2 , 右端排行第三的元素索引为 -3 , 以此类推。 最大的负数索引为列表长度的负数, 也即是 -N

作为例子, 图 4-17 展示了一个包含多个元素的列表, 并给出了列表元素对应的正数索引和负数索引。


图 4-17 列表的索引

_images/IMAGE_LINDEX.jpeg

为了让用户可以方便地取得索引对应的元素, Redis 提供了 LINDEX 命令:

LINDEX list index

这个命令接受一个列表和一个索引作为参数, 然后返回列表在给定索引上的元素; 其中给定索引既可以是正数, 也可以是负数。

比如说, 对于前面展示的图 4-17 , 我们可以通过执行以下命令, 取得 alphabets 列表在指定索引上的元素:

redis> LINDEX alphabets 0
"a"

redis> LINDEX alphabets 3
"d"

redis> LINDEX alphabets 6
"g"

redis> LINDEX alphabets -3
"f"

redis> LINDEX alphabets -7
"b"

处理超出范围的索引

对于一个长度为 N 的非空列表来说:

  • 它的正数索引必然大于等于 0 , 并且少于等于 N-1

  • 而它的负数索引则必然小于等于 -1 , 并且大于等于 -N

如果用户给定的索引超出了这一范围, 那么 LINDEX 命令将返回空值, 以此来表示给定索引上并不存在任何元素:

redis> LINDEX alphabets 100
(nil)

redis> LINDEX alphabets -100
(nil)

其他信息

属性

复杂度

O(N) ,其中 N 为给定列表的长度。

版本要求

LINDEX 命令从 Redis 1.0.0 版本开始可用。

LRANGE:获取指定索引范围上的元素

用户除了可以使用 LINDEX 命令获取给定索引上的单个元素之外, 还可以使用 LRANGE 命令获取给定索引范围上的多个元素:

LRANGE list start end

LRANGE 命令接受一个列表、一个开始索引和一个结束索引作为参数, 然后依次返回列表从开始索引到结束索引范围内的所有元素, 其中开始索引和结束索引对应的元素也会被包含在命令返回的元素当中。

作为例子, 以下代码展示了如何使用 LRANGE 命令去获取 alphabets 列表在不同索引范围内的元素:

redis> LRANGE alphabets 0 3    -- 获取列表索引 0 至索引 3 上的所有元素
1) "a"    -- 位于索引 0 上的元素
2) "b"    -- 位于索引 1 上的元素
3) "c"    -- 位于索引 2 上的元素
4) "d"    -- 位于索引 3 上的元素

redis> LRANGE alphabets 2 6
1) "c"
2) "d"
3) "e"
4) "f"
5) "g"

redis> LRANGE alphabets -5 -1
1) "d"
2) "e"
3) "f"
4) "g"
5) "h"

redis> LRANGE alphabets -7 -4
1) "b"
2) "c"
3) "d"
4) "e"

图 4-18 展示了这些 LRANGE 命令是如何根据给定的索引范围去获取列表元素的。


图 4-18 LRANGE 命令获取范围内元素的过程

_images/IMAGE_LRANGE_1.jpeg _images/IMAGE_LRANGE_2.jpeg

获取列表包含的所有元素

一个快捷地获取列表包含的所有元素的方法, 就是使用 0 作为起始索引、 -1 作为结束索引去调用 LRANGE 命令, 这种方法非常适用于查看长度较短的列表:

redis> LRANGE alphabets 0 -1
1) "a"
2) "b"
3) "c"
4) "d"
5) "e"
6) "f"
7) "g"
8) "h"

处理超出范围的索引

LINDEX 一样, LRANGE 命令也需要处理超出范围的索引:

  • 如果用户给定的起始索引和结束索引都超出了范围, 那么 LRANGE 命令将返回空列表作为结果;

  • 如果用户给定的其中一个索引超出了范围, 那么 LRANGE 命令将对超出范围的索引进行修正, 然后再执行实际的范围获取操作; 其中超出范围的起始索引会被修正为 0 , 而超出范围的结束索引则会被修正为 -1

以下代码展示了 LRANGE 命令在遇到两个超出范围的索引时, 返回空列表的例子:

redis> LRANGE alphabets 50 100
(empty list or set)

redis> LRANGE alphabets -100 -50
(empty list or set)

另一方面, 以下代码展示了 LRANGE 命令在遇到只有一个超出范围的索引时, 对索引进行修正并返回元素的例子:

redis> LRANGE alphabets -100 5
1) "a"  -- 位于索引 0 上的元素
2) "b"
3) "c"
4) "d"
5) "e"
6) "f"  -- 位于索引 5 上的元素

redis> LRANGE alphabets 5 100
1) "f"  -- 位于索引 5 上的元素
2) "g"
3) "h"  -- 位于索引 -1 上的元素

在执行 LRANGE alphabets -100 5 调用时, LRANGE 命令会把超出范围的起始索引 -100 修正为 0 , 然后执行 LRANGE alphabets 0 5 调用; 而在执行 LRANGE alphabets 5 100 调用时, LRANGE 命令会把超出范围的结束索引 100 修正为 -1 , 然后执行 LRANGE alphabets 5 -1 调用。

其他信息

属性

复杂度

O(N) ,其中 N 为给定列表的长度。

版本要求

LRANGE 命令从 Redis 1.0.0 开始可用。

示例:分页

对于互联网上每一个具有一定规模的网站来说, 分页程序都是必不可少的: 新闻站点、博客、论坛、搜索引擎等等, 都会使用分页程序将数量众多的信息分割为多个页面, 使得用户可以以页为单位浏览网站提供的信息, 并以此来控制网站每次取出的信息数量。 图 4-19 就展示了一个使用分页程序对用户发表的论坛主题进行分割的例子。


图 4-19 论坛中的分页示例

_images/IMAGE_V2EX_PAGING.png

代码清单 4-2 展示了一个使用列表实现分页程序的方法, 这个程序可以将给定的元素有序地放入到一个列表里面, 然后使用 LRANGE 命令从列表中取出指定数量的元素, 从而实现分页这一概念。


代码清单 4-2 使用列表实现的分页程序:/list/paging.py

class Paging:

    def __init__(self, client, key):
        self.client = client
        self.key = key

    def add(self, item):
        """
        将给定元素添加到分页列表中。
        """
        self.client.lpush(self.key, item)

    def get_page(self, page_number, item_per_page):
        """
        从指定页数中取出指定数量的元素。
        """
        # 根据给定的 page_number (页数)和 item_per_page (每页包含的元素数量)
        # 计算出指定分页元素在列表中所处的索引范围
        # 例子:如果 page_number = 1 , item_per_page = 10
        # 那么程序计算得出的起始索引就是 0 ,而结束索引则是 9
        start_index = (page_number - 1) * item_per_page
        end_index = page_number * item_per_page - 1
        # 根据索引范围从列表中获取分页元素
        return self.client.lrange(self.key, start_index, end_index)

    def size(self):
        """
        返回列表目前包含的分页元素数量。
        """
        return self.client.llen(self.key)

作为例子, 我们可以通过执行以下代码, 载入并创建出一个针对用户帖子的分页对象:

>>> from redis import Redis
>>> from paging import Paging
>>> client = Redis(decode_responses=True)
>>> topics = Paging(client, "user-topics")

并使用数字 119 作为用户帖子的 ID , 将它们添加到分页列表里面:

>>> for i in range(20):
...   topics.add(i)
...

然后我们就可以使用分页程序, 对这些帖子进行分页了:

>>> topics.get_page(1, 5)   # 以每页 5 个帖子的方式,取出第 1 页的帖子
['19', '18', '17', '16', '15']
>>> topics.get_page(2, 5)   # 以每页 5 个帖子的方式,取出第 2 页的帖子
['14', '13', '12', '11', '10']
>>> topics.get_page(1, 10)  # 以每页 10 个帖子的方式,取出第 3 页的帖子
['19', '18', '17', '16', '15', '14', '13', '12', '11', '10']

最后, 我们可以通过执行以下代码, 取得分页列表目前包含的元素数量:

>>> topics.size()
20

LSET:为指定索引设置新元素

用户可以通过 LSET 命令, 为列表的指定索引设置新元素:

LSET list index new_element

LSET 命令在设置成功时将返回 OK

比如对于以下这个 todo 列表来说:

redis> LRANGE todo 0 -1
1) "buy some milk"
2) "watch tv"
3) "finish homework"

我们可以通过执行以下 LSET 命令, 将 todo 列表索引 1 上的元素设置为 "have lunch"

redis> LSET todo 1 "have lunch"
OK

redis> LRANGE todo 0 -1
1) "buy some milk"
2) "have lunch"  -- 新元素
3) "finish homework"

图 4-20 展示了这个 LSET 命令的执行过程。


图 4-20 LSET 命令的执行过程

_images/IMAGE_LSET.jpeg

处理超出范围的索引

因为 LSET 命令只能对列表已存在的索引进行设置, 所以如果用户给定的索引超出了列表的有效索引范围, 那么 LSET 命令将返回一个错误:

redis> LSET todo 100 "go to sleep"
(error) ERR index out of range

其他信息

属性

复杂度

O(N) ,其中 N 为给定列表的长度。

版本要求

LSET 命令从 Redis 1.0.0 版本开始可用。

LINSERT:将元素插入到列表

通过使用 LINSERT 命令, 用户可以将一个新元素插入到列表某个指定元素的前面或者后面:

LINSERT list BEFORE|AFTER target_element new_element

LINSERT 命令第二个参数的值可以是 BEFORE 或者 AFTER , 它们分别用于指示命令将新元素插入到目标元素的前面或者后面; 命令在完成插入操作之后会返回列表当前的长度作为返回值。

比如说, 对于以下这个 lst 列表:

redis> LRANGE lst 0 -1
1) "a"
2) "b"
3) "c"

我们可以通过执行以下 LINSERT 命令, 将元素 "10086" 插入到元素 "b" 的前面:

redis> LINSERT lst BEFORE "b" "10086"
(integer) 4

redis> LRANGE lst 0 -1
1) "a"
2) "10086"
3) "b"
4) "c"

还可以通过执行以下 LINSERT 命令, 将元素 "12345" 插入到元素 "c" 的后面:

redis> LINSERT lst AFTER "c" "12345"
(integer) 5

redis> LRANGE lst 0 -1
1) "a"
2) "10086"
3) "b"
4) "c"
5) "12345"

图 4-21 展示了上述两个 LINSERT 命令的执行过程。


图 4-21 LINSERT 命令的执行过程

_images/IMAGE_LINSERT_1.jpeg _images/IMAGE_LINSERT_2.jpeg

处理不存在的元素

为了执行插入操作, LINSERT 命令要求用户给定的目标元素必须已经存在于列表当中; 相反地, 如果用户给定的目标元素并不存在, 那么 LINSERT 命令将返回 -1 表示插入失败:

redis> LINSERT lst BEFORE "not-exists-element" "new element"
(integer) -1

在插入操作执行失败的情况下, 列表包含的元素将不会发生任何变化。

其他信息

属性

复杂度

O(N) ,其中 N 为给定列表的长度。

版本要求

LINSERT 命令从 Redis 2.2.0 版本开始可用。

LTRIM:修剪列表

LTRIM 命令接受一个列表和一个索引范围作为参数, 它会移除列表中位于给定索引范围之外的所有元素, 只保留给定范围之内的元素:

LTRIM list start end

LTRIM 命令在移除操作执行完毕之后将返回 OK 作为结果。

比如对于以下这个 alphabets 列表来说:

redis> RPUSH alphabets "a" "b" "c" "d" "e" "f" "g" "h" "i" "j" "k"
(integer) 11

执行以下命令可以让列表只保留索引 0 到索引 6 范围内的七个元素:

redis> LTRIM alphabets 0 6
OK

redis> LRANGE alphabets 0 -1
1) "a"
2) "b"
3) "c"
4) "d"
5) "e"
6) "f"
7) "g"

在此之后, 我们可以继续执行以下命令, 让列表只保留索引 3 到索引 5 范围内的三个元素:

redis> LTRIM alphabets 3 5
OK

redis> LRANGE alphabets 0 -1
1) "d"
2) "e"
3) "f"

图 4-22 展示了以上两个 LTRIM 命令对 alphabets 列表进行修剪的整个过程。


图 4-22 LTRIM 命令的执行过程

_images/IMAGE_LTRIM.jpeg

处理负数索引

LRANGE 命令一样, LTRIM 命令不仅可以接受正数索引, 还可以接受负数索引。

以下代码展示了如何通过给定负数索引, 让 LTRIM 命令只保留列表的最后五个元素:

redis> RPUSH numbers 0 1 2 3 4 5 6 7 8 9
(integer) 10

redis> LTRIM numbers -5 -1
OK

redis> LRANGE numbers 0 -1
1) "5"
2) "6"
3) "7"
4) "8"
5) "9"

其他信息

属性

复杂度

O(N) ,其中 N 为给定列表的长度。

版本要求

LTRIM 命令从 Redis 1.0.0 版本开始可用。

LREM:从列表中移除指定元素

用户可以通过 LREM 命令移除列表中的指定元素:

LREM list count element

count 参数的值决定了 LREM 命令移除元素的方式:

  • 如果 count 参数的值等于 0 , 那么 LREM 命令将移除列表中包含的所有指定元素;

  • 如果 count 参数的值大于 0 , 那么 LREM 命令将从列表的左端开始向右进行检查, 并移除最先发现的 count 个指定元素;

  • 如果 count 参数的值小于 0 , 那么 LREM 命令将从列表的右端开始向左进行检查, 并移除最先发现的 abs(count) 个指定元素 (abs(count) 即是 count 的绝对值);

LREM 命令在执行完毕之后将返回被移除的元素数量作为返回值。

举个例子, 对于以下三个包含相同元素的列表来说:

redis> RPUSH sample1 "a" "b" "b" "a" "c" "c" "a"
(integer) 7

redis> RPUSH sample2 "a" "b" "b" "a" "c" "c" "a"
(integer) 7

redis> RPUSH sample3 "a" "b" "b" "a" "c" "c" "a"
(integer) 7

执行以下命令将移除 sample1 列表包含的所有 "a" 元素:

redis> LREM sample1 0 "a"
(integer) 3  -- 移除了三个 "a" 元素

redis> LRANGE sample1 0 -1
1) "b"  -- 列表里面已经不再包含 "a" 元素
2) "b"
3) "c"
4) "c"

而执行以下命令将移除 sample2 列表最靠近列表左端的两个 "a" 元素:

redis> LREM sample2 2 "a"
(integer) 2  -- 移除了两个 "a" 元素

redis> LRANGE sample2 0 -1
1) "b"
2) "b"
3) "c"
4) "c"
5) "a"

因为上面的 LREM 命令只要求移除最先发现的两个 "a" 元素, 所以位于列表最右端的 "a" 元素并没有被移除, 图 4-23 展示了这个 LREM 命令的执行过程。


图 4-23 从 sample2 列表中移除最靠近列表左端的两个 "a" 元素

_images/IMAGE_REMOVE_LEFT.jpeg

最后, 执行以下命令将移除 sample3 列表最靠近列表右端的两个 "a" 元素:

redis> LREM sample3 -2 "a"
(integer) 2  -- 移除了两个 "a" 元素

redis> LRANGE sample3 0 -1
1) "a"
2) "b"
3) "b"
4) "c"
5) "c"

因为上面的 LREM 调用只要求移除最先发现的两个 "a" 元素, 所以位于列表最左端的 "a" 元素并没有被移除, 图 4-24 展示了这个 LREM 调用的执行过程。


图 4-24 从 sample3 列表里面移除最靠近列表右端的两个 "a" 元素

_images/IMAGE_REMOVE_RIGHT.jpeg

其他信息

属性

复杂度

O(N) ,其中 N 为给定列表的长度。

版本要求

LREM 命令从 Redis 1.0.0 版本开始可用。

示例:待办事项列表

现在很多人都会使用待办事项软件(也就是通常说的TODO软件)去管理他们的待办事项, 这些软件通常会提供一些列表, 用户可以将他们要做的事情记录在待办事项列表里面, 并将已经完成的事项放入到已完成事项列表里面。 比如图 4-25 就展示了一个使用待办事项软件记录日常生活事项的例子。


图 4-25 使用待办事项软件记录日常生活事项

_images/IMAGE_TRELLO.png

代码清单 4-3 展示了一个使用列表实现的待办事项程序, 这个程序的核心概念是使用两个列表来分别记录待办事项和已完成事项:

  • 当用户添加一个新的待办事项时, 程序就把这个事项放入到待办事项列表中。

  • 当用户完成待办事项列表中的某个事项时, 程序就把这个事项从待办事项列表中移除, 并将它放入到已完成事项列表中。


代码清单 4-3 代码事项程序:/list/todo_list.py

def make_todo_list_key(user_id):
    """
    储存待办事项的列表。
    """
    return user_id + "::todo_list"

def make_done_list_key(user_id):
    """
    储存已完成事项的列表。
    """
    return user_id + "::done_list"


class TodoList:

    def __init__(self, client, user_id):
        self.client = client
        self.user_id = user_id
        self.todo_list = make_todo_list_key(self.user_id)
        self.done_list = make_done_list_key(self.user_id)

    def add(self, event):
        """
        将指定事项添加到待办事项列表中。
        """
        self.client.lpush(self.todo_list, event)

    def remove(self, event):
        """
        从待办事项列表中移除指定的事项。
        """
        self.client.lrem(self.todo_list, 0, event)

    def done(self, event):
        """
        将待办事项列表中的指定事项移动到已完成事项列表,
        以此来表示该事项已完成。
        """
        # 从待办事项列表中移除指定事项
        self.remove(event)
        # 并将它添加到已完成事项列表中
        self.client.lpush(self.done_list, event)

    def show_todo_list(self):
        """
        列出所有待办事项。
        """
        return self.client.lrange(self.todo_list, 0, -1)

    def show_done_list(self):
        """
        列出所有已完成事项。
        """
        return self.client.lrange(self.done_list, 0, -1)

done() 方法是 TodoList 程序的核心, 它首先会使用 LREM 命令从代办事项列表中移除指定的事项, 然后再将该事项添加到已完成事项列表中, 使得该事项可以在代办事项列表中消失, 转而出现在已完成列表中。

作为例子, 我们可以通过执行以下代码, 创建出一个 TODO 列表对象:

>>> from redis import Redis
>>> from todo_list import TodoList
>>> client = Redis(decode_responses=True)
>>> todo = TodoList(client, "peter's todo")

然后通过执行以下代码, 往 TODO 列表里面加入待完成事项:

>>> todo.add("go to sleep")
>>> todo.add("finish homework")
>>> todo.add("watch tv")
>>> todo.add("have lunch")
>>> todo.add("buy some milk")
>>> todo.show_todo_list()
['buy some milk', 'have lunch', 'watch tv', 'finish homework', 'go to sleep']

当我们完成某件事情之后, 我们可以把它从待完成事项列表移动到已完成事项列表:

>>> todo.done("buy some milk")
>>> todo.show_todo_list()
['have lunch', 'watch tv', 'finish homework', 'go to sleep']
>>> todo.show_done_list()
['buy some milk']

最后, 如果我们不再需要去做某件事情, 那么可以把它从待完成列表里面移除:

>>> todo.remove("watch tv")
>>> todo.show_todo_list()
['have lunch', 'finish homework', 'go to sleep']

BLPOP:阻塞式左端弹出操作

BLPOP 命令是带有阻塞功能的左端弹出操作, 它接受任意多个列表以及一个秒级精度的超时时限作为参数:

BLPOP list [list ...] timeout

BLPOP 命令会按照从左到右的顺序依次检查用户给定的列表, 并对最先遇到的非空列表执行左端元素弹出操作。 如果 BLPOP 命令在检查了用户给定的所有列表之后都没有发现可以执行弹出操作的非空列表, 那么它将阻塞执行该命令的客户端并开始等待, 直到某个给定列表变为非空, 又或者等待时间超出给定时限为止。

BLPOP 命令成功对某个非空列表执行了弹出操作之后, 它将向用户返回一个包含两个元素的数组: 数组的第一个元素记录了执行弹出操作的列表, 也即是被弹出元素的来源列表; 而数组的第二个元素则是被弹出元素本身。

比如在以下这个 BLPOP 命令执行示例里面, 被弹出的元素 "a" 就来源于列表 alphabets

redis> BLPOP alphabets 5  -- 尝试弹出 alphabets 列表的最左端元素,最多阻塞 5 秒
1) "alphabets"            -- 被弹出元素的来源列表
2) "a"                    -- 被弹出元素

如果用户使用的是 redis-cli 客户端, 并且在执行 BLPOP 命令的过程中曾经被阻塞过, 那么客户端还会将被阻塞的时长也打印出来:

redis> BLPOP message-queue 5
1) "message-queue"
2) "hello world!"
(1.60s)    -- 客户端执行这个命令时被阻塞了 1.6 

注意, 这里展示的阻塞时长只是 redis-cli 客户端为了方便用户而添加的额外信息, BLPOP 命令返回的结果本身并不包含这一信息。

解除阻塞状态

正如前面所说, 当 BLPOP 命令发现用户给定的所有列表都为空时, 它就会让执行命令的客户端进入阻塞状态。 如果在客户端被阻塞的过程中, 有另一个客户端向导致阻塞的列表推入了新的元素, 那么该列表就会变为非空, 而被阻塞的客户端也会随着 BLPOP 命令成功弹出列表元素而重新回到非阻塞状态。

作为例子, 表 4-1 展示了一个客户端从被阻塞到解除阻塞的整个过程。


表 4-1 客户端 A 从被阻塞到解除阻塞的整个过程

时间

客户端 A

客户端 B

T1

执行 BLPOP lst 10 , 因为 lst 为空而导致客户端被阻塞。

T2

执行 RPUSH lst "hello" 命令, 将 "hello" 元素推入到列表 lst 中。

T3

服务器检测到导致这个客户端阻塞的 lst 列表已经非空, 于是从列表中弹出 "hello" 元素并将其返回给客户端。

T4

接收到 "hello" 元素的客户端重新回到非阻塞状态。


如果在同一时间, 有多个客户端因为同一个列表而被阻塞, 那么当导致阻塞的列表变为非空时, 服务器将按照“先阻塞先服务”的规则, 依次为被阻塞的各个客户端弹出列表元素。

比如表 4-2 就展示了一个服务器按照先阻塞先服务规则处理被阻塞客户端的例子: 在这个例子中, A、B、C 三个客户端先后执行了 BLPOP lst 10 命令, 并且都因为 lst 列表为空而被阻塞, 如果在这些客户端被阻塞期间, 客户端 D 执行了 RPUSH lst "hello" "world" "again" 命令, 那么服务器首先会处理客户端 A 的 BLPOP 命令, 并将被弹出的 "hello" 元素返回给它; 接着处理客户端 B 的 BLPOP 命令, 并将被弹出的 "world" 元素返回给它; 最后处理客户端 C 的 BLPOP 命令, 并将被弹出的 "again" 元素返回给它。


表 4-2 先阻塞先服务器处理示例

时间

客户端 A

客户端 B

客户端 C

客户端 D

T1

执行 BLPOP lst 10

T2

执行 BLPOP lst 10

T3

执行 BLPOP lst 10

T4

执行 RPUSH lst "hello" "world" "again"

T5

lst 列表弹出 "hello" 元素并解除阻塞状态。

T6

lst 列表弹出 "world" 元素并解除阻塞状态。

T7

lst 列表弹出 "again" 元素并解除阻塞状态。


最后, 如果被推入到列表的元素数量少于被阻塞的客户端数量, 那么先被阻塞的客户端将会先解除阻塞, 而未能解除阻塞的客户端则需要继续等待下次推入操作。

比如说, 如果有五个客户端因为列表为空而被阻塞, 但是推入到列表的元素只有三个, 那么最先被阻塞的三个客户端将会解除阻塞状态, 而剩下的两个客户端则会继续阻塞。

处理空列表

如果用户向 BLPOP 命令传入的所有列表都是空列表, 并且这些列表在给定的时限之内一直没有变成非空列表, 那么 BLPOP 命令将在给定时限到达之后向客户端返回一个空值, 表示没有任何元素被弹出:

redis> BLPOP empty-list 5
(nil)
(5.04s)

列表名的作用

BLPOP 命令之所以会返回被弹出元素的来源列表, 是为了让用户在传入多个列表的情况下, 知道被弹出的元素来源于哪个列表。

比如在以下这个示例里面, 通过 BLPOP 命令的回复, 我们可以知道被弹出的元素来自于列表 queue2 , 而不是 queue1 或者 queue3

redis> BLPOP queue1 queue2 queue3 5
1) "queue2"
2) "hello world!"

阻塞效果的范围

BLPOP 命令的阻塞效果只对执行这个命令的客户端有效, 其他客户端以及 Redis 服务器本身并不会因为这个命令而被阻塞。

其他信息

属性

复杂度

O(N) ,其中 N 为用户给定的列表数量。

版本要求

BLPOP 命令从 Redis 2.0.0 版本开始可用。

BRPOP:阻塞式右端弹出操作

BRPOP 命令是带有阻塞功能的右端弹出操作, 除了弹出的方向不同之外, 它的其他方面都和 BLPOP 命令一样:

BRPOP list [list ...] timeout

作为例子, 以下代码展示了如何使用 BRPOP 命令去尝试弹出给定列表的最右端元素:

redis> BRPOP queue1 queue2 queue3 10
1) "queue2"    -- 被弹出元素的来源列表
2) "bye bye"   -- 被弹出元素

其他信息

属性

复杂度

O(N) ,其中 N 为用户给定的列表数量。

版本要求

BRPOP 命令从 Redis 2.0.0 版本开始可用。

BRPOPLPUSH:阻塞式弹出并推入操作

BRPOPLPUSH 命令是 RPOPLPUSH 命令的阻塞版本, 它接受一个源列表、一个目标列表以及一个秒级精度的超时时限作为参数:

BRPOPLPUSH source target timeout

根据源列表是否为空, BRPOPLPUSH 命令会产生以下两种行为:

  • 如果源列表非空, 那么 BRPOPLPUSH 命令的行为就和 RPOPLPUSH 命令的行为一样: BRPOPLPUSH 命令会弹出位于源列表最右端的元素, 并将该元素推入到目标列表的左端, 最后向客户端返回被推入的元素。

  • 如果源列表为空, 那么 BRPOPLPUSH 命令将阻塞执行该命令的客户端, 然后在给定的时限内等待可弹出的元素出现, 又或者等待时间超过给定时限为止。

举个例子, 假设现在有 list3list4 两个列表如下:

client-1> LRANGE list3 0 -1
1) "hello"

client-1> LRANGE list4 0 -1
1) "a"
2) "b"
3) "c"

如果我们以这两个列表作为输入执行 BRPOPLPUSH 命令, 由于源列表 list3 非空, 所以 BRPOPLPUSH 命令将不阻塞直接执行, 就像 RPOPLPUSH 命令一样:

client-1> BRPOPLPUSH list3 list4 10
"hello"

client-1> LRANGE list3 0 -1
(empty list or set)

client-1> LRANGE list4 0 -1
1) "hello"
2) "a"
3) "b"
4) "c"

现在, 由于 list3 为空, 如果我们再次执行相同的 BRPOPLPUSH 命令, 那么客户端 client-1 将被阻塞, 直到我们从另一个客户端 client-2list3 推入新元素为止:

client-1> BRPOPLPUSH list3 list4 10
"world"
(1.42s)  -- 被阻塞了 1.42 

client-1> LRANGE list3 0 -1
(empty list or set)

client-1> LRANGE list4 0 -1
1) "world"
2) "hello"
3) "a"
4) "b"
5) "c"
client-2> RPUSH list3 "world"
(integer) 1

表 4-3 展示了客户端从被阻塞到解除阻塞的整个过程。


表 4-3 阻塞 BRPOPLPUSH 命令的执行过程

时间

客户端 client-1

客户端 client-2

T1

尝试执行 BRPOPLPUSH list3 list4 10 并被阻塞。

T2

执行 RPUSH list3 "world" ,向列表 list3 推入新元素。

T3

服务器执行 BRPOPLPUSH 命令, 并将元素 "world" 返回给客户端。

处理源列表为空的情况

如果源列表在用户给定的时限内一直没有元素可供弹出, 那么 BRPOPLPUSH 命令将向客户端返回一个空值, 以此来表示此次操作没有弹出和推入任何元素:

redis> BRPOPLPUSH empty-list another-list 5
(nil)
(5.05s)  -- 客户端被阻塞了 5.05 

BLPOP 命令和 BRPOP 命令一样, redis-cli 客户端也会显示 BRPOPLPUSH 命令的阻塞时长。

其他信息

属性

复杂度

O(1)

版本要求

BRPOPLPUSH 命令从 Redis 2.2.0 版本开始可用。

示例:带有阻塞功能的消息队列

在构建应用程序的时候, 我们有时候会遇到一些非常耗时的操作, 比如发送邮件、将一条新微博同步给上百万个用户、对硬盘进行大量读写、执行庞大的计算等等。 因为这些操作是如此耗时, 所以如果我们直接在响应用户请求的过程中执行它们的话, 那么用户就需要等待非常长时间。

比如说, 为了验证用户身份的有效性, 有些网站在注册新用户的时候, 会向用户给定的邮件地址发送一封激活邮件, 用户只有在点击了验证邮件里面的激活链接之后, 新注册的帐号才能够正常使用。

下面这段伪代码展示了一个带有邮件验证功能的帐号注册函数, 这个函数不仅会为用户输入的用户名和密码创建新帐号, 还会向用户给定的邮件地址发送一封激活:

def register(username, password, email):
    # 创建新帐号
    create_new_account(username, password)
    # 发送激活邮件
    send_validate_email(email)
    # 向用户返回注册结果
    ui_print("帐号注册成功,请访问你的邮箱并激活帐号。")

因为邮件发送操作需要进行复杂的网络信息交换, 所以它并不是一个快速的操作, 如果我们直接在 send_valid_email() 函数里面执行邮件发送操作的话, 那么用户可能就需要等待一段较长的时间才能看到 ui_print() 函数打印出的反馈信息。

为了解决这个问题, 在执行 send_validate_email() 函数的时候, 我们可以不立即执行邮件发送操作, 而是将邮件发送任务放入到一个队列里面, 然后由后台的线程负责实际执行。 这样的话, 程序只需要执行一个入队操作, 然后就可以直接向用户反馈注册结果了, 这比实际地发送邮件之后再向用户反馈结果要快得多。

代码清单 4-4 展示了一个使用 Redis 实现的消息队列, 它使用 RPUSH 命令将消息推入队列, 并使用 BLPOP 命令从队列里面取出待处理的消息。


代码清单 4-4 使用列表实现的消息队列:/list/message_queue.py

class MessageQueue:

    def __init__(self, client, queue_name):
        self.client = client
        self.queue_name = queue_name

    def add_message(self, message):
        """
        将一条消息放入到队列里面。
        """
        self.client.rpush(self.queue_name, message)

    def get_message(self, timeout=0):
        """
        从队列里面获取一条消息,
        如果暂时没有消息可用,那么就在 timeout 参数指定的时限内阻塞并等待可用消息出现。

        timeout 参数的默认值为 0 ,表示一直等待直到消息出现为止。
        """
        # blpop 的结果可以是 None ,也可以是一个包含两个元素的元组
        # 元组的第一个元素是弹出元素的来源队列,而第二个元素则是被弹出的元素
        result = self.client.blpop(self.queue_name, timeout)
        if result is not None:
            source_queue, poped_item = result
            return poped_item

    def len(self):
        """
        返回队列目前包含的消息数量。
        """
        return self.client.llen(self.queue_name)

为了使用这个消息队列, 我们通常需要用到两个客户端:

  • 一个客户端作为消息的发送者(sender), 它需要将待处理的消息推入到队列里面;

  • 而另一个客户端则作为消息的接收者(receiver)和消费者(consumer), 它负责从队列里面取出消息, 并根据消息内容进行相应的处理工作。

下面的这段代码展示了一个简单的消息接收者, 在没有消息的时候, 这个程序将阻塞在 mq.get_message() 调用上面; 当有消息(邮件地址)出现时, 程序就会打印出该消息并发送邮件:

>>> from redis import Redis
>>> from message_queue import MessageQueue
>>> client = Redis(decode_responses=True)
>>> mq = MessageQueue(client, 'validate user email queue')
>>> while True:
...   email_address = mq.get_message()  # 阻塞直到消息出现
...   send_email(email_address)         # 打印出邮件地址并发送邮件
...
peter@exampl.com
jack@spam.com
tom@blahblah.com

而以下代码则展示了消息发送者是如何将消息推入到队列里面的:

>>> from redis import Redis
>>> from message_queue import MessageQueue
>>> client = Redis(decode_responses=True)
>>> mq = MessageQueue(client, 'validate user email queue')
>>> mq.add_message("peter@exampl.com")
>>> mq.add_message("jack@spam.com")
>>> mq.add_message("tom@blahblah.com")

阻塞弹出操作的应用

本节展示的消息队列之所以使用 BLPOP 命令而不是 LPOP 命令来实现出队操作, 是因为阻塞弹出操作可以让消息接收者在队列为空的时候自动阻塞, 而不必手动进行休眠, 从而使得消息处理程序的编写变得更为简单直接, 并且还可以有效地节约系统资源。

作为对比, 以下代码展示了在使用 LPOP 命令实现出队操作的情况下, 如何实现类似上面展示的消息处理程序:

while True:
    # 尝试获取消息,如果没有消息,那么返回 None
    email_address = mq.get_message()
    if email_address is not None:
        # 有消息,发送邮件
        send_email(email_address)
    else:
        # 没有消息可用,休眠一百毫秒之后再试
        sleep(0.1)

因为缺少自动的阻塞操作, 所以这个程序在没有取得消息的情况下, 只能以一百毫秒一次的频率去尝试获取消息, 如果队列为空的时间比较长, 那么这个程序就会发送很多多余的 LPOP 命令, 并因此浪费很多 CPU 资源和网络资源。

使用消息队列实现实时提醒

消息队列除了可以在应用程序的内部中使用, 还可以用于实现面向用户的实时提醒系统。

比如说, 如果我们在构建一个社交网站的话, 那么可以使用 JavaScript 脚本, 让客户端以异步的方式调用 MessageQueue 类的 get_message() 方法, 然后程序就可以在用户被关注的时候、收到了新回复的时候又或者收到新私信的时候, 通过调用 add_message() 方法来向用户发送提醒信息。

重点回顾

  • Redis 的列表是一种线性的有序结构, 它可以按照元素推入到列表中的顺序来储存元素, 并且列表中的元素可以出现重复。

  • 用户可以使用 LPUSHRPUSHRPOPLPOP 等多个命令, 从列表的两端推入或者弹出元素, 也可以通过 LINSERT 命令, 将新元素插入到列表已有元素的前面或后面。

  • 用户可以使用 LREM 命令从列表中移除指定的元素, 又或者直接使用 LTRIM 命令对列表进行修剪。

  • 当用户传给 LRANGE 命令的索引范围超出了列表的有效索引范围时, LRANGE 命令将对传入的索引范围进行修正, 并根据修正后的索引范围来获取列表元素。

  • BLPOPBRPOPBRPOPLPUSH 是阻塞版本的弹出和推入命令, 如果用户给定的所有列表都为空, 那么执行命令的客户端将被阻塞, 直到给定的阻塞时限到达又或者某个给定列表非空为止。