现在读者应该已经知道有序集合是什么和它能干什么了,到目前为止,我们基本了解了Redis提供的5种结构。接下来的一节将展示如何通过结合散列的数据存储能力和有序集合内建的排序能力来解决一个常见的问题。
你好Redis
在对Redis提供的5种结构有了基本的了解之后,现在是时候来学习一下怎样使用这些结构来解决实际问题了。最近几年,越来越多的网站开始提供对网页链接、文章或者问题进行投票的功能,其中包括图1-6展示的reddit以及图1-7展示的StackOverflow。这些网站会根据文章的发布时间和文章获得的投票数量计算出一个评分,然后按照这个评分来决定如何排序和展示文章。本节将展示如何使用Redis来构建一个简单的文章投票网站的后端。
图1-6 Reddit是一个可以对文章进行投票的网站
图1-7 StackOverflow是一个可以对问题进行投票的网站
对文章进行投票
要构建一个文章投票网站,我们首先要做的就是为了这个网站设置一些数值和限制条件:如果一篇文章获得了至少200张支持票(up vote),那么网站就认为这篇文章是一篇有趣的文章;假如这个网站每天发布1000篇文章,而其中的50篇符合网站对有趣文章的要求,那么网站要做的就是把这50篇文章放到文章列表前100位至少一天;另外,这个网站暂时不提供投反对票(down vote)的功能。
为了产生一个能够随着时间流逝而不断减少的评分,程序需要根据文章的发布时间和当前时间来计算文章的评分,具体的计算方法为:将文章得到的支持票数量乘以一个常数,然后加上文章的发布时间,得出的结果就是文章的评分。
我们使用从UTC时区1970年1月1日到现在为止经过的秒数来计算文章的评分,这个值通常被称为Unix时间。之所以选择使用Unix时间,是因为在所有能够运行Redis的平台上面,使用编程语言获取这个值都是一件非常简单的事情。另外,计算评分时与支持票数量相乘的常量为432,这个常量是通过将一天的秒数(86 400)除以文章展示一天所需的支持票数量(200)得出的:文章每获得一张支持票,程序就需要将文章的评分增加432分。
构建文章投票网站除了需要计算文章评分之外,还需要使用Redis结构存储网站上的各种信息。对于网站里的每篇文章,程序都使用一个散列来存储文章的标题、指向文章的网址、发布文章的用户、文章的发布时间、文章得到的投票数量等信息,图1-8展示了一个使用散列来存储文章信息的例子。
图1-8 一个使用散列存储文章信息的例子
使用冒号作为分隔符 本书使用冒号(:)来分隔名字的不同部分:比如图 1-8 里面的键名article:92617就使用了冒号来分隔单词article和文章的ID号92617,以此来构建命名空间(namespace)。使用:作为分隔符只是我的个人喜好,不过大部分Redis用户也都是这么做的,另外 还有一些常见的分隔符,如句号(.)、斜线(/),有些人甚至还会使用管道符号(|)。无论使用哪个符号来做分隔符,都要保持分隔符的一致性。同时,请读者注意观察和学习本书使用冒号创建嵌套命名空间的方法。
我们的文章投票网站将使用两个有序集合来有序地存储文章:第一个有序集合的成员为文章 ID,分值为文章的发布时间;第二个有序集合的成员同样为文章 ID,而分值则为文章的评分。通过这两个有序集合,网站既可以根据文章发布的先后顺序来展示文章,又可以根据文章评分的高低来展示文章,图1-9展示了这两个有序集合的一个示例。
图1-9 两个有序集合分别记录了根据发布时间排序的文章和根据评分排序的文章
为了防止用户对同一篇文章进行多次投票,网站需要为每篇文章记录一个已投票用户名单。为此,程序将为每篇文章创建一个集合,并使用这个集合来存储所有已投票用户的ID,图1-10展示了一个这样的集合示例。
图1-10 为100408号文章投过票的一部分用户
为了尽量节约内存,我们规定当一篇文章发布期满一周之后,用户将不能再对它进行投票,文章的评分将被固定下来,而记录文章已投票用户名单的集合也会被删除。
在实现投票功能之前,让我们来看看图 1-11:这幅图展示了当115423号用户给100408号文章投票的时候,数据结构发生的变化。
图1-11 当115423号用户给100408号文章投票的时候,数据结构发生的变化
既然我们已经知道了网站计算文章评分的方法,也知道了网站存储数据所需的数据结构,那么现在是时候实际地实现这个投票功能了!当用户尝试对一篇文章进行投票时,程序需要使用ZSCORE命令检查记录文章发布时间的有序集合,判断文章的发布时间是否未超过一周。如果文章仍然处于可以投票的时间范围之内,那么程序将使用SADD命令,尝试将用户添加到记录文章已投票用户名单的集合里面。如果添加操作执行成功的话,那么说明用户是第一次对这篇文章进行投票,程序将使用ZINCRBY命令为文章的评分增加432分(ZINCRBY``命令用于对有序集合成员的分值执行自增操作),并使用HINCRBY命令对散列记录的文章投票数量进行更新(HINCRBY``命令用于对散列存储的值执行自增操作),代码清单1-6展示了投票功能的实现代码。
代码清单1-6 article_vote()函数
Redis事务 从技术上来讲,要正确地实现投票功能,我们需要将代码清单1-6里面的SADD、ZINCRBY和HINCRBY这3个命令放到一个事务里面执行,不过因为本书要等到第4章才介绍Redis事务,所以我们暂时忽略这个问题。
这个投票功能还是很不错的,对吧?那么发布文章的功能要怎么实现呢?
发布并获取文章
发布一篇新文章首先需要创建一个新的文章ID,这项工作可以通过对一个计数器(counter)执行INCR命令来完成。接着程序需要使用SADD将文章发布者的ID添加到记录文章已投票用户名单的集合里面,并使用EXPIRE命令为这个集合设置一个过期时间,让Redis在文章发布期满一周之后自动删除这个集合。之后,程序会使用HMSET命令来存储文章的相关信息,并执行两个ZADD命令,将文章的初始评分(initial score)和发布时间分别添加到两个相应的有序集合里面。代码清单1-7展示了发布新文章功能的实现代码。
代码清单1-7 post_article()函数
好了,我们已经陆续实现了文章投票功能和文章发布功能,接下来要考虑的就是如何取出评分最高的文章以及如何取出最新发布的文章了。为了实现这两个功能,程序需要先使用ZREVRANGE命令取出多个文章ID,然后再对每个文章ID执行一次HGETALL命令来取出文章的详细信息,这个方法既可以用于取出评分最高的文章,又可以用于取出最新发布的文章。这里特别要注意的一点是,因为有序集合会根据成员的分值从小到大地排列元素,所以使用ZREVRANGE命令,以“分值从大到小”的排列顺序取出文章ID才是正确的做法,代码清单1-8展示了文章获取功能的实现函数。
代码清单1-8 get_articles()函数
Python的默认值参数和关键字参数 代码清单1-8中的get_articles()函数为order参数设置了默认值score:。Python语言的初学者可能会对“默认值参数”以及“根据名字(而不是位置)来传入参数”的一些细节感到陌生。如果读者在理解函数定义或者参数传递方面有困难,可以考虑去看看《Python语言教程》,教程里面对这两个方面进行了很好的介绍,点击以下短链接就可以直接访问教程的相关章节:http://mng.bz/KM5x。
虽然我们构建的网站现在已经可以展示最新发布的文章和评分最高的文章了,但它还不具备目前很多投票网站都支持的群组(group)功能:这个功能可以让用户只看见与特定话题有关的文章,比如与“可爱的动物”有关的文章、与“政治”有关的文章、与“Java编程”有关的文章或者介绍“Redis用法”的文章等等。接下来的一节将向我们展示为文章投票网站添加群组功能的方法。
对文章进行分组
群组功能由两个部分组成,一个部分负责记录文章属于哪个群组,另一个部分负责取出群组里面的文章。为了记录各个群组都保存了哪些文章,网站需要为每个群组创建一个集合,并将所有同属一个群组的文章ID都记录到那个集合里面。代码清单1-9展示了将文章添加到群组里面的方法,以及从群组里面移除文章的方法。
代码清单1-9 add_remove_groups()函数
初看上去,可能会有读者觉得使用集合来记录群组文章并没有多大用处。到目前为止,读者只看到了集合结构检查某个元素是否存在的能力,但实际上Redis不仅可以对多个集合执行操作,甚至在一些情况下,还可以在集合和有序集合之间执行操作。
为了能够根据评分对群组文章进行排序和分页(paging),网站需要将同一个群组里面的所有文章都按照评分有序地存储到一个有序集合里面。Redis的ZINTERSTORE命令可以接受多个集合和多个有序集合作为输入,找出所有同时存在于集合和有序集合的成员,并以几种不同的方式来组合(combine)这些成员的分值(所有集合成员的分值都会被视为是1)。对于我们的文章投票网站来说,程序需要使用ZINTERSTORE命令选出相同成员中最大的那个分值来作为交集成员的分值:取决于所使用的排序选项,这些分值既可以是文章的评分,也可以是文章的发布时间。
图 1-12 展示了对一个包含少量文章的群组集合和一个包含大量文章及评分的有序集合执行ZINTERSTORE命令的过程,注意观察那些同时出现在集合和有序集合里面的文章是怎样被添加到结果有序集合里面的。
图1-12 对集合groups:programming和有序集合score:进行交集计算得出了新的有序集合score:programming,它包含了所有同时存在于集合groups:programming和有序集合score:的成员。因为集合groups:programming的所有成员的分值都被视为是1,而有序集合score:的所有成员的分值都大于1,并且这次交集计算挑选的分值为相同成员中的最大分值,所以有序集合score:programming的成员的分值实际上是由有序集合score:的成员的分值来决定的
通过对存储群组文章的集合和存储文章评分的有序集合执行ZINTERSTORE命令,程序可以得到按照文章评分排序的群组文章;而通过对存储群组文章的集合和存储文章发布时间的有序集合执行ZINTERSTORE命令,程序则可以得到按照文章发布时间排序的群组文章。如果群组包含的文章非常多,那么执行ZINTERSTORE命令就会比较花时间,为了尽量减少Redis的工作量,程序会将这个命令的计算结果缓存60秒。另外,我们还重用了已有的get_articles()函数来分页并获取群组文章,代码清单1-10展示了网站从群组里面获取一整页文章的方法。
代码清单1-10 get_group_articles()函数
有些网站只允许用户将文章放在一个或者两个群组里面(其中一个是“所有文章”群组,另一个是最适合文章的群组)。在这种情况下,最好直接将文章所在的群组记录到存储文章信息的散列里面,并在article_vote()函数的末尾增加一个ZINCRBY命令调用,用于更新文章在群组中的评分。但是在这个示例里面,我们构建的文章投票网站允许一篇文章同时属于多个群组(比如一篇文章可以同时属于“编程”和“算法”两个群组),所以对于一篇同时属于多个群组的文章来说,更新文章的评分意味着程序需要对文章所属的全部群组执行自增操作。在这种情况下,如果一篇文章同时属于很多个群组,那么更新文章评分这一操作可能会变得相当耗时,因此,我们在get_group_articles()函数里面对ZINTERSTORE命令的执行结果进行了缓存处理,以此来尽量减少ZINTERSTORE命令的执行次数。开发者在灵活性或限制条件之间的取舍将改变程序存储和更新数据的方式,这一点对于任何数据库都是适用的,Redis也不例外。
练习:实现投反对票的功能
我们的示例目前只实现了投支持票的功能,但是在很多实际的网站里面,反对票也能给用户提供有用的反馈信息。因此,请读者能想办法在article_vote()函数和post_article()函数里面添加投反对票的功能。除此之外,读者还可以尝试为用户提供对调投票的功能:比如将支持票转换成反对票,或者将反对票转换成支持票。提示:如果读者在实现对调投票功能时出现了困难,可以参考一下第3章介绍的SMOVE命令。
好的,现在我们已经成功地构建起了一个展示最受欢迎文章的网站后端,这个网站可以获取文章、发布文章、对文章进行投票甚至还可以对文章进行分组。如果你觉得前面展示的内容不好理解,或者弄不懂这些示例,又或者没办法运行本书提供的源代码,那么请阅读下一节来了解如何获取帮助。
寻求帮助
当你遇到与Redis有关的问题时,不要害怕求助于别人,因为其他人可能也遇到过类似的问题。首先,你可以根据错误信息在搜索引擎里面进行查找,看是否有所发现。
如果搜索一无所获,又或者你遇到的问题与本书的示例代码有关,那么你可以到Manning出版社提供的论坛里面发问(http://www.manning-sandbox.com/forum.jspa?forumID=809),我和其他熟悉本书的人将为你提供帮助。
如果你遇到的问题与Redis本身有关,又或者你正在解决的问题在这本书里面没有出现过,那么你可以到Redis的邮件列表里面发问(https://groups.google.com/d/forum/redis-db/),同样地,我和其他熟悉Redis的人将为你提供帮助。
最后,如果你遇到的问题与某个函数库或者某种编程语言有关,那么比起在Redis邮件列表里面发帖提问,更好的方法是直接到你正在使用的那个函数库或者那种编程语言的邮件列表或论坛里面寻求帮助。
小结
本章对Redis进行了初步的介绍,说明了Redis与其他数据库的相同之处和不同之处,以及一些读者可能会使用Redis的理由。在阅读本书的后续章节之前,请记住本书的目标并不是构建一个完整的应用或者工具,而是展示各式各样的问题,并给出使用Redis来解决这些问题的办法。
本章希望向读者传达这样一个概念:Redis是一个可以用来解决问题的工具,它既拥有其他数据库不具备的数据结构,又拥有内存存储(这使得Redis的速度非常快)、远程(这使得Redis可以与多个客户端和服务器进行连接)、持久化(这使得服务器可以在重启之后仍然保持重启之前的数据)和可扩展(通过主从复制和分片)等多个特性,这使得用户可以以熟悉的方式为各种不同的问题构建解决方案。
在阅读本书的后续章节时,请读者注意自己解决问题的方式发生了什么变化:你也许会惊讶地发现,自己思考数据问题的方式已经从原来的“怎样将我的想法塞进数据库的表和行里面”,变成了“使用哪种Redis数据结构来解决这个问题比较好呢?”。
本文摘自即将上架的《Redis实战》
推荐阅读:《Redis入门指南(第2版)》
分片是一种将数据划分为多个部分的方法,对数据的划分可以基于键包含的ID、基于键的散列值,或者基于以上两者的某种组合。通过对数据进行分片,用户可以将数据存储到多台机器里面,也可以从多台机器里面获取数据,这种方法在解决某些问题时可以获得线性级别的性能提升。
客观来讲,memcached也能用在这个简单的场景里,但使用Redis存储聚合数据有以下3个好处:首先,使用Redis可以将彼此相关的聚合数据放在同一个结构里面,这样访问聚合数据就会变得更为容易;其次,使用Redis可以将聚合数据放到有序集合里面,构建出一个实时的排行榜;最后,Redis的聚合数据可以是整数或者浮点数,而memcached的聚合数据只能是整数。