前言
在开发工作中我们常常需要用到redis、memcache这类KV存储,它们将数据存在内存中,来保证数据操作的高性能。虽然不能保证数据可靠性,但在仅仅用作缓存层来使用的时候是没问题的。
当我们的存储的数据量越来越多、需要它能够具备持久化存储、写入的数据保证不丢失、冷热数据分离在内存和磁盘,读写压力变化时无感知扩容的能力等等时,上述KV存储引擎就不满足了。
那么假设我们要设计一个海量的分布式KV存储去满足这种场景的话,应该如何去设计呢,下面仅以我个人的见解来展开设计。
需求分析
功能需求
操作层面上,以redis为参考,应该支持如下操作
- set/get
- mSet/mGet
- incr
- scan
数据层面上要能支持如下
- CAS更新
- TTL过期
而且设计的目标是一个可持久化的KV存储,数据可靠性自然是必须要保证的。即返回客户端操作成功的请求,一定不会丢。
技术需求
- 支持多个业务线使用;
- 物理隔离:保证各个业务不互相影响;
- 高可用:服务不可用的时间要足够短;
- 无感知扩容:数据容量上涨到瓶颈时,能够无感知的进行资源扩容;
- 容灾机制:节点宕机、故障的时候,保证用户无感知的情况下,进行恢复;
- 数据可靠性:对写请求响应成功后,保证数据不会丢失;
整体设计
总体方案
整体以分布式的三板斧为主,以此为基地根据应用场景再做单独的设计
- 元数据存储
- 数据存储
- 接收客户端请求的proxy
元数据存储负责记录所有的的机器节点,并通过心跳监听所有机器的存活状态,以及用户数据对应的存储机器节点,还有一些其他元数据信息。技术选型可以选择zk或者etcd。
数据存储负责基础的数据读写操作,持久化存储,还需要支持数据复制、数据迁移的能力。这里使用的是rocksdb。
proxy负责接收客户端发来的请求,将对应的数据操作分发给底层的数据存储节点,最后再将最终结果返回给客户端。
架构总览
整体结构如图所示:
图中除了上面说的三个关键集群外,又额外部署了一个name center服务。
它的作用是通过和etcd交互,近实时维护业务id和对应可用的proxy的映射关系,功能类似于dns解析。 根据业务方唯一id,查询到对应的可用的proxy列表。
当然也可以抛弃name center这个组件,直接把proxy做成一个大的接入层,集群前放一个反向代理。然后所有业务都走一个proxy集群,再由proxy转发到每个集群对应的server,如下图所示:
前后相比之下,第一种方案单独拆分每个业务集群的proxy的好处是:一来可以提升隔离性,各个业务独享自己的proxy;二来客户端直接连接到具体的proxy节点,减少了反向代理的网络中的一跳,尽可能的减少中间环节带来的性能损失。
核心流程
一次完整的请求过程如下所示
- client携带业务key,请求name center获取对应的proxy节点列表
- 通过sdk中的一些负载均衡算法,选择一个proxy节点建立链接、发送请求
- proxy根据etcd的权限控制信息,判断该key是否有本集群的读写权限,若无则返回拒绝响应
- 权限验证通过后,根据请求的key以及读写类型,配合路由表信息,得出需要请求的server列表
- proxy根据负载均衡策略进行请求转发由,分包到某个server或多个server节点上
- server处理完成,返回给proxy,proxy合并处理结果,返回给客户端
概要设计
整体服务逻辑上为每个业务独立部署一套集群,如图中的业务A、业务B集群,保证业务之间的隔离性。
那么,下面来逐一列举各个节点需要完成的功能。
ETCD
etcd的作用有如下几个:
- 维护集群的元数据信息(业务id和对应集群节点的映射,数据分片对应的server、权限信息等)
- 各个节点注册和发现
- 通过心跳包来确定proxy、server的各个节点可用状态
name center
通过和etcd交互,近实时维护一个Map<业务ID,List<proxyIP>>的映射关系.
proxy
接收客户端发来的请求,是下一层server的代理层,需要实现的功能有如下几个
- 处理客户端的链接
- 路由分包逻辑
- 安全(鉴权
- 监控上报
- 路由配置监听
- 异常检测
- 负载均衡
- server链接管理
server
- 数据分片
- 数据迁移
- 主备选举
- cas 版本号
- 包装rocksdb作为存储引擎
manager
用来处理由管理后台发来的请求,例如:
- server A磁盘容量达到瓶颈,新增节点server X,从server A中迁移部分数据至新增节点server X上
- 读请求量增加,新增读从库server Y Z,从server 1中同步全量数据至这两台节点
功能实现
请求协议
希望实现成本小一些,可以用语义比较清晰明了的HTTP协议来进行交互。
若是开发资源足够,希望传输性能更高,占用更少的带宽的话,可以自定义一个二进制协议。
命令支持
set/get/mSet/mGet这些命令,底层rocksdb就就是kv存储,无需额外的去实现。
如果想要支持额外的数据结构场景,在server层单独去实现。
key规则
rocksDB的key是一个byte数组,并且它本身支持根据前缀搜索的功能,所以我们可以这么设计实际存储的key
key = slotId + dbId + key
slotId是用于数据分片的一个ID,在后面数据分片的章节会详细说明。
dbId是从逻辑上拆分出来多个DB。例如某业务的A功能模块存储在DB 1里,B功能模块的数据存储在DB 2里,这样在需要遍历某个功能模块的数据时候,就可以通过前缀slotId + dbId直接scan遍历出来。
TTL
过期策略
对于数据失效的实现方式有如下几种实现方式
- 主动过期
- 定时删除
- 惰性过期
主动过期
需要设计一个时间轮的数据结构,来存储每个key的过期时间,再由单独的线程去执行数据过期,数据量大的时候资源成本过高。
定时删除
可以设计类似于redis的主动过期机制,随机访问N个key,若过期的key数量占比达到X%,则再拉一批进行过期时间过滤。对于可持久化存储来说,数据量大的时候扫描依然会占用很多资源。
惰性过期
当请求访问数据时,判断是否超过有效时间,若超过则执行数据删除,并发返回空。
数据合并
由于rocksDB底层使用的是LSM树,每层的ssm向下合并的时候,通过实现钩子,来在向下合并的时候过滤已过期的数据。
基于成本考虑,最终可以选择最后惰性过期+文件合并钩子两种方式来实现数据过期。
数据格式
如果把ttl算作value的一个属性的话,每次判断数据是否过期都需要反序列化整个value再取出其中的ttl标识和过期时间戳,浪费了cpu资源。
所以,可以将ttl标识和过期时间戳独立于value单独存储,固定的字节去存储这两个字段,例如下图
这样就能够快速的判断数据是不是需要过期,是不是已经过期
时间戳比较
ttl过期时反复调用系统调用获取时间戳,也是一种性能损耗。可以改为维护内存中一个全局变量,建立一个定时器每秒去更新这个变量。
CAS
为了避免多个请求操作同一个key的时候,产生脏写,例如下图
时间点 | 请求A | 请求B |
---|---|---|
1 | get key | |
2 | if value==1 | get key |
3 | if value==1 | |
4 | incr key | |
5 | incr key |
所以需要支持一个cas的功能来避免这种情况
cas更新数据的流程如下:
- client GET时,传入需要获取cas号的参数
- server 生成cas的版本号,存储起来
- client执行业务逻辑,然后执行UPDATE,并传入第一步获取的cas的版本号
- server 校验cas版本号与内存中的版本号是否一致
- 若一致则进行修改,不一致则有其他请求方已修改过数据。
第2步的存储版本号可以使用Map<key,version>存储,由于多线程并发写入版本号时需要对map进行加锁。为了避免单个map的锁竞争激烈,可以使用多个Map,以数组形式存储Map,即ArrayList<Map<key,version>>这种形式。
加锁时通过对key进行哈希取模,来分配到数组中不同的map上,分散锁竞争的情况。
系统设计
系统设计会从数据可靠性、高性能、高可用、成本几个方面来进行考量和设计。
数据分片
单机存储容量是有上限的,比如在MySQL中数据量达到一定量级后,就需要进行分库分表操作。这个思路在分布式存储中也是一样,需要一个数据分片的策略,通过拆分数据来解决海量数据的存储问题。
分片的思路可以借鉴redis的slot层的概念。
通过使用一致性哈希算法,在实际存储数据的server之上,抽象出来一层slot层的概念。在slot层之下再抽象出来一个group的概念,每个group负责一个范围的slotId存储。
举例说明,假设是1024个虚拟slot ID,当前业务是三个group进行数据存储。
那么每个group的存储范围可能是这样:
- group 1的存储范围为[1,300)
- group 2的存储范围为[300,800)
- group 3的存储范围为[800,1024]
当一个写请求过来时,通过进行slotId = hash(key) % 1024
运算,得到它的slotId后,查询slotId和对应group的master机器ip地址,就可以将该写请求正确分发的到存储节点机器上。
这样可以更好的支持group进行水平扩展,最多可以扩展到1024个group。
1024是一个示例值,也可以设大一些,这样未来能够拆分的group就更多。
多副本存储
如果每个group的数据仅用单节点进行存储,会存在如下问题:
- 节点故障,服务完全不可用
- 单节点承受所有读写请求,存在资源上的单机瓶颈
- 存在数据丢失的可能性
为了解决上述的问题,所以将每个group要搭建成一主多从的集群结构。
那么副本间就需要实现一种的数据复制机制。
可以选择比较成熟的raft共识算法,将操作kv的记录写到raft算法的日志中,再借助它的复制功能进行主备数据间的数据复制工作。
选主则需要借助etcd的注册能力,通过watch机制,同一个group下的节点相互发现、然后进行选主。
raft算法通过leader单点接收写请求来保证数据的一致性,但是这样也存在leader单点写的资源瓶颈。不过上一小节我们对数据进行了分片,通过使用raft group
方式增加存储分组、来分散写请求,从而解决单机写性能的瓶颈。
虽然raft将两阶段提交优化成为了一阶段提交,不过当写数据的时候,依然存在向所有副本同步操作日志的成本,为了保证数据可靠性而损失了一定的性能。若是对数据要求没有那么高,也可以使用类似MySQL的bin log的方式,master写入成功即算成功,然后再异步同步给所有备库,这样能够减少了向副本同步的时间。
读写分离
在上面我们将group的数据进行了多副本的存储之后,就可以将写请求分给leader节点,读请求所有节点共同处理,从而实现了读写分离的功能。
如果客户端希望读到最新的数据,就在GET时候通过传入CAS参数,来强制走master节点。如果能够接受有一定的延迟,则正常GET数据由proxy进行请求分发即可。
当某个group内的读写压力上升后,也可以通过扩容的方式,来更好的应对,比如说:
写压力上升时
当某个group
达到了TPS瓶颈或者达到了存储阈值,通过新增一个group集群然后迁移部分slotId数据出去,来降低压力。
读压力上升时
当某个group
的QPS到达了瓶颈,可以增加当前group的从库,让读请求分散到更多的从节点上,保护每个节点的负荷不会过载,依然能够快速响应客户端的请求。
proxy的负载均衡
上一小节里,将请求进行了读写分离,而proxy的一个作用就是分发请求,那么自然就需要设定一个负载均衡策略了。
流程如下:
首先,proxy通过对key进行hash取模后,确定了key对应的slotID,再根据路由表信息确定对应group集群的节点有哪些,以及哪个是master节点。
如果是一个写请求,那直接转发给master节点。
如果是一个读请求,那就可以设定一个负载均衡的策略,是直接轮询还是按权重分配。通过负载均衡策略来确定进行通讯的节点IP。
批量请求mGet/mSet也是同样的逻辑,只是可能涉及对多个group分发请求,以及最后的合并响应逻辑。
然后,就需要进行转发请求包。
proxy和每个server(所有group里的server)之间在开始时,预先建立N个长连接的链接池,以此来避免频繁的握手挥手。
但是由于TCP协议是一个有序收发的协议,存在队头阻塞问题。 所以,确定好IP以后,还需要再确定通过N个长连接中的哪个链接进行发包,这里同样可以轮询策略,也可以根据每个链接待响应的请求数量进行一个计算,得出使用的链接。
最后,使用选定的链接进行发包收包工作。
server选举leader
在多副本存储章节中提到为了保证数据的一致性,要选出一个leader节点,也就是master节点,负责数据的写入。
那么当leader节点发生网络分区或者宕机的时候,为了保证当前group的数据能够继续对客户端提供服务,就需要从剩余的节点中选举出一个leader。
我们数据复制选择的是raft共识算法,实现raft的库自身就有根据日志编号和任期来进行选举的能力,所以也无需做太多额外的逻辑。
这里感觉无法避免的就是,在group中剩余节点进行投票选举期间,当前group是无法处理写请求的,也就是说服务存在一定的不可用
时间。
这里如果不是选用的raft共识算法,那么还有一种选举方式,就是直接向etcd的当前group编号的leader的目录写(类似于redis中的set not exist),各个副本节点谁先写成功谁就是leader节点,这样的问题是选举出来的leader节点的数据不一定是最新的,可能存在一定的量的数据丢失。如果业务场景能接受的话,也可以考虑使用。
数据迁移
数据迁移可能存在两种场景
- 当前group里的master机器要下线,重新部署实例到另外一台机器上;
- 数据达到瓶颈,需要通过新增group,迁移数据进行扩容,保证数据分布均匀;
情况1中,在新机器中创建一个副本实例后,作为slave从master进行同步数据,同步的seq达到一致后,当前slave主动发起一次leader选举,整体流程比较清晰。
情况2中,则为需要水平扩容的场景。
假如server单节点的存储上限是1T
,group 3
的机器达到了瓶颈的时候(存储的数据量或该group的数据写入量),我们就需要增加一个group 4
,把压力进行分担。
那么扩容的大致步骤如下:
- 部署新集群、发送迁移指令
- 全量数据迁移
- 增量数据迁移
- 路由信息切换
- 老集群删除无用数据
以下将group 3的master称为source master, 将group 4的master称为target master
1.部署新集群、发送迁移指令
扩容的最开始,需要启动新的group 4
的一主二从的实例。
由管理后台发送指令,告知group 4
从group 3
迁移哪些slot过来,假设迁移范围是[950-1024]。 source master和target master建立一个链接,并发送数据迁移请求。
接着开始进行全量
的数据复制。
2.全量数据迁移
由于我们的key设计是slotId为第一个字节,所以在source master
收到了需要迁移slotId的请求后,就可以通过rocksDB的Iterator,根据key前缀使用seek、next方法遍历将每个slotId的数据都扫描出来发送给target master
节点,该节点收到数据后,将数据写入到本机的rocksDB存储中。
3.迁移增量数据
在开始全量数据复制的同时,开始记录slotId在[950-1024]范围的写请求操作到临时的bin log中,这个日志为数据的增量备份
。当全量数据复制完成后,进行增量备份
的传输,log中对每个数据操作日志都有一个自增的seq ID,当target master
节点seq号追赶上了source master
节点时代表着完成数据同步。
4.路由信息切换
达到数据同步的事件节点后,通知etcd进行slotId和group的路由关系切换
,proxy watch到etcd路由信息变更后,将新的路由表应用到本地,新的请求就会由发送给group 3
集群变更为发送给group 4
集群。
5.删除数据
最后,group 4
异步清理迁移完成的slotId在[950-1024]数据范围的数据。
至此完成数据的迁移工作。
这里需要考量的点,就是最后一步的路由信息切换。
在group 4通知etcd进行映射变更成功后,需要由etcd来通知proxy变更请求转发规则,这个过程对于proxy集群的节点会有一个短暂的灰度时间差。
即,部分老proxy依然将请求转发给group 3,部分新proxy请求已经将请求转发给group 4。
这时要从可用性 和可靠性两个方面,来考虑复制链接的断开时机,以及group 3停止服务的时间、group 4开始提供服务的时间。
若是按照下面的步骤来保证数据的可靠性:
- group 4同步的seq触发达到一致的事件。
- group 3设置拒绝range slot的请求,
- gropp 3将存量bin log全部发送给group 4后,主动断开连接
- group 4收到断开连接请求后,变更etcd路由表信息
- group 4开始对外提供服务。
则在2 3 4这三个时间段内,对客户端来说服务是不可用的。但是在任意一个时间点都保证了只有一个可写入的master节点,也就保证了数据的准确性和可靠性。
若是按照下面额度步骤来保证服务的可用性:
- group 4同步的seq触发达到一致的事件。
- group 4发起变更etcd路由表信息,直接开始对外提供服务,
- group 4不断同步同group 3发送过来的log
- gropp 3一定时间窗口没有再收到range slot的请求后,主动断开连接
在第2步执行完成到第4步这个时间窗口内,range slot是group 3和group 4两个节点双写的。虽然内网通讯这个时间很短,但是时间窗口内假如两波请求操作的是同一批key时,就会出现数据不一致的情况。
例如:
时间点 | group 3 | group 4 |
---|---|---|
1 | SET key1 5 | |
2 | SET key1 3 | return success |
3 | return success | sync SET key1 3 |
4 | sync success |
两个客户端端收到响应都是成功,但是实际其中一个并未成功写入数据,所以数据是不可靠的。
那么在发起路由表变更之前,我觉得有几个方案在保证数据可靠性的前提下,兼顾一些服务可用性
- group 3拒绝请求,返回给proxy一个固定的错误码,以及target master的IP,proxy重新请求新的IP
- group 3拒绝请求,返回给proxy一个固定的错误码,proxy去etcd拉取最新的路由表信息,再重新请求
- source master直接将请求转发给target master,当做一个中间人的角色进行转发。
方案1和2的成本是需要在proxy层实现错误码对应的处理逻辑,方案3的成本是需要在server层具有转发的能力,但是个人感觉违背了server本身的能力职责定义,所以感觉选择方案1或2都可以。
这样先完成上述拦截后,再变更路由表信息,灰度的过程中服务也一直是可用的。
容灾
每一层级的容灾都要考虑当前层发生故障时、以及依赖的下一层发生故障时,应该怎么处理。
下面由上至下,逐层确定每层应该做的容灾工作
客户端 SDK
需要本地维护一个业务对应的两个proxy列表:
列表A:正常可用的proxy,定时从name center中拉取更新该列表,避免每次请求时都去拉取的网络开销。
列表B:问题proxy列表,name center返回的可用节点,但是客户端连接失败的proxy节点。
SDK要支持熔断功能
,当多次连接某个proxy失败时,从列表A中剔除
,添加
到列表B中。
一定的时间窗口后,若与name center交互过后仍未剔除该proxy节点,则先放入部分流量请求该proxy,随着请求量的不断增加,若并未再出现过错误,则将该节点恢复至可用proxy列表A中。若依然错误则重新计算下一个重试窗口时间。
这样当name center不可用时,可直接用本地维护的proxy列表信息, 当某一个proxy不可用时,自动更换使用的proxy节点。
name center
自身容灾:以集群域名模式部署,自身无状态,无限水平扩展。每个节点实时与etcd交互,个别节点宕机从反向代理pool中摘除,不提供服务即可。
对etcd的容灾:与SDK一样本地维护一个列表,etcd失效时使用本地的列表。
proxy
自身容灾:无状态,数据规则来源于etcd,支持横向扩展
对server的容灾:需要和客户端SDK实现类似的熔断功能,通过监控请求/响应的时间差,实时监测server状态,出现请求超时的数量达到一定阈值后,在负载均衡池中摘除该server节点。
server
主从结构,master宕机后,slave节点可以通过raft算法自主投票选举出master再进行提供服务。
**etcd **
etcd底层实现了raft算法,来保证数据强一致和自动选举,通常来说部署3个节点即可。但是我们可以横向添加多个节点来提升容错性,但是也不宜太多,因为协商成本也会随着节点的增加而增加。
可以选择部署5个实例,然后进行物理隔离,分别部署在多个交换机的多个机架上。避免由于个别交换机出现网络问题,整体服务不可用的情况。
数据监控
数据监控是看见系统状态的眼睛,在一个完备的系统里必不可少,个人感觉应该有如下几项:。
机器维度的监控:
- CPU负载
- 内存
- 磁盘使用量
- 网卡流量
需要关注的业务数据指标:
- 请求量
- 成功量
- 响应时间
- 异常量
- 超时量
在proxy层中收集以上数据项,按照server IP、group、业务集群等维度进行汇总展示。
演进规划
自动扩缩容
能够通过实时监控机器的负载情况、磁盘容量等指标,进行自动化的扩容/缩容,保证系统不会产生过载,并能够将数据均匀的分布在各个group中。
支持更多的数据类型
更丰富的存储类型,例如redis中的list、set、hash等。
server层支持mvcc
proxy层支持SQL
这样就逐步演进成了类似于TiDB的存储了。
总结
以上就是假如由我设计一个海量数据的分布式KV,从整体到各个组件的设计思路了。
其中数据分片、多副本存储、数据复制、分布式协议都是常常在分布式结构中需要使用到的。
如果各位老哥有什么意见和建议欢迎评论区进行交流。