Nebula 基于 ElasticSearch 的全文搜索引擎的文本搜索

本文首发于 Nebula Graph 公众号 NebulaGraphCommunity,Follow 看大厂图数据库技术实践。

Nebula 基于全文搜索引擎的文本搜索

1 背景

Nebula 2.0 中已经支持了基于外部全文搜索引擎的文本查询功能。在介绍这个功能前,我们先简单回顾一下 Nebula Graph 的架构设计和存储模型,更易于下边章节的描述。

1.1 Nebula Graph 架构简介

Nebula 基于全文搜索引擎的文本搜索

如图所示,Storage Service 共有三层,最底层是 Store Engine,它是一个单机版 local store engine,提供了对本地数据的get/put/scan/delete操作,相关的接口放在 KVStore / KVEngine.h 文件里面,用户完全可以根据自己的需求定制开发相关 local store plugin,目前 Nebula 提供了基于 RocksDB 实现的 Store Engine。

在 local store engine 之上,便是我们的 Consensus 层,实现了 Multi Group Raft,每一个 Partition 都对应了一组 Raft Group,这里的 Partition 便是我们的数据分片。目前 Nebula 的分片策略采用了静态 Hash的方式,具体按照什么方式进行 Hash,在下一个章节 schema 里会提及。用户在创建 SPACE 时需指定 Partition 数,Partition 数量一旦设置便不可更改,一般来讲,Partition 数目要能满足业务将来的扩容需求。

在 Consensus 层上面也就是 Storage Service 的最上层,便是我们的Storage Interfaces,这一层定义了一系列和图相关的 API。 这些 API 请求会在这一层被翻译成一组针对相应 Partition 的 KV 操作。正是这一层的存在,使得我们的存储服务变成了真正的图存储,否则,Storage Service 只是一个 KV 存储罢了。而 Nebula 没把 KV 作为一个服务单独提出,其最主要的原因便是图查询过程中会涉及到大量计算,这些计算往往需要使用图的 Schema,而 KV 层是没有数据 Schema 概念,这样设计会比较容易实现计算下推。

1.2 Nebula Graph 存储介绍

Nebula Graph 在 2.0 中,对存储结构进行了改进,其包含点、边和索引的存储结构,接下来我们将简单回顾一下 2.0 的存储结构。通过存储结构的解释,大家基本也可以简单了解 Nebula Graph 的数据和索引扫描原理。

1.2.1 Nebula 数据存储结构

Nebula 数据的存储包含“点”和“边”的存储,“点” 和 “边” 的存储均是基于 KV 模型存储,这里我们主要介绍其 Key 的存储结构,其结构如下所示

  • Type: 1 个字节,用来表示 key 的类型,当前的类型有 vertex、edge、index、system 等。
  • PartID: 3 个字节,用来表示数据分片 partition,此字段主要用于 partition 重新分布(balance)时方便根据前缀扫描整个 partition 数据
  • VertexID: n 个字节, 出边里面用来表示源点的 ID, 入边里面表示目标点的 ID。
  • Edge Type: 4 个字节, 用来表示这条边的类型,如果大于 0 表示出边,小于 0 表示入边。
  • Rank: 8 个字节,用来处理同一种类型的边存在多条的情况。用户可以根据自己的需求进行设置,这个字段可存放交易时间交易流水号、或某个排序权重
  • PlaceHolder: 1 个字节,对用户不可见,未来实现分布式做事务的时候使用。
  • TagID:4 个字节,用来表示 tag 的类型。
1.2.1.1 点的存储结构
Type (1 byte) PartID (3 bytes) VertexID (n bytes) TagID (4 bytes)
1.2.1.2 边的存储结构
Type (1 byte) PartID (3 bytes) VertexID (n bytes) EdgeType (4 bytes) Rank (8 bytes) VertexID (n bytes) PlaceHolder (1 byte)

1.2.2 Nebula 索引存储结构

  • props binary (n bytes):tag 或 edge 中的 props 属性值。如果属性为 NULL,则会填充 0xFF。
  • nullable bitset (2 bytes):标识 prop 属性值是否为 NULL,共有 2 bytes(16 bit),由此可知,一个 index 最多可以包含 16 个字段。
1.2.2.1 tag index 存储结构
Type (1 byte) PartID (3 bytes) IndexID (4 bytes) props binary (n bytes) nullable bitset (2 bytes) VertexID (n bytes)
1.2.2.2 edge index 存储结构
Type (1 byte) PartID (3 bytes) IndexID (4 bytes) props binary (n bytes) nullable bitset (2 bytes) VertexID (n bytes) Rank (8 bytes) VertexID (n bytes)

1.3 借用第三方全文搜索引擎的原因

由以上的存储结构推理可以看出,如果我们想要对某个 prop 字段进行文本的模糊查询,都需要进行一个 full table scanfull index scan,然后逐行过滤,由此看来,查询性能将会大幅下降,数据量大的情况下,很有可能还没扫描完毕就出现内存溢出的情况。另外,如果将 Nebula 索引的存储模型设计为适合文本搜索的倒排索引模型,那将背离 Nebula 索引初始的设计原则。经过一番调研和讨论,所谓术业有专攻,文本搜索的工作还是交给外部的第三方全文搜索引擎来做,在保证查询性能的基础上,同时也降低了 Nebula 内核的开发成本。

2 目标

2.1 功能

2.0 版本我们只对 LOOKUP 支持了文本搜索功能。也就是说基于 Nebula 的内部索引,借助第三方全文搜索引擎来完成 LOOKUP 的文本搜索功能。对于第三方全文引擎来说,目前只使用了一些基本的数据导入、查询等功能。如果是要做一些复杂的、纯文本的查询计算的话,Nebula 目前的功能还有待完善和改进,期待广大的社区用户提出宝贵的建议。目前所支持的文本搜索表达式如下:

  • 模糊查询
  • 前缀查询
  • 通配符查询
  • 正则表达式查询

2.2 性能

这里所说的性能,指数据同步性能和查询性能。

  • 数据同步性能:既然我们使用了第三方的全文搜索引擎,那不可避免的是需要在第三方全文搜索引擎中也保存一份数据。经过验证,第三方全文搜索引擎的导入性能要低于 Nebula 自身的数据导入性能,为了不影响 Nebula 自身的数据导入性能,我们通过异步数据同步的方案来进行第三方全文搜索引擎的数据导入工作。具体的数据同步逻辑我们将在以下章节中详细介绍。
  • 数据查询性能:刚刚我们提到了,如果不借助第三方全文搜索引擎,Nebula 的文本搜索将是一场噩梦。目前 LOOKUP 中通过第三方全文引擎支持了文本搜索,不可避免的性能会慢于 Nebula 原生的索引扫描,有时甚至第三方全文引擎自身的查询都会很慢,此时我们需要有一个时效机制来保证查询性能。即 LIMITTIMEOUT,将在下列章节中详细介绍。

3 名词解释

名称 说明
Tag 用于点上的属性结构,一个 vertex 可以附加多个 tag,以 tagId 标示。
Edge 类似于 tag,edge 是用于边上的属性结构,以 edgetype 标示。
Property tag 或 edge 上的属性值,其数据类型由 tag 或 edge 的结构确定。
Partition Nebula Graph 的最小逻辑存储单元,一个 Storage Engine 可包含多个 partition。Partition 分为 leader 和 follower 的角色,raftex 保证了 leader 和 follower 之间的数据一致性。
Graph space 每个 graph space 是一个独立的业务 graph 单元,每个 graph space 有其独立的 tag 和 edge 集合。一个 Nebula Graph 集群中可包含多个 graph space。
Index 下文中出现的 index 指 Nebula Graph 中点和边上的属性索引。其数据类型依赖于 tag 或 edge。
TagIndex 基于 tag 创建的索引,一个 tag 可以创建多个索引。因暂不支持复合索引,因此一个索引只可以基于一个 tag。
EdgeIndex 基于 edge 创建的索引。同样,一个 edge 可以创建多个索引,但一个索引只可以基于一个 edge。
Scan Policy index 的扫描策略,往往一条查询语句可以有多种索引的扫描方式,但具体使用哪种扫描方式需要 scan policy 来决定。
Optimizer 对查询条件进行优化,例如对 WHERE 子句的表达式树进行子表达式节点的排序、分裂、合并等。其目的是获取更高的查询效率。

4 实现逻辑

目前我们兼容的第三方全文搜索引擎是 ElasticSearch,此章节中主要围绕 ElasticSearch 来进行描述。

4.1 存储结构

4.1.1 DocID

partId(10 bytes) schemaId(10 bytes) encoded_columnName(32 bytes) encoded_val(max 344 bytes)
  • partId:对应于 Nebula 的 partition ID,当前的 2.0 版本中还没有用到,主要用于今后的查询下推和 es routing 机制。
  • schemaId:对应于 Nebula 的 tagId 或 edgetype。
  • encoded_columnName:对应于 tag 或 edge 中的 column name,此处做了一个 md5 的编码,用以避免 ES DocID 中不兼容的字符。
  • encoded_val 之所以最大为 344 个 byte,是因为 prop value 做了一个 base64 的编码,用于解决 prop 中存在某些 docId 不支持的可见字符的问题。实际的 val 大小被限制在 256 byte。这里为什么会将长度限制在 256?设计之初,主要的目的是完成 LOOKUP 中的文本搜索功能。基于 Nebula 自身的 index,其长度也有限制,类似传统关系数据库 MySQL 一样,其索引的字段长度建议在 256 个字符之内。因此将第三次搜索引擎的长度也限制在 256 之内。此处并没有支持长文本的全文搜索
  • ES 的 docId 最长为 512 byte,目前有大约 100 个 byte 的保留字节。

4.1.2 Doc Fields

  • schema_id:对应于 Nebula 的 tagId 或 edgetype。
  • column_id:nebula tag 或 edge 中 column 的编码。
  • value:对应于 Nebula 原生索引中的属性值。

4.2 数据同步逻辑

Leader & Listener

上边的章节中简单介绍了数据异步同步的逻辑,此逻辑将在本章节中详细介绍。介绍之前,先让我们认识一下 Nebula 的 Leader 和 Listener。

  • Leader:Nebula 本身是一个可水平扩展的分布式系统,其分布式协议是 raft。一个分区(Partition)在分布式系统中可以有多种角色,例如 Leader、Follower、Learner 等。当有新数据写入时,会由 Leader 发起 WAL 的同步事件,将 WAL 同步给 Follower 和 Learner。当有网络异常、磁盘异常等情况发生时,其 partition 角色也会随之改变。由此保证了分布式数据库的数据安全。无论是 Leader、Follower,还是 Learner,都是在 nebula-storaged 进程中控制,其系统参数由配置参数nebula-stoage.conf决定。
  • Listener:不同于 Leader、Follower 和 Learner,Listener 由一个单独的进程控制,其配置参数由 nebula-stoage-listener.conf 决定。Listener 作为一个监听者,会被动的接收来自于 Leader 的 WAL,并定时的将 WAL 进行解析,并调用第三方全文引擎的数据插入 API 将数据同步到第三方全文搜索引擎中。对于 ElasticSearch,Nebula 支持 PUTBULK 接口。

接下来我们介绍一下数据同步逻辑:

  1. 通过 Client 或 Console 插入 vertex 或 edge
  2. graph 层通过 Vertex ID 计算出相关 partition
  3. graph 层通过 storageClient 将 INSERT 请求发送到相关 Partition 的 Leader
  4. Leader 解析 INSERT 请求,并将 WAL 同步到 Listener 中
  5. Listener 会定时处理新同步来的 WAL,并解析 WAL,获取 tag 或 edge 中字段类型为 string 的属性值。
  6. 将 tag 或 edge 的元数据和属性值组装成 ElasticSearch 兼容的数据结构
  7. 通过 ElasticSearch 的 PUTBULK 接口写入到 ElasticSearch 中。
  8. 如果写入失败,则回到第 5 步,继续重试失败的 WAL,直到写入成功。
  9. 写入成功后,记录成功的 Log ID 和 Term ID,做为下次 WAL 同步的起始值。
  10. 回到第 5 步的定时器,处理新的 WAL。

在以上步骤中,如果因为 ElasticSearch 集群挂掉,或 Listener 进程挂掉,则停止 WAL 同步。当系统恢复后,会接着上次成功的 Log ID 继续进行数据同步。在这里有一个建议,需要 DBA 通过外部监控工具实时监控 ES 的运行状态,如果 ES 长期处于无效状态,会导致 Listener 的 log 日志暴涨,并且无法做正常的查询操作。

4.3 查询逻辑

Nebula 基于全文搜索引擎的文本搜索

由上图可知,其文本搜索的关键步骤是 “Send Fulltext Scan Request” → "Fulltext Cluster" → "Collect Constant Values" → "IndexScan Optimizer"。

  • Send Fulltext Scan Request: 根据查询条件、schema ID、Column ID 生成全文索引的查询请求(即封装成 ES 的 CURL 命令)
  • Fulltext Cluster:发送查询请求到 ES,并获取 ES 的查询结果。
  • Collect Constant Values:将返回的查询结果作为常量值,生成 Nebula 内部的查询表达式。例如原始的查询请求是查询 C1 字段中以“A”开头的属性值,如果返回的结果中包含 “A1” 和 "A2"两条结果,那么在这一步,将会解析为 neubla 的表达式 C1 == "A1" OR C1 == "A2"
  • IndexScan Optimizer:根据新生成的表达式,基于 RBO 找出最优的 Nebula 内部 Index,并生成最优的执行计划。
  • 在"Fulltext Cluster"这一步中,可能会有查询性能慢,或海量数据返回的情况,这里我们提供了 LIMITTIMEOUT 机制,实时中断 ES 端的查询。

5 演示

5.1 部署外部ES集群

对于 ES 集群的部署,这里不再详细介绍,相信大家都很熟悉了。这里需要说明的是,当 ES 集群启动成功后,我们需要对 ES 集群创建一个通用的 template,其结构如下:

{
 "template": "nebula*",
  "settings": {
    "index": {
      "number_of_shards": 3,
      "number_of_replicas": 1
    }
  },
  "mappings": {
    "properties" : {
            "tag_id" : { "type" : "long" },
            "column_id" : { "type" : "text" },
            "value" :{ "type" : "keyword"}
        }
  }
}

5.2 部署 Nebula Listener

  • 根据实际环境,修改配置参数 nebula-storaged-listener.conf
  • 启动 Listener:./bin/nebula-storaged --flagfile ${listener_config_path}/nebula-storaged-listener.conf

5.3 注册 ElasticSearch 的客户端连接信息

nebula> SIGN IN TEXT SERVICE (127.0.0.1:9200);
nebula> SHOW TEXT SEARCH CLIENTS;
+-------------+------+
| Host        | Port |
+-------------+------+
| "127.0.0.1" | 9200 |
+-------------+------+
| "127.0.0.1" | 9200 |
+-------------+------+
| "127.0.0.1" | 9200 |
+-------------+------+

5.4 创建 Nebula Space

CREATE SPACE basketballplayer (partition_num=3,replica_factor=1, vid_type=fixed_string(30));
 
USE basketballplayer;

5.5 添加 Listener

nebula> ADD LISTENER ELASTICSEARCH 192.168.8.5:46780,192.168.8.6:46780;
nebula> SHOW LISTENER;
+--------+-----------------+-----------------------+----------+
| PartId | Type            | Host                  | Status   |
+--------+-----------------+-----------------------+----------+
| 1      | "ELASTICSEARCH" | "[192.168.8.5:46780]" | "ONLINE" |
+--------+-----------------+-----------------------+----------+
| 2      | "ELASTICSEARCH" | "[192.168.8.5:46780]" | "ONLINE" |
+--------+-----------------+-----------------------+----------+
| 3      | "ELASTICSEARCH" | "[192.168.8.5:46780]" | "ONLINE" |
+--------+-----------------+-----------------------+----------+

5.6 创建 Tag、Edge、Nebula Index

此时建议字段 “name” 的长度应该小于 256,如果业务允许,建议 player 中字段 name 的类型定义为 fixed_string 类型,其长度小于 256。

nebula> CREATE TAG player(name string, age int);
nebula> CREATE TAG INDEX name ON player(name(20));

5.7 插入数据

nebula> INSERT VERTEX player(name, age) VALUES \
  "Russell Westbrook": ("Russell Westbrook", 30), \
  "Chris Paul": ("Chris Paul", 33),\
  "Boris Diaw": ("Boris Diaw", 36),\
  "David West": ("David West", 38),\
  "Danny Green": ("Danny Green", 31),\
  "Tim Duncan": ("Tim Duncan", 42),\
  "James Harden": ("James Harden", 29),\
  "Tony Parker": ("Tony Parker", 36),\
  "Aron Baynes": ("Aron Baynes", 32),\
  "Ben Simmons": ("Ben Simmons", 22),\
  "Blake Griffin": ("Blake Griffin", 30);

5.8 查询

nebula> LOOKUP ON player WHERE PREFIX(player.name, "B");
+-----------------+
| _vid            |
+-----------------+
| "Boris Diaw"    |
+-----------------+
| "Ben Simmons"   |
+-----------------+
| "Blake Griffin" |
+-----------------+

6 问题跟踪与解决技巧

对于系统环境的搭建过程中,可能某个步骤错误导致功能无法正常运行,在之前的用户反馈中,我总结了三类可能发生的错误,对分析和解决问题的技巧概况如下

  • Listener 无法启动,或启动后不能正常工作
    • 检查 Listener 配置文件,确保 Listener 的 IP:Port 不和已有的 nebula-storaged 冲突
    • 检查 Listener 配置文件,确保 Meta 的 IP:Port 正确,这个要和 nebula-storaged 中保持一致
    • 检查 Listener 配置文件,确保 pids 目录和 logs 目录独立,不要和 nebula-storaged 冲突
    • 当启动成功后,因为配置错误,修改了配置,再重启后仍然无法正常工作,此时需要清理 meta 的相关元数据。对此提供了操作命令,请参考 nebula 的帮助手册:文档链接
  • 数据无法同步到 ES 集群
    • 检查 Listener 是否从 Leader 端接受到了 WAL,可以查看 nebula-storaged-listener.conf 配置文件中 –listener_path 的目录下是否有文件。
    • 打开 vlog(UPDATE CONFIGS storage:v=3),并关注 log 中 CURL 命令是否执行成功,如果有错误,可能是 ES 配置或 ES 版本兼容性错误
  • ES 集群中有数据,但是无法查询出正确的结果
    • 同样打开 vlog (UPDATE CONFIGS graph:v=3),关注 graph 的 log,检查 CURL 命令是什么原因执行失败
    • 查询时,只能识别小写字符,不能识别大写字符。可能是 ES 的 template 创建错误。请对照 nebula 帮助手册进行创建:文档链接

7 TODO

  • 针对特定的 tag 或 edge 建立全文索引
  • 全文索引的重构(REBUILD)

交流图数据库技术?加入 Nebula 交流群请先填写下你的 Nebula 名片,Nebula 小助手会拉你进群~~

想要和其他大厂交流图数据库技术吗?NUC 2021 大会等你来交流:NUC 2021 报名传送门

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容