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 是 Costin Leau 使用 Spring Data 框架将 Retwis 移植到 Java 的版本。其源代码可以在 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 变为 12,而是变成了 11。这是因为使用 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,但它不是返回指定的范围,而是修剪列表,所以它就像从 mylist 获取范围,将此范围设置为新值,但它以原子方式执行。
集合数据类型
在本教程中,我们目前没有使用集合类型,但由于我们使用的是有序集合(它是集合功能更强大的版本),所以最好先介绍集合(它本身就是一个非常有用的数据结构),然后介绍有序集合。
除了列表之外,还有更多的数据类型。Redis 还支持集合,它是元素的无序集合。可以添加、删除和测试成员是否存在,以及执行不同集合之间的交集。当然,也可以获取集合的元素。一些例子会让它更清楚。请记住,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
有序集合数据类型
有序集合类似于集合:元素集合。但是,在有序集合中,每个元素都与一个浮点值相关联,称为元素分数。由于分数的存在,有序集合中的元素是有序的,因为我们总是可以通过分数比较两个元素(如果分数恰好相同,我们则将两个元素作为字符串进行比较)。
与集合类似,在有序集合中,无法添加重复的元素,每个元素都是唯一的。但是,可以更新元素的分数。
有序集合命令以 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 会很有用,因此每次添加用户时,我们还会使用用户名作为字段,以及其 ID 作为值来填充 users
键,这是一个哈希。
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 的键。
我们需要两件事才能使这个东西以稳健的方式工作。首先:当前的身份验证密钥(随机的不可猜测字符串)应该是用户对象的一部分,因此,当用户被创建时,我们也会在其 Hash 中设置一个auth
字段。
HSET user:1000 auth fea5e81ac8ca77622bed1c2132a021f9
此外,我们需要一种方法将身份验证密钥映射到用户 ID,因此我们还使用一个auths
键,该键的值是一个 Hash 类型,它将身份验证密钥映射到用户 ID。
HSET auths fea5e81ac8ca77622bed1c2132a021f9 1000
为了认证用户,我们将执行以下简单的步骤(参见 Retwis 源代码中的login.php
文件)。
- 通过登录表单获取用户名和密码。
- 检查
users
Hash 中是否实际存在username
字段。 - 如果存在,则我们有用户 ID(例如:1000)。
- 检查用户:1000 的密码是否匹配,如果不匹配,则返回错误消息。
- 好的,已认证!将“fea5e81ac8ca77622bed1c2132a021f9”(用户: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 的值称为
<authcookie>
。 - 检查
auths
Hash 中是否存在<authcookie>
字段,以及它的值(用户 ID)是什么(在示例中是 1000)。 - 为了使系统更加稳健,还要验证用户: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
作为一个单独的函数对于我们的应用程序来说有点过分,但在复杂的应用程序中这是一个很好的方法。所有身份验证中唯一缺少的就是注销。注销时我们该怎么办?很简单,我们只需更改用户:1000 auth
字段中的随机字符串,从auths
Hash 中删除旧的身份验证密钥,然后添加新的密钥。
重要:注销过程解释了为什么我们不是在auths
Hash 中查找身份验证密钥后就认证用户,而是要与用户:1000 的auth
字段进行双重检查。真正的身份验证字符串是后者,而auths
Hash 只是一个身份验证字段,它甚至可能不稳定,或者,如果程序中有错误或脚本被中断,我们甚至可能在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"
如您所见,每个帖子都用一个具有三个字段的 Hash 表示。拥有该帖子的用户的 ID、发布该帖子的时间,以及最后,帖子的正文,即实际的状态消息。
创建帖子并获得帖子 ID 后,我们需要将 ID LPUSH 到每个关注该帖子作者的用户的 timeline 中,当然也要 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
获取当前用户的所有关注者,然后循环将 LPUSH
该帖子推送到每个关注者的 timeline 列表中。
请注意,我们还维护了一个包含所有帖子的全局 timeline,以便在 Retwis 的首页中,我们可以轻松地显示每个人的更新。这只需对timeline
列表执行一次 LPUSH
即可。让我们面对现实,难道你没有开始觉得用 SQL 的ORDER BY
对按时间顺序添加的内容进行排序有点奇怪吗?我认为是这样。
在上面的代码中要注意一个有趣的事情:我们在全局 timeline 中执行 LPUSH
操作后使用了一个名为 LTRIM
的新命令。这是为了将列表修剪到只有 1000 个元素。全局 timeline 实际上只是为了在首页中显示一些帖子,没有必要保存所有帖子的完整历史记录。
基本上,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 列表由链表支持。如果一个系统是为对数百万个项目的深度分页而设计的,那么最好使用有序集合。
关注用户
这并不难,但我们还没有检查如何创建关注/被关注的关系。如果用户 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
,我们可以得到两个不同用户的following
的交集,这样我们就可以在我们的 Twitter 克隆中添加一个功能,这样当你去访问其他人的个人资料时,它就可以非常快地告诉你,“你和 Alice 有 34 个共同关注者”,等等。
您可以在follow.php
文件中找到设置或删除关注/被关注关系的代码。
实现水平扩展
亲爱的读者,如果您已经读到了这里,您已经是英雄了。谢谢。在讨论水平扩展之前,值得检查一下单个服务器上的性能。Retwis 非常快,没有任何缓存。在一台非常慢且负载很重的服务器上,一个具有 100 个并行客户端发出 100000 个请求的 Apache 基准测试测量到平均页面浏览量需要 5 毫秒。这意味着你只需一台 Linux 服务器就可以每天为数百万用户提供服务,而这台服务器的速度慢得像猴子一样...想象一下,如果使用更新的硬件,结果会怎样。
但是你不能永远只使用一台服务器,那么如何扩展键值存储呢?
Retwis 不执行任何多键操作,因此,使其可扩展很简单:您可以使用客户端端分片,或者使用 Twemproxy 等分片代理,或者即将推出的 Redis 集群。
要了解有关这些主题的更多信息,请阅读 有关分片的文档。然而,这里需要强调的是,在键值存储中,如果谨慎设计,数据集将被分割成多个独立的小键。将这些键分布到多个节点比使用语义上更复杂的数据库系统更加直接和可预测。