使用开源技术构建有赞分布式 KV 存储服务

背景

在有赞早期的时候,当时只有 MySQL 做存储,codis 做缓存,随着业务发展,某些业务数据用 MySQL 不太合适, 而 codis 由于当缓存用, 并不适合做存储系统, 因此, 急需一款高性能的 NoSQL 产品做补充。考虑到当时运维和开发人员都非常少, 我们需要一个能快速投入使用, 又不需要太多维护工作的开源产品。 当时对比了几个开源产品, 最终选择了 aerospike 作为我们的 KV 存储方案。 事实证明, aerospike 作为一个成熟的商业化的开源产品承载了一个非常好的过渡时期 在很少量的开发和运维工作支持下, 一直稳定运行没有什么故障, 期间满足了很多的业务需求, 也因此能抽出时间投入更多精力解决其他的中间件问题。

然而随着有赞的快速发展, 单纯的 aerospike 集群慢慢开始无法满足越来越多样的业务需求。 虽然性能和稳定性依然很优秀, 但是由于其索引必须加载到内存, 对于越来越多的海量数据, 存储成本会居高不下。 更多的业务需求也决定了我们将来需要更多的数据类型来支持业务的发展。 为了充分利用已有的 aerospike 集群, 并考虑到当时的开源产品并无法满足我们所有的业务需求, 因此我们需要构建一个能满足有赞未来多年的 KV 存储服务。

设计与架构

在设计这样一个能满足未来多年发展的底层 KV 服务, 我们需要考虑以下几个方面:

  • 需要尽量使用有大厂背书并且活跃的开源产品, 避免过多的工作量和太长的周期
  • 避免完全依赖和耦合一个开源产品, 使得无法适应未来某个开源产品的不可控变化, 以及无法享受将来的技术迭代更新和升级
  • 避免使用过于复杂的技术栈, 增加后期运维成本
  • 由于业务需要, 我们需要有能力做方便的扩展和定制
  • 未来的业务需求发展多样, 单一产品无法满足所有的需求, 可能需要整合多个开源产品来满足复杂多样的需求
  • 允许 KV 服务后端的技术变化的同时, 对业务接口应该尽量稳定, 后继升级不应该带来过多的迁移成本。

基于以上几点, 我们做了如下的架构设计:

image

为了整合和方便以后的扩展, 我们使用 proxy 屏蔽了具体的后端细节, 并且使用广泛使用的 redis 协议作为我们对上层业务的接口, 一方面充分利用了开源的 redis 客户端产品减少了开发工作量, 一方面减少了业务的接入学习成本, 一方面也能对已经使用的 aerospike 集群和 codis 集群做比较平滑的整合减少业务迁移工作量。 在此架构下, 我们未来也能通过在 proxy 层面做一些协议转换工作就能很方便的利用未来的技术成果, 通过对接更多优秀的开源产品来进一步扩展我们的 KV 服务能力。

有了此架构后, 我们就可以在不改动现有 aerospike 集群的基础上, 来完善我们目前的 KV 服务短板, 因此我们基于几个成熟的开源产品自研了 ZanKV 这个分布式 KV 存储。 自研 ZanKV 有如下特点:

  • 使用 Golang 语言开发, 利用其高效的开发效率, 也能减少后期维护难度, 方便后期定制。
  • 使用大厂且成熟活跃的开源组件 etcd raft,RocksDB 等构建, 减少开发工作量
  • CP 系统和现有 aerospike 的 AP 系统结合满足不同的需求
  • 提供更丰富的数据结构
  • 支持更大的容量, 和 aerospike 结合在不损失性能需求的前提下大大减少存储成本

自研 ZanKV 的整体架构图如下所示:

zankv-arch

整个集群由 placedriver + 数据节点 datanode + etcd + rsync 组成。 各个节点的角色如下:

  • PD node: 负责数据分布和数据均衡, 协调集群里面所有的 zankv node 节点, 将元数据写入 etcd
  • datanode: 负责存储具体的数据
  • etcd: 负责存储元数据, 元数据包括数据分布映射表以及其他用于协调的元数据
  • rsync: 用于传输 snapshot 备份文件

下面我们来一一讲述具体的内部实现细节。

实现内幕

DataNode 数据节点

首先, 我们需要一个单机的高性能高可靠的 KV 存储引擎作为基石来保障后面的所有工作的展开, 同时我们可能还需要考虑可扩展性, 以便未来引入更好的底层存储引擎。 在这一方面, 我们选择了 RocksDB 作为起点, 考虑到它的接口和易用性, 而且是 FB 经过多年的时间打造的一个已经比较稳定的开源产品, 它同时也是众多开源产品的共同选择, 基本上不会有什么问题, 也能及时响应开源社区的需求。

RocksDB 仅仅提供了简单的 Get,Set,Delete 几个有限的接口, 为了满足 redis 协议里面丰富的数据结构, 我们需要在 KV 基础上封装更加复杂的数据结构, 因此我们在 RocksDB 上层构建了一个数据映射层来满足我们的需求, 数据映射也是参考了几个优秀的开源产品 (pika, ledis, tikv 等)。

完成单机存储后, 为了保证数据的可靠性, 我们通过 raft 一致性协议来可靠的将数据复制到多台机器上, 确保多台机器副本数据的一致性。 选择 raft 也是因为 etcd 已经使用 Golang 实现了一个比较完整且成熟的 raft library 供大家使用。但是 etcd 本身并不能支持海量数据的存储, 因此为了能无限扩展存储能力, 我们在 etcd raft 基础上引入了 raft group 分区概念, 使得我们能够通过不断增加 raft 分区的方法来实现同时并行处理多个 raft 复制的能力。

最后, 我们通过 redis 协议来完成对外服务, 可以看到, 通过以上几个分层 ZanKV DataNode 节点就能提供丰富的数据存储服务能力了, 分层结构如下图所示:

zankv-datanode

Namespace 与分区

为了支持海量数据, 单一分区的 raft 集群是无法满足无限扩展的目标的, 因此我们需要支持数据分区来完成 scale out。 业界常用的分区算法可以分为两类: hash 分区和 range 分区, 两种分区算法各有自己的适用场景, range 分区优势是可以全局有序, 但是需要实现动态的 merge 和 split 算法, 实现复杂, 并且某些场景容易出现写热点。 hash 分区的优势是实现简单, 读写数据一般会比较均衡分散, 缺点是分区数一般在初始化时设定为固定值, 增减分区数需要迁移大量数据, 而且很难满足全局有序的查询。 综合考虑到开发成本和某些数据结构的顺序需求, 我们目前采取前缀 hash 分区算法, 这样可以保证前缀相同的数据全局有序满足一部分业务需求的同时, 减少了开发成本保证系统能尽快上线。

另外, 考虑到有赞今后的业务会越来越多, 未来需要能方便的隔离不同业务, 也方便不断的加入新的特性同时能平滑升级, 我们引入了 namespace 的概念。 namespace 可以动态的添加到集群, 并且 namespace 之间的配置和数据完全隔离, 包括副本数, 分区数, 分区策略等配置都可以不同。 并且 namespace 可以支持指定一些节点放置策略, 保证 namespace 和某些特性的节点绑定 (目前多机房方案通过机架感知方式实现副本至少分布在一个以上机房)。 有了 namespace, 我们就可以把一些核心的业务和非核心的业务隔离到不同的 namespace 里面, 也可以将不兼容的新特性加到新的 namespace 给新业务用, 而不会影响老的业务, 从而实现平滑升级。

PlaceDriver Node 全局管理节点

可以看到, 一个大的集群会有很多 namespace, 每个 namespace 又有很多分区数, 每个分区又需要多个副本, 这么多数据, 必须得有一个节点从全局的视角去优化调度整个集群的数据来保证集群的稳定和数据节点的负载均衡。 placedriver 节点需要负责指定的数据分区的节点分布,还会在某个数据节点异常时, 自动重新分配数据分布。 这里我们使用分离的无状态 PD 节点来实现, 这样带来的好处是可以独立升级方便运维, 也可以横向扩展支持大量的元数据查询服务, 所有的元数据存储在 etcd 集群上。 多个 placedriver 通过 etcd 选举来产生一个 master 进行数据节点的分配和迁移任务。 每个 placedriver 节点会 watch 集群的节点变化来感知整个集群的数据节点变化。

目前数据分区算法是通过 hash 分片实现的, 对于 hash 分区来说, 所有的 key 会均衡的映射到设定的初始分区数上, 一般来说分区数都会是 DataNode 机器节点数的几倍, 方便未来扩容。 因此 PD 需要选择一个算法将分区分配给对应的 DataNode, 有些系统可能会使用一致性 hash 的方式去把分区按照环形排列分摊到节点上, 但是一致性 hash 会导致数据节点变化时负载不均衡, 也不够灵活。 在 ZanKV 里我们选择维护映射表的方式来建立分区和节点的关系, 映射表会根据一定的算法并配合灵活的策略生成。

hash-partition

从上图来看, 整个读写流程: 客户端进行读写访问时, 对主 key 做 hash 得到一个整数值, 然后对分区总数取模, 得到一个分区 id, 再根据分区 id, 查找分区 id 和数据节点映射表, 得到对应数据节点, 接着客户端将命令发送给这个数据节点, 数据节点收到命令后, 根据分区算法做验证, 并在数据节点内部发送给本地拥有指定分区 id 的数据分区的 leader 来处理, 如果本地没有对应的分区 id 的 leader, 写操作会在 raft 内部转发到 leader 节点, 读操作会直接返回错误 (可能在做 leader 切换)。 客户端会根据错误信息决定是否需要刷新本地 leader 信息缓存再进行重试。

可以看到读写压力都在分区的 leader 上面, 因此我们需要尽可能的确保每个节点上拥有均衡数量的分区 leader, 同时还要尽可能减少增减节点时发生的数据迁移。 在数据节点发生变化时, 需要动态的修改分区到数据节点的映射表, 动态调整映射表的过程就是数据平衡的过程。 数据节点变化时会触发 etcd 的 watch 事件, placedriver 会实时监测数据节点变化, 来判断是否需要做数据平衡。 为了避免影响线上服务, 可以设置数据平衡的允许时间区间。 为了避免频繁发生数据迁移, 节点发生变化后, 会根据紧急情况, 判断数据平衡的必要性, 特别是在数据节点升级过程中, 可以避免不必要的数据迁移。 考虑以下几种情况:

  • 新增节点: 平衡优先级最低, 仅在允许的时间区间并且没有异常节点时尝试迁移数据到新节点
  • 少于半数节点异常: 等待一段时间后, 才会尝试将异常节点的副本数据迁移到其他节点, 避免节点短暂异常时迁移数据。
  • 集群超过半数节点异常: 很可能发生了网络分区, 此时不会进行自动迁移, 如果确认不是网络分区, 可以手动强制调整集群稳定节点数触发迁移。
  • 可用于分配的节点数不足: 假如副本数配置是 3, 但是可用节点少于 3 个, 则不会发生数据迁移

稳定集群节点数默认只会增加, 每次发现新的数据节点, 就自动增加, 节点异常不会自动减少。 如果稳定集群节点数需要减少, 则需要调用缩容 API 进行设置, 这样可以避免网络分区时不必要的数据迁移。 当集群正常节点数小于等于稳定节点数一半时, 自动数据迁移将不会发生, 除非人工介入。

数据过期的实现

数据过期作为 redis 的功能特性之一,也是 ZanKV 需要重点考虑和设计支持的。与 redis 作为内存存储不同,ZanKV 作为强一致性的持久化存储,面临着需要处理大量过期的落盘数据的场景,在整体设计上,存在着诸多的权衡和考虑。

首先,ZanKV 并不支持毫秒级的数据过期 (对应 redis 的 pexpire 命令),这是因为在实际的业务场景中很少存在毫秒级数据过期的需求,且在实际的生产网络环境中网络请求的 RTT 也在毫秒级别,精确至毫秒级的过期对系统压力过大且实际意义并不高。

在秒级数据过期上, ZanKV 支持了两种数据过期策略,分别用以不同的业务场景。用户可以根据自己的需求,针对不同的 namespace 配置不同的过期策略。下面将详细阐述两种不同过期策略的设计和权衡。

一致性数据过期

最初设计数据过期功能时,预期的设计目标为:保持数据一致性的情况下完全兼容 redis 数据过期的语义。一致性数据过期,就是为了满足该设计目标所做的设计方案。

正如上文中提到的,ZanKV 目前是使用 rocksdb 作为存储引擎的落盘存储系统,无论是何种过期策略或者实现,都需要将数据的过期信息通过一定方式的编码落盘到存储中。在一致性过期的策略下,数据的过期信息编码方式如下:

data-expire

如上图所示,在存在过期时间的情况下,任何一个 key 都需要额外存储两个信息:

  • key 对应的数据过期时间。我们称之为表 1
  • 使用过期时间的 unix 时间戳为前缀编码的 key 表。我们称之为表 2

rocksdb 使用 LSM 作为底层数据存储结构,扫描按照过期时间顺序存储的表 2 速度是比较快的。在上述数据存储结构的基础上,ZanKV 通过如下方式实现一致性数据过期: 在每个 raft group 中,由 leader 进行过期数据扫描 (即扫描表 2),每次扫描出至当前时间点需要过期的数据信息, 通过 raft 协议发起删除请求,在删除请求处理过程中将存储的数据和过期元数据信息(表 1 和表 2 的数据) 一并删除。在一致性过期的策略下,所有的数据操作都通过 raft 协议进行,保证了数据的一致性。同时,所有 redis 过期的命令都得到了很好的支持,用户可以方便的获取和修改 key 的生存时间(分别对应 redis 的 TTL 和 expire 命令),或者对 key 进行持久化(对应 redis 的 persist 指令)。但是,该方案存在以下两个明显的缺陷:

在大量数据过期的情况下,leader 节点会产生大量的 raft 协议的数据删除请求,造成集群网络压力。同时,数据过期删除操作在 raft 协议中处理,会阻塞写入请求,降低集群的吞吐量,造成写入性能抖动。

目前,我们正在计划针对这个缺陷进行优化。具体思路是在过期数据扫描由 raft group 的 leader 在后台进行,扫描后仅通过 raft 协议同步需要过期至的时间戳,各个集群节点在 raft 请求处理中删除该时间戳之前的所有过期数据。图示如下:

data-expire3

该策略能有效的减少大量数据过期情况下的 raft 请求,降低网络流量和 raft 请求处理压力。有兴趣的读者可以在 ZanKV 的开源项目上帮助我们进行相应的探索和实现。

另外一个缺点是任何数据的删除和写入,需要同步操作表 1 和表 2 的数据,写放大明显。因此,该方案仅适用于过期的数据量不大的情况,对大量数据过期的场景性能不够好。所以,结合实际的业务使用场景,又设计了非一致性本地删除的数据过期策略。

非一致性本地删除

该策略的出发点在于,绝大多数的业务仅仅关注数据保留的时长,如业务要求相关的数据保留 3 个月或者半年,而并不关注具体的数据清理时间,也不会在写入之后多次调整和修改数据的过期时间。在这种业务场景的考虑下,设计了非一致性本地删除的数据过期策略。

与一致性数据过期不同的是,在该策略下,不再存储表 1 的数据,而仅仅保留表 2 的数据,如下图所示:

data-expire2

同时,数据过期删除不再通过 raft 协议发起,而是集群中各个节点每隔 5 分钟扫描一次表 2 中的数据,并对过期的数据直接进行本地删除。

因为没有表 2 的数据,所以在该策略下,用户无法通过 ttl 指令获取到 key 对应的过期时间,也无法在设置过期时间后重新设置或者删除 key 的过期时间。但是,这也有效的减少了写放大,提高了写入性能。

同时,因为删除操作都由本地后台进行,消除了同步数据过期带来的集群写入性能抖动和集群网络流量压力。但是,这也牺牲了部分数据一致性。与此同时,每隔 5 分钟进行一次的扫描也无法保证数据删除的实时性。

总而言之,非一致性本地删除是一种权衡后的数据过期策略,适用于绝大多数的业务需求,提高了集群的稳定和吞吐量,但是牺牲了一部分的数据一致性,同时也造成部分指令的语义与 redis 不一致。

用户可以根据自己的需求和业务场景,在不同的 namespace 中配置不同的数据过期策略。

前缀定期清理

虽然非一致性删除通过优化, 已经大幅减少了服务端压力, 但是对于数据量特别大的特殊场景, 我们还可以进一步减少服务端压力。 此类业务场景一般是数据都有时间特性, 因此 key 本身会有时间戳信息 (比如日志监控这种数据), 这种情况下, 我们提供了前缀清理的接口, 可以一次性批量删除指定时间段的数据, 进一步避免服务端扫描过期数据逐个删除的压力。

跨机房方案

ZanKV 目前支持两种跨机房部署模式,分别适用于不同的场景。

单个跨多机房集群模式

此模式, 部署一个大集群, 并且都是同城机房, 延迟较小, 一般是 3 机房模式。 部署此模式, 需要保证每个副本都在不同机房均匀分布, 从而可以容忍单机房宕机后, 不影响数据的读写服务, 并且保证数据的一致性。

部署时, 需要在配置文件中指定当前机房的信息, 用于数据分布时感知机房信息。不同机房的数据节点, 使用不同机房信息, 这样 placedriver 进行副本配置时, 会保证每个分区的几个副本都均匀分布在不同的机房中。

跨机房的集群, 通过 raft 来完成各个机房副本的同步, 发生单机房故障时, 由于另外 2 个机房拥有超过一半的副本, 因此 raft 的读写操作可以不受影响, 且数据保证一致。 等待故障机房恢复后, raft 自动完成故障期间的数据同步, 使得故障机房数据在恢复后能保持同步。此模式在故障发生和恢复时都无需任何人工介入, 在多机房情况下保证单机房故障的可用性的同时,数据一致性也得到保证。 此方式由于有跨机房同步, 延迟会有少量影响。

多个机房内集群间同步模式

如果是异地机房, 或者机房网络延迟较高, 使用跨机房单集群部署方式, 可能会带来较高的同步延迟, 使得读写的延迟都大大增加。 为了优化延迟问题, 可以使用异地机房集群间同步模式。 由于异地机房是后台异步同步的, 异地机房不影响本地机房的延迟, 但同时引入了数据同步滞后的问题, 在故障时可能会发生数据不一致的情况。

此模式的部署方式稍微复杂一些, 基本原理是通过在异地机房增加一个 raft learner 节点异步的拉取 raft log 然后重放到异地机房集群。 由于每个分区都是一个独立的 raft group, 因此分区内是串行回放, 各个分区间是并行回放 raft log。 异地同步机房默认是只读的, 如果主机房发生故障需要切换时, 可能发生部分数据未同步, 需要在故障恢复后根据 raft log 进行人工修复。 此方式缺点是运维麻烦, 且故障时需要修数据, 好处是减少了正常情况下的读写延迟。

性能调优经验

ZanKV 在初期线上运行时, 积累了一些调优经验, 主要是 RocksDB 参数的调优和操作系统的参数调优, 大部分调优都是参考官方的文档, 这里重点说明以下几个参数:

  • block cache: 由于 block cache 里面都是解压后的 block, 和 os 自带文件 cache 功能有所区别, 因此需要平衡两者之间的比例 (一些压测经验建议 10%~30% 之间)。 另外分区数很多, 因此需要配置不同 RocksDB 实例共享来避免过多的内存占用。
    write buffer: 这个无法在多个 rocksdb 实例之间共享, 因此需要避免太多, 同时又不能因为太小而发送写入 stall。 另外需要和其他几个参数配合保证: level0_file_num_compaction_trigger * write_buffer_size * min_write_buffer_number_tomerge = max_bytes_for_level_base 来减少写放大。
  • 后台 IO 限速: 这个主要是使用 rocksdb 自带的后台 IO 限速来避免后台 compaction 带来的读写毛刺。
  • 迭代器优化: 这个主要是避免 rocksdb 的标记删除特性影响数据迭代性能, 在迭代器上使用rocksdb::ReadOptions::iterate_upper_bound参数来提前结束迭代, 详细可以参考这篇文章: https://www。cockroachlabs。com/blog/adventures-performance-debugging/
  • 禁用透明大页 THP: 操作系统的透明大页功能在存储系统这种访问模式下, 基本都是建议关闭的, 不然读写毛刺现象会比较严重。
# echo never > /sys/kernel/mm/redhat_transparent_hugepage/enabled
# echo never > /sys/kernel/mm/redhat_transparent_hugepage/defrag

Roadmap

虽然 ZanKV 目前已经在有赞内部使用了一段时间, 但是仍然有很多需要完善和改进的地方, 目前还有以下几个规划的功能正在设计和开发:

二级索引

主要是在 HASH 这种数据类型时实现如下类似功能, 方便业务通过其他 field 字段查询数据

IDX。FROM test_hash_table WHERE “age>24 AND age<31"

优化 raft log

目前 etcd 的 raft 实现会把没有 snapshot 的 raft log 保存在 memory table 里面, 在 ZanKV 这种多 raft group 模式下会占用太多内存, 需要优化使得大部分 raft log 保存在磁盘, 内存只需要保留最近少量的 log 用于 follower 和 leader 之间的交互。 选择 raft log 磁盘存储需要避免双层 WAL 降低写入性能。

多索引过滤

二级索引只能满足简单的单 field 查询, 如果需要高效的使用多个字段同时过滤, 来满足更丰富的多维查询能力, 则需要引入多索引过滤。 此功能可以满足一大类不需要全文搜索以及精确排序需求的数据搜索场景。 业界已经有支持 range 查询的压缩位图来实现的开源产品, 在索引过滤这种特殊场景下, 性能会比倒排高出不少。

数据实时导出和 OLAP 优化

主要是利用 raft learner 的特点, 实时的把 raft log 导出到其他系统。 进一步做针对性的场景, 比如转换成列存做 OLAP 场景等。

以上特性都有巨大的开发工作量, 目前人力有限, 欢迎有志之士加入我们或者参与我们的开源项目, 希望能充分利用开源社区的力量使得我们的产品快速迭代, 提供更稳定, 更丰富的功能。

总结

限于篇幅, 以上只能大概讲述 ZanKV 几个重要的技术思路, 还有很多实现细节无法一一讲述清晰, 项目已经开源: https://github.com/youzan/ZanRedisDB , 欢迎大家通过阅读源码来进一步了解细节, 并贡献源码来共同构建一个更好的开源产品, 也敬请期待后继更佳丰富的功能特性实现细节介绍。