Redis 模式示例
提示
来自deepseek解释
原文链接:https://redis.io/docs/latest/develop/clients/patterns/twitter-clone/
本文介绍了使用 PHP 编写的一个非常简单的 Twitter 克隆的设计与实现,其中 Redis 是唯一的数据库。编程社区传统上认为键值存储是一种专用数据库,不能作为关系数据库的替代品用于 Web 应用开发。本文将尝试说明,在键值层之上的 Redis 数据结构是一种高效的数据模型,可以实现多种类型的应用。
注意:本文的原始版本写于 2009 年 Redis 发布之时。当时并不完全清楚 Redis 的数据模型是否适合编写完整的应用程序。如今 5 年过去了,有很多应用将 Redis 作为其主要存储,因此本文现在的目标是为 Redis 新手提供一个教程。您将学习如何使用 Redis 设计简单的数据布局,以及如何应用不同的数据结构。
我们的 Twitter 克隆,名为 Retwis,结构简单,性能出色,并且可以轻松地分布在任意数量的 Web 服务器和 Redis 服务器上。查看 Retwis 源代码。
我使用 PHP 作为示例是因为其普遍可读性。使用 Ruby、Python、Erlang 等也可以获得相同(或更好)的结果。有一些克隆实现(但并非所有克隆都使用与当前教程版本相同的数据布局,因此,为了更好地理解本文,请坚持使用官方的 PHP 实现)。
- Retwis-RB 是 Retwis 到 Ruby 和 Sinatra 的移植,由 Daniel Lucraft 编写。
- 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 (得到 10)
y = GET foo (得到 10)
x = x + 1 (x 现在是 11)
y = y + 1 (y 现在是 11)
SET foo x (foo 现在是 11)
SET foo y (foo 现在是 11)
出问题了!我们递增了两次值,但我们的键没有从 10 变为 12,而是保持为 11。这是因为使用 GET / increment / SET 进行的递增不是原子操作。而 Redis、Memcached 等提供的 INCR 是原子实现,服务器会在完成递增所需的时间内保护该键,以防止并发访问。
Redis 与其他键值存储的不同之处在于,它提供了类似于 INCR 的其他操作,可用于建模复杂的问题。这就是为什么您可以使用 Redis 编写完整的 Web 应用程序,而无需使用其他数据库(如 SQL 数据库),也不会因此抓狂。
超越键值存储:列表
在本节中,我们将了解构建 Twitter 克隆需要哪些 Redis 特性。首先要知道的是,Redis 的值不仅仅是字符串。Redis 支持列表、集合、哈希、有序集合、位图和 HyperLogLog 类型作为值,并且有原子操作来操作它们,因此即使在多次访问同一个键时我们也是安全的。让我们从列表开始:
LPUSH mylist a (现在 mylist 包含 'a')
LPUSH mylist b (现在 mylist 包含 'b','a')
LPUSH mylist c (现在 mylist 包含 'c','b','a')
LPUSH 表示 Left Push,即在存储于 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 文件,以及我们在此示例中使用的 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 很有用,因此每当我们添加用户时,我们还会填充 users 键(这是一个哈希),以用户名为字段,其 ID 为值。
HSET users antirez 1000
这可能乍看有些奇怪,但请记住,我们只能以直接方式访问数据,没有二级索引。不可能告诉 Redis 返回持有特定值的键。这也是我们的优势。这种新范式迫使我们组织数据,以便一切都可以通过_主键_访问(用关系数据库术语来说)。
关注者、关注和更新
我们的系统中还有另一个核心需求。一个用户可能有关注他们的人,我们称之为关注者。一个用户可能关注其他用户,我们称之为关注。我们有一个完美的数据结构来实现这一点。那就是……集合。 集合元素的唯一性,以及我们可以在常数时间内测试存在性,是两个有趣的特性。但是,如何记住给定用户开始关注另一个用户的时间呢?在我们简单 Twitter 克隆的增强版本中,这可能会有用,因此我们不使用简单的集合,而是使用有序集合,使用关注者或关注用户的用户 ID 作为元素,使用用户之间关系创建时的 Unix 时间作为分值。
所以让我们定义我们的键:
followers:1000 => 包含所有关注者用户 ID 的有序集合
following:1000 => 包含所有关注用户 ID 的有序集合
我们可以这样添加新的关注者:
ZADD followers:1000 1401267618 1234 => 添加用户 1234,时间为 1401267618
另一个重要的事情是,我们需要一个地方来添加更新,以显示在用户的主页上。稍后我们需要按时间顺序访问这些数据,从最新更新到最旧,因此最适合的数据结构是列表。基本上,每个新更新都会被 LPUSH 到用户更新键中,借助 LRANGE,我们可以实现分页等等。注意,我们互换使用 updates 和 posts,因为更新在某种程度上实际上是“小帖子”。
posts:1000 => 帖子 ID 的列表 - 每个新帖子都会被 LPUSH 到这里。
这个列表基本上就是用户的时间线。我们将推送她自己/他自己的帖子 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)。
- 检查 user:1000 的密码是否匹配,如果不匹配,返回错误信息。
- 认证成功!将 "fea5e81ac8ca77622bed1c2132a021f9"(user:1000
auth字段的值)设置为 "auth" cookie。
这是实际代码:
include("retwis.php");
# 表单健全性检查
if (!gt("username") || !gt("password"))
goback("您需要输入用户名和密码才能登录。");
# 表单正常,检查用户名是否可用
$username = gt("username");
$password = gt("password");
$r = redisLink();
$userid = $r->hget("users",$username);
if (!$userid)
goback("用户名或密码错误");
$realpassword = $r->hget("user:$userid","password");
if ($realpassword != $password)
goback("用户名或密码错误");
# 用户名/密码正确,设置 cookie 并重定向到 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>。 - 检查
<authcookie>字段是否存在于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 哈希只是一个认证字段,可能是不稳定的,或者如果程序中有错误或脚本被中断,我们甚至可能在 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 到每个关注帖子作者的用户的 timeline 中,当然也包括作者自己的帖子列表(每个人都虚拟地关注自己)。这是文件 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']; /* 也将帖子添加到我们自己的帖子中 */
foreach($followers as $fid) {
$r->lpush("posts:$fid",$postid);
}
# 将帖子推送到时间线,并将时间线修剪为最新的 1000 个元素。
$r->lpush("timeline",$postid);
$r->ltrim("timeline",0,1000);
header("Location: index.php");函数的核心是 foreach 循环。我们使用 ZRANGE 获取当前用户的所有关注者,然后循环将帖子 LPUSH 到每个关注者的 timeline 列表中。
注意,我们还维护了一个全局时间线,用于所有帖子,这样在 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 列表由链表支持。如果系统设计用于深度分页百万级项目,最好使用有序集合。
关注用户
这并不难,但我们还没有检查如何创建关注/关注者关系。如果用户 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 Cluster。
要了解有关这些主题的更多信息,请阅读我们的分片文档。然而,这里要强调的一点是,在键值存储中,如果精心设计,数据集会被分割成许多独立的小键。与使用语义上更复杂的数据库系统相比,将这些键分布到多个节点更直接且更可预测。