Design
整体架构
集群架构
整个集群由placedriver + 数据节点datanode + etcd + rsync组成. 各个节点的角色如下:
PD node: 负责数据分布和数据均衡, 协调集群里面所有的zankv node节点, 将元数据写入etcd
datanode: 负责存储具体的数据
etcd: 负责存储元数据, 数据分布情况以及其他用于协调的元数据
rsync: 用于传输snapshot备份文件
数据节点架构
数据节点datanode有多个不同的分区组成, 每个分区是一个raftgroup. 每个分区由协议层redis, 日志同步层raftlog, 数据映射层和数据存储层构成.
redis协议层: 负责读取和解析客户端的redis命令, 并提交到raft层同步
raft日志同步层: 负责raft副本同步, 保证数据一致性
数据映射层: 负责将redis数据结构编码映射成db层的kv形式, 保证符合redis的数据结构语意
数据存储层: 负责封装rocksdb的kv操作.
数据节点会往etcd注册节点信息, 并且定时更新, 使得placedriver可以通过etcd感知自己.
placedriver节点
placedriver负责指定的数据分区的节点分布, 还会在某个数据节点异常时, 自动重新分配数据分布. 总体上负责调度整个集群的数据节点的元数据. placedriver本身并不存储任何数据, 关于数据分布的元数据都是存储在etcd集群, 可以有多个placedriver作为读负载均衡, 多个placedriver通过etcd选举来产生一个master进行数据节点的分配和迁移任务. placedriver节点会watch集群的节点变化来感知整个集群的数据节点变化.
placedriver提供了数据分布查询接口, 供客户端sdk查询对应的数据分区所在节点.
数据分区的分布
目前数据分区算法是通过hash分片实现的, 分区算法是namespace级别的, 因此不同namespace可以使用不同的分区算法. 某条数据请求读写hash分区的过程如下:
- 创建namespace时指定分区总数, 比如 16
- 客户端对redis命令的主key做hash得到一个整数值, 然后对分区总数取模, 得到一个分区id
- 根据分区id, 查找分区id和数据节点映射表, 得到数据节点
- 客户端将命令发送个指定的数据节点服务端
- 数据节点收到命令后, 根据分区算法做验证, 在数据节点内部发送给本地节点拥有指定分区id的数据分区, 如果本地没有对应的分区id, 则返回错误.
- 数据节点内部本地数据分区处理具体的redis命令
其中, 分区到数据节点的映射表, 会根据算法生成后写入etcd, 生成算法需要保证如下几点
- 每个分区的不同副本必须分布在不同节点
- 每个节点拥有尽可能平均数量的leader副本, 以及尽可能的保证分区follower副本的平均
- 尽可能的减少节点异常后的副本数据迁移
- 机房感知模式部署时, 还需要将分区的副本分散到不同机房
- 其他特殊部署需求, 通过对节点打tag的方式, 过滤掉一部分节点, 限制部署到特定机器上
为了满足以上目标, 在计算映射表时通过以下方式:
- 将存活的正常节点过滤掉不符合tag的条件后, 按照机房分类放到不同的列表中, 如 a:[1,3,2], b:[2,3,1]
- 相同机房内的节点按照名称排序, a:[1,2,3], b:[1,2,3]
- 不同机房的节点按照顺序交叉放入候选节点数组中 [a1, b1, a2, b2, a3, b3]
- 按照分区从小到大分配leader和follower节点,分配的起始节点位置依次顺延. 分区0->[a1(leader), b1(follower), a2(follower)], 分区1->[b1, a2, b2], 分区2->[a2, b2, a3], 分区3->[b2, a3, b3], 分区4->[a3, b3, a1], 分区5->[b3, a1, b1]
- 假如b1节点异常, 新的候选节点变成 [a1, b2, a2, b3, a3], 此时映射表会更新成, 分区0->[a1(leader), b2(follower), a2(follower)], 分区1->[b2, a2, b3], 分区2->[a2, b3, a3], 分区3->[b3, a3, a1], 分区4->[a3, a1, b2], 分区5->[a1, b2, a2].
数据平衡
在数据节点发生变化时, 需要动态的修改分区到数据节点的映射表, 动态调整映射表的过程就是数据平衡的过程. 数据节点变化时会触发etcd的watch事件, placedriver会实时监测数据节点变化, 来判断是否需要做数据平衡. 为了避免影响线上服务, 可以设置数据平衡的允许时间区间.
为了避免频繁发生数据迁移, 节点发生变化后, 会根据紧急情况, 判断数据平衡的必要性. 特别是在数据节点升级过程中, 可以避免不必要的数据迁移. 考虑以下几种情况:
- 新增节点: 平衡优先级最低, 仅在允许的时间区间并且没有异常节点时尝试迁移数据到新节点
- 少于半数节点异常: 等待一段时间后, 尝试将异常节点的副本数据迁移到其他节点.
- 超过半数节点异常: 可能发生网络分区, 此时不会进行自动迁移, 如果确认不是网络分区, 可以强制调整集群稳定节点数触发迁移.
- 可用于分配的节点数不足: 假如副本数配置是3, 但是可用节点少于3个, 则不会发生数据迁移
稳定集群节点数默认只会增加, 每次发现新的数据节点, 就自动增加, 节点异常不会自动减少. 如果稳定集群节点数需要减少, 则需要调用API设置:
POST /stable/nodenum?number=xx
维护稳定集群节点总数, 是为了避免集群网络分区时不必要的数据迁移. 当集群正常节点数小于等于稳定节点数一半时, 自动数据迁移将停止.
HA流程
服务端
- 正常下线时, 当前节点通过raft转移自己节点拥有的分区的leader节点, 自动摘除本机节点
- 新被选举出来的leader自动重新注册到etcd, 每个分区将当前最新的leader信息更新到etcd元数据
- placedriver监控leader节点变化, 一旦触发watch, 则自动从etcd刷新最新的leader返回给客户端
- 如果有客户端读写请求发送到非leader节点上, 服务端会返回特定的集群变更错误. 客户端刷新集群数据后重试
go-sdk处理
- sdk启动时会启动一个定时lookup线程, 线程会定时的从placedriver服务中查询最新的leader信息, 并缓存到本地
- 读写操作时, 会从当前缓存的节点中找到对应的分区leader连接
- 通过连接发起读写操作时, 如果服务端返回了特定的错误信息, 则判断是否集群发生变更, 如果发生集群变更, 则立即触发查询最新leader信息, 并等待自动重试, 一直等到重试成功或者超过指定的超时时间和次数.
- 定时placedriver服务查询线程会剔除已经摘除的节点连接
动态namespace
分布式索引
由于分布式系统中, 一个索引会有多个分片, 为了保证多个分片之间的数据一致性, 需要协调多个分片的索引创建流程. 基本流程如下:
跨机房集群
zankv目前支持两种跨机房部署模式, 分别适用于不同的场景
单个跨多机房集群模式
此模式, 部署一个大集群, 并且都是同城机房, 延迟较小, 一般是3机房模式. 部署此模式, 需要保证每个副本都在不同机房均匀分布, 从而可以容忍单机房宕机后, 不影响数据的读写服务, 并且保证数据的一致性.
部署时, 需要在配置文件中指定当前机房的信息, 用于数据分布时感知机房信息.增加如下配置项:
"tags": {"dc_info":"dc1"}
不同机房的数据节点, 使用不同的dc_info配置, placedriver进行副本配置时, 会保证每个分区的几个副本都均匀分布在不同的dc中.
跨机房的集群, 通过raft来完成各个机房副本的同步, 发生单机房故障时, 由于另外2个机房拥有超过一半的副本, 因此raft的读写操作可以不受影响, 且数据保证一致. 等待故障机房恢复后, raft自动完成故障期间的数据同步, 使得故障机房数据在恢复后能保持同步.此模式在故障发生和恢复时都无需任何人工介入, 保证单机房故障的可用性的同时, 数据一致性也得到保证.
多个机房内集群同步模式
如果是异地机房, 使用跨机房单集群部署方式, 可能会带来较高的同步延迟, 使得读写的延迟都大大增加. 为了优化延迟问题, 可以使用异地机房同步模式. 由于异地机房是后台异步同步的, 异地机房不影响本地机房的延迟, 但同时引入了数据同步滞后的问题, 在故障时可能会发生数据不一致的情况.
此模式的部署方式稍微复杂一些, 具体的部署和故障处理可以参考运维指南.基本原理是通过在异地机房增加一个raft learner节点, 通过raft异步的拉取log然后重放到异地机房集群. 由于每个分区都是一个独立的raft group, 因此分区内是串行回放, 各个分区间是并行回放raft log. 异地同步机房默认是只读的, 如果主机房发生故障需要切换时, 可能发生部分数据未同步, 需要在故障恢复后根据raft log进行人工修复.
数据过期功能设计
为了满足各种场景下的业务需求,ZanKV设计了多种不同的数据过期策略。在不同的数据过期策略下,ZanKV对数据过期有不同的支持和表现。 目前,ZanKV支持的数据过期策略有: - 一致性同步过期 - 非一致性本地删除
与redis不同的是,ZanKV只支持秒级的数据过期,不支持pexpire指令(毫秒级别的数据过期)。
一致性同步过期
当ZanKV集群的数据过期策略配置为:一致性同步过期时。所有的数据过期操作都由leader发起,通过raft协议进行删除操作。
在实际操作中,过期数据处理逻辑层每间隔1s会调用存储层接口,获取当前已经过期的数据key和类型。为减少集群node间网络交互的次数,逻辑层会对相同类型的key通过一条指令、一次raft交互进行批量删除。
使用该策略时,数据过期删除需要集群节点之间频繁的网络交互,在大量数据过期的情况下,会大大增加网络的负担和leader节点的压力。该策略适用于:过期数据量不大,业务方依赖于数据的TTL进行业务逻辑处理。
但是,在一般情况下,业务方并不会依赖于数据的TTL进行业务逻辑处理。相反的,业务方关注的是,数据需要保持一定的时间(半年、三个月、一周等),过了保存期,这些数据可以自动失效、删除,即在业务上数据已经没有用了,业务方不会再次访问或者关注这些数据,存储集群可以自行删除数据。在这种情况下,采用非一致性本地删除策略更加的合适。
非一致性本地删除
在非一致性本地删除的策略下,数据删除操作由各个集群节点自己进行,删除操作不通过raft协议进行。
具体的:数据删除操作由存储层自动进行,数据处理逻辑层不需要参与。ZanKV集群中的每个节点每间隔5min会扫描一次过期数据,并对扫描出的待删除数据进行本地直接删除。
相比于一致性同步过期策略,该策略具有以下特性:
更大量的数据过期支持。过期数据删除不需要集群节点之间的通信,可以避免在大量数据过期情况下造成的网络和性能处理瓶颈,进而支撑更大量的数据过期服务。 具有更高的数据吞吐量。该策略考虑到用户不关注于具体的TTL时间,优化了存储数据的编码格式,减少了数据存储时的写放大,提供更好的性能支持。 数据过期扫描间隔为5min。保证数据一定不会被提前删除,但是不保证数据删除的实时性,业务方不应该依赖于数据是否还存在进行业务逻辑处理。 用户无法通过TTL指令获取数据生存时间,也不支持Persist指令对过期数据进行持久化。这是为了提高吞吐量,提供更大量的数据过期支持所做的trade off。
前缀清理删除
非一致性删除虽然提供了更好的性能, 但是面对非常海量的过期数据时, 依然会产生大量的本地删除操作, 增加底层rocksdb的压力, 为了进一步减少删除过期的影响, 对于具有时效性特点的数据, ZanKV支持按照前缀清理. 这种模式下, 业务方的数据都是时效性的, 比如监控数据或者日志数据, 写入时业务会在key前面加上时间戳前缀. 使用删除API的时候, 指定业务前缀, 可以删除特定时间内的所有数据, 大大提高删除的效率. 前缀删除的API底层使用了rocksdb的DelRange方法, 此方法相比大量Delete操作, 对rocksdb的压力大大减少.