*教程:使用 PHP 和 Redis 键值存储设计与实现一个简单的 Twitter 克隆

*

本文描述了使用 PHP 编写、以 Redis 作为唯一数据库的一个非常简单的 Twitter 克隆的设计与实现。编程社区传统上认为键值存储是一种特殊用途的数据库,不能作为关系型数据库的直接替代品来开发 Web 应用程序。本文将试图展示,Redis 在键值层之上的数据结构是实现多种应用程序的有效数据模型。

在继续之前,你可以花几秒钟玩一下 Retwis 在线演示,看看我们实际上要建模什么。简而言之:它是一个玩具,但足够复杂,可以作为学习如何创建更复杂应用程序的基础。

注意:本文的原始版本写于 2009 年 Redis 发布时。当时并不太清楚 Redis 数据模型是否适合编写整个应用程序。现在 5 年过去了,有很多使用 Redis 作为主存储的应用程序案例,所以本文今天的目标是成为 Redis 新手的教程。你将学习如何使用 Redis 设计简单的数据布局,以及如何应用不同的数据结构。

我们的 Twitter 克隆,名为 Retwis,结构简单,性能非常好,并且可以轻松地分布在任意数量的 Web 和 Redis 服务器上。你可以在这里找到源代码。

我在示例中使用了 PHP,因为大家都能读懂。使用 Ruby、Python、Erlang 等可以获得相同(或更好)的结果。存在几个克隆版本(然而并非所有克隆都使用与本教程当前版本相同的数据布局,所以为了更好地跟随文章,请坚持使用官方 PHP 实现):

  • Retwis-RB 是 Daniel Lucraft 用 Ruby 和 Sinatra 编写的 Retwis 移植版!完整源代码当然包含在内,本文末尾有其 Git 仓库的链接。本文其余部分针对 PHP,但 Ruby 程序员也可以查看 Retwis-RB 源代码,因为它在概念上非常相似。
  • Retwis-JCostin Leau 使用 Spring Data Framework 用 Java 编写的 Retwis 移植版。其源代码可以在 GitHub 上找到,在 springsource.org 上有全面的文档。

*什么是键值存储?

键值存储的本质是将一些数据(称为 value)存储在键内部的能力。只有当我们知道数据存储在哪个特定键中时,才能稍后检索该值。没有直接的方式通过值来搜索键。在某种程度上,它就像一个非常大的哈希/字典,但它是持久化的,即当应用程序结束时,数据不会消失。例如,我可以使用命令 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 表示 左推,即向存储在 mylist 中的列表的左侧(或头部)添加一个元素。如果键 mylist 不存在,它会在 PUSH 操作之前自动创建为空列表。你可以想象,还有一个 RPUSH 操作,它将元素添加到列表的右侧(尾部)。这对我们的 Twitter 克隆非常有用。例如,用户更新可以添加到存储在 username:updates 的列表中。

当然,也有从列表中获取数据的操作。例如,LRANGE 返回列表的一个范围,或整个列表。

LRANGE mylist 0 1 => c,b

LRANGE 使用从零开始的索引——即第一个元素是 0,第二个是 1,依此类推。命令参数是 LRANGE key first-index last-indexlast-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 完成。创建唯一 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 => 所有关注者用户的 uid 的有序集合
following:1000 => 所有被关注用户的 uid 的有序集合

我们可以用以下方式添加新关注者:

ZADD followers:1000 1401267618 1234 => 以时间 1401267618 添加用户 1234

我们还需要一个地方来添加要在用户主页上显示的更新。稍后我们需要按时间顺序访问这些数据,从最新更新到最旧更新,所以这种数据结构的完美类型是列表。基本上,每个新更新都会被 LPUSH 到用户更新键中,而且多亏 LRANGE,我们可以实现分页等。注意,我们交替使用 updatesposts 这两个词,因为更新实际上就是某种意义上的"小帖子"。

posts:1000 => 帖子 ID 的列表 - 每个新帖子都会在这里 LPUSH。

这个列表基本上是用户的时间线。我们会推送他/她自己的帖子的 ID,以及被关注用户创建的所有帖子的 ID。基本上,我们将实现一个写入扇出。

*认证

好的,我们或多或少有了关于用户的一切,除了认证。我们将以一种简单但稳健的方式处理认证:我们不想使用 PHP 会话,因为我们的系统必须准备好轻松地分布在不同的 Web 服务器上,所以我们将把整个状态保存在 Redis 数据库中。我们只需要一个随机的不可猜测的字符串作为认证用户的 cookie 设置,以及一个包含持有该字符串的客户端用户 ID 的键。

我们需要两件事才能使这个系统稳健地工作。首先:当前认证 secret(随机不可猜测的字符串)应该是用户对象的一部分,所以在创建用户时,我们还要在其哈希中设置一个 auth 字段:

HSET user:1000 auth fea5e81ac8ca77622bed1c2132a021f9

此外,我们需要一种将认证 secret 映射到用户 ID 的方法,所以我们还取一个 auths 键,其值是一个哈希类型,将认证 secret 映射到用户 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>
  • 检查 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 哈希中移除旧的认证 secret,并添加新的。

重要: 登出过程解释了为什么我们不只是通过在 auths 哈希中查找认证 secret 来认证用户,而是还要针对 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 "我正在 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']; /* 也将帖子添加到我们自己的帖子中 */

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 到每个关注者的时间线列表中。

注意,我们还维护了一个所有帖子的全局时间线,以便在 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>发表于 '.$elapsed.' 前 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_idfollower_id 等字段的单个表中。你可以使用 SQL 查询提取每个用户的关注者或关注。使用键值数据库,事情有点不同,因为我们需要同时设置 1000 正在关注 50005000 被 1000 关注 两种关系。这是需要付出的代价,但另一方面,访问数据更简单且极快。将这些东西作为单独的集合使我们能够做有趣的事情。例如,使用 ZINTERSTORE 我们可以获取两个不同用户的 following 的交集,所以我们可以给我们的 Twitter 克隆添加一个功能,当你访问别人的个人资料时,它能很快地告诉你,"你和 Alice 有 34 个共同关注者",等等。

你可以在 follow.php 文件中找到设置或移除关注/关注者关系的代码。

*水平扩展

亲爱的读者,如果你读到了这里,你已经是个英雄了。谢谢你。在谈论水平扩展之前,值得检查一下单台服务器上的性能。Retwis 极快,没有任何缓存。在一台非常慢且负载高的服务器上,使用 100 个并行客户端发出 100000 个请求的 Apache 基准测试测得平均页面查看时间为 5 毫秒。这意味着你可以用仅仅一台 Linux 盒子每天为数百万用户提供服务,而且这台服务器还慢得像猴子屁股……想象一下用更新的硬件会有什么结果。

然而你不能永远只用一台服务器,如何扩展键值存储呢?

Retwis 不执行任何多键操作,所以使其可扩展很简单:你可以使用客户端分片,或者像 Twemproxy 这样的分片代理,或者即将到来的 Redis 集群。

想了解更多关于这些主题的信息,请阅读我们关于分片的文档。然而,这里要强调的重点是,在键值存储中,如果你仔细设计,数据集被分成 许多独立的小键。与使用语义上更复杂的数据库系统相比,将这些键分布到多个节点更加直接和可预测。