4.3 排序

2016-03-18 16:21:32 3,302 0

午后,宋老师正在批改学生们提交的程序,再过几天就会迎来第一次计算机全市联考。他在每个学生的程序代码末尾都用注释详细地做了批注——严谨的治学态度让他备受学生们的爱戴。

一个电话打来。“小白的?”宋老师拿出手机,“博客最近怎么样了?”未及小白开口,他就抢先问道。

特别好!现在平均每天都有50多人访问我的博客。不过咋天我收到一个访客的邮件,他向我反映了一个问题:查看一个标签下的文章列表时文章不是按照时间顺序排列的,找起来很麻烦。我看了一下代码,发现程序中是使用SMEMBERS命令获取标签下的文章列表,因为集合类型是无序的,所以不能实现按照文章的发布时间排列。我考虑过使用有序集合类型存储标签,但是有序集合类型的集合操作不如集合类型强大。您有什么好方法来解决这个问题吗?

方法有很多,我推荐使用SORT命令,你先挂了电话,我写好后发邮件给你吧。


4.3.1 有序集合的集合操作

集合类型提供了强大的集合操作命令,但是如果需要排序就要用到有序集合类型。Redis的作者在设计Redis的命令时考虑到了不同数据类型的使用场景,对于不常用到的或者在不损失过多性能的前提下可以使用现有命令来实现的功能,Redis就不会单独提供命令来实现。这一原则使得Redis在拥有强大功能的同时保持着相对精简的命令。

有序集合常见的使用场景是大数据排序,如游戏的玩家排行榜,所以很少会需要获得键中的全部数据。同样Redis认为开发者在做完交集、并集运算后不需要直接获得全部结果,而是会希望将结果存入新的键中以便后续处理。这解释了为什么有序集合只有ZINTERSTORE和ZUNIONSTORE命令而没有ZINTER和ZUNION命令。

当然实际使用中确实会遇到像小白那样需要直接获得集合运算结果的情况,除了等待Redis加入相关命令,我们还可以使用MULTI, ZINTERSTORE, ZRANGE, DEL 和EXEC 这5个命令自己实现ZINTER:

MULTI
ZINTERSTORE tempKey ...
ZRANGE tempKey ...
DEL tempKey
EXEC

4.3.2 SORT命令

除了使用有序集合外,我们还可以借助Redis提供的SORT命令来解决小白的问题。SORT命令可以对列表类型、集合类型和有序集合类型键进行排序,并且可以完成与关系数据库中的连接查询相类似的任务。

小白的博客中标有“ruby”标签的文章的ID分别是:“2”,“6”,“12”,“26”。由于在集合类型中所有元素是无序的,所以使用SMEMBERS命令并不能获得有序的结果① 。为了能够让博客的标签页面下的文章也能按照发布的时间顺序排列(如果不考虑发布后再修改文章发布时间,就是按照文章ID的顺序排列),可以借助SORT命令实现,方法如下所示:

注释:①集合类型经常被用于存储对象的ID,很多情况下都是整数。所以Redis对这种情况进行了特殊的优化,元素的排列是有序的。4.6节会详细介绍具体的原理。

redis>SORT tag:ruby:posts
1) "2"
2) "6"
3) "12"
4) "26"

是不是十分简单?除了集合类型,SORT命令还可以对列表类型和有序集合类型进行排序:

redis>LPUSH mylist 4 2 6 1 3 7
(integer)6
redis>SORT mylist
1) "1"
2) "2"
3) "3"
4) "4"
5) "6"
6) "7"

在对有序集合类型排序时会忽略元素的分数,只针对元素自身的值进行排序。例如:

redis>ZADD myzset 50 2 40 3 20 1 60 5
(integer) 4
redis>SORT myzset
1) "1"
2) "2"
3) "3"
4) "5"

除了可以排列数字外,SORT命令还可以通过ALPHA参数实现按照字典顺序排列非数字元素,就像这样:

redis>LPUSH mylistalpha a c e d B C A
(integer) 7
redis>SORT mylistalpha
(error) ERR One or more scores can't be converted into double
redis>SORT mylistalpha ALPHA
1) "A"
2) "B"
3) "C"
4) "a"
5) "c"
6) "d"
7) "e"

从这段示例中可以看到如果没有加ALPHA参数的话,SORT命令会尝试将所有元素转换成双精度浮点数来比较,如果无法转换则会提示错误。

回到小白的问题,SORT命令默认是按照从小到大的顺序排列,而一般博客中显示文章的顺序都是按照时间倒序的,即最新的文章显示在最前面。SORT命令的 DESC参数可以实现将元素按照从大到小的顺序排列:

redis>SORT tag:ruby:posts DESC
1) "26"
2) "12"
3) "6"
4) "2"

那么如果文章数量过多需要分页显示呢?SORT命令还支持LIMIT参数来返回指定范围的结果。用法和SQL语句一样,LIMIT offset count,表示跳过前offset个元素并获取之后的count个元素。

SORT命令的参数可以组合使用,像这样:

redis>SORT tag:ruby:posts DESC LIMIT 1 2
1) "12"
2) "6"

4.3.3 BY参数

很多情况下列表(或集合、有序集合)中存储的元素值代表的是对象的ID(如标签集合中存储的是文章对象的ID),单纯对这些ID自身排序有时意义并不大。更多的时候我们希望根据ID对应的对象的某个属性进行排序。回想3.6节,我们通过使用有序集合键来存储文章ID列表,使得小白的博客能够支持修改文章时间,所以文章ID的顺序和文章的发布时间的顺序并不完全一致,因此4.3.2节介绍的对文章ID本身排序就变得没有意义了。小白的博客是使用散列类型键存储文章对象的,其中time字段存储的就是文章的发布时间。

现在我们知道ID为“1352619200”,“1352619600”,“1352620100”和“1352620000”(Unix时间)。

如果要按照文章的发布时间递减排列结果应为“12”,“26”,“6”,“2”。为了获得这样的结果,需要使用SORT命令的另一个强大的参数——BY。

BY 参数的语法为“BY参考键”。其中参考键可以是字符串类型键或者是散列类型键的某个字段(表示为键名->字段名)。如果提供了BY参数,SORT命令将不再依据元素自身的值进行排序,而是对每个元素使用元素的值替换参考键中的第一个“*”并获取其值,然后依据该值对元素排序。就像这样:

redis>SORT tag:ruby:posts BY post:*->time DESC
1) "12"
2) "26"
3) "6"
4) "2"

在上例中SORT命令会读取post:2、post:6、post:12、post:26几个散列键中的time字段的值并以此决定tag:ruby:posts键中各个文章ID的顺序。

除了散列类型之外,参考键还可以是字符串类型,比如:

redis>LPUSH sortbylist 2 1 3
(integer) 3
redis>SET itemscore:1 50
OK
redis>SET itemscore:2 100
OK
redis>SET itemscore:3 -10
OK
redis>SORT sortbylist BY itemscore:* DESC
1) "2"
2) "1"
3) "3"

当参考键名不包含“*”时(即常量键名,与元素值无关),SORT命令将不会执行排序操作,因为Redis认为这种情况是没有意义的(因为所有要比较的值都一样)。例如:

redis>SORT sortbylist BY anytext
1) "3"
2) "1"
3) "2"

例子中anytext是常量键名(甚至anytext键可以不存在),此时SORT的结果与LRANGE的结果相同,没有执行排序操作。在不需要排序但需要借助SORT命令获得与元素相关联的数据时(见4.3.4节),常量键名是很有用的。

如果几个元素的参考键值相同,则SORT命令会再比较元素本身的值来决定元素的顺序。像这样:

redis>LPUSH sortbylist 4
(integer) 4
redis>SET itemscore:4 50
OK
redis>SORT sortbylist BY itemscore:* DESC
1) "2"
2) "4"
3) "1"
4) "3"

示例中元素“4”的参考键itemscore:4的值和元素“1”的参考键itemscore:1的值都是50,所以SORT命令会再比较“4”和“1”元素本身的大小来决定两者的顺序。

当某个元素的参考键不存在时,会默认参考键的值为0:

redis>LPUSH sortbylist 5
(integer) 5
redis>SORT sortbylist BY itemscore:* DESC
1) "2"
2) "4"
3) "1"
4) "5"
5) "3"

上例中“5”排在了“3”的前面,是因为“5”的参考键不存在,所以默认为0,而“3”的参考键值为10。

补充知识 参考键虽然支持散列类型,但是“*”只能在“->”符号前面(即键名部分)才有用,在“->”后(即字段名部分)会被当成字段名本身而不会作为占位符被元素的值替換,即常量键名。但是实际运行时会发现一个有趣的结果:

redis>SORT sortbylist BY somekey->somefield:*
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"

上面提到了当参考键名是常量键名时SORT命令将不会执行排序操作,然而上例中确进行了排序,而且只是对元素本身进行排序。这是因为Redis判断参考键名是不是常量键名的方式是判断参考键名中是否包含“*”,而somekey->somefield:*中包含“*”所以不是常量键名。所以在排序的时候Redis对每个元素都会读取键somekey中的somefield:*字段(“*”不会被替換),无论能否获得其值,每个元素的参考键值是相同的,所以Redis会按照元素本身的大小排列。

4.3.4 GET参数

现在小白的博客已经可以按照文章的发布顺序获得一个标签下的文章ID列表了,接下来要做的事就是对每个ID都使用HGET命令获取文章的标题以显示在博客列表页中。有没有觉得很麻烦?不论你的答案如何,都有一种更简单的方式来完成这个操作,那就是借助SORT命令的GET参数。

GET参数不影响排序,它的作用是使SORT命令的返回结果不再是元素自身的值,而是GET参数中指定的键值。GET参数的规则和BY参数一样,GET参数也支持字符串类型和散列类型的键,并使用“*”作为占位符。要实现在排序后直接返回ID对应的文章标题,可以这样写:

redis>SORT tag:ruby:posts BY post:*->time DESC GET post:*->title
1) "Windows 8 app designs"
2) "RethinkDB - An open-source distributed database built with love"
3) "Uses for cURL"
4) "The Nature of Ruby"

在一个SORT命令中可以有多个GET参数(而BY参数只能有一个),所以还可以这样用:

redis>SORT tag:ruby:posts BY post:*->time DESC GET post:*->title GET post:*->time
1) "Windows 8 app designs"
2) "1352620100"
3) "RethinkDB - An open-source distributed database built with love"
4) "1352620000"
5) "Uses for cURL"
6) "1352619600"
7) "The Nature of Ruby"
8) "1352619200"

可见有N个GET参数,每个元素返回的结果就有N行。这时有个问题:如果还需要返回文章ID该怎么办?答案是使用GET #。就像下面这样:

redis>SORT tag:ruby:posts BY post:*->time DESC GET post:*->title GET post:*-
>time GET #
1) "Windows 8 app designs"
2) "1352620100"
3) "12"
4) "RethinkDB - An open-source distributed database built with love"
5) "1352620000"
6) "26"
7) "Uses for cURL"
8) "1352619600"
9) "6"
10) "The Nature of Ruby"
11) "1352619200"
12) "2"

也就是说,GET #会返回元素本身的值。

4.3.5 STORE参数

默认情况下SORT会直接返回排序结果,如果希望保存排序结果,可以使用STORE参数。如希望把结果保存到sort.result键中:

redis>SORT tag:ruby:posts BY post:*->time DESC GET post:*->title GET post:*->time
GET # STORE sort.result
(integer) 12
redis>LRANGE sort.result 0 -1
1) "Windows 8 app designs"
2) "1352620100"
3) "12"
4) "RethinkDB - An open-source distributed database built with love"
5) "1352620000"
6) "26"
7) "Uses for cURL"
8) "1352619600"
9) "6"
10) "The Nature of Ruby"
11) "1352619200"
12) "2"

保存后的键的类型为列表类型,如果键已经存在则会覆盖它。加上STORE参数后SORT命令的返回值为结果的个数。

STORE参数常用来结合EXPIRE命令缓存排序结果,如下面的伪代码:

#判断是否存在之前排序结果的缓存
isCacheExists = EXISTS cache.sort
if isCacheExists is 1
#如果存在则直接返回
return LRANGE cache.sort, 0, -1
else
#如果不存在,则使用SORT命令排序并将结果存入cache.sort键中作为缓存
sortResult=SORT some.list STORE cache.sort
#设置缓存的生存时间为10分钟
EXPIRE cache.sort, 600
#返回排序结果
return sortResult

4.3.6 性能优化

SORT是Redis中最强大最复杂的命令之一,如果使用不好很容易成为性能瓶颈。SORT命令的时间复杂度是0(n+mlogm),其中n表示要排序的列表(集合或有序集合)中的元素个数,m表示要返回的元素个数。当n较大的时候SORT命令的性能相对较低,并且Redis在排序前会建立一个长度为n① 的容器来存储待排序的元素,虽然是一个临时的过程,但如果同时进行较多的大数据量排序操作则会严重影响性能。

注释:①有一个例外是当键类型为有序集合且参考键为常量键名时容器大小为m而不是n。

所以开发中使用SORT命令时需要注意以下几点。

(1)尽可能减少待排序键中元素的数量(使n尽可能小)。

(2)使用LIMIT参数只获取需要的数据(使m尽可能小)。

(3)如果要排序的数据数量较大,尽可能使用STORE参数将结果缓存。


上一篇:4.2 生存时间 下一篇:4.4 消息通知