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 用 Ruby 和 Sinatra 编写的 Retwis 移植。
  • Retwis-J 是 Retwis 移植到 Java 的版本,使用了 Spring Data Framework,由 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 变为 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 开始的索引 - 也就是说第一个元素是 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 进行。通过使用我们的原子 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 文件)

  • 通过登录表单获取用户名和密码。
  • 检查 users 哈希中是否实际存在 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 哈希中是否存在 <authcookie> 字段,以及该值(用户 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 作为一个单独的函数对于我们的应用程序来说有些过分,但它在复杂应用程序中是一种好方法。所有身份验证中唯一缺少的是注销。我们注销时做什么?很简单,我们只需更改用户:1000 auth 字段中的随机字符串,从 auths 哈希中删除旧的身份验证密钥,并添加新的密钥。

重要提示:注销过程解释了为什么我们不仅仅在 auths 哈希中查找身份验证密钥后对用户进行身份验证,而是对用户: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 后,我们需要在关注帖子作者的每个用户的时序中 LPUSH 该 ID,当然还要在作者本人的帖子列表中(每个人实际上都在关注自己)。这是 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 是在 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_idfollower_id 等字段的单个表中。您可以使用 SQL 查询提取每个用户的关注者或关注。使用键值数据库时,情况会有些不同,因为我们需要设置 1000 关注 50005000 被 1000 关注 这两种关系。这是必须付出的代价,但另一方面,访问数据更简单、速度极快。将这些内容作为单独的集合使我们能够做一些有趣的事情。例如,使用 ZINTERSTORE,我们可以获得两个不同用户的 following 的交集,因此我们可以在 Twitter 克隆中添加一项功能,以便在您访问其他人的个人资料时能够非常快速地告诉您,“您和爱丽丝有 34 个共同的关注者”,诸如此类。

您可以在 follow.php 文件中找到用于设置或删除关注/粉丝关系的代码。

使其横向可扩展

亲爱的读者,如果您读到这里,您已经是英雄了。谢谢。在讨论横向扩展之前,值得检查一下单台服务器上的性能。Retwis 非常快,没有任何缓存。在非常慢且负载很高的服务器上,使用 100 个并行客户端发出 100000 个请求的 Apache 基准测试测量平均页面浏览量为 5 毫秒。这意味着您每天只需使用一台 Linux 机箱即可为数百万用户提供服务,而这台机箱非常慢...想象一下使用更新的硬件会得到什么结果。

但是您不能永远使用一台服务器,您如何扩展键值存储?

Retwis 不执行任何多键操作,因此使其可扩展很简单:您可以使用客户端分片,或类似 Twemproxy 或即将推出的 Redis 集群的分片代理。

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

对本页进行评分