如何设计一个分布式KV存储

前言

在开发工作中我们常常需要用到redis、memcache这类KV存储,它们将数据存在内存中,来保证数据操作的高性能。虽然不能保证数据可靠性,但在仅仅用作缓存层来使用的时候是没问题的。

当我们的存储的数据量越来越多、需要它能够具备持久化存储、写入的数据保证不丢失、冷热数据分离在内存和磁盘,读写压力变化时无感知扩容的能力等等时,上述KV存储引擎就不满足了。

那么假设我们要设计一个海量的分布式KV存储去满足这种场景的话,应该如何去设计呢,下面仅以我个人的见解来展开设计。

需求分析

功能需求

操作层面上,以redis为参考,应该支持如下操作

  1. set/get
  2. mSet/mGet
  3. incr
  4. scan

数据层面上要能支持如下

  1. CAS更新
  2. TTL过期

而且设计的目标是一个可持久化的KV存储,数据可靠性自然是必须要保证的。即返回客户端操作成功的请求,一定不会丢。

技术需求

  1. 支持多个业务线使用;
  2. 物理隔离:保证各个业务不互相影响;
  3. 高可用:服务不可用的时间要足够短;
  4. 无感知扩容:数据容量上涨到瓶颈时,能够无感知的进行资源扩容;
  5. 容灾机制:节点宕机、故障的时候,保证用户无感知的情况下,进行恢复;
  6. 数据可靠性:对写请求响应成功后,保证数据不会丢失;

整体设计

总体方案

整体以分布式的三板斧为主,以此为基地根据应用场景再做单独的设计

  1. 元数据存储
  2. 数据存储
  3. 接收客户端请求的proxy

元数据存储负责记录所有的的机器节点,并通过心跳监听所有机器的存活状态,以及用户数据对应的存储机器节点,还有一些其他元数据信息。技术选型可以选择zk或者etcd。

数据存储负责基础的数据读写操作,持久化存储,还需要支持数据复制、数据迁移的能力。这里使用的是rocksdb。

proxy负责接收客户端发来的请求,将对应的数据操作分发给底层的数据存储节点,最后再将最终结果返回给客户端。

架构总览

整体结构如图所示:

图中除了上面说的三个关键集群外,又额外部署了一个name center服务。

它的作用是通过和etcd交互,近实时维护业务id和对应可用的proxy的映射关系,功能类似于dns解析。 根据业务方唯一id,查询到对应的可用的proxy列表。

当然也可以抛弃name center这个组件,直接把proxy做成一个大的接入层,集群前放一个反向代理。然后所有业务都走一个proxy集群,再由proxy转发到每个集群对应的server,如下图所示:

前后相比之下,第一种方案单独拆分每个业务集群的proxy的好处是:一来可以提升隔离性,各个业务独享自己的proxy;二来客户端直接连接到具体的proxy节点,减少了反向代理的网络中的一跳,尽可能的减少中间环节带来的性能损失。

核心流程

一次完整的请求过程如下所示

  1. client携带业务key,请求name center获取对应的proxy节点列表
  2. 通过sdk中的一些负载均衡算法,选择一个proxy节点建立链接、发送请求
  3. proxy根据etcd的权限控制信息,判断该key是否有本集群的读写权限,若无则返回拒绝响应
  4. 权限验证通过后,根据请求的key以及读写类型,配合路由表信息,得出需要请求的server列表
  5. proxy根据负载均衡策略进行请求转发由,分包到某个server或多个server节点上
  6. server处理完成,返回给proxy,proxy合并处理结果,返回给客户端

概要设计

整体服务逻辑上为每个业务独立部署一套集群,如图中的业务A、业务B集群,保证业务之间的隔离性。

那么,下面来逐一列举各个节点需要完成的功能。

ETCD

etcd的作用有如下几个:

  1. 维护集群的元数据信息(业务id和对应集群节点的映射,数据分片对应的server、权限信息等)
  2. 各个节点注册和发现
  3. 通过心跳包来确定proxy、server的各个节点可用状态

name center

通过和etcd交互,近实时维护一个Map<业务ID,List<proxyIP>>的映射关系.

proxy

接收客户端发来的请求,是下一层server的代理层,需要实现的功能有如下几个

  1. 处理客户端的链接
  2. 路由分包逻辑
  3. 安全(鉴权
  4. 监控上报
  5. 路由配置监听
  6. 异常检测
  7. 负载均衡
  8. server链接管理

server

  1. 数据分片
  2. 数据迁移
  3. 主备选举
  4. cas 版本号
  5. 包装rocksdb作为存储引擎

manager

用来处理由管理后台发来的请求,例如:

  1. server A磁盘容量达到瓶颈,新增节点server X,从server A中迁移部分数据至新增节点server X上
  2. 读请求量增加,新增读从库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

过期策略

对于数据失效的实现方式有如下几种实现方式

  1. 主动过期
  2. 定时删除
  3. 惰性过期

主动过期

需要设计一个时间轮的数据结构,来存储每个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更新数据的流程如下:

  1. client GET时,传入需要获取cas号的参数
  2. server 生成cas的版本号,存储起来
  3. client执行业务逻辑,然后执行UPDATE,并传入第一步获取的cas的版本号
  4. server 校验cas版本号与内存中的版本号是否一致
  5. 若一致则进行修改,不一致则有其他请求方已修改过数据。

第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的数据仅用单节点进行存储,会存在如下问题:

  1. 节点故障,服务完全不可用
  2. 单节点承受所有读写请求,存在资源上的单机瓶颈
  3. 存在数据丢失的可能性

为了解决上述的问题,所以将每个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节点的数据不一定是最新的,可能存在一定的量的数据丢失。如果业务场景能接受的话,也可以考虑使用。

数据迁移

数据迁移可能存在两种场景

  1. 当前group里的master机器要下线,重新部署实例到另外一台机器上;
  2. 数据达到瓶颈,需要通过新增group,迁移数据进行扩容,保证数据分布均匀;

情况1中,在新机器中创建一个副本实例后,作为slave从master进行同步数据,同步的seq达到一致后,当前slave主动发起一次leader选举,整体流程比较清晰。

情况2中,则为需要水平扩容的场景。

假如server单节点的存储上限是1Tgroup 3的机器达到了瓶颈的时候(存储的数据量或该group的数据写入量),我们就需要增加一个group 4,把压力进行分担。

那么扩容的大致步骤如下:

  1. 部署新集群、发送迁移指令
  2. 全量数据迁移
  3. 增量数据迁移
  4. 路由信息切换
  5. 老集群删除无用数据

以下将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开始提供服务的时间。

若是按照下面的步骤来保证数据的可靠性

  1. group 4同步的seq触发达到一致的事件。
  2. group 3设置拒绝range slot的请求,
  3. gropp 3将存量bin log全部发送给group 4后,主动断开连接
  4. group 4收到断开连接请求后,变更etcd路由表信息
  5. group 4开始对外提供服务。

则在2 3 4这三个时间段内,对客户端来说服务是不可用的。但是在任意一个时间点都保证了只有一个可写入的master节点,也就保证了数据的准确性和可靠性。

若是按照下面额度步骤来保证服务的可用性

  1. group 4同步的seq触发达到一致的事件。
  2. group 4发起变更etcd路由表信息,直接开始对外提供服务,
  3. group 4不断同步同group 3发送过来的log
  4. 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

两个客户端端收到响应都是成功,但是实际其中一个并未成功写入数据,所以数据是不可靠的。

那么在发起路由表变更之前,我觉得有几个方案在保证数据可靠性的前提下,兼顾一些服务可用性

  1. group 3拒绝请求,返回给proxy一个固定的错误码,以及target master的IP,proxy重新请求新的IP
  2. group 3拒绝请求,返回给proxy一个固定的错误码,proxy去etcd拉取最新的路由表信息,再重新请求
  3. 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个实例,然后进行物理隔离,分别部署在多个交换机的多个机架上。避免由于个别交换机出现网络问题,整体服务不可用的情况。

数据监控

数据监控是看见系统状态的眼睛,在一个完备的系统里必不可少,个人感觉应该有如下几项:。

机器维度的监控:

  1. CPU负载
  2. 内存
  3. 磁盘使用量
  4. 网卡流量

需要关注的业务数据指标:

  1. 请求量
  2. 成功量
  3. 响应时间
  4. 异常量
  5. 超时量

在proxy层中收集以上数据项,按照server IP、group、业务集群等维度进行汇总展示。

演进规划

自动扩缩容

能够通过实时监控机器的负载情况、磁盘容量等指标,进行自动化的扩容/缩容,保证系统不会产生过载,并能够将数据均匀的分布在各个group中。

支持更多的数据类型

更丰富的存储类型,例如redis中的list、set、hash等。

server层支持mvcc

proxy层支持SQL

这样就逐步演进成了类似于TiDB的存储了。

总结

以上就是假如由我设计一个海量数据的分布式KV,从整体到各个组件的设计思路了。

其中数据分片、多副本存储、数据复制、分布式协议都是常常在分布式结构中需要使用到的。

如果各位老哥有什么意见和建议欢迎评论区进行交流。

相关推荐
丁总学Java几秒前
如何使用 maxwell 同步到 redis?
数据库·redis·缓存
爱吃南瓜的北瓜8 分钟前
Redis的Key的过期策略是怎样实现的?
数据库·redis·bootstrap
一心只为学22 分钟前
Oracle密码过期问题,设置永不过期
数据库·oracle
伯牙碎琴27 分钟前
十一、SOA(SOA的具体设计模式)
架构
小光学长31 分钟前
基于vue框架的宠物销售管理系统3m9h3(程序+源码+数据库+调试部署+开发环境)系统界面在最后面。
数据库
wn53138 分钟前
【Go - 类型断言】
服务器·开发语言·后端·golang
小菜yh1 小时前
关于Redis
java·数据库·spring boot·redis·spring·缓存
希冀1231 小时前
【操作系统】1.2操作系统的发展与分类
后端
Microsoft Word1 小时前
数据库系统原理(第一章 数据库概述)
数据库·oracle
华为云开源1 小时前
openGemini 社区人才培养计划:助力成长,培养新一代云原生数据库人才
数据库·云原生·开源