3.4 列表类型
场景:
正当小白踌躇满志地写着文章列表页的代码时,一个很重要的问题阻碍了他的开发。
原来小白是使用如下流程获得文章列表的:
(1)读取posts:count键获得博客中最大的文章ID;
(2)根据这个ID来计算当前列表页面中需要展示的文章ID列表(小白规定博客每页只显示 10篇文章,按照ID的倒序排列),如第n页的文章ID范围是从“最大的文章ID-(n-1) * 10”到“max(最大的文章ID-n * 10+1,1)”;
(3)对每个ID使用HMGET命令来获得文章数据。
对应的伪代码如下:
#每页显示10篇文章
$postsPerPage=10
#获得最后发表的文章ID
$lastPostID=GET posts:count
# currentPage存储的是当前页码,第一页时$currentPage的值为1,依此类推
$start= $lastPostID-( $currentPage-1) * $postsPerPage
$end=max( $lastPostID- $currentPage * $postsPerPage+1,1)
#遍历文章ID获取数据
for $i= $start down to $end
#获取文章的标题和作者并打印出来
post=HMGET post: $i, title, author
print $post[0] #文章标题
print $post[1] #文章作者
可是这种方式要求用户不能删除文章以保证ID连续,否则小白就必须在程序中使用 EXISTS 命令判断某个ID的文章是否存在,如果不存在则跳过。由于每删除一篇文章都会影响后面的页码分布,为了保证每页的文章列表都能正好显示10篇文章,不论是第几页,都不得不
从最大的文章ID开始遍历来获得当前页面应该显示哪些文章。
小白摇了摇头,心想:“真是个灾难!”然后看向老师,试探地问道:“我想到了KEYS命令,可不可以使用KEYS命令获得所有以“post:”开头的键,然后再根据键名分页呢?”
老师回答道:“确实可行,不过KEYS命令需要遍历数据库中的所有键,出于性能考虑一般很少在生产环境中使用这个命令。至于你提到的问题,可以使用Redis的列表类型来解决。”
3.4.1 介绍
列表类型(list)可以存储一个有序的字符串列表,常用的操作是向列表两端添加元素,或者获得列表的某一个片段。
列表类型内部是使用双向链表(double linked list)实现的,所以向列表两端添加元素的时间复杂度为0(1),获取越接近两端的元素速度就越快。这意味着即使是一个有几千万个元素的列表,获取头部或尾部的10条记录也是极快的(和从只有20个元素的列表中获取头部或尾部的 10条记录的速度是一样的)。
不过使用链表的代价是通过索引访问元素比较慢,设想在iPad mini发售当天有1000个人在三里屯的苹果店排队等候购买,这时苹果公司宣布为了感谢大家的排队支持,决定奖励排在第486位的顾客一部免费的iPad mini。为了找到这第486位顾客,工作人员不得不从队首一个一个地数到第486个人。但同时,无论队伍多长,新来的人想加入队伍的话直接排到队尾就好了,和队伍里有多少人没有任何关系。这种情景与列表类型的特性很相似。
这种特性使列表类型能非常快速地完成关系数据库难以应付的场景:如社交网站的新鲜事,我们关心的只是最新的内容,使用列表类型存储,即使新鲜事的总数达到几千万个,获取其中最新的100条数据也是极快的。同样因为在两端插入记录的时间复杂度是0(1),列表类型也适合用来记录日志,可以保证加入新日志的速度不会受到已有日志数量的影响。
借助列表类型,Redis还可以作为队列使用,4.4节会详细介绍。
与散列类型键最多能容纳的字段数量相同,一个列表类型键最多能容纳2^32-1个元素。
3.4.2 命令
1.向列表两端增加元素
LPUSH key value [value …]
RPUSH key value [value …]
LPUSH命令用来向列表左边增加元素,返回值表示增加元素后列表的长度。
redis>LPUSH numbers 1
(integer) 1
这时numbers键中的数据如图3-8所示。LPUSH命令还支持同时增加多个元素,例如:
redis>LPUSH numbers 2 3
(integer) 3
LPUSH会先向列表左边加入"2",然后再加入"3",所以此时numbers键中的数据如图3-9所示。
向列表右边增加元素的话则使用RPUSH命令,其用法和LPUSH命令一样:
redis>RPUSH numbers 0 -1
(integer) 5
此时numbers 键中的数据如图3-10所示。
2.从列表两端弹出元素
LPOP key
RPOP key
有进有出,LPOP命令可以从列表左边弹出一个元素。LPOP命令执行两步操作:第一步是 将列表左边的元素从列表中移除,第二步是返回被移除的元素值。例如,从numbers列表左边弹出一个元素(也就是"3"):
redis>LPOP numbers
"3"
此时numbers键中的数据如图3-11所示。
同样,RPOP命令可以从列表右边弹出一个元素:
redis>RPOP numbers
"-1"
此时numbers键中的数据如图3-12所示。
结合上面提到的4个命令可以使用列表类型来模拟栈和队列的操作:如果想把列表当做栈,则搭配使用LPUSH和LPOP或RPUSH和RPOP,如果想当成队列,则搭配使用LPUSH和 RPOP或RPUSH和LPOP。
3.获取列表中元素的个数
LLEN key
当键不存在时LLEN会返回0:
redis>LLEN numbers
(integer) 3
LLEN命令的功能类似SQL语句SELECT COUNT(*) FROM table_name,但是LLEN的时间复杂度为0(1),使用时Redis会直接读取现成的值,而不需要像部分关系数据库(如使用InnoDB 存储引擎的MySQL 表)那样需要遍历一遍数据表来统计条目数量。
4.获得列表片段
LRANGE key start stop
LRANGE命令是列表类型最常用的命令之一,它能够获得列表中的某一片段。LRANGE命令将返回索引从start到stop之间的所有元素(包含两端的元素)。与大多数人的直觉相同,Redis 的列表起始索引为0:
redis>LRANGE numbers 0 2
1) "2"
2) "1"
3) "0"
LRANGE命令在取得列表片段的同时不会像LPOP一样删除该片段,另外LRANGE命令与很多语言中用来截取数组片段的方法slice有一点区别是LRANGE返回的值包含最右边的元 素,如在JavaScript中:
var numbers=[2, 1, 0];
console.log(numbers.slice(0, 2)); //返回数组:[2, 1]
LRANGE命令也支持负索引,表示从右边开始计算序数,如"-1"表示最右边第一个元 素,"-2"表示最右边第二个元素,依次类推:
redis>LRANGE numbers -2 -1
1) "1"
2) "0"
显然,LRANGE numbers 0 -1可以获取列表中的所有元素。另外一些特殊情况如下。
(1)如果start的索引位置比stop的索引位置靠后,则会返回空列表。
(2)如果stop大于实际的索引范围,则会返回到列表最右边的元素:
redis>LRANGE numbers 1 999
1) "1"
2) "0"
5.删除列表中指定的值
LREM key count value
LREM命令会删除列表中前count个值为value的元素,返回值是实际删除的元素个数。根据count值的不同,LREM命令的执行方式会略有差异:
●当count>0时LREM命令会从列表左边开始删除前count个值为value的元素;
●当count<0时LREM 命令会从列表右边开始删除前|count|个值为value的元素;
●当count=0是LREM命令会删除所有值为value的元素。例如:
redis>RPUSH numbers 2
(integer) 4
redis>LRANGE numbers 0 -1
1) "2"
2) "1"
3) "0"
4) "2"
#从右边开始删除第一个值为"2"的元素
redis>LREM numbers -1 2
(integer) 1
redis>LRANGE numbers 0 -1
1) "2"
2) "1"
3) "0"
3.4.3 实践
1.存储文章ID列表
为了解决小白遇到的问题,我们使用列表类型键posts:list记录文章ID列表。当发布新文章时使用LPUSH命令把新文章的ID加入这个列表中,另外删除文章时也要记得把列表中的文章 ID删除,就像这样:LREM posts:list 1要删除的文章ID 有了文章ID列表,就可以使用LRANGE命令来实现文章的分页显示了。伪代码如下:
$postsPerPage=10
$start=( $currentPage - 1) * $postsPerPage
$end= $currentPage * $postsPerPage-1
$postsID=LRANGE posts:list, $start, $end
#获得了此页需要显示的文章ID列表,我们通过循环的方式来读取文章
for each $id in $postsID
$post=HGETALL post: $id
print文章标题: $post.title
这样显示的文章列表是根据加入列表的顺序倒序的(即最新发布的文章显示在前面),如果想让最旧的文章显示在前面,可以使用LRANGE命令获取需要的部分并在客户端中将顺序反转显示出来,具体的实现交由读者来完成。
小白的问题至此就解决了,美中不足的一点是散列类型没有类似字符串类型的MGET命令那样可以通过一条命令同时获得多个键的键值的版本,所以对于每个文章ID都需要请求一次数据库,也就都会产生一次往返时延(round-trip delay time),之后我们会介绍使用管道和脚本来优化这个问题。
另外使用列表类型键存储文章ID列表有以下两个问题。
(1)文章的发布时间不易修改:修改文章的发布时间不仅要修改post:文章ID中的time字段,还需要按照实际的发布时间重新排列posts:list中的元素顺序,而这一操作相对比较繁琐。
(2)当文章数量较多时访问中间的页面性能较差:前面已经介绍过,列表类型是通过链表实现的,所以当列表元素非常多时访问中间的元素效率并不高。
但如果博客不提供修改文章时间的功能并且文章数量也不多时,使用列表类型也不失为 一种好办法。对于小白要做的博客系统来讲,现阶段的成果已经足够实用且值得庆祝了。3.6 节将介绍使用有序集合类型存储文章ID列表的方法。
2.存储评论列表
在博客中还可以使用列表类型键存储文章的评论。由于小白的博客不允许访客修改自己发表的评论,而且考虑到读取评论时需要获得评论的全部数据(评论者姓名,联系方式,评论时间和评论内容),不像文章一样有时只需要文章标题而不需要文章正文。所以适合将一条评 论的各个元素序列化成字符串后作为列表类型键中的元素来存储。
我们使用列表类型键post:文章ID:comments来存储某个文章的所有评论。发布评论的伪代码如下(以ID为42的文章为例):
#将评论序列化成字符串
$serializedComment=serialize( $author, $email, $time, $content)
LPUSH post:42:comments, $serializedComment
读取评论时同样使用LRANGE命令即可,具体的实现在此不再赘述。
3.4.5 命令拾遗
1.获得/设置指定索引的元素值
LINDEX key index
LSET key index value
如果要将列表类型当作数组来用,LINDEX命令是必不可少的。LINDEX命令用来返回指定索引的元素,索引从0开始。如:
redis>LINDEX numbers 0
" 2"
如果index是负数则表示从右边开始计算的索引,最右边元素的索引是-1。例如:
redis>LINDEX numbers -1
"0"
LSET是另一个通过索引操作列表的命令,它会将索引为index的元素赋值为value。例如:
redis>LSET numbers 1 7
OK
redis>LINDEX numbers 1
"7"
2.只保留列表指定片段
LTRIM key start end
LTRIM命令可以删除指定索引范围之外的所有元素,其指定列表范围的方法和LRANGE 命令相同。就像这样:
redis>LRANGE numbers 0 1
1) "1"
2) "2"
3) "7"
4) "3"
"0"
redis>LTRIM numbers 1 2
OK
redis>LRANGE numbers 0 1
1) "2"
2) "7"
LTRIM命令常和LPUSH命令一起使用来限制列表中元素的数量,比如记录日志时我们希望只保留最近的100条日志,则每次加入新元素时调用一次LTRIM命令即可:
LPUSH logs $newLog
LTRIM logs 0 99
3.向列表中插入元素
LINSERT key BEFORE|AFTER pivot value
LINSERT命令首先会在列表中从左到右查找值为pivot的元素,然后根据第二个参数是BEFORE还是AFTER来决定将value插入到该元素的前面还是后面。 LINSERT命令的返回值是插入后列表的元素个数。示例如下:
redis>LRANGE numbers 0 -1
1) "2"
2) "7"
3) "0"
redis>LINSERT numbers AFTER 7 3
(integer) 4
redis>LRANGE numbers 0 -1
1) "2"
2) "7"
3) "3"
4) "0"
redis>LINSERT numbers BEFORE 2 1
(integer) 5
redis>LRANGE numbers 0 -1
1) "1"
2) "2"
3) "7"
4) "3"
5) "0"
4.将元素从一个列表转到另一个列表R
POPLPUSH source destination
RPOPLPUSH是个很有意思的命令,从名字就可以看出它的功能:先执行RPOP命令再执行LPUSH 命令。RPOPLPUSH命令会先从source列表类型键的右边弹出一个元素,然后将其加入 到destination列表类型键的左边,并返回这个元素的值,整个过程是原子的。其具体实现可以表示为伪代码:
def rpoplpush( $source, $destination)
$value=RPOP $source
LPUSH $destination, $value
return $value
当把列表类型作为队列使用时,RPOPLPUSH命令可以很直观地在多个队列中传递数据。 当source和destination相同时,RPOPLPUSH命令会不断地将队尾的元素移到队首,借助这个特性我们可以实现一个网站监控系统:使用一个队列存储需要监控的网址,然后监控程序不断地 使用RPOPLPUSH命令循环取出一个网址来测试可用性。这里使用RPOPLPUSH命令的好处在 于在程序执行过程中仍然可以不断地向网址列表中加入新网址,而且整个系统容易扩展,允许多个客户端同时处理队列。
3.5 集合类型
博客首页,文章页面,评论页面……眼看着博客逐渐成型,小白的心情也是越来越好。不过一个他无法解决的问题:小白不知道该怎么在Redis中存储文章标签(tag)。他想过使用散列类型或列表类型存储,虽然都能实现,但是总觉得颇有不妥,再加上之前几天领略了Redis的强大功能后 小白相信一定有一种合适的数据类型能满足他的需求。于是小白给老师发了封询问邮件。 转天收到了老师的回复:你想的没错,Redis有一种数据类型很适合存储文章的标签,它就是集合类型。
3.5.1 介绍
集合的概念高中的数学课就学习过。在集合中的每个元素都是不同的,且没有顺序。一个集合类型(set)键可以存储至多2^32-1个(相信这个数字对大家来说已经很熟悉了)字符串。 集合类型和列表类型有相似之处,但很容易将它们区分开来,如表3-4所示。
集合类型 | 列表类型 | |
存储内容 | 至多2^32-1个字符串 | 至多2^32-1个字符串 |
有序性 | 否 | 是 |
唯一性 | 是 | 否 |
集合类型的常用操作是向集合中加入或删除元素、判断某个元素是否存在等,由于集合类型在Redis内部是使用值为空的散列表(hash table)实现的,所以这些操作的时间复杂度都是 0(1)。最方便的是多个集合类型键之间还可以进行并集、交集和差集运算,稍后就会看到灵活 运用这一特性带来的便利。
3.5.2 命令
1.增加/删除元素
SADD key member [member …]
SREM key member [member …]
SADD命令用来向集合中增加一个或多个元素,如果键不存在则会自动创建。因为在一个集合中不能有相同的元素,所以如果要加入的元素已经存在于集合中就会忽略这个元素。本命令的返回值是成功加入的元素数量(忽略的元素不计算在内)。例如:
redis>SADD letters a
(integer) 1
redis> SADD letters a b c
(integer) 2
第二条SADD命令的返回值为2是因为元素“a”已经存在,所以实际上只加入了两个元素。
SREM命令用来从集合中删除一个或多个元素,并返回删除成功的个数,例如:
redis>SREM letters c d
(integer) 1
由于元素“d”在集合中不存在,所以只删除了一个元素,返回值为1。
2.获得集合中的所有元素
SMEMBERS key
SMEMBERS命令会返回集合中的所有元素,例如:
redis>SMEMBERS letters
1) "b"
2) "a"
3.判断元素是否在集合中
SISMEMBER key member
判断一个元素是否在集合中是一个时间复杂度为0(1)的操作,无论集合中有多少个元素,SISMEMBER命令始终可以极快地返回结果。当值存在时SISMEMBER命令返回1,当值不存在或键不存在时返回0,例如:
redis>SISMEMBER letters a
(integer) 1
redis>SISMEMBER letters d
(integer) 0
4.集合间运算
SDIFF key [key …]
SINTER key [key …]
SUNION key [key …]
接下来要介绍的3个命令都是用来进行多个集合间运算的。
(1)SDIFF命令用来对多个集合执行差集运算。集合A与集合B的差集表示为A-B,代表所有属于A且不属于B的元素构成的集合(如图3-13所示),即A-B={x|x∈A且x∈/B}。例如:
{1, 2, 3}-{2, 3, 4}={1}
{2, 3, 4}-{1, 2, 3}={4}
SDIFF命令的使用方法如下:
redis>SADD setA 1 2 3
(integer) 3
redis>SADD setB 2 3 4
(integer) 3
redis>SDIFF setA setB
1) "1"
redis>SDIFF setB setA
1 ) "4"
SDIFF 命令支持同时传入多个键,例如:
redis>SADD setC 2 3
(integer) 2
redis>SDIFF setA setB setC
1 ) "1"
计算顺序是先计算setA-setB,再计算结果与setC的差集。
(2)SINTER命令用来对多个集合执行交集运算。集合A与集合B的交集表示为A∩B,代表所有属于A且属于B的元素构成的集合(如图3-14所示),即A∩B={x|x∈A且x∈B}。例如:
{1, 2, 3}∩{2, 3, 4}={2, 3}
SINTER命令的使用方法如下:
redis>SINTER setA setB
1) "2"
2) "3"
SINTER命令同样支持同时传入多个键,如:
redis>SINTER setA setB setC
1) "2"
2) "3"
(3)SUNION命令用来对多个集合执行并集运算。集合A与集合B的并集表示为AUB,代表所有属于A或属于B的元素构成的集合(如图3-15所示),即AUB={x|x∈A 或x∈B}。例如:
{1, 2, 3}∪{2, 3, 4}={1, 2, 3, 4}
SUNION命令的使用方法如下:
redis>SUNION setA setB
1) "1"
2) "2"
3) "3"
4) "4"
SUNION命令同样支持同时传入多个键,例如:
redis>SUNION setA setB setC
1) "1"
2) "2"
3) "3"
4) "4"
3.5.3 实践
1.存储文章标签
考虑到一个文章的所有标签都是互不相同的,而且展示时对这些标签的排列顺序并没有要求,我们可以使用集合类型键存储文章标签。 对每篇文章使用键名为“post:文章 ID:tags”的键存储该篇文章的标签。具体操作如伪代码:
#给ID为42的文章增加标签:
SADD post:42:tags, 闲言碎语, 技术文章, Java
# 删除标签:
SREM post:42:tags, 闲言碎语
#显示所有的标签:
$tags=SMEMBERS post:42:tags
print $tags
使用集合类型键存储标签适合需要单独增加或删除标签的场合。
另一方面,有些地方需要用户直接设置所有标签后一起上传修改,例如某网站的个人资料编辑页面,用户编辑自己的爱好后提交,程序直接覆盖原来的标签数据,整个过程没有针对单个标签的操作,并未利用到集合类型的优势,所以此时也可以直接使用字符串类型键存储标签数据。
之所以特意提到这个在实践中的差别是想说明对于Redis存储方式的选择并没有绝对的规则,比如3.4节介绍过使用列表类型存储访客评论,但是在一些特定的场合下散列类型甚至 字符串类型,设置个人爱好可能更适合。
2.通过标签搜索文章
有时我们还需要列出某个标签下的所有文章,甚至需要获得同时属于某几个标签的文章列表,这种需求在传统关系数据库中实现起来比较复杂,下面举一个例子。
表3-5 posts表结构
字段名 | 说明 |
post_id | 文章ID |
post_title | 文章标题 |
表3-6 tags表结构
字段名 | 说明 |
tag_id | 标签ID |
tag_name | 标签名称 |
表3-7 posts_tags表结构
字段名 | 说明 |
post_id | 对应的文章ID |
tag_id | 对应的标签ID |
为了找到同时属于“Java”、“MySQL”和“Redis”这3个标签的文章,需要使用如下的SQL语
句:
SELECT p.post_title
FROM posts_tags pt, posts p, tags t
WHERE pt.tag_id = t.tag_id
AND (t.tag_name IN ('Java', 'MySQL', 'Redis'))
AND p.post_id = pt.post_id
GROUP BY p.post_id
HAVING COUNT(p.post_id)=3;
可以很明显看到这样的SQL语句不仅效率相对较低,而且不易阅读和维护。而使用Redis 可以很简单直接地实现这一需求。
具体做法是为每个标签使用一个名为“tag:标签名称:posts”的集合类型键存储标有该标签的文章ID列表。假设现在有3篇文章,ID分别为1、2、3,其中ID为1的文章标签是“Java”,ID为2 的文章标签是“Java”、“MySQL”,ID为3的文章标签是“Java”、“MySQL”和“Redis”,则有关标签 部分的存储结构如图3-18所示① 。
注释:①集合类型键中元素是无序的,图3-18中为了便于读者阅读将元素按照大小顺序进 行了排列。
最简单的,当需要获取标记“MySQL”标签的文章时只需要使用命令SMEMBERStag:MySQL:posts即可。如果要实现找到同时属于Java、MySQL和Redis这3个标签 的文章,只需要将tag:Java:posts、tag:MySQL:posts和tag:Redis:posts这3个键取交集,借助 SINTER命令即可轻松完成。
3.5.4 命令拾遗
1.获得集合中元素个数
SCARD key
SCARD命令用来获得集合中的元素个数,例如:
redis>SMEMBERS letters
1) "b"
2) "a"
redis>SCARD letters
(integer) 2
2.进行集合运算并将结果存储
SDIFFSTORE destination key [key …]
SINTERSTORE destination key [key …]
SUNIONSTORE destination key [key …]
SDIFFSTORE命令和SDIFF命令功能一样,唯一的区别就是前者不会直接返回运算结果,而是将结果存储在destination键中。
SDIFFSTORE命令常用于需要进行多步集合运算的场景中,如需要先计算差集再将结果和其他键计算交集。
SINTERSTORE和SUNIONSTORE命令与之类似,不再赘述。
3.随机获得集合中的元素
SRANDMEMBER key [count]
SRANDMEMBER命令用来随机从集合中获取一个元素,如:
redis>SRANDMEMBER letters
"a"
redis>SRANDMEMBER letters
"b"
redis>SRANDMEMBER letters
"a"
还可以传递count参数来一次随机获得多个元素,根据count的正负不同,具体表现也不同。
(1)当count为正数时,SRANDMEMBER会随机从集合里获得count个不重复的元素。如果 count的值大于集合中的元素个数,则SRANDMEMBER会返回集合中的全部元素。
(2)当count为负数时,SRANDMEMBER会随机从集合里获得|count|个的元素,这些元素有 可能相同。
为了示例,我们先在letters集合中加入两个元素:
redis>SADD letters c d
(integer) 2
目前letters集合中共有“a”、“b”、“c”、“d”4个元素,下面使用不同的参数对 SRANDMEMBER命令进行测试:
redis>SRANDMEMBER letters 2
1) "a"
2) "c"
redis>SRANDMEMBER letters 2
1) "a"
2) "b"
redis>SRANDMEMBER letters 100
1) "b"
2) "a"
3) "c"
4) "d"
redis>SRANDMEMBER letters -2
1) "b"
2) "b"
redis>SRANDMEMBER letters -10
1) "b"
2) "b"
3) "c"
4) "c"
5) "b"
6) "a"
7) "b"
8) "d"
9) "b"
10) "b"
细心的读者可能会发现SRANDMEMBER命令返回的数据似乎并不是非常的随机,从SRANDMEMBER letters -10这个结果中可以很明显地看出这个问题(b元素出现的次数相对较 多① ),出现这种情况是由集合类型采用的存储结构(散列表)造成的。散列表使用散列函数将 元素映射到不同的存储位置(桶)上以实现0(1)时间复杂度的元素查找,举个例子,当使用散列表存储元素b时,使用散列函数计算出b的散列值是0,所以将b存入编号为0 的桶(bucket)中, 下次要查找b时就可以用同样的散列函数再次计算b的散列值并直接到相应的桶中找到b。当两 个不同的元素的散列值相同时会出现冲突,Redis使用拉链法来解决冲突,即将散列值冲突的 元素以链表的形式存入同一桶中,查找元素时先找到元素对应的桶,然后再从桶中的链表中 找到对应的元素。使用SRANDMEMBER命令从集合中获得一个随机元素时,Redis首先会从所 有桶中随机选择一个桶,然后再从桶中的所有元素中随机选择一个元素,所以元素所在的桶 中的元素数量越少,其被随机选中的可能性就越大,如图3-19所示。
注释:①如果你亲自跟着输入了命令可能会发现得到的结果与书中的结果并不相同,这是正常现象,见后文描述。
图3-19 Redis会先从3个桶中随机挑一个非空的桶,然后再从桶中随机选择一个元素,所以选中元素b的概率会大一些 4.从集合中弹出一个元素
SPOP key
3.4节中我们学习过LPOP命令,作用是从列表左边弹出一个元素(即返回元素的值并删除 它)。SPOP命令的作用与之类似,但由于集合类型的元素是无序的,所以SPOP命令会从集合中 随机选择一个元素弹出。例如:
redis>SPOP letters
"b"
redis>SMEMBERS letters
1) "a"
2) "c"
3) "d"
3.6 有序集合类型
了解了集合类型后,小白终于被Redis的强大功能所折服了,但他却不愿止步于此。小白又想给博客加上按照文章访问量排序的功能:
老师您好,之前您已经介绍过了如何使用列表类型键存储文章ID列表,不过我还想加上按照文章访问量排序的功能,因为我觉得很多访客更希望看那些热门的文章。
老师回答到:
这个功能很好实现,不过要用到一个新的数据类型——有序集合。
3.6.1 介绍
有序集合类型(sorted set)的特点从它的名字中就可以猜到,它与上一节介绍的集合类型的区别就是“有序”二字。
在集合类型的基础上有序集合类型为集合中的每个元素都关联了一个分数,这使得我们不仅可以完成插入、删除和判断元素是否存在等集合类型支持的操作,还能够获得分数最高 (或最低)的前N个元素、获得指定分数范围内的元素等与分数有关的操作。虽然集合中每个元 素都是不同的,但是它们的分数却可以相同。
有序集合类型在某些方面和列表类型有些相似。
(1)二者都是有序的。
(2)二者都可以获得某一范围的元素。
但是二者有着很大的区别,这使得它们的应用场景也是不同的。
(1)列表类型是通过链表实现的,获取靠近两端的数据速度极快,而当元素增多后,访问中间数据的速度会较慢,所以它更加适合实现如“新鲜事”或“日志”这样很少访问中间元素的应用。
(2)有序集合类型是使用散列表和跳跃表(Skip list)实现的,所以即使读取位于中间部分 的数据速度也很快(时间复杂度是O(log(N)))。
(3)列表中不能简单地调整某个元素的位置,但是有序集合可以(通过更改这个元素的分 数)。
(4)有序集合要比列表类型更耗费内存。
有序集合类型算得上是 Redis的5种数据类型中最高级的类型了,在学习时可以与列表类型和集合类型对照理解。
3.6.2 命令
1.增加元素
ZADD key score member [score member …]
ZADD命令用来向有序集合中加入一个元素和该元素的分数,如果该元素已经存在则会用新的分数替换原有的分数。ZADD命令的返回值是新加入到集合中的元素个数(不包含之前 已经存在的元素)。
假设我们用有序集合模拟计分板,现在要记录Tom、Peter和David三名运动员的分数(分别 是89分、67分和100分):
redis>ZADD scoreboard 89 Tom 67 Peter 100 David
(integer) 3
这时我们发现Peter的分数录入有误,实际的分数应该是76分,可以用ZADD命令修改Peter 的分数:
redis>ZADD scoreboard 76 Peter
(integer) 0
分数不仅可以是整数,还支持双精度浮点数:
redis>ZADD testboard 17E+307 a
(integer) 1
redis>ZADD testboard 1.5 b
(integer) 1
redis>ZADD testboard +inf c
(integer) 1
redis>ZADD testboard -inf d
(integer) 1
其中+inf和-inf分别表示正无穷和负无穷。
2.获得元素的分数
ZSCORE key member
示例如下:
redis>ZSCORE scoreboard Tom
"89"
3.获得排名在某个范围的元素列表
ZRANGE key start stop [WITHSCORES]
ZREVRANGE key start stop [WITHSCORES]
ZRANGE命令会按照元素分数从小到大的顺序返回索引从start到stop之间的所有元素(包含两端的元素)。ZRANGE命令与LRANGE命令十分相似,如索引都是从0开始,负数代表从后 向前查找(-1表示最后一个元素)。就像这样:
redis>ZRANGE scoreboard 0 2
1) "Peter"
2) "Tom"
3) "David"
redis>ZRANGE scoreboard 1 -1
1) "Tom"
2) "David"
如果需要同时获得元素的分数的话可以在ZRANGE命令的尾部加上WITHSCORES参数, 这时返回的数据格式就从“元素1, 元素2, …, 元素n”变为了“元素1, 分数1, 元素2, 分数2, …, 元 素n, 分数n”,例如:
redis>ZRANGE scoreboard 0 -1 WITHSCORES
1) "Peter"
2) "76"
3) "Tom"
4) "89"
5) "David"
6) "100"
ZRANGE命令的时间复杂度为0(logn+m)(其中n为有序集合的基数,m为返回的元素个 数)。
如果两个元素的分数相同,Redis会按照字典顺序(即"0"<"9"<"A"<"Z"<"a"<"z"这样的顺序)来进行排列。再进一步,如果元素的值是中文怎么处理呢?答案是取决于中文的编码方式,如使用UTF-8编码:
redis>ZADD chineseName 0 马华 0 刘墉 0 司马光 0 赵哲
(integer) 4
redis>ZRANGE chineseName 0 -1
1) "\xe5\x88\x98\xe5\xa2\x89"
2) "\xe5\x8f\xb8\xe9\xa9\xac\xe5\x85\x89"
3) "\xe8\xb5\xb5\xe5\x93\xb2"
4) "\xe9\xa9\xac\xe5\x8d\x8e"
可见此时Redis依然按照字典顺序排列这些元素。
ZREVRANGE命令和ZRANGE的唯一不同在于ZREVRANGE命令是按照元素分数从大到小的顺序给出结果的。
4.获得指定分数范围的元素
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
ZRANGEBYSCORE命令参数虽然多,但是都很好理解。该命令按照元素分数从小到大的顺序返回分数在min和max之间(包含min和max)的元素:
redis>ZRANGEBYSCORE scoreboard 80 100
1) "Tom"
2) "David"
如果希望分数范围不包含端点值,可以在分数前加上“(”符号。例如,希望返回80分到100 分的数据,可以含80分,但不包含100分,则稍微修改一下上面的命令即可:
redis>ZRANGEBYSCORE scoreboard 80 (100
1) "Tom"
min和max还支持无穷大,同ZADD命令一样,-inf 和+inf分别表示负无穷和正无穷。比如你 希望得到所有分数高于80分(不包含80分)的人的名单,但你却不知道最高分是多少(虽然有些背离现实,但是为了叙述方便,这里假设可以获得的分数是无上限的),这时就可以用上+inf 了:
redis>ZRANGEBYSCORE scoreboard (80 +inf
1) "Tom"
2) "David"
WITHSCORES参数的用法与ZRANGE命令一样,不再赘述。
了解SQL语句的读者对LIMIT offset count应该很熟悉,在本命令中LIMIToffset count与SQL 中的用法基本相同,即在获得的元素列表的基础上向后偏移offset个元素,并且只获取前count 个元素。为了便于演示,我们先向scoreboard键中再增加些元素:
redis>ZADD scoreboard 56 Jerry 92 Wendy 67 Yvonne
(integer) 3
现在scoreboard键中的所有元素为:
redis>ZRANGE scoreboard 0 -1 WITHSCORES
1) "Jerry"
2) "56"
3) "Yvonne"
4) "67"
5) "Peter"
6) "76"
7) "Tom"
8) "89"
9) "Wendy"
10) "92"
11) "David"
12) "100"
想获得分数高于60分的从第二个人开始的3个人:
redis>ZRANGEBYSCORE scoreboard 60 +inf LIMIT 1 3
1) "Peter"
2) "Tom"
3) "Wendy"
那么,如果想获取分数低于或等于100分的前3个人怎么办呢?这时可以借助ZREVRANGEBYSCORE命令实现。对照前文提到的ZRANGE命令和ZREVRANGE命令之间的关系,相信读者很容易能明白ZREVRANGEBYSCORE命令的功能。需要注意的是ZREVRANGEBYSCORE命令不仅是按照元素分数从大往小的顺序给出结果的,而且它的min和max参数的顺序和ZRANGEBYSCORE命令是相反的。就像这样:
redis>ZREVRANGEBYSCORE scoreboard 100 0 LIMIT 0 3
1) "David"
2) "Wendy"
3) "Tom"
5.增加某个元素的分数
ZINCRBY key increment member
ZINCRBY命令可以增加一个元素的分数,返回值是更改后的分数。例如,想给Jerry加4分:
redis>ZINCRBY scoreboard 4 Jerry
"60"
increment也可以是个负数表示减分,例如,给Jerry减4分:
redis>ZINCRBY scoreboard -4 Jerry
" 56"
如果指定的元素不存在,Redis在执行命令前会先建立它并将它的分数赋为0再执行操作。
3.6.3 实践
1.实现按点击量排序
要按照文章的点击量排序,就必须再额外使用一个有序集合类型的键来实现。在这个键中以文章的ID作为元素,以该文章的点击量作为该元素的分数。将该键命名为 posts:page.view,每次用户访问一篇文章时,博客程序就通过“ZINCRBY posts:page.view 1文章 ID”更新访问量。
需要按照点击量的顺序显示文章列表时,有序集合的用法与列表的用法大同小异:
$postsPerPage=10
$start=( $currentPage-1) * $postsPerPage
$end= $currentPage * $postsPerPage - 1
$postsID=ZREVRANGE posts:page.view, $start, $end
for each $id in $postsID
$postData=HGETALL post: $id
print文章标题: $postData.title
另外3.2节介绍过使用字符串类型键“post:文章ID:page.view”来记录单个文章的访问量,现在这个键已经不需要了,想要获得某篇文章的访问量可以通过ZSCOREposts:page.view文章ID 来实现。
2.改进按时间排序
3.4 节介绍了每次发布新文章时都将文章的ID加入到名为posts:list的列表类型键中来获得按照时间顺序排列的文章列表,但是由于列表类型更改元素的顺序比较麻烦,而如今不少博 客系统都支持更改文章的发布时间,为了让小白的博客同样支持该功能,我们需要一个新的方案来实现按照时间顺序排列文章的功能。
为了能够自由地更改文章发布时间,可以采用有序集合类型代替列表类型。自然地,元素 仍然是文章的ID,而此时元素的分数则是文章发布的Unix时间① 。通过修改元素对应的分数就 可以达到更改时间的目的。
注释:①UNIX时间指UTC时间1970年1月1日0时0分0秒起至现在的总秒数(不包括闰秒)。为什么是1970年呢?因为UNIX在1970年左右诞生。
另外借助ZREVRANGEBYSCORE命令还可以轻松获得指定时间范围的文章列表,借助这个功能可以实现类似WordPress的按月份查看文章的功能。
3.6.4 命令拾遗
1.获得集合中元素的数量
ZCARD key
例如:
redis>ZCARD scoreboard
(integer) 6
2.获得指定分数范围內的元素个数
ZCOUNT key min max
例如:
redis>ZCOUNT scoreboard 90 100
(integer) 2
ZCOUNT命令的min和max参数的特性与ZRANGEBYSCORE命令中的一样:
redis>ZCOUNT scoreboard (89 +inf
(integer) 2
3.删除一个或多个元素
ZREM key member [member …]
ZREM命令的返回值是成功删除的元素数量(不包含本来就不存在的元素)。
redis>ZREM scoreboard Wendy
(integer) 1
redis>ZCARD scoreboard
(integer) 5
4.按照排名范围删除元素
ZREMRANGEBYRANK key start stop
ZREMRANGEBYRANK命令按照元素分数从小到大的顺序(即索引0表示最小的值)删除处在指定排名范围内的所有元素,并返回删除的元素数量。如:
redis>ZADD testRem 1 a 2 b 3 c 4 d 5 e 6 f
(integer) 6
redis>ZREMRANGEBYRANK 0 2
(integer) 3
redis>ZRANGE testRem 0 -1
1) "d"
2) "e"
3) "f"
5.按照分数范围删除元素
ZREMRANGEBYSCORE key min max
ZREMRANGEBYSCORE命令会删除指定分数范围内的所有元素,参数min和max的特性和ZRANGEBYSCORE命令中的一样。返回值是删除的元素数量。如:
redis>ZREMRANGEBYSCORE testRem (4 5
(integer) 1
redis>ZRANGE testRem 0 -1
1) "d"
2) "f"
6.获得元素的排名
ZRANK key member ZREVRANK key member
ZRANK命令会按照元素分数从小到大的顺序获得指定的元素的排名(从0开始,即分数最 小的元素排名为0)。如:
redis>ZRANK scoreboard Peter
(integer) 0
ZREVRANK命令则相反(分数最大的元素排名为0):
redis>ZREVRANK scoreboard Peter
(integer) 4
7.计算有序集合的交集
ZINTERSTORE destination numkeys key [key …] [WEIGHTS weight [weight …]] [AGREGATE SUM|MIN|MAX]
ZINTERSTORE命令用来计算多个有序集合的交集并将结果存储在destination键中(同样以有序集合类型存储),返回值为destination键中的元素个数。
destination键中元素的分数是由AGGREGATE参数决定的。
(1)当AGGREGATE是SUM时(也就是默认值),destination键中元素的分数是每个参与计 算的集合中该元素分数的和。例如:
redis>ZADD sortedSets1 1 a 2 b
(integer) 2
redis>ZADD sortedSets2 10 a 20 b
(integer) 2
redis>ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2
(integer) 2
redis>ZRANGE sortedSetsResult 0 -1 WITHSCORES
1) "a"
2) "11"
3) "b"
4) "22"
(2)当AGGREGATE是MIN时,destination键中元素的分数是每个参与计算的集合中该元素分数的最小值。例如:
redis>ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2 AGGREGATE MIN
(integer) 2
redis>ZRANGE sortedSetsResult 0 -1 WITHSCORES
1) "a"
2) "1"
3) "b"
4) "2"
(3)当AGGREGATE是MAX时,destination键中元素的分数是每个参与计算的集合中该元素分数的最大值。例如:
redis>ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2 AGGREGATE MAX
(integer) 2
redis>ZRANGE sortedSetsResult 0 -1 WITHSCORES
1) "a"
2) "10"
3) "b"
4)"20"
ZINTERSTORE命令还能够通过WEIGHTS参数设置每个集合的权重,每个集合在参与计算时元素的分数会被乘上该集合的权重。例如:
redis>ZINTERSTORE sortedSetsResult 2 sortedSets1 sortedSets2 WEIGHTS 1 0.1
(integer) 2
redis>ZRANGE sortedSetsResult 0 -1 WITHSCORES
1) "a"
2) "2"
3) "b"
4) "4"
另外还有一个命令与ZINTERSTORE命令的用法一样,名为ZUNIONSTORE,它的作用是计算集合间的并集,这里不再赘述。