Redis 模式示例
通过构建一个Twitter克隆来学习几种Redis模式
本文描述了一个使用PHP编写并以Redis作为唯一数据库的非常简单的Twitter克隆的设计和实现。编程社区传统上认为键值存储是一种特殊用途的数据库,不能作为关系数据库的直接替代品用于Web应用程序的开发。本文将尝试展示,在键值层之上的Redis数据结构是实现多种应用程序的有效数据模型。
注意:本文的原始版本写于2009年Redis发布时。当时并不完全清楚Redis数据模型是否适合编写整个应用程序。现在,经过5年,有许多应用程序使用Redis作为其主要存储的案例,因此本文的目标是成为Redis新手的教程。您将学习如何使用Redis设计简单的数据布局,以及如何应用不同的数据结构。
我们的Twitter克隆,名为Retwis,结构简单,性能非常好,并且可以轻松地分布在任意数量的Web和Redis服务器之间。查看Retwis源代码。
我使用PHP作为示例是因为它的普遍可读性。使用Ruby、Python、Erlang等也可以获得相同(或更好)的结果。 存在一些克隆版本(然而并非所有克隆版本都使用与本教程当前版本相同的数据布局,因此为了更好地跟随文章,请坚持使用官方的PHP实现)。
- Retwis-RB 是由 Daniel Lucraft 编写的 Retwis 到 Ruby 和 Sinatra 的移植版本。
- Retwis-J 是 Retwis 的 Java 移植版本,使用了 Spring Data 框架,由 Costin Leau 编写。其源代码可以在 GitHub 上找到,并且在 springsource.org 上有全面的文档可供查阅。
什么是键值存储?
键值存储的本质是能够将一些数据(称为值)存储在键中。只有在我们知道存储它的特定键时,才能稍后检索该值。没有直接的方法可以通过值来搜索键。在某种意义上,它就像一个非常大的哈希/字典,但它是持久的,即当您的应用程序结束时,数据不会消失。因此,例如,我可以使用命令SET
将值bar存储在键foo中:
SET foo bar
Redis 永久存储数据,所以如果我稍后问“键 foo 中存储的值是什么?” Redis 将回复 bar:
GET foo => bar
键值存储提供的其他常见操作包括DEL
,用于删除给定的键及其关联的值,SET-if-not-exists(在Redis中称为SETNX
),仅在键不存在时为其赋值,以及INCR
,用于原子性地增加存储在给定键中的数字:
SET foo 10
INCR foo => 11
INCR foo => 12
INCR foo => 13
原子操作
INCR
有一些特别之处。你可能会想知道,如果我们可以用一点代码自己实现,为什么 Redis 还要提供这样的操作?毕竟,它就像这样简单:
x = GET foo
x = x + 1
SET foo x
问题是,只要一次只有一个客户端使用键foo,这种方式递增就会有效。看看如果两个客户端同时访问这个键会发生什么:
x = GET foo (yields 10)
y = GET foo (yields 10)
x = x + 1 (x is now 11)
y = y + 1 (y is now 11)
SET foo x (foo is now 11)
SET foo y (foo is now 11)
出问题了!我们两次增加了值,但键的值从10变成了11,而不是12。这是因为使用GET / increment / SET
不是原子操作。相反,Redis、Memcached等提供的INCR是原子实现,服务器会在完成增加所需的时间内保护键,以防止同时访问。
Redis与其他键值存储的不同之处在于,它提供了类似于INCR的其他操作,这些操作可以用来建模复杂的问题。这就是为什么你可以使用Redis来编写整个Web应用程序,而不需要使用像SQL数据库这样的其他数据库,也不会发疯。
超越键值存储:列表
在本节中,我们将了解构建我们的Twitter克隆需要哪些Redis功能。首先要知道的是,Redis的值不仅仅是字符串。Redis支持列表、集合、哈希、有序集合、位图和HyperLogLog类型作为值,并且有原子操作来操作它们,因此即使对同一个键进行多次访问,我们也是安全的。让我们从列表开始:
LPUSH mylist a (now mylist holds 'a')
LPUSH mylist b (now mylist holds 'b','a')
LPUSH mylist c (now mylist holds 'c','b','a')
LPUSH
表示 左推,即将一个元素添加到存储在 mylist 中的列表的左侧(或头部)。如果键 mylist 不存在,它会在 PUSH 操作之前自动创建为一个空列表。正如你所想象的,还有一个 RPUSH
操作,它将元素添加到列表的右侧(尾部)。这对于我们的 Twitter 克隆非常有用。例如,用户更新可以添加到存储在 username:updates
中的列表中。
当然,有一些操作可以从列表中获取数据。例如,LRANGE 返回列表中的一个范围或整个列表。
LRANGE mylist 0 1 => c,b
LRANGE 使用基于零的索引 - 即第一个元素是0,第二个是1,依此类推。命令参数是 LRANGE key first-index last-index
。last-index 参数可以是负数,具有特殊含义:-1 是列表的最后一个元素,-2 是倒数第二个,依此类推。因此,要获取整个列表,请使用:
LRANGE mylist 0 -1 => c,b,a
其他重要的操作包括LLEN,它返回列表中元素的数量,以及LTRIM,它类似于LRANGE,但不是返回指定的范围,而是修剪列表,因此它类似于从我的列表中获取范围,将此范围设置为新值,但以原子方式完成。
集合数据类型
目前我们在本教程中不使用Set类型,但由于我们使用Sorted Sets,这是一种功能更强大的Set版本,因此最好先介绍Set(这是一种非常有用的数据结构本身),然后再介绍Sorted Sets。
除了列表之外,还有更多的数据类型。Redis 还支持集合(Sets),集合是无序的元素集合。可以添加、删除和测试成员的存在性,并执行不同集合之间的交集操作。当然,也可以获取集合的元素。一些例子会让这一点更加清晰。请记住,SADD
是添加到集合的操作,SREM
是从集合中移除的操作,SISMEMBER
是测试是否为成员的操作,而SINTER
是执行交集的操作。其他操作包括SCARD
用于获取集合的基数(元素的数量),以及SMEMBERS
用于返回集合的所有成员。
SADD myset a
SADD myset b
SADD myset foo
SADD myset bar
SCARD myset => 4
SMEMBERS myset => bar,a,foo,b
请注意,SMEMBERS
不会按照我们添加的顺序返回元素,因为集合是无序的元素集合。当您希望按顺序存储时,最好使用列表。以下是一些针对集合的更多操作:
SADD mynewset b
SADD mynewset foo
SADD mynewset hello
SINTER myset mynewset => foo,b
SINTER
可以返回集合之间的交集,但它不仅限于两个集合。你可以请求4、5甚至10000个集合的交集。最后,让我们看看SISMEMBER
是如何工作的:
SISMEMBER myset foo => 1
SISMEMBER myset notamember => 0
有序集合数据类型
有序集合(Sorted Sets)与集合(Sets)类似:都是元素的集合。然而,在有序集合中,每个元素都与一个浮点数值相关联,称为元素分数。由于分数的存在,有序集合中的元素是有序的,因为我们总是可以通过分数来比较两个元素(如果分数相同,则将两个元素作为字符串进行比较)。
与 Sorted Sets 中的 Sets 类似,无法添加重复元素,每个元素都是唯一的。但是可以更新元素的分数。
有序集合命令以Z
为前缀。以下是有序集合使用的一个示例:
ZADD zset 10 a
ZADD zset 5 b
ZADD zset 12.55 c
ZRANGE zset 0 -1 => b,a,c
在上面的例子中,我们使用ZADD
添加了一些元素,然后使用ZRANGE
检索这些元素。正如你所看到的,元素根据它们的分数按顺序返回。为了检查给定元素是否存在,并在存在时检索其分数,我们使用ZSCORE
命令:
ZSCORE zset a => 10
ZSCORE zset non_existing_element => NULL
有序集合是一种非常强大的数据结构,你可以通过分数范围、字典顺序、逆序等方式查询元素。了解更多信息,请查看官方Redis命令文档中的有序集合部分。
哈希数据类型
这是我们在程序中使用的最后一种数据结构,非常容易理解,因为几乎每种编程语言中都有对应的概念:哈希。Redis 哈希基本上类似于 Ruby 或 Python 中的哈希,是一组与值相关联的字段:
HMSET myuser name Salvatore surname Sanfilippo country Italy
HGET myuser surname => Sanfilippo
HMSET
可以用来设置哈希中的字段,这些字段稍后可以通过
HGET
来获取。可以使用 HEXISTS
来检查字段是否存在,或者
使用 HINCRBY
来增加哈希字段的值等等。
哈希是表示对象的理想数据结构。例如,我们在我们的Twitter克隆中使用哈希来表示用户和更新。
好的,我们刚刚介绍了Redis主要数据结构的基础知识,我们准备好开始编码了!
先决条件
如果您还没有下载Retwis源代码,请现在获取。它包含一些PHP文件,以及Predis的副本,这是我们在本示例中使用的PHP客户端库。
你可能需要的另一件事是一个可运行的Redis服务器。只需获取源代码,使用make
进行构建,使用./redis-server
运行,你就可以开始了。为了在你的电脑上玩耍或运行Retwis,完全不需要任何配置。
数据布局
在使用关系型数据库时,必须设计数据库模式,以便我们知道数据库将包含的表、索引等。在Redis中没有表,那么我们需要设计什么呢?我们需要确定表示对象所需的键以及这些键需要保存的值类型。
让我们从用户开始。当然,我们需要表示用户,包括他们的用户名、用户ID、密码、关注给定用户的用户集合、给定用户关注的用户集合等等。第一个问题是,我们应该如何识别一个用户?就像在关系数据库中一样,一个好的解决方案是用不同的数字来识别不同的用户,这样我们就可以为每个用户关联一个唯一的ID。对这个用户的所有其他引用都将通过ID来完成。通过使用我们的原子INCR
操作,创建唯一ID非常简单。当我们创建一个新用户时,我们可以这样做,假设用户名为“antirez”:
INCR next_user_id => 1000
HMSET user:1000 username antirez password p1pp0
注意:在实际应用中,您应该使用哈希密码,为了简单起见,我们以明文形式存储密码。
我们使用next_user_id
键来确保每个新用户都能获得一个唯一的ID。然后,我们使用这个唯一ID来命名存储用户数据的哈希键。这是键值存储中的一种常见设计模式!请记住这一点。
除了已经定义的字段外,我们还需要一些其他内容来完全定义一个用户。例如,有时能够从用户名获取用户ID会很有用,因此每次添加用户时,我们还会填充users
键,这是一个哈希,以用户名为字段,其ID为值。
HSET users antirez 1000
起初这可能看起来很奇怪,但请记住,我们只能以直接的方式访问数据,没有二级索引。无法告诉Redis返回持有特定值的键。这也是我们的优势。这种新范式迫使我们组织数据,以便所有内容都可以通过主键访问,用关系数据库的术语来说。
关注者、关注和更新
我们的系统中还有另一个核心需求。一个用户可能有其他用户关注他们,我们称之为他们的粉丝。一个用户可能关注其他用户,我们称之为关注。我们有一个完美的数据结构来处理这个需求。那就是...集合。 集合元素的唯一性,以及我们可以在常数时间内测试元素是否存在,是两个有趣的特点。但是,如果我们还想记住某个用户开始关注另一个用户的时间呢?在我们简单的Twitter克隆的增强版本中,这可能是有用的,因此我们不再使用简单的集合,而是使用有序集合,使用关注者或被关注者的用户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
,我们可以实现分页等功能。请注意,我们交替使用更新和帖子这两个词,因为更新在某种程度上实际上是“小帖子”。
posts:1000 => a List of post ids - every new post is LPUSHed here.
这个列表基本上是用户的时间线。我们将推送她/他自己发布的帖子的ID,以及由以下用户创建的所有帖子的ID。基本上,我们将实现一个写扇出。
认证
好的,除了认证之外,我们几乎拥有了关于用户的所有信息。我们将以一种简单但稳健的方式处理认证:我们不希望使用PHP会话,因为我们的系统必须准备好轻松地分布在不同的Web服务器上,因此我们将整个状态保存在我们的Redis数据库中。我们只需要一个随机的不可猜测的字符串作为认证用户的cookie,以及一个包含持有该字符串的客户端用户ID的键。
为了使这个功能以稳健的方式工作,我们需要两样东西。
首先:当前的认证密钥(随机的不可猜测的字符串)
应该是用户对象的一部分,因此当用户创建时,我们也在其哈希中设置一个auth
字段:
HSET user:1000 auth fea5e81ac8ca77622bed1c2132a021f9
此外,我们需要一种将认证密钥映射到用户ID的方法,因此我们还采用了一个auths
键,其值为一个哈希类型,用于将认证密钥映射到用户ID。
HSET auths fea5e81ac8ca77622bed1c2132a021f9 1000
为了验证用户身份,我们将执行以下简单步骤(请参阅Retwis源代码中的login.php
文件):
- 通过登录表单获取用户名和密码。
- 检查
username
字段是否实际存在于users
哈希中。 - 如果存在,我们就有用户ID(即1000)。
- 检查用户:1000的密码是否匹配,如果不匹配,返回错误信息。
- 认证成功!将 "fea5e81ac8ca77622bed1c2132a021f9"(user:1000
auth
字段的值)设置为 "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 username 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)。 - 为了使系统更加健壮,还需验证用户:1000的认证字段是否匹配。
- 好的,用户已经通过认证,并且我们在
$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
哈希只是一个可能不稳定的认证字段,或者如果程序中有错误或脚本被中断,我们甚至可能在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");
这正是我们所描述的,应该很容易理解。
更新
更新,也称为帖子,甚至更简单。为了在数据库中创建一个新帖子,我们这样做:
INCR next_post_id => 10343
HMSET post:10343 user_id $owner_id time $time body "I'm having fun with Retwis"
正如你所看到的,每个帖子仅由一个包含三个字段的哈希表示。拥有该帖子的用户的ID,帖子发布的时间,最后是帖子的正文,也就是实际的状态消息。
在我们创建帖子并获取帖子ID后,我们需要将ID LPUSH到每个关注帖子作者的用户的时间线中,当然也包括作者自己的帖子列表中(每个人实际上都在关注自己)。这是文件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
来获取当前用户的所有关注者,然后循环将帖子推送到每个关注者的时间线列表中。
请注意,我们还为所有帖子维护了一个全局时间线,以便在Retwis主页上轻松显示每个人的更新。这只需要对timeline
列表执行LPUSH
操作。让我们面对现实吧,你不觉得使用SQL的ORDER BY
按时间顺序排序添加的内容有点奇怪吗?我认为是的。
在上面的代码中有一个有趣的事情需要注意:我们在全局时间线中执行了LPUSH
操作后,使用了一个名为LTRIM
的新命令。这是为了将列表修剪到仅1000个元素。全局时间线实际上仅用于在主页上显示一些帖子,不需要保留所有帖子的完整历史记录。
基本上,LTRIM
+ LPUSH
是在 Redis 中创建有上限的集合的一种方式。
分页更新
现在应该很清楚我们如何使用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 = "<a class=\"username\" href=\"profile.php?u=".urlencode($username)."\">".utf8entities($username)."</a>";
echo('<div class="post">'.$userlink.' '.utf8entities($post['body'])."<br>");
echo('<i>posted '.$elapsed.' ago via web</i></div>');
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
获取一系列帖子,然后将它们传递给 showPosts
。
注意:如果帖子列表开始变得非常大,并且我们想要访问列表中间的元素,LRANGE
并不是非常高效,因为 Redis 列表是由链表支持的。如果一个系统设计用于数百万项的深度分页,最好改用 Sorted Sets。
关注用户
这并不难,但我们还没有检查如何创建关注/被关注关系。如果用户ID 1000(antirez)想要关注用户ID 5000(pippo),我们需要同时创建关注和被关注的关系。我们只需要进行ZADD
调用:
ZADD following:1000 5000
ZADD followers:5000 1000
注意,同样的模式一次又一次地出现。理论上,使用关系型数据库,关注者和被关注者的列表将包含在一个表中,字段如following_id
和follower_id
。你可以使用SQL查询提取每个用户的关注者或被关注者。使用键值数据库时,情况有些不同,因为我们需要设置1000 is following 5000
和5000 is followed by 1000
这两种关系。这是需要付出的代价,但另一方面,访问数据更简单且非常快速。将这些内容作为单独的集合允许我们做一些有趣的事情。例如,使用ZINTERSTORE
我们可以得到两个不同用户的following
的交集,因此我们可以在我们的Twitter克隆中添加一个功能,当你访问别人的个人资料时,它能够非常快速地告诉你,“你和Alice有34个共同关注者”,诸如此类的事情。
你可以在follow.php
文件中找到设置或删除关注/粉丝关系的代码。
使其水平可扩展
亲爱的读者,如果您已经读到这里,那么您已经是一位英雄了。谢谢。在讨论水平扩展之前,值得先检查一下单台服务器的性能。Retwis 非常快,即使没有任何缓存。在一个非常慢且负载很重的服务器上,使用100个并行客户端发出100000个请求的Apache基准测试显示,平均页面浏览时间为5毫秒。这意味着您只需一台Linux服务器就可以每天为数百万用户提供服务,而这台服务器的性能还非常差...想象一下使用更新硬件的效果。
然而,你不能永远只使用一个服务器,如何扩展一个键值存储?
Retwis 不执行任何多键操作,因此使其可扩展非常简单:您可以使用客户端分片,或者像 Twemproxy 这样的分片代理,或者即将推出的 Redis 集群。
要了解更多关于这些主题的信息,请阅读 我们的分片文档。然而,这里要强调的重点是,在键值存储中,如果你设计得当,数据集会被分割成许多独立的小键。与使用语义上更复杂的数据库系统相比,将这些键分发到多个节点更加直接和可预测。