单台 Redis 实例存在的问题
在业务量上升时,单机程序通常面临着随着访问量上升,并发性能存在着瓶颈。数据库如此、中间件也是如此,但为了应对业务的增长而带来的访问量,保护组件在高并发环境下能够顶住流量的压力不崩溃、响应速度不变慢,通常有 2 种方案:纵向扩展和横向扩展。这里只针对 Redis:
纵向扩展:升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。比如从原来实例内存 8GB、50G 硬盘扩展到内存 24GB、磁盘 150GB。
横向扩展:指的是增加当前 Redis 实例的个数,就比如从原来一个 Redis 实例增加到 3 个使用相同配置的 Redis 实例。
那么这两种方案的优缺点分别是什么?
首先,纵向扩展的好处是,实施起来简单、直接。不过,这个方案也面临两个潜在的问题。
第一个问题是,当使用 RDB 对数据进行持久化时,如果数据量增加,需要的内存也会增加,主线程 fork 子进程时就可能会阻塞。不过,如果你不要求持久化保存 Redis 数据,那么,纵向扩展会是一个不错的选择。
不过,这时,还要面对第二个问题:纵向扩展会受到硬件和成本的限制。这很容易理解,毕竟,把内存从 32GB 扩展到 64GB 还算容易,但是,要想扩充到 1TB,就会面临硬件容量和成本上的限制了。
与纵向扩展相比,横向扩展是一个扩展性更好的方案。这是因为,要想保存更多的数据,采用这种方案的话,只用增加 Redis 的实例个数就行了,不用担心单个实例的硬件和成本限制。在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。
不过,在只使用单个实例的时候,数据存在哪儿,客户端访问哪儿,都是非常明确的,但是,切片集群不可避免地涉及到多个实例的分布式管理问题。要想把切片集群用起来,我们就需要解决两大问题:
数据切片后,在多个实例之间如何分布?
客户端怎么确定想要访问的数据在哪个实例上?
Redis 的切片集群,现在叫 Redis Cluster ,该方案就解决了这个问题。
数据和实例的对应分布关系
Redis 采用一个哈希槽(Hash Slot)的方式来处理数据和实例之间的映射关系。在 Cluster 方案中,一个切片集群一共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,而哈希槽会被放置到切片集群的一个实例中。
那 key 如何映射到哈希槽呢?根据什么规则去映射呢?
具体的映射过程分为两大步:
- 根据键值对的 key,按照 CRC16 算法计算得出一个 16bit 的值;
- 使用这个 16bit 值对哈希槽数量 16346 取模,得到 0-16384 范围内的模数,每一个模数代表着一个相应编号的哈希槽。
得出这个键值对落到哪个哈希槽了,那哈希槽又如何确定到具体哪一个实例呢?
集群的数量是无法确定的,可以使用 cluster create 命令去创建 Redis 的切片集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽的个数应为 16384/N 个。
也可以是手动指定一些哈希槽分配到具体实例,使用 cluster addslots 命令,指定每个实例上的哈希槽个数。
举个例子,假设集群中不同 Redis 实例的内存大小配置不一,如果把哈希槽均分在各个实例上,在保存相同数量的键值对时,和内存大的实例相比,内存小的实例就会有更大的容量压力。遇到这种情况时,你可以根据不同实例的资源配置情况,使用 cluster addslots 命令手动分配哈希槽。
下面这张图就反映了键值对数据、哈希槽、实例这三者的映射分布情况:
示意图中的切片集群一共有 3 个实例,同时假设有 5 个哈希槽,首先可以通过下面的命令手动分配哈希槽:实例 1 保存哈希槽 0 和 1,实例 2 保存哈希槽 2 和 3,实例 3 保存哈希槽 4。
1 | redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1 |
在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 取模,再根据各自的模数结果,就可以被映射到对应的实例 1 和实例 3 上了。
在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作,上面只是演示 2 个命令的用法,具体场景中还需要一个实例去承载 5-16383 哈希槽
小结
至此,通过键值对的 key 进行 CRC16 函数获得一个 16bit 的数,根据这个 16bit 数对 16384 进行取模,获得的结果就是落到具体的一个哈希槽。
而哈希槽落到哪一个具体实例可以在创建集群的时候由 Redis 自动分配,也可以手动指定哈希槽落到一个具体的实例上。使用手动方式时要注意,要把 16384 个哈希槽全部分配完,Redis 集群才能正常工作。
那客户端又是如何定位数据的呢?
客户端获取数据
在定位键值对的 key 数据时,它所处的哈希槽是可以通过计算得到的,这个计算可以在客户端发送请求时来执行。但是,要进一步定位到实例,还需要知道哈希槽分布在哪个实例上。
一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。
那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?
这是因为,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。 当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。
客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。
但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:
- 在集群中,实例有新增或删除,Redis 需要重新分配哈希槽;
- 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍。
此时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致了,那该怎么办呢?
Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。
那客户端又是怎么知道重定向时的新实例的访问地址呢?当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。
1 | GET hello:key |
其中,MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。通过返回的 MOVED 命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和 172.16.19.5 连接,并发送操作请求了。
下面用一张图来说明一下整个流程:
可以看到,由于负载均衡,Slot2 的数据已经从实例 2 迁移到了实例 3,但是,客户端感知不到这变化,缓存仍然记录着 Slot 在实例 2
的信息,所以会给实例 2 发送命令。
实例 2 在接收到客户端的请求时,会发现 Slot2 不在自己这里,检查自身映射信息之后返回一条 MOVED 命令,把 Slot2 的最新位置(也就是在实例 3 上)返回给客户端。
客户端拿到实例 2 的回复后,根据 MOVED 命令的信息会再此给实例 3 发送请求,同时更新本地缓存,把 SLot2 与实例的对应关系更新。客户端也就拿到了所需要的信息。
需要注意的是,在上图中的流程,是假设 Slot2 中的数据全部已经迁移到了实例 3 中的。那这样就会有一个问题了,客户端像实例 2 发送请求,但此时因为 Slot2 的数据比较多,Slot2 的数据只有一部分迁移到了实例 3,还有部分数据没有迁移,那这时候 Redis 会怎么做?
在这种迁移部分完成的情况下,Redis 就会向客户端发送一条 ASK 报错信息,如下:
1 | GET hello:key |
这个结果中的 ASK 命令就表示,客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移。
此时,客户端需要先给 172.16.19.5 这个实例发送一个 ASKING 命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据。
ASK 命令表示两层含义:第一,表明 Slot 数据还在迁移中;第二,ASK 命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令。
结合下图:
其中与前一张图的区别就是在第 2 步和第三步,在第 2 步中 Redis 返回的不是 MOVED 命令了,而是 ASK 命令,该命令携带了要访问的哈希槽值和该哈希槽所在实例的 IP 地址;客户端拿到实例的 IP 地址后,在执行查询命令前会先执行 ASKING 命令,然后再发送操作命令。
和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求。这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而不像 MOVED 命令那样,会更改本地缓存,让后续所有命令都发往新实例。
小结
在客户端角度来看,对 Redis 的集群是不感知的,请求数据时只要往其中一个 Redis 实例发送请求即可,Redis 实例根据自身映射信息来查看存放该数据 key 的哈希槽在集群中的哪个实例中,在本机中的话直接返回数据,如果不在本机则返回 MOVED 命令,告知客户端哪一个实例中会有你想要的数据。客户端拿到请求后直接访问数据即可。
得知 Redis 的哈希槽一共有 16384 个,这些哈希槽是分布在集群实例中的,在 Redis 进行拓展时就会重新分配哈希槽,分配哈希槽时就会把该槽的数据迁移到新的实例上去。那如果客户端请求的数据在正在迁移中的 Slot 时 Redis 该怎么做呢?
Redis 实例接收到客户端请求,检查发现存储数据的 Slot 正在迁移,会发送一个 ASK 回复信息,该信息包含了承载数据的哈希槽值和承载该哈希槽的新实例地址;客户端得到回复后,需要向新实例发送 ASKING 命令,然后再发送操作命令。
需要注意的是,MOVED 命令会更新客户端缓存的哈希槽分配信息,而 ASK 命令并不会更新客户端缓存的哈希槽分配信息。