本文介绍了使用 PHP 编写的一个非常简单的 Twitter 克隆的设计和实现,其中 Valkey 是唯一的数据库。编程社区传统上认为键值存储是一种特殊用途的数据库,不能作为关系数据库的直接替代品用于开发 Web 应用程序。本文将尝试展示,在键值层之上构建 Valkey 数据结构是实现多种应用程序的有效数据模型。
注意:本文的原始版本写于 2009 年 Redis OSS 发布之时。当时尚不完全清楚这种数据模型是否适合编写整个应用程序。现在有许多应用程序使用 Valkey 作为其主要存储,因此本文今天的目标是为新用户提供一个教程。您将学习如何使用 Valkey 设计简单的数据布局,以及如何应用不同的数据结构。
我们的 Twitter 克隆,名为Retwis,结构简单,性能出色,并且只需少量工作即可在任意数量的 Web 服务器和 Valkey 服务器之间进行分布式部署。查看 Retwis 源代码。
我使用 PHP 作为示例,因为它具有普遍的可读性。使用 Ruby、Python、Erlang 等语言也能获得相同(或更好)的结果。存在一些克隆版本(但并非所有克隆版本都使用与本教程当前版本相同的数据布局,因此为了更好地理解本文,请坚持使用官方 PHP 实现)。
- Retwis-RB 是 Daniel Lucraft 编写的将 Retwis 移植到 Ruby 和 Sinatra 的版本。
- Retwis-J 是 Costin Leau 编写的将 Retwis 移植到 Java 的版本,使用了 Spring Data 框架。其源代码可在GitHub上找到,并且在springsource.org提供了全面的文档。
什么是键值存储?
键值存储的本质是能够将一些数据(称为值)存储在键中。只有当我们知道存储该值的特定键时,才能在以后检索该值。没有直接通过值来搜索键的方法。从某种意义上说,它就像一个非常大的哈希/字典,但它是持久的,即当您的应用程序结束时,数据不会消失。因此,例如,我可以使用命令SET
将值bar存储在键foo中
SET foo bar
Valkey 永久存储数据,因此如果我稍后询问“键 foo 中存储的值是什么?”,Valkey 将回复bar
GET foo => bar
键值存储提供的其他常见操作包括:DEL
,用于删除给定键及其关联值;SET-if-not-exists(在 Valkey 上称为SETNX
),仅当键不存在时才为键赋值;以及INCR
,用于原子地递增存储在给定键中的数字
SET foo 10
INCR foo => 11
INCR foo => 12
INCR foo => 13
原子操作
INCR
有一些特殊之处。您可能想知道,既然我们可以通过编写一点代码自己实现,为什么 Valkey 还要提供这样的操作?毕竟,它就像这样简单
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
完成的递增不是原子操作。而 Valkey、Memcached 等提供的 INCR 都是原子实现,服务器会在完成递增所需的时间内保护键,以防止并发访问。
Valkey 与其他键值存储不同之处在于它提供了类似于 INCR 的其他操作,可用于建模复杂问题。这就是为什么您可以使用 Valkey 来编写整个 Web 应用程序,而无需使用 SQL 数据库等其他数据库,并且不会让您抓狂。
超越键值存储:列表
在本节中,我们将了解构建 Twitter 克隆所需的 Valkey 功能。首先要知道的是,Valkey 的值不仅仅可以是字符串。Valkey 支持列表(Lists)、集合(Sets)、哈希(Hashes)、有序集合(Sorted Sets)、位图(Bitmaps)和 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 获取范围,将此范围设置为新值,但它是原子操作)。
集合数据类型
目前本教程中没有使用 Set 类型,但由于我们使用了 Sorted Sets(它算是 Sets 功能更强大的版本),因此最好先介绍 Sets(它本身就是一个非常有用的数据结构),然后再介绍 Sorted Sets。
除了列表之外,还有更多数据类型。Valkey 还支持集合(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
不会按照我们添加的顺序返回元素,因为 Sets 是无序的元素集合。当您想按顺序存储时,最好使用列表。更多针对 Sets 的操作
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
有序集合是一种非常强大的数据结构,您可以按分数范围、按字典顺序、按逆序等方式查询元素。要了解更多信息,请查阅 Valkey 官方命令文档中的有序集合部分。
哈希数据类型
这是我们在程序中使用的最后一种数据结构,它非常容易理解,因为几乎所有编程语言中都有其等价物:哈希。哈希基本上就像 Ruby 或 Python 的哈希,是字段与值关联的集合
HMSET myuser name Salvatore surname Sanfilippo country Italy
HGET myuser surname => Sanfilippo
HMSET
可用于设置哈希中的字段,稍后可通过HGET
检索。可以使用HEXISTS
检查字段是否存在,或者使用HINCRBY
递增哈希字段,等等。
哈希是表示对象的理想数据结构。例如,我们在 Twitter 克隆中用哈希来表示用户(Users)和更新(Updates)。
好了,我们已经介绍了 Valkey 主要数据结构的基础知识,准备开始编码了!
先决条件
如果您还没有下载Retwis 源代码,请立即获取。它包含一些 PHP 文件,以及我们在本示例中使用的 PHP 客户端库Predis的副本。
您可能还需要一个正在运行的 Valkey 服务器。只需获取源代码,使用make
构建,然后运行./valkey-server
,即可开始使用。在您的计算机上运行 Retwis 无需任何配置。
数据布局
当使用关系数据库时,必须设计数据库 Schema,以便我们知道数据库将包含的表、索引等。Valkey 中没有表,那么我们需要设计什么呢?我们需要确定表示对象所需的键,以及这些键需要保存什么类型的值。
让我们从用户开始。当然,我们需要表示用户,包括他们的用户名、用户 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
这最初可能看起来很奇怪,但请记住,我们只能以直接方式访问数据,而没有二级索引。无法告诉 Valkey 返回持有特定值的键。这也是我们的优势。这种新范式迫使我们组织数据,使得所有内容都可以通过主键访问,用关系数据库术语来说。
关注者、关注和更新
我们的系统中还有另一个核心需求。一个用户可能有关注他们的人,我们称之为他们的关注者(followers)。一个用户也可能关注其他人,我们称之为关注(following)。我们有一种完美的数据结构来处理这个问题。那就是……集合(Sets)。集合元素的唯一性,以及我们可以在常数时间内测试是否存在的事实,是两个有趣的特性。然而,如果还要记住给定用户开始关注另一个用户的时间呢?在我们的简单 Twitter 克隆的增强版本中,这可能很有用,所以我们不使用简单的 Set,而是使用 Sorted Set,将关注者或被关注用户的用户 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
我们还需要一个地方来添加在用户主页上显示的更新。稍后我们将需要按时间顺序访问这些数据,从最新更新到最旧更新,因此最适合的数据结构是列表(List)。基本上,每个新的更新都将通过LPUSH
添加到用户更新键中,并且借助LRANGE
,我们可以实现分页等功能。请注意,我们交替使用updates和posts这两个词,因为更新在某种程度上实际上是“小帖子”。
posts:1000 => a List of post ids - every new post is LPUSHed here.
此列表基本上是用户时间线。我们将推送她/他自己帖子的 ID,以及所有关注用户创建的帖子的 ID。基本上,我们将实现写入扇出(write fanout)。
认证
好的,除了认证之外,我们或多或少已经掌握了关于用户的一切。我们将以一种简单而健壮的方式处理认证:我们不想使用 PHP 会话,因为我们的系统必须易于在不同的 Web 服务器之间进行分布式部署,所以我们将整个状态保存在 Valkey 数据库中。我们所需要的就是一个随机的、无法猜测的字符串作为已认证用户的 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");
# 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
哈希中是否存在<authcookie>
字段,以及其值(用户 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
哈希只是一个认证字段,它可能是易失的,或者,如果程序存在 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");
这正是我们所描述的,应该很容易理解。
更新
更新,也称为帖子,甚至更简单。为了在数据库中创建新帖子,我们这样做
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
获取当前用户的所有关注者,然后循环会将帖子LPUSH
到每个关注者的时间线列表中。
请注意,我们还维护了一个所有帖子的全局时间线,以便在 Retwis 主页上轻松显示每个人的更新。这只需要对timeline
列表进行LPUSH
操作。说实话,你是否开始觉得用 SQL 的ORDER BY
来按时间顺序排序添加的东西有点奇怪?我就是这么想的。
上面的代码中有一个有趣的细节:在对全局时间线执行LPUSH
操作后,我们使用了一个名为LTRIM
的新命令。这用于将列表截断到仅 1000 个元素。全局时间线实际上只用于在主页上显示少量帖子,没有必要保留所有帖子的完整历史记录。
基本上,LTRIM
+ LPUSH
是在 Valkey 中创建有上限集合的一种方式。
更新分页
现在应该很清楚我们如何使用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
的效率不高,因为列表是由链表支持的。如果一个系统是为百万级项目的深度分页设计的,最好改用有序集合(Sorted Sets)。*
关注用户
这不难,但我们还没有检查如何创建关注/被关注关系。如果用户 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 这样的分片代理,或者即将推出的 Valkey 集群。
要了解更多关于这些主题的信息,请阅读我们关于分片的文档。然而,这里需要强调的是,在键值存储中,如果您精心设计,数据集会被分解成许多独立的小键。与使用语义更复杂的数据库系统相比,将这些键分发到多个节点更加直接和可预测。