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 编写的 Retwis 到 Ruby 和 Sinatra 的移植版本。
  • 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 (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变成了11,而不是12。这是因为使用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,第二个是1,依此类推。命令参数是 LRANGE key first-index last-indexlast-index 参数可以是负数,具有特殊含义:-1 是列表的最后一个元素,-2 是倒数第二个,依此类推。因此,要获取整个列表,请使用:

LRANGE mylist 0 -1 => c,b,a

其他重要的操作包括LLEN,它返回列表中元素的数量,以及LTRIM,它类似于LRANGE,但不是返回指定的范围,而是修剪列表,因此它类似于从我的列表中获取范围,将此范围设置为新值,但以原子方式完成。

集合数据类型

目前我们在本教程中不使用Set类型,但由于我们使用Sorted Sets,这是一种功能更强大的Set版本,因此最好先介绍Set(这是一种非常有用的数据结构本身),然后再介绍Sorted Sets。

除了列表之外,还有更多的数据类型。Redis 还支持集合(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 不会按照我们添加的顺序返回元素,因为集合是无序的元素集合。当您希望按顺序存储时,最好使用列表。以下是一些针对集合的更多操作:

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

有序集合数据类型

有序集合(Sorted Sets)与集合(Sets)类似:都是元素的集合。然而,在有序集合中,每个元素都与一个浮点数值相关联,称为元素分数。由于分数的存在,有序集合中的元素是有序的,因为我们总是可以通过分数来比较两个元素(如果分数相同,则将两个元素作为字符串进行比较)。

与 Sorted Sets 中的 Sets 类似,无法添加重复元素,每个元素都是唯一的。但是可以更新元素的分数。

有序集合命令以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文件):

  • 通过登录表单获取用户名和密码。
  • 检查username字段是否实际存在于users哈希中。
  • 如果存在,我们就有用户ID(即1000)。
  • 检查用户: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的值为
  • 检查auths哈希中的字段是否存在,以及其值(用户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作为一个单独的函数是多余的,但在复杂的应用程序中这是一个好方法。所有认证中唯一缺少的是注销。我们在注销时做什么?这很简单,我们只需更改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到每个关注帖子作者的用户的时间线中,当然也包括作者自己的帖子列表中(每个人实际上都在关注自己)。这是文件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来获取当前用户的所有关注者,然后循环将帖子推送到每个关注者的时间线列表中。

请注意,我们还为所有帖子维护了一个全局时间线,以便在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 列表是由链表支持的。如果一个系统设计用于数百万项的深度分页,最好改用 Sorted Sets。

关注用户

这并不难,但我们还没有检查如何创建关注/被关注关系。如果用户ID 1000(antirez)想要关注用户ID 5000(pippo),我们需要同时创建关注和被关注的关系。我们只需要进行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服务器就可以每天为数百万用户提供服务,而这台服务器的性能还非常差...想象一下使用更新硬件的效果。

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

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

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

RATE THIS PAGE
Back to top ↑