我曾遇到过这么一个需求:要用 Redis 保存 5000 万个键值对,每个键值对大约是 512B,
为了能快速部署并对外提供服务,我们采用云主机来运行 Redis 实例,
那么,该如何选择云主机的内存容量呢?
我粗略地计算了一下,这些键值对所占的内存空间大约是 25 GB(5000 万 *512B)。

所以,我想到的第一个方案就是:选择一台 32GB 内存的云主机来部署 Redis。
因为 32GB 的内存能保存所有数据,而且还留有 7 GB,可以保证系统的正常运行。
同时,我还采用 RDB 对数据做持久化,以确保 Redis 实例故障后,还能从 RDB 恢复数据。

但是,在使用的过程中,我发现,Redis 的响应有时会非常慢。
后来,我们使用 INFO 命令查看 Redis 的 latest_fork_usec 指标值(表示最近一次 fork 的耗时),结果显示这个指标值特别高,快到秒级别了。
这跟 Redis 的持久化机制有关系。
在使用 RDB 进行持久化时,Redis 会 fork 子进程来完成,
fork 操作的用时和 Redis 的数据量是正相关的,而 fork 在执行时会阻塞主线程。
数据量越大,fork 操作造成的主线程阻塞的时间越长。
所以,在使用 RDB 对 25GB 的数据进行持久化时,数据量较大,
后台运行的子进程在 fork 创建时阻塞了主线程,于是就导致 Redis 响应变慢了。

看来,第一个方案显然是不可行的,我们必须要寻找其他的方案。
这个时候,我们注意到了 Redis 的切片集群。
虽然组建切片集群比较麻烦,但是它可以保存大量数据,而且对 Redis 主线程的阻塞影响较小。
切片集群,也叫分片集群,就是指:启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。

回到上面的场景中,如果把 25GB 的数据平均分成 5 份(也可以不做均分),使用 5 个实例来保存,
每个实例只需要保存 5GB 数据。如下图所示:
793251ca784yyf6ac37fe46389094b26.webp
在切片集群中,实例在为 5GB 数据生成 RDB 时,数据量就小了很多,
fork 子进程不会给主线程带来较长时间的阻塞。
采用多个实例保存数据切片后,我们既能保存 25GB 数据,又避免了 fork 子进程阻塞主线程而导致的响应变慢。

在实际应用 Redis 时,随着用户或业务规模的扩展,保存大量数据的情况通常是无法避免的。
而切片集群,就是一个非常好的解决方案。

如何保存更多数据

在上面的案例里,为了保存大量数据,我们使用了大内存云主机 和 切片集群两种方法。
实际上,这两种方法分别对应着 Redis 应对数据量增多的两种方案:

  • 纵向扩展 (scale up):升级单个 Redis 实例的资源配置,

包括:增加内存容量、增加磁盘容量、使用更高配置的 CPU。

  • 横向扩展 (scale out):横向增加当前 Redis 实例的个数

这两种方式的优缺点分别是什么呢?
纵向扩展的好处是,实施起来简单、直接。
不过,纵向扩展面临两个潜在的问题。

  • 第一个问题:当使用 RDB 对数据进行持久化时,如果数据量增加,需要的内存也会增加,主线程 fork 子进程时就可能会阻塞。
  • 第二个问题:纵向扩展会受到硬件和成本的限制。这很容易理解,把内存从 32GB 扩展到 64GB 还算容易,但是要想扩充到 1TB,就会面临硬件容量和成本上的限制了。

与纵向扩展相比,横向扩展是一个扩展性更好的方案。
这是因为,要想保存更多的数据,采用这种方案的话,
只用增加 Redis 实例的个数就可以了,不用担心单个实例的硬件和成本限制。
在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。

不过,在只使用单个实例的时候,数据存储在哪里,客户端访问哪个 Redis 实例,这些问题的答案是明确的,
但是,切片集群不可避免地涉及到多个实例的分布式管理问题。
要想用切片集群,我们就需要解决两大问题:

  • 数据切片后,数据在多个实例之间如何分布?
  • 客户端怎么确定想要访问的数据在哪个实例上?

    数据和实例的对应规则

    在切片集群中,数据需要分布在不同实例上,那么,数据和实例之间如何对应呢?
    这就和 Redis Cluster 方案有关了。

我们要先明白切片集群和 Redis Cluster 的联系与区别。
切片集群是一种保存大量数据的通用机制,这个机制可以有不同的实现方案。
在 Redis 3.0 之前,官方并没有针对切片集群提供具体的方案。
从 Redis 3.0 开始,官方提供了一个名为 Redis Cluster 的方案,用于实现切片集群。


Redis Cluster 方案中规定了数据和实例的对应规则。
Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和实例之间的映射关系。
在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,
这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。

具体的映射过程分为两大步:

  • 首先根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值;
  • 然后,再用这个 16 bit 值对 16384 取模,得到 0~16383 范围内的模数,

每个模数代表一个相应编号的哈希槽。

这些哈希槽又是如何被映射到具体的 Redis 实例上的呢?
我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,
此时,Redis 会自动把这些槽平均分布在集群实例上。
举例说明:如果集群中有 N 个实例,那么,每个实例上槽的个数为 16384 / N 个。

我们也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,
再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。
举例说明:假设集群中不同 Redis 实例的内存大小配置不一,如果把哈希槽均分在各个实例上,
在保存相同数量的键值对时,和内存大的实例相比,内存小的实例就会有更大的容量压力。
遇到这种情况时,你可以根据不同实例的资源配置情况,使用 cluster addslots 命令手动分配哈希槽。
在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
7d070c8b19730b308bfaabbe82c2f1ab.webp


通过哈希槽,切片集群就实现了数据到哈希槽、哈希槽再到实例的分配。
但是,即使实例有了哈希槽的映射信息,客户端又是怎么知道要访问的数据在哪个实例上呢?

客户端如何确定数据在哪个实例上

在定位键值对数据时,数据所处的哈希槽可以通过计算得到,这个计算可以在客户端发送请求时来执行。
但是,要进一步定位到实例,还需要知道哈希槽分布在哪个实例上。

一般来说,客户端和集群实例建立网络连接后,实例就会把哈希槽的分配信息发给客户端。
但是,在集群刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,
是不知道其他实例拥有的哈希槽信息的。

但是,为什么客户端可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?
这是因为 Redis 实例会把自己的哈希槽信息发送给和它相连接的其它实例,来完成哈希槽分配信息的扩散。
当实例之间相互网络连接后,每个实例就有所有哈希槽的映射关系了。
客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。
当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在切片集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

  • 在切片集群中,实例有新增或删除,Redis 需要重新分配哈希槽
  • 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍。

此时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,
但是,客户端是无法主动感知这些变化的。
这就会导致,客户端缓存的分配信息和最新的分配信息不一致,那该怎么办呢?


Redis Cluster 方案提供了一种重定向机制,
所谓的“重定向”,就是指:客户端给一个实例发送数据读写操作时,
这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

那客户端又是怎么知道重定向时的新实例的访问地址呢?
当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,
这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。
GET hello:key
(error) MOVED 13320 172.16.19.5:6379
MOVED 命令表示:客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。
通过返回的 MOVED 命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。
这样一来,客户端就可以直接和 172.16.19.5 连接,并发送操作请求了。

350abedefcdbc39d6a8a8f1874eb0809.webp
可以看到,由于负载均衡,Slot 2 中的数据已经从实例 2 迁移到了实例 3,
但是,客户端缓存仍然记录着“Slot 2 在实例 2”的信息,所以会给实例 2 发送命令。
实例 2 给客户端返回一条 MOVED 命令,把 Slot 2 的最新位置(在实例 3 上),返回给客户端,
客户端就会再次向实例 3 发送请求,同时还会更新本地缓存,把 Slot 2 与实例的对应关系更新过来。

需要注意的是,在上图中,当客户端给实例 2 发送命令时,Slot 2 中的数据已经全部迁移到了实例 3。
在实际应用时,如果 Slot 2 中的数据比较多,就可能会出现一种情况:客户端向实例 2 发送请求,但此时,Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。
在这种迁移部分完成的情况下,客户端就会收到一条 ASK 报错信息,如下所示:
GET hello:key
(error) ASK 13320 172.16.19.5:6379
这个结果中的 ASK 命令就表示:客户端请求的键值对所在的哈希槽 13320,
在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移。
此时,客户端需要先给 172.16.19.5 这个实例发送一个 ASKING 命令。
ASKING 命令的意思是:让这个实例允许执行客户端接下来发送的命令。
然后,客户端再向这个实例发送 GET 命令,以读取数据。

e93ae7f4edf30724d58bf68yy714eeb0.webp
如图所示:Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移到实例 3 了,key3 和 key4 还在实例 2。
客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。
ASK 命令表示两层含义:

  1. 表明 Slot 数据还在迁移中
  2. ASK 命令把客户端所请求数据的最新实例地址返回给客户端

此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令。

和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。
所以,在上图中,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求。
这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而不像 MOVED 命令那样,会更改客户端的本地缓存,让后续所有命令都发往新实例。

小结

这节课,我们学习了三点

  • 切片集群在保存大量数据方面的优势
  • 基于哈希槽的数据分布机制
  • 客户端定位键值对的方法

在应对数据量扩容时,增加内存这种纵向扩展的方法简单直接,但是会造成数据库的内存过大,导致性能变慢。

Redis 切片集群提供了横向扩展的模式,也就是使用多个实例,并给每个实例配置一定数量的哈希槽,
数据可以通过键的哈希值映射到哈希槽,再通过哈希槽分散保存到不同的实例上。
这样做的好处是:扩展性好,不管有多少数据,切片集群都能应对。

集群的实例增减,或者是为了实现负载均衡而进行的数据重新分布,会导致哈希槽和实例的映射关系发生变化,客户端发送请求时,会收到命令执行报错信息。
了解了 MOVED 和 ASK 命令,你就不会为这类报错而头疼了。

在 Redis 3.0 之前,Redis 官方并没有提供切片集群方案,但是,当时业界已经有了一些切片集群的方案,
例如:基于客户端分区的 ShardedJedis,基于代理的 Codis、Twemproxy 等。
这些方案的应用早于 Redis Cluster 方案,在支撑的集群实例规模、集群稳定性、客户端友好性方面也都有着各自的优势,我会在后面的课程中,专门和你聊聊这些方案的实现机制,以及实践经验。
这样一来,当你再碰到业务发展带来的数据量巨大的难题时,就可以根据这些方案的特点,选择合适的方案实现切片集群,以应对业务需求了。

每课一问

Redis Cluster 方案通过哈希槽的方式把键值对分配到不同的实例上,
这个过程需要对键值对的 key 做 CRC 计算,然后再和哈希槽做映射,这样做有什么好处吗?
如果用一个表直接把键值对和实例的对应关系记录下来(例如键值对 1 在实例 2 上,键值对 2 在实例 1 上),
这样就不用计算 key 和哈希槽的对应关系了,只用查表就行了,Redis 为什么不这么做呢?


如果使用表记录键值对和实例的对应关系,
一旦键值对和实例的对应关系发生了变化(例如实例有增减或者数据重新分布),就要修改表。
如果是单线程操作表,那么所有操作都要串行执行,性能慢;
如果是多线程操作表,就涉及到加锁开销。
此外,如果数据量非常大,使用表记录键值对和实例的对应关系,需要的额外存储空间也会增加。
基于哈希槽计算时,虽然也要记录哈希槽和实例的对应关系,但是哈希槽的个数要比键值对的个数少很多,
无论是修改哈希槽和实例的对应关系,还是使用额外空间存储哈希槽和实例的对应关系,
都比直接记录键值对和实例的关系的开销小得多。
评论区大佬的回答
Redis Cluster 不采用把 key 直接映射到实例的方式,而采用哈希槽的方式原因:

  • 整个集群存储 key 的数量是无法预估的,key 的数量非常多时,直接记录每个 key 对应的实例映射关系,这个映射表会非常庞大,这个映射表无论是存储在服务端还是客户端都占用了非常大的内存空间。
  • Redis Cluster 采用无中心化的模式(无 proxy,客户端与服务端直连),

客户端在某个节点访问一个 key,如果这个 key 不在这个节点上,这个节点需要有纠正客户端路由到正确节
点的能力(MOVED 响应),这就需要节点之间互相交换路由表,每个节点拥有整个集群完整的路由关系。
如果存储的都是key与实例的对应关系,节点之间交换信息也会变得非常庞大,消耗过多的网络资源,而且就
算交换完成,相当于每个节点都需要额外存储其他节点的路由表,内存占用过大造成资源浪费。

  • 当集群在扩容、缩容、数据均衡时,节点之间会发生数据迁移,迁移时需要修改每个 key 的映射关系,维护成本高。
  • 而在中间增加一层哈希槽,可以把数据和实例解耦,key 通过 Hash 计算,只需要关心映射到了哪个哈希槽,然后再通过哈希槽和实例的映射表找到实例,相当于消耗了很少的 CPU 资源,不但让数据分布更均匀,还可以让这个映射表变得很小,利于客户端和服务端保存,节点之间交换信息时也变得轻量。
  • 当集群在扩容、缩容、数据均衡时,节点之间的操作例如数据迁移,都以哈希槽为基本单位进行操作,简化了节点扩容、缩容的难度,便于集群的维护和管理。

评论区大佬的补充知识
另外,我想补充一下 Redis 集群相关的知识,以及我的理解:
Redis 使用集群方案就是为了解决单个节点数据量大、写入量大产生的性能瓶颈的问题。
多个实例组成一个集群,可以提高集群的性能和可靠性,
但随之而来的就是集群的管理问题,最核心问题有2个:请求路由、数据迁移(扩容 / 缩容 /数据平衡)。

请求路由:一般都是采用哈希槽的映射关系表找到指定实例,然后在这个实例上操作的方案。

Redis Cluster 在每个实例记录完整的映射关系(便于纠正客户端的错误路由请求),
同时也发给客户端让客户端缓存一份,便于客户端直接找到指定实例,客户端与服务端配合完成数据的路由,
这需要业务在使用 Redis Cluster 时,必须升级为集群版的 SDK 才支持客户端和服务端的协议交互。

其他 Redis 集群化方案,例如:Twemproxy、Codis 都是中心化模式(增加 Proxy 层),
客户端通过 Proxy 对整个集群进行操作,Proxy 后面可以挂 N 多个 Redis 实例,Proxy 维护了路由的转发逻辑。
操作 Proxy 就像是操作一个普通 Redis 一样,客户端也不需要更换 SDK,
而 Redis Cluster 是把这些路由逻辑做在了 SDK 中。
当然,增加一层 Proxy 也会带来一定的性能损耗。


数据迁移:当切片集群不足以支撑业务需求时,就需要增加实例,增加实例就意味着实例之间的数据需要做迁移,而迁移过程中是否会影响到业务,这也是判定一个集群方案是否成熟的标准。

Twemproxy 不支持在线增加实例,它只解决了请求路由的问题,增加实例时需要停机做数据重新分配。
而 Redis Cluster 和 Codis 都做到了在线增加实例(不影响业务或对业务的影响非常小),
重点就是在数据迁移过程中,客户端对于正在迁移的 key 进行操作时,集群如何处理,才能保证响应正确的结果

Redis Cluster 和 Codis 都需要服务端和客户端 / Proxy 层互相配合,
迁移过程中,服务端针对正在迁移的 key,需要让客户端 / Proxy 去新节点访问(重定向),
这个重定向的过程就是为了保证业务在访问这些 key 时依旧不受影响,而且可以得到正确的结果。
由于重定向的存在,所以这个期间的访问延迟会变大。
等迁移完成之后,Redis Cluster 的每个实例会更新路由映射表,同时也会让客户端感知到,更新客户端缓存。Codis 会在 Proxy 层更新路由表,客户端在整个过程中无感知。

除了访问正确的节点之外,数据迁移过程中还需要解决:异常情况(迁移超时、迁移失败)、性能问题(如何让数据迁移更快、bigkey 如何处理),这个过程中的细节也很多。

Redis Cluster 的数据迁移是同步的,迁移一个 key 会同时阻塞源节点和目标节点,迁移过程中会有性能问题。
而 Codis 提供了异步迁移数据的方案,迁移速度更快,对性能影响最小,当然,实现方案也比较复杂。