Redis 模式示例

通过构建 Twitter 克隆来了解几种 Redis 模式

本文介绍了使用 PHP 编写的非常简单的 Twitter 克隆的设计和实现,其中 Redis 是唯一的数据库。编程社区传统上将键值存储视为一种特殊用途的数据库,不能用作用于开发 Web 应用程序的关系数据库的直接替代品。本文将尝试说明键值层之上的 Redis 数据结构是实现多种应用程序的有效数据模型。

注意:本文的原始版本写于 2009 年,当时 Redis 是 释放。当时并不完全清楚 Redis 数据模型是 适合编写整个应用程序。现在 5 年后有很多 使用 Redis 作为其主要 store 的应用程序,因此今天本文的目标 是 Redis 新手的教程。您将学习如何设计一个简单的 使用 Redis 进行数据布局,以及如何应用不同的数据结构。

我们的 Twitter 克隆称为 Retwis,结构简单,性能非常好,可以毫不费力地分布在任意数量的 Web 和 Redis 服务器之间。查看 Retwis 源代码

我使用 PHP 作为示例,因为它具有普遍的可读性。使用 Ruby、Python、Erlang 等可以获得相同(或更好)的结果。 存在一些克隆(但并非所有克隆都使用与 本教程的当前版本,因此请坚持使用官方 PHP 实现,以便更好地遵循文章)。

  • Retwis-RB 是 Retwis 到 Ruby 和 Sinatra 的移植,由 Daniel Lucraft 编写。
  • Retwis-J 是 Retwis 到 Java 的移植,使用 Costin Léau 编写的 Spring Data Framework。它的源代码可以在 GitHub 上找到,springsource.org 上提供了全面的文档。

什么是键值存储?

键值存储的本质是能够在 key 中存储一些数据,称为。只有当我们知道该值的存储特定 key 时,才能在以后检索该值。没有直接的方法可以按值搜索键。从某种意义上说,它就像一个非常大的哈希 / 字典,但它是持久的,即当你的应用程序结束时,数据不会消失。因此,例如,我可以使用命令SET要将 value bar 存储在键 foo 中:

SET foo bar

Redis 永久存储数据,因此如果我稍后问“存储在 foo 键中的值是多少?Redis 将回复 bar

GET foo => bar

键值存储提供的其他常见作包括DEL删除给定键及其关联值,SET-if-not-exists(调用SETNX在 Redis 上),以便仅在键不存在时才为键赋值,以及INCR,以原子方式递增存储在给定 key 中的数字:

SET foo 10
INCR foo => 11
INCR foo => 12
INCR foo => 13

原子作

有一些特别之处INCR.你可能会想,如果我们能用一点代码自己做,为什么 Redis 会提供这样的作呢?毕竟,它就像:

x = GET foo
x = x + 1
SET foo x

问题是,只要一次只有一个 Client 端使用键 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)

出了点问题!我们将值递增了两次,但我们的 key 不是从 10 增加到 12,而是 11。这是因为使用GET / increment / SET 不是原子作。相反,Redis 提供的 INCR、Memcached 等是原子实现,服务器将在完成增量所需的时间内负责保护密钥,以防止同时访问。

Redis 与其他键值存储的不同之处在于,它提供了类似于 INCR 的其他作,可用于对复杂问题进行建模。这就是为什么您可以使用 Redis 编写整个 Web 应用程序,而无需使用其他数据库(如 SQL 数据库),也不会发疯。

超越键值存储:列表

在本节中,我们将了解构建 Twitter 克隆所需的 Redis 功能。首先要知道的是 Redis 值可以比字符串多。Redis 支持 Lists、Sets、Hashes、Sorted Sets、Bitmaps 和 HyperLogLog 类型作为值,并且有原子作可以对它们进行作,因此即使多次访问同一个键,我们也是安全的。让我们从 Lists 开始:

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表示 Left Push,即在 mylist 中存储的列表的左侧(或头部)添加一个元素。如果键 mylist 不存在,则会在 PUSH作之前自动将其创建为空列表。可以想象,还有一个RPUSH将元素添加到列表右侧(尾部)的作。这对我们的 Twitter 克隆非常有用。用户更新可以添加到存储在username:updates例如。

当然,有一些作可以从 List 中获取数据。例如,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,以及类似于 LRANGE 但不返回指定范围的 LTRIM 来修剪列表,因此它类似于从 mylist 获取范围,将此范围设置为新值,但以原子方式执行此作。

Set 数据类型

目前,我们在本教程中没有使用 Set 类型,但由于我们使用 Sorted Sets,这是 Set 的更强大的版本,它更好 首先开始引入 Sets(这是一个非常有用的数据结构 本身)和后来的 Sorted Sets 进行排序。

除了 Lists 之外,还有更多的数据类型。Redis 还支持 Set,它们是未排序的元素集合。可以添加、删除和测试成员的存在,并执行不同 Set 之间的交集。当然,可以获取 Set 的元素。一些例子会更清楚地说明。请记住,SADDAdd to Set作,SREMRemove from set作,SISMEMBER测试 if member作,并且SINTERperform intersection作。其他作包括SCARD获取 Set 的基数(元素数),以及SMEMBERS返回 Set 的所有成员。

SADD myset a
SADD myset b
SADD myset foo
SADD myset bar
SCARD myset => 4
SMEMBERS myset => bar,a,foo,b

请注意,SMEMBERS不会按照我们添加元素的顺序返回元素,因为 Set 是未排序的元素集合。当您想按顺序存储时,最好改用 Lists 。针对 Set 的更多作:

SADD mynewset b
SADD mynewset foo
SADD mynewset hello
SINTER myset mynewset => foo,b

SINTER可以返回 Set 之间的交集,但不限于两个 Set。您可以要求 4,5 或 10000 套的交集。最后,让我们看看如何SISMEMBER工程:

SISMEMBER myset foo => 1
SISMEMBER myset notamember => 0

Sorted Set 数据类型

Sorted Set 类似于 Sets: 元素集合。但是,在 Sorted 中 设置每个元素都与一个浮点值相关联,称为元素分数。由于分数的原因,Sorted Set 中的元素是 ordered,因为我们总是可以通过 score 比较两个元素(如果 score 恰好是一样的,我们把两个元素当成字符串来比较)。

与 Sorted Sets 中的 Sets 一样,无法添加重复的元素,每个 元素是唯一的。但是,可以更新元素的 score。

Sorted Set 命令以Z.下面是一个示例 Sorted Sets 用法:

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 命令文档中的 Sorted Set 部分

Hash 数据类型

这是我们在程序中使用的最后一个数据结构,非常简单 要掌握,因为几乎每种编程语言中都有一个等效项 there:哈希。Redis 哈希基本上类似于 Ruby 或 Python 哈希,一个 与 Values 关联的字段集合:

HMSET myuser name Salvatore surname Sanfilippo country Italy
HGET myuser surname => Sanfilippo

HMSET可用于设置哈希中的字段,这些字段可以使用HGET后。可以使用HEXISTS或 增加哈希字段HINCRBY等等。

哈希是表示对象的理想数据结构。例如,我们 使用哈希来表示 Twitter 克隆中的用户和更新。

好了,我们刚刚公开了 Redis 主要数据结构的基础知识, 我们准备好开始编码了!

先决条件

如果您还没有下载 Retwis 源代码,请立即下载。它包含一些 PHP 文件,以及一个 Preset 的副本,这是我们在此示例中使用的 PHP 客户端库。

您可能想要的另一件事是正常工作的 Redis 服务器。只需获取源代码,使用make,使用./redis-server,然后您就可以开始了。完全不需要配置即可在计算机上使用或运行 Retvis。

数据布局

使用关系数据库时,必须设计数据库架构,以便我们知道数据库将包含的表、索引等。我们在 Redis 中没有表,那么我们需要设计什么呢?我们需要确定需要哪些键来表示我们的对象,以及这些键需要保存什么样的值。

让我们从 Users 开始。当然,我们需要用用户的用户名、用户 ID、密码、关注给定用户的用户集、给定用户关注的用户集等来表示用户。第一个问题是,我们应该如何识别用户?就像在关系型数据库中一样,一个好的解决方案是用不同的数字来识别不同的用户,这样我们就可以为每个用户关联一个唯一的 ID。对此用户的所有其他引用都将由 id 完成。使用我们的 atomic 创建唯一 ID 非常简单INCR操作。当我们创建一个新用户时,我们可以做这样的事情,假设这个用户叫 “antirez”:

INCR next_user_id => 1000
HMSET user:1000 username antirez password p1pp0

注意:为了简单起见,您应该在实际应用程序中使用哈希密码 我们将密码以明文形式存储。

我们使用next_user_id键,以便始终为每个新用户获取唯一 ID。然后,我们使用此唯一 ID 来命名持有包含用户数据的 Hash 的密钥。这是键值存储的常见设计模式!请记住这一点。 除了已经定义的字段之外,我们还需要更多的东西来完全定义 User。例如,有时能够从用户名中获取用户 ID 可能很有用,因此每次添加用户时,我们还会填充userskey,它是一个 Hash,用户名为字段,其 ID 为值。

HSET users antirez 1000

乍一看这可能很奇怪,但请记住,我们只能以直接方式访问数据,而无需二级索引。无法告诉 Redis 返回包含特定值的 key。这也是我们的强项。这种新范式迫使我们组织数据,以便所有内容都可以通过主键访问,用关系数据库术语来说。

关注者、关注和更新

我们的系统中还有另一个核心需求。用户可能有关注他们的用户,我们称之为他们的关注者。用户可能会关注其他用户,我们称之为 follow。为此,我们有一个完美的数据结构。那是。。。集。 Sets 元素的唯一性,以及我们可以在恒定时间内测试 存在,是两个有趣的特征。但是,记住 给定用户开始关注另一个用户的时间?在增强的 版本,这可能很有用,因此与其使用 一个简单的 Set 中,我们使用 Sorted Set,使用 following 或 follower 的用户 ID user 作为元素,以及 users 之间关系的 UNIX 时间 创建,作为我们的 Score。

因此,让我们定义我们的键:

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。基本上每个新的更新都会是LPUSHed 在用户更新键中,并感谢LRANGE,我们可以实现分页等。请注意,我们可以互换使用 updatesposts 这两个词,因为 updates 在某种程度上实际上是 “小帖子”。

posts:1000 => a List of post ids - every new post is LPUSHed here.

此列表基本上是 User 时间线。我们将推送她/他自己的 ID 帖子,以及以下用户创建的所有帖子的 ID。 基本上,我们将实现一个 write fanout。

认证

好的,除了身份验证之外,我们或多或少地了解了用户的所有信息。我们将以一种简单但健壮的方式处理身份验证:我们不想使用 PHP 会话,因为我们的系统必须准备好轻松地在不同的 Web 服务器之间分发,因此我们将整个状态保存在 Redis 数据库中。我们只需要一个随机的不可猜测的字符串,将其设置为经过身份验证的用户的 cookie,以及一个包含持有该字符串的客户端的用户 ID 的密钥。

我们需要两件事才能使它以稳健的方式工作。 第一:当前身份验证密钥(随机的不可猜测字符串) 应该是 User 对象的一部分,因此在创建用户时,我们还会设置 一authfield 的 Hash 中:

HSET user:1000 auth fea5e81ac8ca77622bed1c2132a021f9

此外,我们需要一种方法将身份验证 secret 映射到用户 ID,因此 我们还采用authskey,其值为 Hash 类型映射 用户 ID 的身份验证密钥。

HSET auths fea5e81ac8ca77622bed1c2132a021f9 1000

为了对用户进行身份验证,我们将执行以下简单步骤(请参阅login.php文件):

  • 通过登录表单获取用户名和密码。
  • 检查username字段实际上存在于users散 列。
  • 如果存在,我们有用户 ID(即 1000)。
  • 检查 user:1000 password 是否匹配,如果不匹配,则返回错误消息。
  • 好的,已通过身份验证!设置 “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>.
  • 检查<authcookie>字段中的auths哈希是否存在,以及值 (用户 ID) 是什么(示例中为 1000)。
  • 为了使系统更加健壮,还要验证 user:1000 auth 字段是否也匹配。
  • 好的,用户通过身份验证,我们在$Userglobal 变量。

代码比描述更简单,可能:

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字段中,从authsHash 并添加新的 Hash。

重要提示:注销过程解释了为什么我们在auths哈希值,但请仔细检查它是否与 user:1000auth田。true 身份验证字符串是后者,而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"

如您所见,每个帖子都由一个具有三个字段的 Hash 表示。拥有帖子的用户的 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要获取当前用户的所有 follower,则循环将LPUSH在每个关注者时间线列表中推送帖子。

请注意,我们还为所有帖子维护了一个全局时间线,以便在 Retwis 主页中我们可以轻松显示每个人的更新。这只需要执行LPUSHtimeline列表。让我们面对现实吧,你是不是开始觉得必须按时间顺序对添加的东西进行排序有点奇怪ORDER BY使用 SQL?我认为如此。

在上面的代码中,有一件有趣的事情需要注意:我们使用了一个新的 命令调用LTRIM在我们执行LPUSH全球运营 时间线。这是为了将列表修剪到仅 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 格式的 Post,而showUserPosts获取一系列帖子,然后将它们传递给showPosts.

注意:LRANGE如果帖子列表开始非常 big,并且我们想要访问位于列表中间的元素,因为 Redis 列表由链表支持。如果系统专为 百万个项目的深度分页,最好求助于 Sorted Sets 相反。

关注用户

这并不难,但我们还没有检查我们如何建立关注/关注者关系。如果 1000 号(antirez)想要关注 5000 号(pippo)用户,则需要同时建立 follower 和 follower 关系。我们只需要ZADD调用:

    ZADD following:1000 5000
    ZADD followers:5000 1000

一次又一次地注意相同的模式。理论上,对于关系数据库,关注者和关注者的列表将包含在一个表中,其中包含如下字段following_idfollower_id.您可以使用 SQL 查询提取每个用户的关注者或关注者。使用键值数据库时,情况会有所不同,因为我们需要将1000 is following 50005000 is followed by 1000关系。这是要付出的代价,但另一方面,访问数据更简单且非常快速。把这些东西放在单独的集合里,可以让我们做一些有趣的事情。例如,使用ZINTERSTORE我们可以有following两个不同的用户,因此我们可能会在我们的 Twitter 克隆中添加一项功能,以便当您访问其他人的个人资料时,它能够非常快速地告诉您,“您和 Alice 有 34 个共同的关注者”,诸如此类。

您可以在follow.php文件。

使其水平可扩展

温柔的读者,如果你读到这里,你已经是一个英雄了。谢谢。在讨论水平扩展之前,值得检查单个服务器的性能。Retwis 的速度非常快,没有任何缓存。在速度非常慢且负载严重的服务器上,具有 100 个并行客户端发出 100000 个请求的 Apache 基准测试测得的平均网页浏览时间为 5 毫秒。这意味着您每天只需一个 Linux 机器就可以为数百万用户提供服务,而这个机器的速度很慢......想象一下使用更新的硬件的结果。

但是,你不能永远使用单个服务器,你如何扩展一个 key-value 商店?

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

要了解有关这些主题的更多信息,请阅读我们关于分片的文档。然而,这里的重点 需要强调的是,在键值存储中,如果您谨慎设计,数据集 被拆分为多个独立的小 key。分发这些密钥 添加到多个节点中,与使用 语义上更复杂的数据库系统。

为本页评分
返回顶部 ↑