make错误 redis6_阿里资深专家聊Redis 6系列(06)——实现一个Twitter

莲花山公园-小平同志塑像

2014年,利用工作之余,我翻译了Redis 3非稳定版的官方文档,在网络上被大量转载、推荐和盗链。6年时光白驹过隙,Redis 6稳定版已经发布,增加了很多新特性,鉴于各种资料参差不齐,或陈旧或残缺或错误,于是抽空再倒腾下。

74095f68ecc5be50825c6ffa3efec66a.png

本文讲述使用PHP以及Redis来设计和实现一个简单的微博。编程社区传统上认为,在开发web应用程序时,作为特殊目的的键值存储数据库不能用于替换关系型数据库。本文将向你展示Redis在键值层之上的数据结构是实现各种应用程序的有效数据模型。

在继续之前,你可以花点时间体验一下在线演示(http://retwis.redis.io,译者注),看看我们究竟要做什么。长话短说:这是个玩具,但是已经足够复杂到让你学习如何创建一个更复杂的程序的基础。

注意:这篇文章的原始版本写于2009年Redis发布时。当时还不清楚Redis的数据模型适合整个应用。5年以后的今天,已经有许多应用程序使用Redis作为他们的主要存储,所以今天这篇文章的目的就是作为新学者的教程。你将学习如何使用Redis设计一个简单的数据层,如何应用不同的数据结构。

我们的微博系统,叫做Retwis,结构简单,具有很高的性能,只需少许努力就能够部署于任意数量的Web和Redis服务器。你可以在这里找到源代码(http://code.google.com/p/redis/downloads/list,译者注)。

我使用PHP来做这个例子,是因为每个人都能看懂。使用Ruby,Python,Erlang等等语言也能得到同样(或更好)的结果。也有一些其他的实现(但不是所有的实现都使用和当前版本教程同样的数据层,所以请使用PHP官方实现会更好)。

  • Retwis-RB是由Daniel Lucraft使用Ruby和Sinatra实现的版本!当然包含了全部的源代码,以及文章底部一个指向Git仓库的链接。本文剩下的部分定位为PHP,但是Ruby程序员也可以查看Redis-RB的代码,因为它们从概念上非常相似。

  • Retwis-J是由Costin Leau使用Spring Data Framework和Java实现的版本。代码可以在GitHub上找到,springsource.org上有更全面的文档介绍。

此处省略一万字。。。

(原文此处是对Redis数据类型的介绍,可以参考本系列之前文章,译者注)

前提条件

如果你还没有下载Retwis源码,请先下载。它包含一些PHP文件和Predis (例子中我们使用的客户端库) 的一份拷贝。

另外你想要做的一件事是运行一个Redis服务器。下载源码,使用make构建,使用./redis-server运行,你就可以开始了。如果只是玩玩或者运行我们的Retwis的话,不需要任何配置。

数据设计

当使用关系型数据库时,必须先设计数据库模式,这样我们先需要知道表、索引等数据库确定的东西。Redis没有表,那我们需要设计什么呢?我们需要确定需要什么键来表示我们的对象,以及这些键需要存储什么值。

让我们从用户开始。我们需要用户名、用户id、密码、用户粉丝、关注列表等等来表示用户。第一个问题是,我们如何标识一个用户?像在关系型数据库,一个好的解决方案是用不同的号码来标识不同的用户,所以我们可以关联一个唯一ID给每个用户。对这个用户的引用通过其ID。产生唯一ID非常简单,使用我们的原子INCR操作。当我们创建一个新用户我们就可以(假设用户名为antirez):

INCR next_user_id => 1000

HMSET user:1000 username antirez password p1pp0

注意:在真实程序中你应该使用哈希过的密码,为了简化我们直接存储密码明文。

我们使用next_user_id键为每一位新用户提供唯一ID。然后我们使用唯一ID来命名存储用户数据的哈希结构的键。记住,这是使用键值存储的通用设计模式!除了字段已经被定义了以外,我们还需要更多东西来完整定义一个用户。例如,有时通过用户名获得用户ID,于是我们每次添加一个用户,我们也需要操作用户的键,使用用户名作为字段、用ID作为值的哈希。

HSET users antirez 1000

这一开始看起来有点奇怪,但是记住,我们只能采取直接访问数据的方式,而没有第二层索引。没法告诉Redis根据一个指定值返回其键。这也是我们的优势。强制我们使用按照主键来访问一切的新的范式来组织数据,此处的主键概念是关系型数据库中的术语。

粉丝(followers),关注(following),和帖子(updates)

我们的系统还有一个核心需求。一个用户可能有很多关注他的用户,我们称他们为其粉丝。一个用户也可能会关注其他用户,我们称他们为其关注者。我们有一个为此量身打造的数据结构,就是集合。独一无二的集合元素、常量时间内测试存在性,是两个非常有趣的特性。然而,记录一个用户开始关注另一个用户的时间怎么办?在我们加强版的微博系统里面,我们使用有序集合而不是一个简单的集合,用粉丝或者粉丝的用户ID作为元素,用用户关系创建时的unix时间作为分数。

让我们来定义我们的键:

followers:1000 => Sorted Set of uids of all the followers users

following:1000 => Sorted Set of uids of all the following users

我们添加一个粉丝:

ZADD followers:1000 1401267618 1234 => Add user 1234 with time 1401267618

另外一件重要的事情是,我们需要一个用户首页的位置来展示用户的帖子。我们需要按照时间顺序来访问这些数据,从最近的到最老的,为此最好的数据结构就是列表。基本上每一个更新都会被LPUSH到用户的更新键,多亏了LRANGE,我们能实现分页等等。注意,我们可以互换地使用更新(updates)和帖子(posts)这两个词,因为某种意义上说,更新其实就是小型帖子。

posts:1000 => a List of post ids - every new post is LPUSHed here.

这个列表基本上就是用户的时间轴。我们会加入他自己帖子的ID,以及其关注者创建的帖子。基本上我们实现了一个展开写(fanout)。

身份验证(Authentication)

好了,我们或多或少已经有了关于用户的一切,除了身份验证。我们会用一种简单而又健壮的方式处理身份验证:我们不想使用PHP的会话机制,我们的系统要能被轻松地分布式部署于很多web服务器上而做好准备,所以我们会保存全部状态到Redis数据库中。所有我们要做的,就是要设置一个猜不出来的字符串作为认证用户的cookie,以及一个持有该字符串的客户端的用户ID的一个键。

我们需要两件事情来使得这个可以工作得健壮。第一,当前认证秘钥(不可猜测的字符串)是用户对象的一部分,所以当创建用户时,我们需要在哈希中设置一个认证字段:

HSET user:1000 auth fea5e81ac8ca77622bed1c2132a021f9

另外,我们需要映射认证秘钥到用户ID,所以我们也需要一个认证键,使用哈希来映射秘钥和用户ID。

HSET auths fea5e81ac8ca77622bed1c2132a021f9 1000

为了认证一个用户,我们只需要简单几步(请查看Retwis项目中的login.php源代码):

  • 从登陆表单获取用户名和密码。

  • 检查用户名是否存在于users哈希中。

  • 如果存在,我们获取其ID(例如1000)。

  • 检查user:1000的密码是否匹配,否则返回错误消息。

  • 认证完毕,设置"fea5e81ac8ca77622bed1c2132a021f9"(user:1000的auth字段)作为认证cookie。

这是真实的代码:

include("retwis.php");

# Form sanity checks

if (!gt("username") || !gt("password"))

    goback("You need to enter both username and password to login.");

# The form is ok, check if the username is available

$username = gt("username");

$password = gt("password");

$r = redisLink();

$userid = $r->hget("users", $username);

if (!$userid)

    goback("Wrong username or password");

$realpassword = $r->hget("user:$userid", "password");

if ($realpassword != $password)

    goback("Wrong useranme or password");

# Username / password OK, set the cookie and redirect to index.php

$authsecret = $r->hget("user:$userid","auth");

setcookie("auth",$authsecret,time()+3600*24*365);

header("Location: index.php");

这些发生在每次用户登录时,但是我们还需要一个isLoggedIn函数来检查用户是否已经通过身份认证。以下是isLoggedIn函数的逻辑步骤:

  • 从用户获取auth cookie。如果没有cookie则用户没有登录。我们称这个cookie值为。

  • 检查是否存在于auths哈希字段中,以及其值(即用户ID,本例中是1000)。

  • 为了系统更加健壮,验证user:1000的auth字段是否匹配。

  • 用户验证完成,我们从$User全局变量中加载一些信息。

代码也许比上面的描述更简单:

function isLoggedIn() {

    global $User, $_COOKIE;

    if (isset($User)) return true;

    if (isset($_COOKIE['auth'])) {

        $r = redisLink();

        $authcookie = $_COOKIE['auth'];

        if ($userid = $r->hget("auths",$authcookie)) {

            if ($r->hget("user:$userid","auth") != $authcookie) return false;

            loadUserInfo($userid);

            return true;

        }

    }

    return false;

}

function loadUserInfo($userid) {

    global $User;

    $r = redisLink();

    $User['id'] = $userid;

    $User['username'] = $r->hget("user:$userid","username");

    return true;

}

把loadUserInfo作为一个单独的函数有点大题小做了,但是在复杂的程序中这是一个很好的方法。认证中唯一被遗漏的事情就是登出了。我们怎么来做登出呢?很简单,我们改变user:1000的auth字段中的随机串,从auths哈希中删除旧的认证秘钥,然后添加一个新的。

重要:登出的步骤解释了为什么我们不是仅仅在auths哈希中查看认证秘钥以后认证用户,而是双重检查user:1000的auth字段。真正的认证字符串是后者,auths哈希只不过是一个会挥发(volatile)的认证字段,或者,如果程序中bug或者脚本被中断,我们会发现auths键中有多个对应同一个用户ID的入口。登出代码如下(logout.php):

include("retwis.php");

if (!isLoggedIn()) {

    header("Location: index.php");

    exit;

}

$r = redisLink();

$newauthsecret = getrand();

$userid = $User['id'];

$oldauthsecret = $r->hget("user:$userid", "auth");

$r->hset("user:$userid", "auth", $newauthsecret);

$r->hset("auths", $newauthsecret, $userid);

$r->hdel("auths", $oldauthsecret);

header("Location: index.php");

这就是我们所描述的,你需要去理解的。

更新(Updates)

更新(updates),也就是我们知道的帖子(posts),更加简单。为了创建一个新的帖子我们这么干:

INCR next_post_id => 10343

HMSET post:10343 user_id $owner_id time $time body "I'm having fun with Retwis"

如你所见,每篇帖子由3个字段的哈希组成。帖子拥有者的用户ID,帖子创建时间,最后是帖子的正文,真正的状态消息。

创建一个帖子后,我们获取其帖子ID,LPUSH其ID到帖子作者的每个粉丝用户的时间轴中,当然还有作者自己的帖子列表中(每个人事实上关注了他自己)。post.php文件展示了这一切是怎么执行的:

include("retwis.php");

if (!isLoggedIn() || !gt("status")) {

    header("Location:index.php");

    exit;

}

$r = redisLink();

$postid = $r->incr("next_post_id");

$status = str_replace("\n"," ",gt("status"));

$r->hmset("post:$postid","user_id",$User['id'],"time",time(),"body",$status);

$followers = $r->zrange("followers:".$User['id'], 0, -1);

$followers[] = $User['id']; /* Add the post to our own posts too */

foreach($followers as $fid) {

    $r->lpush("posts:$fid", $postid);

}

# Push the post on the timeline, and trim the timeline to the

# newest 1000 elements.

$r->lpush("timeline", $postid);

$r->ltrim("timeline", 0, 1000);

header("Location: index.php");

函数的核心是这个foreach循环。我们使用ZRANGE获取当前用户的所有粉丝,然后通过遍历LPUSH帖子到每一位粉丝的时间轴列表中。

注意,我们也为所有的帖子维护了一个全局的时间轴,这样我们就可以在Retwis首页轻易的展示每个人的帖子。这只需要执行LPUSH到时间轴列表。回到现实,我们难道没有开始觉得在SQL中使用ORDER BY来排序按照时间顺序添加的东西有一点点奇怪吗?至少我是这么认为的。

上面的代码有个有意思的地方值得注意:我们对全局时间轴执行完LPUSH操作之后使用了一个新命令LTRIM。这是为了裁剪列表到1000个元素。全局时间轴事实上只会用在首页展示少量帖子,没有必要获取全部历史帖子。

基本上LTRIM+LPUSH是Redis中创建上限(capped)集合的一种方式。

帖子分页(Paginating)

我们如何使用LRANGE来获取一个范围的帖子,并展现这些帖子到屏幕上,现在已经相当清楚了。代码很简单:

function showPost($id) {

    $r = redisLink();

    $post = $r->hgetall("post:$id");

    if (empty($post)) return false;

    $userid = $post['user_id'];

    $username = $r->hget("user:$userid","username");

    $elapsed = strElapsed($post['time']);

    $userlink = "".utf8entities($username)."";

    echo('

'.$userlink.' '.utf8entities($post['body'])."
");

    echo('posted '.$elapsed.' ago via web

');

    return true;

}

function showUserPosts($userid, $start, $count) {

    $r = redisLink();

    $key = ($userid == -1) ? "timeline" : "posts:$userid";

    $posts = $r->lrange($key, $start, $start+$count);

    $c = 0;

    foreach($posts as $p) {

        if (showPost($p)) $c++;

        if ($c == $count) break;

    }

    return count($posts) == $count+1;

}

showPost只是转换和打印一篇HTML帖子,showUserPosts获取一个范围的帖子然后传递给showPost。

注意:如果帖子列表的开始位置很大的话,我们想访问列表的中间元素,那么LRANGE比较低效。因为Redis列表的背后实现是链表。如果系统设计为为几百万的项分页,那最好求助于有序集合。

关注用户(Following users)

我们还没有讨论如何创建关注/粉丝关系,尽管这并不困难。如果ID为1000的用户(antirez)想关注用户ID为5000的用户(pippo),我们需要同时创建关注和被关注关系。我们只需要调用ZADD:

ZADD following:1000 5000

ZADD followers:5000 1000

仔细关注一下这个相同的模式。理论上,在关系型数据库中,关注者列表和粉丝列表会在同一张表中,使用像following_id和follower_id这样的列。你可以使用SQL查询来抽取每个用户的关注者和粉丝。在键值数据库中则有一些不同,因为我们需要设置1000关注5000,同时5000被1000关注的双重关系。这是要付出的代价,但是另一方面,访问数据很简单并相当的快。将这些作为独立的集合可以让我们做一些有意思的事情。例如,使用ZINTERSTORE我们可以获得两个不同用户的粉丝的交集,于是我们可以给我们的Twitter系统增加一个特性,当你访问某个人的主页时,可以很快的告诉你”你和Alice有34个共同粉丝”这样类似的事情。

你可以在follow.php中找到设置和删除关注/粉丝关系的代码。

水平伸缩(horizontally scalable)

亲爱的老铁,如果你意识到了这一点你就已经是一个英雄了。谢谢你。在讨论水平伸缩之前有必要查看一下单台服务器的性能。Retwis相当的快,没有任何的缓存。在一台很慢的过载的服务器上,apache的benchmark使用100个并发客户端发出10000个请求,测量出平均pv为5毫秒。这意味着单台Linux服务器每天可以服务数以百万计的用户,这个像猴子屁股一样的慢,想象一下如果用更新的硬件会是什么结果。

然而,你不可能永远使用单台服务器,如何伸缩一个键值存储?

Retwis不执行任何多键操作,所以伸缩很简单:你可以使用客户端分片(sharding),或者类似于Twemproxy的分片代理,或者是即将横空出世的Redis集群(已经有了,译者注)。

想更多的了解这个主题请阅读我们的分片文档。这里我们想强调的是,在键值存储系统中,如果你小心设计,数据集是可以拆分到相互独立的小的键上去。相比较使用语义上更复杂的数据库系统,分布这些键到多个节点更简单直接和可预见。

=========================

程序员阮威,毕业于华中科技大学,硕士研究生,校招加入腾讯,从事电子商务相关研发工作。连续两段创业经历后,最近一份经历,是阿里巴巴国际化中台深圳团队负责人,从事阿里电商中台架构、核心交易链路、营销优惠、团队管理、双十一大促、稳定性等工作。

我的人生理想是,白天当一名中学老师,晚上当一名滴滴司机。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值