作者简介:Patrick Yu,携程云原生研发专家,关注非关系型分布式数据存储及相关技术。
随着互联网世界产生的数据越来越多,数据之间的联系越来越复杂层次越来越深,人们希望从这些纷乱复杂的数据中探索各种关联的需求也在与日递增。为了更有效地应对这类场景,图技术受到了越来越多的关注及运用。
在携程,很早就有一些业务尝试了图技术,并将其运用到生产中,以 Neo4j 和 JanusGraph 为主。2021 年开始,我们期望规范业务的使用,并适配携程已有的各种系统,更好地服务业务方。经过调研,我们选择分布式图数据库 NebulaGraph 作为管理的对象,主要基于以下几个因素考虑:
NebulaGraph 是一个分布式的计算存储分离架构,如下图:
其主要由 graphd,metad 和 storaged 三部分服务组成,分别负责计算,元数据存取,图数据(点,边,标签等数据)的存取。在携程的网络环境中,我们提供了三种部署方式来支撑业务,分别是:三机房部署、单机房部署和蓝绿双活部署。
用于满足一致性和容灾的要求,优点是任意一个机房发生机房级别故障,集群仍然可以使用,适用于核心应用。但缺点也是比较明显的,数据通过 raft 协议进行同步的时候,会遇到跨机房问题,性能会受到影响。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lfkeco3O-1670826978140)(https://nebula-website-cn.oss-cn-hangzhou.aliyuncs.com/nebula-blog/ctrip-ops-practice-about-graph-database-nebulagraph-4.png)]
集群所有节点都在一个机房中,节点之间通讯可以避免跨机房问题(应用端与服务端之间仍然会存在跨机房调用),由于机房整体出现问题时该部署模式的系统将无法使用,所以适用于非核心应用进行访问。
在实际使用中,以上两种常规部署方式并不能满足一些业务方的需求,比如:性能要求较高的核心应用,三机房的部署方式所带来的网络损耗可能会超出预期。根据携程酒店某个业务场景真实测试数据来看,本地三机房的部署方式延迟要比单机房高 50%+,但单机房部署无法抵抗单个 IDC 故障。此外,还有用户希望能存在类似数据回滚的能力,以应对应用发布,集群版本升级可能导致的错误。
考虑到使用图数据库的业务大多数据来自离线系统,通过离线作业将数据导入到图数据库中,数据一致的要求并不高,在这种条件下使用蓝绿部署能够在灾备和性能上得到很好的满足。
与此同时我们还增加了一些配套的辅助功能,比如:
蓝绿双活方式是在性能、可用性、一致性上的一个折中的选择,使用此方案时应用端架构也需要有更多的调整以配合数据的存取。
生产上的一个例子:
上图为三机房情况,下图为蓝绿部署情况:
我们基于 K8s CRD 和 Operator 来进行 NebulaGraph 的部署,同时通过服务集成到现有的部署配置页面和运维管理页面,来获得对 Pod 的执行和迁移的控制能力。基于 sidecar
模式监控、收集 NebulaGraph 的核心指标并通过 Telegraf 发送到携程自研的 Hickwall 集中展示,并设置告警等一系列相关工作。
此外,我们集成了跨机房的域名分配功能,为节点自动分配域名用于内部访问(域名只用于集群内部,集群与外部连通是通过 IP 直连的),这样做是为了避免节点漂移造成 IP 变更,影响集群的可用性。
在客户端上,相比原生客户端,我们主要做了以下几个改进和优化:
原生客户端 Session 管理比较弱,尤其是 v2.x 早期几个版本,多线程访问 Session 并不是线程安全的,Session 过期或者失效都需要调用方来处理,不适合大规模使用。同时,虽然官方客户端创建的 Session 是可以复用的,并不需要 release,官方也鼓励用户复用,但是却没有提供统一的 Session 管理功能来帮助用户复用。因此,我们增加了 Session Pool 的概念来实现复用。
其本质上是管理一个或多个 Session Object Queue,通过 borrow-and-return
的方式(下图),确保了一个 Session 在同一时间只会由一个执行器在使用,避免了共用 Session 产生的问题。同时通过对队列的管理,我们可以进行 Session 数量和版本的管理,比如:预生成一定量的 Session,或者在管理中心发出消息之后变更 Session 的数量或者访问的路由。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sWdM6D3F-1670826978142)(https://nebula-website-cn.oss-cn-hangzhou.aliyuncs.com/nebula-blog/ctrip-ops-practice-about-graph-database-nebulagraph-8.png)]
上面章节中介绍了蓝绿部署,相应的客户端也需要改造以支持访问 2 个集群。由于生产中,读和写的逻辑往往不同,比如:读操作希望可以由 2 个集群共同提供数据,而写的时候只希望影响单边,所以我们在进行蓝绿处理的时候也增加了读写分离(下图)。
如果要考虑到单边切换以及读写不同的路由策略,就需要增加流量分配功能。我们没有采用携程内广泛使用的 Virtual IP 作为访问路由,希望有更为强大的定制管理能力及更好的性能。
通过构造面向不同 IDC 的 Session Pool,并根据配置进行权重轮询,就可以达到按比例分配访问流量的目的(下图)。
将流量分配集成进蓝绿模式,就基本实现了基本的客户端改造(下图)。
图 DSL 目前主流的有两种,Gremlin 和 Cypher,前者是过程式语言而后者是声明式语言。NebulaGraph 支持了 openCypher(Cypher 的开源项目)语法和自己设计的 nGQL 原生语法,这两种都是声明式语言,在风格上比较类似 SQL。尽管如此,对于一些较为简单的语句,类似 Gremlin 风格的过程式语法对用户会更为友好,并且有利用监控埋点。基于这个原因,我们封装了一个过程式的语句生成器。
例如:
由于建模,使用场景,业务需求的差异,使用Nebula Graph的过程中所遇到的问题很可能会完全不同,以下以携程酒店信息图谱线上具体的例子进行说明,在整个落地过程我们遇到的问题及处理过程(文中以下内容是基于Nebula Graph 2.6.1进行的)。
关于酒店该业务的更多细节,可以阅读《信息图谱在携程酒店的应用》这篇文章。
起因是酒店应用上线后发生了一次故障,大量的访问超时,并伴随着 “The leader has changed”
这样的错误信息。稍加排查,我们发现 metad 集群有问题,metad0 的 local ip 和 metad_server_address 的配置不一致,所以 metad0 实际上一直没有工作。
但这本身并不会导致系统问题,因为 3 节点部署,只需要 2 个节点工作即可。后来 metad1 容器又意外被漂移了,导致 IP 变更,这个时候实际上 metad 集群已经无法工作(下图),导致整个集群都受到了影响。
在处理完以上故障并重启之后,整个系统却并没有恢复正常,CPU 的使用率很高。此时,外部应用并没有将流量接入进来,但整个 metad 集群内部网络流量却很大,如下图所示:
监控显示 metad 磁盘空间使用量很大,检查下来 WAL 在不断增加,说明这些流量主要是数据的写入操作。我们打开 WAL 数据的某几个文件,其大部分都是 Session 的元数据,因为 Session 信息是会在 NebulaGraph 集群内持久化的,所以考虑问题可能出在这里。通过阅读源码我们注意到,graphd 会从 metad 中同步所有的 Session 信息,并在修改之后将数据再全部回写到 metad 中,所以如果流量都是 session 信息的话,那么问题就可能:
检查发现该集群没有配置 Session 超时时间,所以我们修改以下配置来处理这个问题:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VW04bxZR-1670826978151)(https://nebula-website-cn.oss-cn-hangzhou.aliyuncs.com/nebula-blog/ctrip-ops-practice-about-graph-database-nebulagraph-1401.png)]
修改之后,metad 的磁盘空间占用下降,同时通信流量和磁盘读写也明显下降(下图):
系统逐步恢复正常,但是还有一个问题没有解决,就是为什么有如此之多的 Session 数据?查看应用端日志,我们注意到 Session 创建次数超乎寻常,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QMWck6SD-1670826978152)(https://nebula-website-cn.oss-cn-hangzhou.aliyuncs.com/nebula-blog/ctrip-ops-practice-about-graph-database-nebulagraph-16.png)]
通过日志发现是我们自己开发的客户端中的 bug 造成的。我们会在报错时让客户端释放对应的 Session,并重新创建。但,由于系统抖动,这个行为造成了比较多的超时,导致更多的 Session 被释放并重建,引起了恶性循环。针对这个问题,对客户端进行了如下优化:
序号 | 修改 |
---|---|
1 | 将创建 session 行为由并发改为串行,每次只允许一个线程进行创建工作,不参与创建的线程监听 session pool |
2 | 进一步增强 session 的复用,当 session 执行失败的时候,根据失败原因来决定是否需要 release。原有的逻辑是一旦执行失败就 release 当前 session,但有些时候并非是 session 本身的问题,比如超时时间过短,nGQL 有错误这些应用层的情况也会导致执行失败,这个时候如果直接 release,会导致 session 数量大幅度下降从而造成大量 session 创建。根据问题合理的划分错误情况来进行处理,可以最大程度保持 session 状况的稳定 |
3 | 增加预热功能,根据配置提前创建好指定数量的 session,以避免启动时集中创建 session 导致超时 |
酒店业务方在增加访问量的时候,每次到 80% 的时候集群中就有少数 storaged 不稳定,CPU 使用率突然暴涨,导致整个集群响应增加,从而应用端产生大量超时报错,如下图所示:
和酒店方排查下来初步怀疑是存在稠密点问题(在图论中,稠密点是指一个点有着极多的相邻边,相邻边可以是出边或者是入边),部分 storaged 被集中访问引起系统不稳定。由于业务方强调稠密点是其业务场景难以避免的情况,我们决定采取一些调优手段来缓解这个问题。
回忆之前的官方架构图,数据在 storaged 中是分片的,且 raft 协议中只有 leader 才会处理请求,所以,重新进行数据平衡操作,是有可能将多个稠密点分摊到不同的服务上以减轻单一服务的压力。同时,我们对整个集群进行 Compaction 操作(由于 storaged 内部使用了 RocksDB 作为存储引擎,数据是通过追加来进行修改的,Compaction 可以清楚过时的数据,提高访问效率)。
操作之后集群的整体 CPU 是有一定的下降,同时服务的响应速度也有小幅的提升,如下图。
但在运行一段时间之后仍然遇到了 CPU 突然增加的情况,稠密点显然没有被平衡掉,也说明在分片这个层面是没法缓解稠密点带来的访问压力的。
进一步调研出现问题的 storaged 的 CPU 的使用率,可以看到当流量增加的时候,内核占用的 CPU 非常高,如下图所示:
抓取 perf 看到,锁竞争比较激烈,即使在“正常”情况下,锁的占比也很大,而在竞争激烈的时候,出问题的 storaged 服务上这个比例超过了 50%。如下图所示:
所以我们从减少冲突入手,对 NebulaGraph 集群主要做了如下改动:
重新上线之后,整个集群服务变得比较平滑,CPU 的负载也比较低,正常情况下锁竞争也下降不少(下图),酒店也成功地将流量推送到了 100%。
但运行了一段时间之后,我们仍然遇到了服务响应突然变慢的情况,热点访问带来的压力的确超过了优化带来的提升。
考虑到在分片级别的 balance 不起作用,而 CPU 的上升主要是因为锁竞争造成的,那我们想到如果减小锁的颗粒度,是不是就可以尽可能减小竞争?RocksDB 的 LRUCache 允许调整 shared 数量,我们对此进行了修改:
版本 | LRUCache 默认分片数 | 方式 |
---|---|---|
v2.5.0 | 2^8 | 修改代码,将分片改成 2^10 |
v2.6.1及以上 | 2^8 | 通过配置 cache_bucket_exp = 10 ,将分片数改为 2^10 |
观察下来效果不明显,无法解决热点竞争导致的雪崩问题。其本质同 balance 操作一样,只是粒度的大小的区别,在热点非常集中的情况下,在数据层面进行处理是走不通的。
竞争的锁来源是 block cache 造成的。NebulaGraph storaged 使用 RocksDB 作为存储,其使用的是 LRUCache 作为 block cache 等一系列 cache 的存储模块,LRUCache 在任何类型的访问的时候需要需要加锁操作,以进行一些 LRU 信息的更新,排序的调整及数据的淘汰,存在吞吐量的限制。
由于我们主要面临的就是锁竞争,在业务数据没法变更的情况下,我们希望其他 cache 模块来提升访问的吞吐。按照 RocksDB 官方介绍,其还支持一种 cache 类型 ClockCache,特点是在查询时不需要加锁,只有在插入时才需要加锁,会有更大的访问吞吐,考虑到我们主要是读操作,看起来 ClockCache 会比较合适。
LRU cache和Clock cache的区别:https://rocksdb.org.cn/doc/Block-Cache.html
经过修改源码和重新编译,我们将缓存模块改成了 ClockCache,如下图所示:
但集群使用时没几分钟就 core,查找资料我们发现目前 ClockCache 支持还存在问题(https://github.com/facebook/rocksdb/pull/8261),此方案目前无法使用。
可以看到整个系统在当前配置下,是存在非常多的线程的,如下图所示。
如果是单线程,就必然不会存在锁竞争。但作为一个图服务,每次访问几乎会解析成多个执行器来并发访问,强行改为单线程必然会造成访问堆积。
所以我们考虑将原有的线程池中的进程调小,以避免太多的线程进行同步等待带来的线程切换,以减小系统对 CPU 的占用。
调整之后整个系统 CPU 非常平稳,绝大部分物理机 CPU 在 20% 以内,且没有之前遇到的突然上下大幅波动的情况(瞬时激烈锁竞争会大幅度提升 CPU 的使用率),说明这个调整对当前业务来说是有一定效果的。
随之又遇到了下列问题,前端服务突然发现 NebulaGraph 的访问大幅度超时,而从系统监控的角度却毫无波动(下图 24,19:53 系统其实已经响应出现问题了,但 CPU 没有任何波动)。
原因是在于,限制了 thread 确实有效果,减少了竞争,但随着压力的正大,线程吞吐到达极限。但如果增加线程,资源的竞争又会加剧,无法找到平衡点。
在没有特别好的方式避免锁竞争的情况,我们重新回顾了锁竞争的整个发生过程,锁产生本身就是由 cache 自身的结构带来的,尤其是在读操作的时候,我们并不希望存在什么锁的行为。
使用 block cache,是为了在合理的缓存空间中尽可能的提高缓存命中率,以提高缓存的效率。但如果缓存空间非常充足,且命中长期的数据长期处于特定的范围内,实际上并没有观察到大量的缓存淘汰的情况,且当前服务的缓存实际上也并没有用满,所以想到,是不是可以通过关闭 block cache,而直接访问 page cache 来避免读操作时的加锁行为。
除了 block cache,存储端还有一大类内存使用是 indexes and filter blocks,与此有关的设置在 RocksDB 中是 cache_index_and_filter_blocks
。当这个设置为 true 的时候,数据会缓存到 block cache 中,所以如果关闭了 block cache,我们就需要同样关闭 cache_index_and_filter_blocks
(在 NebulaGraph 中,通过配置项 enable_partitioned_index_filter
替代直接修改 RocksDB 的 cache_index_and_filter_blocks
)。
但仅仅修改这些并没有解决问题,实际上观察 perf 我们仍然看到锁的竞争造成的阻塞(下图):
这是因为当 cache_index_and_filter_blocks
为 false 的时候,并不代表 index 和 filter 数据不会被加载到内存中,这些数据其实会被放进 table cache 里,仍然需要通过 LRU 来维护哪些文件的信息需要淘汰,所以 LRU 带来的问题并没有完全解决。处理的方式是将 max_open_files
设置为 -1,以提供给系统无限制的 table cache 的使用,在这种情况下,由于没有文件信息需要置换出去,算法逻辑被关闭。
总结下来核心修改如下表:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-exC4HZKV-1670826978155)(https://nebula-website-cn.oss-cn-hangzhou.aliyuncs.com/nebula-blog/ctrip-ops-practice-about-graph-database-nebulagraph-2701.png)]
避免文件被 table cache 淘汰,避免文件描述符被关闭,加快文件的读取
关闭了 block cache 后,整个系统进入了一个非常稳定的状态,线上集群在访问量增加一倍以上的情况下,系统的 CPU 峰值反而稳定在 30% 以下,且绝大部分时间都在 10% 以内(下图)。
需要说明的是,酒店场景中关闭 block cache 是一个非常有效的手段,能够对其特定情况下的热点访问起到比较好的效果,但这并非是一个常规方式,我们在其他业务方的 NebulaGraph 集群中并没有关闭 block cache。
起因酒店业务在全量写入的时候,即使量不算很大(4~5w/s),在不特定的时间就会导致整个 graphd 集群完全 down 机。由于 graphd 集群都是无状态的,且互相之间没有关系,如此统一的在某个时刻集体 down 机,我们猜测是由于访问请求造成。通过查看堆栈发现了明显的异常(下图):
可以看到上图中的三行语句被反复执行,很显然这里存在递归调用,并且无法在合理的区间内退出,猜测为堆栈已满。在增加了堆栈大小之后,整个执行没有任何好转,说明递归不仅层次很深,且可能存在指数级的增加的情况。同时观察 down 机时的业务请求日志,失败瞬间大量执行失败,但有一些执行失败显示为 null 引用错误,如下图所示:
这是因为返回了报错,但没有 error message,导致发生了空引用(空引用现象是客户端未合理处理这种情况,也是我们客户端的 bug),但这种情况很奇怪,为什么会没有 error message,检查其 trace 日志,发现这些请求执行 NebulaGraph 时间都很长,且存在非常大段的语句。如下图所示:
预感是这些语句导致了 graphd 的 down 机,由于执行被切断导致客户端生成了一个 null 值。将这些语句进行重试,可以必现 down 机的场景。检查这样的请求发现其是由 500 条语句组成(业务方语句拼接上限 500),并没有超过配置设置的最大执行语句数量(512)。
看起来这是一个 NebulaGraph 官方的 bug,我们已经将此问题提交给官方。同时,业务方语句拼接限制从 500 降为 200 后顺利避免该问题导致的 down 机,该 bug 已在新版中修复。
当前我们对 NebulaGraph 的修改主要集中的几个运维相关的环节上,比如新增了命令来指定迁移 storaged 中的分片,以及将 leader 迁移到指定的实例上(下图)。
谢谢你读完本文 (///▽///)
如果你想尝鲜图数据库 NebulaGraph,体验云上图数据库一键服务你的业务 ->☆白嫖 NebulaGraph 云服务;NebulaGraph 也是一款开源的图数据库,上 GitHub 看代码、(з)-☆ star 它 -> GitHub;和其他的 NebulaGraph 用户一起交流图数据库技术和应用技能,留下「你的名片」一起玩耍呀~