logo
企业版

用户案例特性讲解产品实践

vivo:基于 NebulaGraph 的大规模特征存储实践

Large Scale Feature Storage with NebulaGraph: Best Practices at vivo

作者:黄伟锋 来源:微信公众号-vivo互联网技术 原文链接:https://mp.weixin.qq.com/s/u1LrIBtY6wNVE9lzvKXWjA

本文旨在介绍 vivo 内部的特征存储实践、演进以及未来展望,抛砖引玉,吸引更多优秀的想法。

一、需求分析

AI 技术在 vivo 内部应用越来越广泛,其中特征数据扮演着至关重要的角色,用于离线训练、在线预估等场景,我们需要设计一个系统解决各种特征数据可靠高效存储的问题。

1. 特征数据特点

(1)Value 大

特征数据一般包含非常多的字段,导致最终存到 KV 上的 Value 特别大,哪怕是压缩过的。

(2)存储数据量大、并发高、吞吐大

特征场景要存的数据量很大,内存型的 KV(比如 Redis Cluster)是很难满足需求的,而且非常昂贵。不管离线场景还是在线场景,并发请求量大,Value 又不小,吞吐自然就大了。

(3)读写性能要求高,延时低

大部分特征场景要求读写延时非常低,而且持续平稳,少抖动。

(4)不需要范围查询

大部分场景都是单点随机读写。

(5)定时灌海量数据

很多特征数据刚被算出来的时候,是存在一些面向 OLAP 的存储产品上,而且定期算一次,希望有一个工具能把这些特征数据及时同步到在线 KV 上。

(6)易用

业务在接入这个存储系统时,最好没有太大的理解成本。

2. 潜在需求

  • 扩展为通用磁盘 KV,支撑各个场景的大容量存储需求

我们的目标是星辰大海,绝不仅限于满足特征场景。

  • 支撑其他 Nosql/Newsql 数据库,资源复用

从业务需求出发,后续我们会有各种各样 Nosql 数据库的需求,如图数据库、时序数据库、对象存储等等,如果每个产品之间都是完全隔离,没有任何资源(代码、平台能力等等)复用,维护成本是巨大的。

  • 可维护性强

首先实现语言不能太小众,否则人才招聘上会比较困难,而且最好能跟我们的技术栈发展方向匹配。

架构设计上不能依赖太多第三方服务组件,降低运维的复杂性。

3. 存储系统的冰山

综合以上需求,最终我们决定兼容 Redis 协议,用户看到的只是一个类似单机版的 Redis 服务,但背后我们做了大量的可靠性保障工作。

vivo 的服务意向

二、方案选型

在方案选型上,我们遵循一些基本原则:

  • 源自开源,按需定制。
  • 内部开源,集思广益。
  • 语言主流,架构主流。
  • 可靠至上,高可维护。

先简单介绍一下我们早期方案调研的一些优缺点分析:

各大数据方案对比

说实话,调研的都是优秀的开源项目,但光靠官方代码和设计文档,没有深入的实践经验,我们是很难断定一个开源产品是真正适合我们的,适当的赛马可以更好校准方案选型,同时也一定程度反映出我们较强的执行力。

总的来说我们是要在现有需求、潜在需求、易用性、架构先进性、性能、可维护性等各个方面中找到一个最优平衡,经过一段时间的理论调研和实践以后,最终我们选择了 Nebula。

三、Nebula 简介

NebulaGraph 是一个高性能、高可用、高可靠、数据强一致的、开源的分布式图数据库

1. 存储计算分离

Nebula 采用存储计算分离的设计,有 状态的存储服务 和 无状态的计算服务 是分层的,使得存储层可以集中精力提升数据可靠性,只暴露简单的 KV 接口,计算层可以聚焦在用户直接需要的计算逻辑上,而且大大提升运维部署的灵活性。

不过作为图数据库,为了提升性能,Nebula 把一部分图计算逻辑下沉到存储层,这也是灵活性与性能之间的一个比较现实的权衡。

2. 强一致,架构主流

Nebula 的强一致使用 Raft,是目前实现多副本一致性的主流方法,而且这个 Raft 实现已经初步通过了 Jepsen 线性一致性测试,作为一个刚起步不久的开源项目,对增加用户的信心很有帮助。

3. 可伸缩

Nebula 的横向扩展能力得益于其 Hash-based 的 Multi-raft 实现,同时自带一个用于负载均衡的调度器(Balancer),架构和实现都比较简洁(至少目前还是),上手成本低。

4.易维护

Nebula 内核使用 C++ 实现,跟我们基础架构的技术栈发展方向比较匹配。经过评估,Nebula 一些基本的平台能力(如监控接口、部署模式)比较简单易用,跟我们自身平台能很好对接。

代码实现做了较好抽象,可以灵活支持多种存储引擎,为我们后来针对特征场景的性能优化奠定了很好的基础。

四、Nebula Raft 简介

上文提到 Nebula 是依赖 Raft 保证强一致的,这里简单介绍一下 Nebula Raft 的特点:

1. 选主与任期

一个 Raft Group 的生命周期是由一个又一个连续的任期组成的,每个任期开始会选出一个 Leader,其他成员为 Follower,一个任期内只有一个 Leader,如果任期内 Leader 不可用,会马上进入下一个任期,选新的 Leader。这种 Strong Leader 机制使得 Raft 的工程实现难度远低于它的祖师爷 - Paxos。

2. 日志复制、压缩

标准的 Raft 实现中,每个从客户端来的写请求都会转换成 “操作日志” 写到 wal 文件中,Leader 在把操作日志更新到自己状态机后,会主动向所有 Follower 异步复制日志,直到超过半数的 Follower 应答后,才返回客户端写入成功。

实际运行中,wal 的文件会越来越大,如果没有一个合理的 wal 日志回收机制,wal 文件将很快占满整个磁盘,这个回收机制就是日志压缩(Log Compaction)。Nebula 的 Log Compaction 实现比较简洁,用户只需要配置一个 wal_ttl 参数,即可在不破坏集群正确性的前提下,把 wal 文件的空间占用控制在一个稳定的范围。

Nebula 实现了 Raft batch 和 pipeline 机制,支持 Leader 到 Follower 的批量和乱序日志提交,在高并发场景下,能有效提升集群整体吞吐能力。

3. 成员变更

跟典型的 Raft 实现类似,这里着重提一下 Nebula Raft 的 Snapshot 机制。

当一个 Raft Group 增加成员时,新成员节点需要从当前的 Leader 中获取所有的日志并重放到自身的状态机中,这是一个不容小觑的资源开销,对 Leader 造成较大的压力。为此一般的 Raft 会提供一个 Snapshot 机制,以此解决节点扩容的性能问题,以及节点故障恢复的时效问题。

Snapshot,即 Leader 把自身状态机打成一个“镜像”单独保存,在 Nebula Raft 实现中,“镜像”就是 Rocksdb 实例(即状态机本身),新成员加入时,Leader 会调用 Rocksdb 的 Iterator 扫描整个实例,过程中把读到的值分批发送给新成员,最终完成整个 Snapshot 的拷贝过程。

4. Multi-raft 实现

如果一个集群只有一个 Raft Group,很难通过加机器实现横向扩展,适用场景非常有限,自然想到的方法就是把集群的数据拆分出多个不同的 Raft Group,这里就引入了 2 个新问题:(1)数据如何分片(2)分片如何均匀分布到集群中。

实现 Multi-raft 是一个有挑战且很有意思的事情,业界有 2 种主流的实现方式,一种是 Hash-based 的,一种是 Region-based,各有利弊,大部分情况下,前者比较简单有效,Nebula 目前采用 Hash-based 的方式,也是我们需要的,但面向图场景,后续有没有进一步的规划,需要持续关注社区动态。

五、特征存储平台介绍

1. 系统架构

vivo 技术架构

在 Nebula 原有架构基础上,增加了一些组件,包括 Redis Proxy、Rediscluster Proxy 以及平台化相关的组件。

Meta 实例是存整个集群的元信息,包括数据分片路由规则,space 信息等等,其本身也是一个 Raft Group。

  • Storage 实例是实际存数据的节点,假设一个集群多个分片对应 m 个 Raft Group,每个 Raft Group 对应 n 个副本,Nebula 就是把 m * n 个副本均匀分布到这多个 Storage 实例中,并力求每个实例中的 Leader 数也相近。
  • Graph 实例是图 API 的服务提供者以及整个集群的 Console,无状态。
  • Redis 实例兼容了 Redis 协议,实现了部分 Redis 原生的数据结构,无状态。
  • Rediscluster 实例兼容了 Redis Cluster 协议,无状态。

2. 性能优化

1. 集群调优

实际接入生产业务时,往往需要针对不同场景调整参数,这个工作在在早期占用了大量的时间,但确实也为我们积累宝贵的经验。

2. WiscKey

前文提到的大部分特征场景的 Value 都比较大,单纯依赖 Rocksdb 会导致严重的写放大,原因在于频繁触发 Compaction 逻辑,而且每次 Compaction 的时候都会把 Key 和 Value 从磁盘扫出来,在 Value 大的场景下,这个开销非常可怕。为此学术界提出过一些解决方案,其中 WiscKey 以实用性而广受认可,工业界也落地了其开源实现(Titandb)。

Titandb 详细原理可参考其 官方文档,简单来说,就是改造 Rocksdb,兼容对外接口,保留 LSM-tree,新增 BlobFile 存储,Key Value 分离存储,Key 存  LSM-tree,Value 存 BlobFile,依赖 SSD 磁盘随机读写性能,牺牲范围查询性能,减少大 Value 场景下的写放大。

得益于 Nebula 支持多存储引擎的设计,Titandb 很轻松就集成到 Nebula Storage,在实际生产中,的确在性能上给我们带来不错的收益。

3. TTL 机制

不管是 Rocksdb, 还是 Titandb,都兼容了 Compaction Filter 接口,即在 Compaction 的时候会调用这个 Filter 来判断是否需要过滤掉具体的数据。我们在实际写入 Storage 的 Value 中种入了 TTL,在 Compaction Filter 的时候,扫描每个 Value,提取出 TTL 判断 Value 是否过期了,如果是,则删除掉对应 Key-Value 对。

然而,实践中我们发现,Titandb 在 Compaction 的时候,如果 Value 很大被分离到 BlobFile 后,Filter 是读不到具体 Value 的(只有留在 LSM-tree 里的小 Value 才能被读到)。这就对我们 TTL 机制造成很大的不利,导致过期的数据没有办法回收。为此,我们做了一点特殊处理,当大 Value 被分离到 BlobFile 后,LSM-tree 里会存 Key-Index 对,Index 就是 Value 在 BlobFile 中的位置,我们尝试把 TTL 种到 Index 中,使得 Filter 时能解析出 TTL,从而实现所有过期数据的物理删除。

4. 易用

易用性是一个数据库走向成熟的标志,是一个很大的课题。

从不同用户的视角出发,会引申出不同的需求集合,用户角色可以包括 运维 dba、业务研发工程师、运维工程师等等,最终我们希望在各个视角都能超出预期,实现真正高易用的存储产品。这里简单介绍我们在易用性上的一些实践:

1. 兼容 redis 协议

我们改造了美图开源的 KVrocks(一个基于 Rocksdb 的兼容 redis 协议的单机磁盘 KV 产品),依赖 Nebula C++ 版本的 Storage Client,把底层依赖 Rocksdb 的逻辑替换成 Nebula Storage KV 接口的读写逻辑,从而实现一个无状态的 redis 协议兼容层(Proxy),同时我们根据实际需要额外实现了一些命令。当然,我们只是针对特征场景实现了一些 redis 命令,要在分布式 KV 基础上兼容所有 redis 的指令,需要考虑分布式事务,这里我先卖个关子,敬请期待。

2. 支持从 Hive 批量导入数据到 KV

对特征场景来说,这个功能也是易用性的一种体现,Nebula 目前针对图结构的数据已经实现了从 Hive 导数据,稍加改造就能兼容 KV 格式。

3. 平台化运维

前期我们在公共配置中心上维护了所有线上集群的元信息,并落地了一些简单的作业,如一键部署集群、一键卸载集群、定时监控上报、定时命令正确性检查、定时实例健康检测、定时集群负载监控等等,能满足日常运维的基本需求。同时,vivo 内部在建设一个功能完善的 DBaaS 平台,已经实际支撑了不少 DB 产品的平台化运维,包括 redis、mysql、elasticsearch、mongodb 等等,大大提升业务的数据管理效率,所以,最终特征存储是要跟平台全面结合、共同演进,不断实现产品易用性和健壮性的突破。

5. 灾备

1. 定期冷备

Nebula 本身提供了冷备机制,我们只需要设计好个性化的定时备份策略,即可较好满足业务需求,这里不详细描述,感兴趣可以看看 Nebula 的 集群快照机制

2. 实时热备

热备落地一共分两期:

第一期:比较简单,只考虑增量备份,且容忍有损。

目前 KV 主要服务特征场景(或缓存场景),对数据可靠性要求不是特别高,而且数据在存储中驻留的时间不会很长,很快就会被 TTL 清理掉。为此热备方案中暂不支持存量数据的备份。

至于增量备份,就是在 Proxy 层把 “写请求” 再异步写一次到备集群,主集群还是继续执行同步写,只要 Proxy cpu 资源足够,不会影响主集群本身的读写性能。这里会存在数据丢失的风险,比如 Proxy 异步没写完,进程突然挂了,这时备集群是会丢一点数据的,但正如之前提到,大部分特征场景(或缓存场景)对这种程度的数据丢失是可容忍。

第二期: 既保证增量备份,也要保证存量备份。

Nebula Raft 引入了 Learner,它也是 Raft Group 中的一个副本,但既不参与选主,也不影响多数派提交,它只是默默的接收来自 Leader 的日志复制请求。跟其他 Follower 一样,Learner 一旦挂了,Leader 会不断重试复制日志给 Learner,直到 Learner 重启恢复。

有了这个机制,要实现存量备份就变的简单了,我们可以实现一个灾备组件,伪装成 Learner,挂到 Raft Group 中,这时 Raft 的成员变更机制会保证 Leader 中的存量数据和增量数据都能以日志的形式同步给灾备组件,同时组件另一侧依赖 Nebula Storage Client 把源日志数据转换成写请求应用到灾备集群。

6. 跨机房双活

双活也是分两期落地:

第一期:不考虑冲突处理,不保证集群间的最终一致。

这个版本的实现同样简单,可以理解是 2 个集群互为灾备,对有同城双活、故障转移需求,对最终一致性要求不高的业务还是很有帮助的。

第二期:引入 CRDT 处理冲突,实现最终一致。

这个版本对可靠性的要求比较高,复用灾备二期的能力,在 Learner 中获取集群的写请求日志。

一般双活情况下,两个 KV 集群会分布在不同机房,单元化的业务服务会各自读写本机房 KV 的数据,两个不同机房的 KV 相互同步变更。假如两个 KV 更新了同一个 Key,并同步给对方,这时应该怎么处理冲突呢?

最简单直接的方案就是最 “晚” 写的数据更新到两个 KV,保证最终一致,这里的 “晚” 不是指绝对意义上的先来后到,而是根据写操作发生的时间戳,同一个 Key 两个机房的写操作都能取到各自的时间戳,但机房之间时钟不一定同步,这就可能导致实际先发生的操作 时间戳可能更大,但我们的目标是实现最终一致,不是跟时钟同步机制较劲,所以问题不大。针对这个思路,知名最终一致性方案 CRDT 已经给出了相应的标准实现。

KV 实际存的数据只有 String 类型,对应于 CRDT 里的 Register 数据结构,其中一种实现就是 Op-based LWW(Last-Write-Wins) Register,顾名思义,就是最 “晚” 写的 Value 成为最终一致的状态,算法原型如下:

vivo 算法

对 CRDT 感兴趣的可以看看网上的其他资料,这里不详细描述。

庆幸的是,vivo 内部已经在 Redis Cluster 上实现了 CRDT Register ,并提供了保障数据跨机房可靠传输的组件,使得新 KV 存储可以站在巨人的肩膀上。需要注意的是,KV 线上大量 mset 的写请求,而 CRDT Register 只支持单个 Set 的请求冲突处理,所以在双活组件 Learner 中,从 Leader 收到的 Batch Write 请求需要拆解成一个一个的 Set 命令,然后再同步给 Peer 集群。

六、未来展望

1. 扩展成通用 KV 存储

我们立项特征存储的时候,就目标要做成通用 KV 存储,成为更多数据库的强力底座。但要做成一个通用 KV 存储,还需要很多工作要落实,包括可靠性、平台能力、低成本方面的提升。庆幸业界已经有很多优秀的实践,给我们提供很大的参考价值。

2. 持续完善平台能力

最简单的,参考 vivo 内部以及各大互联网公司 redis 平台化管理实践,新 KV 的平台能力建设还有非常多的事情要做,而且后续还会跟智能化 DB 运维结合在一起,想象空间更大。

3. 持续完善正确性校验机制

数据可靠性和正确性是一个数据库产品的安身立命之本,需要持续完善相应的校验机制。

现阶段我们还没法承诺金融级的数据可靠性,我们会持续往这个方向努力,目前满足一些特征场景和缓存场景还是可行的。

我们已经在逐渐引入一些开源的 chaos 工具,希望能持续深入挖掘出系统的潜在问题,为用户提供更可靠的数据存储服务。

4. 强化调度能力

分布式数据库核心是围绕存储、计算、调度 3 个话题展开的,可见调度的重要性,负载均衡就是其中一个环节,目前 Hash-based 的分片规则,后续能否改成 Region-based 的分片规则?能否跟 k8s 结合构建云原生的 KV 存储产品?能否让数据分布调整变得更智能、更自动化 …… 我们拭目以待。

5. 冷热数据分离

本质还是成本和性能的权衡,对一些规模特别大的集群,可能 90% 的数据是很少被访问的,这些数据哪怕存到闪存,也是一种资源的浪费。一方面我们希望被频繁访问的数据能得到更好的读写性能,另一方面我们希望能最大限度的节省成本。

一个比较直接的方法,就是把热数据存到内存和闪存上,一些冰封的冷数据则存到一些更便宜的介质(比如机械磁盘),这就需要系统自身具备判断能力,能持续动态区分出哪些属于热数据,哪些属于冷数据。

6. 支持更多类型的存储引擎

目前已经支持了 Rocksdb 和 Titandb,后续会考虑引入更多类型的存储引擎,比如纯内存的,或者基于 AEP 等新闪存硬件产品的存储引擎。

7.支持远端 HDFS 冷备

对于在线场景,数据备份还是很重要的,当前 Nebula 已经支持本地集群级的快照备份,但机器挂了,还是会存在大量数据丢失的风险,我们会考虑把数据冷备到远端,比如 HDFS。是不是只要把 HDFS 挂载成本地目录,集群把快照 dump 到指定目录就可以了呢?我们会做进一步的思考和设计。

8. SPDK 磁盘读写

实际测试告诉我们,同样是依赖 nvme 磁盘,单机上使用 SPDK 比不使用 SPDK 吞吐提升接近 1 倍。SPDK 这种 Bypass Kernel 的方案已经是大势所趋,对磁盘 io 容易成为瓶颈的场景,使用 SPDK 能有效提升资源利用率。

9. KV SSD

鉴于 SPDK Bypass Kernel 的优势,业界提出了一种新的解决方案(KV SSD)。

Rocksdb 基于 LSM-tree 实现,Compaction 机制会带来严重的写放大,而 KV SSD 提供了原生的 KV接口,兼容 Rocksdb API,可以将新的数据记录直接写入到 SSD 中,不需要再进行反复的 Compaction 操作,从而将 Rocksdb 的写放大减小到 1,是一个非常值得尝试的新技术。

10. 支撑图数据库

我们的 KV 产品之所以订制 Nebula,其中一个重要原因是为图数据库做准备的,目前已经在尝试接入一些有图需求的业务,以后希望能跟开源社区合作,共建领先的图数据库能力。

11. 支撑时序数据库

在 5G 和 物联网时代,时序数据库起着非常重要的作用。

这个领域 Influxdb 目前比较领先,但开源版本不支持分布式,只依赖一种为时序数据设计的单机存储引擎(TSM),实用价值非常有限。

我们的 KV 产品提供了现成的分布式复制能力、标准化的平台能力、高可用保障措施,我们希望能尽可能复用起来。

结合起来,是不是可以考虑把 TSM 跟分布式复制能力做一个整合,外加对时序场景友好的 Sharding 策略,构建一个高可用的分布式时序存储引擎,替换掉开源 InfluxDB 的单机存储层。

12.  支撑对象存储的元数据存储

元数据存储对“对象存储”来说至关重要,既然我们已经提供了一个强大的 KV 存储产品,是不是可以复用起来,减轻运维和研发维护的负担呢?

七、最后

实践过程中我们需要不断协调资源、收集需求、迭代产品,力求接入更多场景,收集更多需求,更好打磨我们的产品,尽早进入良性循环,一句话总结下心得体会

vivo 的目标是