微博及 Twitter 这两大社交平台都重度依赖 Redis 来承载海量用户访问。本文介绍如何使用 Redis 来设计一个社交系统,以及如何扩展 Redis 让其能够承载上亿用户的访问规模。
虽然单台 Redis 具备极佳的性能,但随着系统规模增大,单台服务器不能存储所有数据、以及没办法处理所有读写请求的问题迟早都会出现,这时我们就需要对 Redis 进行扩展,让它能够满足需求。
在介绍如何扩展之前,我们先看下如何用 Redis 来搭建一个社交平台。
使用 Redis 搭建社交平台
用 Redis 来搭建一个社交平台,需要首先考虑以下几个核心功能。
1. 已发表微博
可以使用 Redis 的 hash 来保存已发表微博。
一条微博通常包括多个字段,比如发表时间、发表用户、正文内容等,通常使用微博 id 作为 key 将多个键值对作为 hash 保存在 Redis 中。
2. 信息流
当一个用户访问它的首页信息流时候,他可以看到他所有关注用户最新的信息。key 是当前用户的 uid, 信息流的内容以 id / timestamp 的形式保存在 zset 中,timestamp 用于排序,以便返回的列表是按照时间顺序排列。微博的 id 用于业务下一步获取微博的相关信息。
3. 关注与粉丝
我们可以把关注及粉丝库也存在 zset 中,依旧使用 timestamp 来排序。key 是当前用户 uid。
了解上述结构之后,我们继续来看如何使用 Redis 来扩展整个系统,具备处理亿级用户的能力。
我们首先要做的,就是在 Redis 能够存储所有数据并且能够正常地处理写查询的情况下,让 Redis 的读查询处理能力超过单台 Redis 服务器所能提供的读查询处理能力。
扩展读性能
假定我们用 Redis 构建一个与微博或 Twitter 具有相同特性和功能的社交网站,网站的其中一个特性就是允许用户查看他们自己的 profile 页和个人首页信息流,每当用户访问时,程序就会从信息流里面获取大约 30 条内容。
因为一台专门负责获取信息流的 Redis 服务器每秒至少可以同时为 3,000 ~ 10,000 个用户获取信息流消息,所以这一操作对于规模较小的社交网站来说并不会造成什么问题。
但是对于规模更大的社交网站来说,程序每秒需要获取的信息流消息数量将远远超过单台 Redis 服务器所能处理的上限,因此我们必须想办法提升 Redis 每秒能够获取的信息流消息数量。
下面我们将会讨论如何使用只读的从服务器提升系统处理读查询的性能,使得系统的整体读性能能够超过单台 Redis 服务器所能提供的读查询性能上限。
在对读查询的性能进行扩展,并将额外的服务器用作从服务器以提高系统处理读查询的性能之前,让我们先来回顾一下 Redis 提高性能的几个途径。
在使用短结构时,请确保压缩列表的最大长度不会太大以至于影响性能。
根据程序需要执行的查询的类型,选择能够为这种查询提供最好性能的结构。比如说,不要把 LIST 当作 SET 使用;也不要获取整个 HASH 然后在客户端里面对其进行排序,而是应该直接使用 ZSET;诸如此类。
在将大体积的对象缓存到 Redis 之前,考虑对它进行压缩以减少读取和写入对象时所需的网络带宽。对比压缩算法 lz4、gzip 和 bzip2,看看哪个算法能够对被存储的数据提供最好的压缩效果和最好的性能。
使用 pipeline(pipeline 是否启用事务性质由具体的程序决定)以及连接池。
在做好了能确保读查询和写查询能够快速执行的一切准备之后,接下来要考虑的就是如何实际解决“怎样才能处理更多读请求”这个正题。
提升 Redis 读取能力的最简单方法,就是添加提供读能力的从服务器。
用户可以运行一些额外的服务器,让它们与主服务器进行连接,然后接受主服务器发送的数据副本并通过网络进行准实时的更新(具体的更新速度取决于网络带宽)。通过将读请求分散到不同的从服务器上面进行处理,用户可以从新添加的从服务器上获得额外的读查询处理能力。
记住:只对主服务器进行写入
在使用只读从服务器的时候,请务必记得只对 Redis 主服务器进行写入。在默认情况下,尝试对一个被配置为从服务器的 Redis 服务器进行写入将引发一个错误(就算这个从服务器是其他从服务器的主服务器,也是如此)。
简单来说,要将一个 Redis 服务器变为从服务器,我们只需要在 Redis 的配置文件里面,加上一条slaveof host port语句,并将 host 和 port 两个参数的值分别替换为主服务器的 IP 地址和端口号就可以了。除此之外,我们还可以通过对一个正在运行的 Redis 服务器发送SLAVEOF host port命令来把它配置为从服务器。需要注意的一点是,当一个从服务器连接至主服务器的时候,从服务器原本存储的所有数据将被清空。最后,通过向从服务器发送SLAVEOF no one命令,我们可以让这个从服务器断开与主服务器的连接。
使用多个 Redis 从服务器处理读查询时可能会遇到的最棘手的问题,就是主服务器临时下线或者永久下线。每当有从服务器尝试与主服务器建立连接的时候,主服务器就会为从服务器创建一个快照,如果在快照创建完毕之前,有多个从服务器都尝试与主服务器进行连接,那么这些从服务器将接收到同一个快照。从效率的角度来看,这种做法非常好,因为它可以避免创建多个快照。
但是,同时向多个从服务器发送快照的多个副本,可能会将主服务器可用的大部分带宽消耗殆尽。使主服务器的延迟变高,甚至导致主服务器已经建立了连接的从服务器断开。
解决从服务器重同步(resync)问题的其中一个方法,就是减少主服务器需要传送给从服务器的数据数量,这可以通过构建树状复制中间层来完成。
(图:一个 Redis 主从复制树示例,树的最底层由 9 个从服务器组成,而中间层则由 3 个复制辅助服务器组成)
从服务器树非常有用,在对不同数据中心(data center)进行复制的时候,这种从服务器树甚至是必需的:通过缓慢的广域网(WAN)连接进行重同步是一件相当耗费资源的工作,这种工作应该交给位于中间层的从服务器去做,而不必劳烦最顶层的主服务器。但是另一方面,构建从服务器树也会带来复杂的网络拓扑结构(topology),这增加了手动和自动处理故障转移的难度。
除了构建树状的从服务器群组之外,解决从服务器重同步问题的另一个方法就是对网络连接进行压缩,从而减少需要传送的数据量。一些 Redis 用户就发现使用带压缩的 SSH 隧道(tunnel)进行连接可以明显地降低带宽占用,比如某个公司就曾经使用这种方法,将复制单个从服务器所需的带宽从原来的 21Mbit 降低为 1.8Mbit(http://mng.bz/2ivv)。如果读者也打算使用这个方法的话,那么请记得使用 SSH 提供的选项来让 SSH 连接在断线后自动重连。
加密和压缩开销
一般来说,使用 SSH 隧道带来的加密开销并不会给服务器造成大的负担,因为2.6 GHz 主频的英特尔酷睿 2 单核处理器在只使用单个处理核心的情况下,每秒能够使用 AES-128 算法加密 180MB 数据,而在使用 RC4 算法的情况下,每秒则可以加密大约 350MB 数据。在处理器足够强劲并且拥有千兆网络连接的情况下,程序即使在加密的情况下也能够充分地使用整个网络连接。
唯一可能会出问题的地方是压缩—因为 SSH 默认使用的是 gzip 压缩算法。SSH 提供了配置选项,可以让用户选择指定的压缩级别(具体信息可以参考SSH的文档),它的 1 级压缩在使用之前提到的 2.6GHz 处理器的情况下,可以在复制的初始时候,以每秒 24~52MB 的速度对 Redis 的 RDB 文件进行压缩;并在复制进入持续更新阶段之后,以每秒 60~80MB 的速度对 Redis 的 AOF 文件进行压缩。
使用 Redis Sentinel
Redis Sentinel 可以配合 Redis 的复制功能使用,并对下线的主服务器进行故障转移。Redis Sentinel 是运行在特殊模式下的 Redis 服务器,但它的行为和一般的 Redis 服务器并不相同。
Sentinel 会监视一系列主服务器以及这些主服务器的从服务器,通过向主服务器发送PUBLISH命令和SUBSCRIBE命令,并向主服务器和从服务器发送PING命令,各个 Sentinel 进程可以自主识别可用的从服务器和其他 Sentinel。
当主服务器失效的时候,监视这个主服务器的所有 Sentinel 就会基于彼此共有的信息选出一个 Sentinel,并从现有的从服务器当中选出一个新的主服务器。当被选中的从服务器转换成主服务器之后,那个被选中的 Sentinel 就会让剩余的其他从服务器去复制这个新的主服务器(在默认设置下,Sentinel 会一个接一个地迁移从服务器,但这个数量可以通过配置选项进行修改)。
一般来说,使用 Redis Sentinel 的目的就是为了向主服务器属下的从服务器提供自动故障转移服务。此外,Redis Sentinel 还提供了可选的故障转移通知功能,这个功能可以通过调用用户提供的脚本来执行配置更新等操作。
更深入了解 Redis Sentinel 可以阅读http://redis.io/topics/sentinel
在了解如何扩展读性能的方法之后,接下来我们该考虑如何扩展写性能了。
扩展写性能和内存容量
随着被缓存的数据越来越多,当数据没办法被存储到单台机器上面的时候,我们就需要想办法把数据分割存储到由多台机器组成的集群里面。
扩展写容量
尽管这一节中讨论的是如何使用分片来增加可用内存的总数量,但是这些方法同样可以在一台 Redis 服务器的写性能到达极限的时候,提升 Redis 的写吞吐量。
在对写性能进行扩展之前,首先需要确认我们是否已经用尽了一切办法去降低内存占用,并且是否已经尽可能地减少了需要写入的数据量。
对自己编写的所有方法进行了检查,尽可能地减少程序需要读取的数据量。
将无关的功能迁移至其他服务器。
在对 Redis 进行写入之前,尝试在本地内存中对将要写入的数据进行聚合计算,这一做法可以应用于所有分析方法和统计计算方法。
使用锁去替换可能会给速度带来限制的 WATCH/MULTI/EXEC 事务,或者使用 Lua 脚本。
在使用 AOF 持久化的情况下,机器的硬盘必须将程序写入的所有数据都存储起来,这需要花费一定的时间。对于 400,000 个短命令来说,硬盘每秒可能只需要写入几 MB 的数据;但是对于 100,000 个长度为 1KB 的命令来说,硬盘每秒将需要写入100MB 的数据。
如果用尽了一切方法降低内存占用并且尽可能地提高性能之后,问题仍然未解决,那么说明我们已经遇到了只使用单台机器带来的瓶颈,是时候将数据分片到多台机器上面了。
本文介绍的数据分片方法要求用户使用固定数量的 Redis 服务器。举个例子,如果写入量预计每 6 个月就会增加 4 倍,那么我们可以将数据预先分片(preshard)到 256 个分片里面,从而拥有一个在接下来的 2 年时间里面都能够满足预期写入量增长的分片方案(具体要规划多长远的方案要由你自己决定)。
为了应对增长而进行预先分片
在为了应对未来可能出现的流量增长而对系统进行预先分片的时候,我们可能会陷入这样一种处境:目前拥有的数据实在太少,按照预先分片方法计算出的机器数量去存储这些数据只会得不偿失。为了能够如常地对数据进行分割,我们可以在单台机器上面运行多个 Redis 服务器,并将每个服务器用作一个分片。
注意,在同一台机器上面运行多个 Redis 服务器的时候,请记得让每个服务器都监听不同的端口,并确保所有服务器写入的都是不同的快照文件或 AOF 文件。
在单台机器上面运行多个 Redis 服务器
上面介绍了如何将写入命令分片到多台服务器上面执行,从而增加系统的可用内存总量并提高系统处理写入操作的能力。但是,如果你在执行诸如搜索和排序这样的复杂查询时,感觉系统的性能受到了 Redis 单线程设计的限制,而你的机器又有更多的计算核心、更多的通信网络资源,以及更多用于存储快照文件和 AOF 文件的硬盘 I/O,那么你可以考虑在单台机器上面运行多个 Redis 服务器。你需要做的就是对位于同一台机器上面的所有服务器进行配置,让它们分别监听不同的端口,并确保它们拥有不同的快照配置或 AOF 配置。
扩展复杂的业务场景
在对各式各样的 Redis 服务进行扩展的时候,常常会遇到这样一种情况:因为服务执行的查询并不只是读写那么简单,所以只对数据进行简单分片并不足以满足复杂业务场景的需求。
对社交网站进行扩展
下面介绍如何对类似微博或者 Twitter 这样的社交网站进行扩展,介绍的目的是为了让我们更好的理解使用什么样的数据结构及方法来构建一个大型社交网络,这些方法几乎可以无限制地进行——只要资金允许,我们可以将一个社交网站扩展至任意规模。
对社交网站进行扩展的第一步,就是找出经常被读取的数据以及经常被写入的数据,并思考是否有可能将常用数据和不常用数据分开。
首先,假设我们已经把用户已发表的微博放在一个独立的 Redis 服务器,并使用只读的从服务器处理针对这些数据进行大量读取操作。那么一个社交网站上需要进行扩展的主要是两个类型的数据:信息流、关注及粉丝列表。
扩展已发表微博的数据库
当你的社交网站获得一定的访问量之后,我们需要对存储已发表微博的数据库做进一步的扩展,而不仅仅只添加从服务器。
因为每条微博都完整地存储在一个单独的 HASH 里面,所以程序可以很容易地基于散列所在的键,把各条微博 hash 分片到由多个 Redis 服务器组成的集群里面。
因为对每条微博 hash 进行分片并不困难,所以分片的工作应该并不难完成。扩展微博数据库的另一种方法,就是将 Redis 用作缓存,并把最新发布的消息存储到 Redis 里,而较旧(也就是较少读取)的消息则存储到以硬盘存储为主的服务器里面,像 PostgreSQL、MySQL、Riak、MongoDB 等。
在一个社交网站上,主要的信息流有 3 种:用户首页的信息流、profile 信息流以及分组信息流。各个信息流本身都是相似的,所以我们将使用相同的处理方式。
下面我们来看社交系统中最核心的两种系统如何通过不同的分片策略对其进行扩展。
1.对信息流列表进行分片
标题所说的“对信息流进行分片”实际上有些词不达意,因为首页信息流和分组列表信息流通常都比较短(最大通常只有 1,000 条,实际的数量由zset-max-ziplist-size选项的值决定),因此实际上并不需要对信息流的内容进行分片;我们真正要做的是根据键名,把不同的信息流分别存储到不同的分片上面。
另一方面,社交网站每个用户 profile 信息流通常无限增长的。尽管绝大多数用户每天最多只会发布几条微博,但也有话痨用户以明显高于这一频率的速度发布大量信息。以 Twitter 为例,该网站上发布信息最多的 1,000 个用户,每人都发布了超过 150,000 条推文,而其中发布最多的 15 个用户,每人都发布了上百万条推文。
从实用性的角度来看,一个合乎情理的做法是限制每个用户的已发表微博最多只能存储大约 20,000 条信息,并将最旧的信息删除或者隐藏——这种做法足以处理 99.999% 的 Twitter 用户,而我们也会使用这一方案来对社交网站的个人信息流进行扩展。扩展个人信息流的另一种方法,就是使用本节稍后介绍的关注库进行扩展的技术。
2.通过分片对关注及粉丝列表扩展
虽然对信息流进行扩展的方法相当直观易懂,但是对关注和粉丝列表这些由有序集合构成的“列表”进行扩展却并不容易。这些有序集合绝大多数都很短(如 Twitter 上 99.99% 的用户的关注者都少于 1,000 人),但是也存在少量用户的列表非常大,他们关注了非常多的人或者拥有数量庞大的粉丝。
从实用性的角度来考虑,一个合理的做法是给用户以及分组可以关注的人数设置一个上限(比如新浪微博普通用户最大允许关注 2,000 用户)。不过这个方法虽然可以控制用户的关注人数,但是仍然解决不了单个用户的粉丝数人数过多的问题。
为了处理关注和粉丝列表变得非常巨大的情况,我们需要将实现这些列表的有序集合划分到多个分片上面,说得更具体一样,也就是根据分片的数量把用户的粉丝划分为多个部分,存在多个 zset 中。为此,我们需要为ZADD命令、ZREM命令和ZRANGEBYSCORE命令实现特定的分片版本。
和信息流分片的区别是,这次分片的对象是数据而不是键。此外,为了减少程序创建和调用连接的数量,把关注和粉丝的数据放置在同一个分片里面将是一种非常有意义的做法。因此这次我们将使用新的方法对数据进行分片。
为了能够在关注及粉丝数据进行分片的时候,把两者数据都存储到同一个分片里面,程序将会把关注者和被关注者双方的 ID 用作查找分片键的其中一个参数。
总结
本章对各式各样的程序进行了回顾,介绍了一些对它们进行扩展以处理更多读写流量并获得更多可用内存的方法,其中包括使用只读从服务器、使用可以执行写查询的从服务器、使用分片以及使用支持分片功能的类和函数。尽管这些方法可能没有完全覆盖读者在扩展特定程序时可能会遇到的所有问题,但是这些例子中展示的每项技术都可以广泛地应用到其他情景里面。
本文希望向读者传达这样一个概念:对任何系统进行扩展都是一项颇具挑战性的任务。但是通过 Redis,我们可以使用多种不同的方法来对平台进行扩展,从而把平台扩展成我们想要的规模。