这里引用Cockroach(Multi-Raft的先驱,出来的比TiDB早,哈哈)对Multi-Raft的定义:node
In CockroachDB, we use the Raft consensus algorithm to ensure that your data remains consistent even when machines fail. In most systems that use Raft, such as etcd and Consul, the entire system is one Raft consensus group. In CockroachDB, however, the data is divided into ranges, each with its own consensus group. This means that each node may be participating in hundreds of thousands of consensus groups. This presents some unique challenges, which we have addressed by introducing a layer on top of Raft that we call MultiRaft.git
简单来讲,Multi-Raft是在整个系统中,把所管理的数据按照必定的方式切片,每个切片的数据都有本身的副本,这些副本之间的数据使用Raft来保证数据的一致性,在全局来看整个系统中同时存在多个Raft-Group,就像这个样子:github
延伸阅读:架构
单个Raft-Group在KV的场景下存在一些弊端:分布式
系统的存储容量受制于单机的存储容量(使用分布式存储除外)ide
系统的性能受制于单机的性能(读写请求都由Leader节点处理)性能
Multi-Raft须要解决的一些核心问题:优化
数据何如分片
分片中的数据愈来愈大,须要分裂产生更多的分片,组成更多Raft-Group
分片的调度,让负载在系统中更平均(分片副本的迁移,补全,Leader切换等等)
一个节点上,全部的Raft-Group复用连接(不然Raft副本之间两两建链,连接爆炸了)
如何处理stale的请求(例如Proposal和Apply的时候,当前的副本不是Leader、分裂了、被销毁了等等)
Snapshot如何管理(限制Snapshot,避免带宽、CPU、IO资源被过分占用)
要实现一个Multi-Raft仍是很复杂和颇有挑战的一件事情。
2017年初,咱们刚开始作Elasticell的时候,开源的Multi-Raft实现不多,当时咱们知道开源的实现有Cockroach和TiDB(两者都是受Google的Spanner和F1的启发)。Cockroach是Go语言实现,TiDB是Rust实现,Raft基础库都是使用Etcd的实现(TiDB是把Etcd的Raft移植到了Rust上)。二者在架构上一个很重要的不一样是TiDB是一个分离式的设计,整个架构上有PD、TiDB、TiKV三个。咱们当时以为元信息使用PD独立出来管理,架构更清晰,工程实现也相对简单,因此咱们决定参照TiDB来实现Multi-Raft。
Elasticell参考的是2017年3月份左右的TiDB的版本,大致思路基本一致,实现方式上有一些不同的地方,更多的是语言的差别。TiDB的实现是Rust的实现,Elasticell是pure Go的实现。
在咱们决定用Go开发Elasticell的时候,就有些担忧CGO和GC的开销问题,当时还咨询了PingCAP的黄东旭,最后认为在KV场景下,这个开销应该能够接受。后来开发完成后,咱们作了一些常见的优化(合并一些CGO调用,使用对象池,内存池等),发现系统的瓶颈基本在IO上,目前CGO和GC的开销是能够接受的。
Elasticell支持两种分片方式适用于不一样的场景
按照用户的Key作字典序,系统一开始只有1个分片,分片个数随着系统的数据量逐渐增大而不断分裂(这个实现和TiKV一致)
按照Key的hash值,映射到一个uint64的范围,能够初始化的时候就能够设置N个分片,让系统一开始就能够支持较高的并发,后续随着数据量的增大继续分裂
这部分的思路就和TiKV彻底一致了。PD负责调度指令的下发,PD经过心跳收集调度须要的数据,这些数据包括:节点上的分片的个数,分片中leader的个数,节点的存储空间,剩余存储空间等等。一些最基本的调度:
PD发现分片的副本数目缺乏了,寻找一个合适的节点,把副本补全
PD发现系统中节点之间的分片数相差较多,就会转移一些分片的副本,保持系统中全部节点的分片数目大体相同(存储均衡)
PD发现系统中节点之间分片的Leader数目不太一致,就会转移一些副本的Leader,保持系统中全部节点的分片副本的Leader数目大体相同(读写请求均衡)
延伸阅读:
假设这个分片1有三个副本分别运行在N1,N2,N3三台机器上,其中N1机器上的副本是Leader,分片的大小限制是1GB。
当分片1管理的数据量超过1GB的时候,分片1就会分裂成2个分片,分裂后,分片1修改数据范围,更新Epoch,继续服务。
分片2形也有三个副本,分别也在N1,N2,N3上,这些是元信息,可是只有在N1上存在真正被建立的副本实例,N2,N3并不知道这个信息。这个时候N1上的副本会当即进行Campaign Leader的操做,这个时候,N2和N3会收到来自分片2的Vote的Raft消息,N2,N3发现分片2在本身的节点上并无副本,那么就会检查这个消息的合法性和正确性,经过后,当即建立分片2的副本,刚建立的副本没有任何数据,建立完成后会响应这个Vote消息,也必定会选择N1的副本为Leader,选举完成后,N1的分片2的Leader会给N2,N3的副本直接发送Snapshot,最终这个新的Raft-Group造成而且对外服务。
按照Raft的协议,分片2在N1副本称为Leader后不该该直接给N2,N3发送snapshot,可是这里咱们沿用了TiKV的设计,Raft初始化的Log Index是5,那么按照Raft协议,N1上的副本须要给N2,N3发送AppendEntries,这个时候N1上的副本发现Log Index小于5的Raft Log不存在,因此就会转为直接发送Snapshot。
因为分片的副本会被调度(转移,销毁),分片自身也会分裂(分裂后分片所管理的数据范围发生了变化),因此在Raft的Proposal和Apply的时候,咱们须要检查Stale请求,如何作呢?其实仍是蛮简单的,TiKV使用Epoch的概念,咱们沿用了下来。一个分片的副本有2个Epoch,一个在分片的副本成员发生变化的时候递增,一个在分片数据范围发生变化的时候递增,在请求到来的时候记录当前的Epoch,在Proposal和Apply的阶段检查Epoch,让客户端重试Stale的请求。
咱们的底层存储引擎使用的是RocksDB,这是一个LSM的实现,支持对一个范围的数据进行Snapshot和Apply Snapshot,咱们基于这个特性来作。Raft中有一个RPC用于发送Snapshot数据,可是若是把全部的数据放在这个RPC里面,那么会有不少问题:
一个RPC的数据量太大(取决于一个分片管理的数据,可能上GB,内存吃不消)
若是失败,总体重试代价太大
难以流控
咱们修改成这样:
Raft的snapshot RPC中的数据存放,snapshot文件的元信息(包括分片的ID,当前Raft的Term,Index,Epoch等信息)
发送Raft snapshot的RPC后,异步发送具体数据文件
数据文件分Chunk发送,重试的代价小
发送 Chunk的连接和Raft RPC的连接不复用
限制并行发送的Chunk个数,避免snapshot文件发送影响正常的Raft RPC
接收Raft snapshot的分片副本阻塞,直到接收完毕完整的snapshot数据文件
咱们在Raft上作的一些优化
如何支持全文索引
敬请期待
https://github.com/deepfabric/elasticell
PingCAP团队(健壮的Multi-Raft实现)
@Ed Huang (私下咨询了不少问题)