在传统的数据库中,对数据关系的描述无外乎三种:一对一、一对多和多对多关系。 如果有关系相关的数据,我们一般在建表的时候加上主外键。 建立数据链接,然后在查询或者统计中通过 join 恢复或者补全数据,最后得到我们需要的结果数据,然后转换到 Elasticsearch中,如何处理这些关系数据呢?
我们都知道 Elasticsearch 是一个 NoSQL 类型的数据库,弱化了对关系的处理,因为像 Lucene、Elasticsearch、Solor 这样的全文搜索框架对性能的要求更高。 一旦发生 join 操作,性能会很差,所以在使用搜索框架的时候,应该避免把搜索引擎当作关系型数据库来使用。
当然实际数据肯定是有关联的,那么在 Elasticsearch 中如何处理和管理这些关联数据呢?
大家都知道 Elasticsearch 天生支持 JSON 数据是完美的,只要是标准 JSON 结构的数据,不管多复杂,不管嵌套多少层,都可以存储在 Elasticsearch 中,然后可以查询分析,检索。在该机制中,处理和管理关系的方式主要有以下三种:
1)使用 object 和 array[object] 字段类型自动存储多层结构的 JSON 数据
这是 Elasticsearch 默认的机制,也就是我们没有设置任何 mapping,直接往 Elasticsearch 服务器插入一个复杂的 JSON 数据,也能插入成功,而且可以支持检索,(可以这样是因为 Elasticsearch 默认是动态的 mapping ,只要插入标准的 JSON 结构就会自动转换,当然我们也可以控制映射类型,Elasticsearch 支持动态映射和静态映射,静态映射也分严格类型,弱类型,通用类型,不再在这里展开。有兴趣的可以到官网了解)如下数据之一:
PUT cars/_doc/1
{
"name": "Zach",
"car": [
{
"maker": "Saturn",
"model": "SL"
},
{
"maker": "Subaru",
"model": "Imprezza"
}
]
}
我们在 Kibana 中直接打入上面的命令,我们可以查看这个 cars 索引的 mapping。
GET cars/_mapping
上面的命令返回的结果:
{
"cars": {
"mappings": {
"properties": {
"car": {
"properties": {
"maker": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"model": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
},
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
}
生成的存储结构类似于以下内容:
{
"name" : "Zach" ,
"car.maker" : [ "Saturn" , "Subaru" ]
"car.model" : [ "SL" , "Imprezza" ]
}
因为 Elasticsearch 的底层 Lucene 是天然支持多值存储的,所以看起来像上面的数组结构。 实际上,Elasticsearch 是作为一个多值字段存储在这个字段中的。
这样的数据实际上包含了数据和关系。 它看起来像一个一对多的关系。 一个人拥有多辆汽车。 但其实并不是严格的关系,因为 Lucene 底层是平放存储的,所以多辆车的数据其实是混在一起的 数据,你不能根据人名来返回其中的一辆车,因为整个数据是一个整体,无论什么操作都会返回整个数据。
上述的数据结够,在有些时候是很有用的,但是它不能维护 car.maker 及 car.model 的对应关系。比如我们查询 car.maker 为 Subaru 时,它不能返回 car.model 为 Imprezza。
更多阅读,请参阅我之前的文章 “Elasticsearch: object 及 nested 数据类型”。
2)使用 nested[object] 类型来存储具有多级关系的数据
在上面的场景中,我们指出了 array 中存储的数组对象并不是严格相关的,因为第二层的数据没有分离。 如果要分离,则必须使用 nested 类型显式定义数据结构。 只有这样,第二层的多辆汽车数据才相互独立,也就是说可以单独获取或查询某辆汽车的数据。Nested 类型是 object 数据类型的特殊版本。它允许对象数组以一种可以彼此独立查询的方式进行索引
同样的 JSON 数据:
"name": "Zach",
"car": [
{
"maker": "Saturn",
"model": "SL"
},
{
"maker": "Subaru",
"model": "Imprezza"
}
]
如果我们使用上面的 object 来进行存储的话,那么 Elasticsearch 将把整个信息当做一个整体进行存储。如果我们把 car 数据定义为 nested 数据类型,它的形式如下:
PUT cars
{
"mappings": {
"properties": {
"name": {
"type": "text"
},
"car": {
"type": "nested",
"properties": {
"maker": {
"type": "keyword"
},
"model": {
"type": "keyword"
}
}
}
}
}
}
如上所示, car 被定义为 nested 数据类型。最终 Elasticsearch 显示的存储为3个:1 个是 root 文档,另外两个是 car 数组中的两个文档。查询的时候可以独立查询,性能还不错,缺点是更新的代价比较大,每次子文档更新都要重建整个结构的索引,所以 nested 适用于嵌套多级关系不经常更新的场景。
Nested 类型的数据,需要使用其指定的查询和聚合方式才能生效,普通的 Elasticsearch 查询只能查询 1 级或根级属性,nested 属性无法查询,如果要查询,必须使用 embedded 的 Set 查询或聚合。
嵌套应用程序有两种模式:
- 嵌套查询:每个查询在单个文档中有效,包括排序
- 嵌套聚合或过滤:同级别所有文档全局有效,包括过滤排序
更多阅读:Elasticsearch: object 及 nested 数据类型
3)父/子关系
父/子模式与嵌套非常相似,但应用侧重点不同。
在使用 parent/children 管理关系时,Elasticsearch 会在每个 shard 的内存中维护一张关系表。 检索时,关联数据由 has_parent 和 has_child 过滤器获取。 在这种模式下,使用父文档和子文档。 也是独立的,查询性能会比嵌套模式略低,因为插入时父文档和子文档会通过路由分布在同一个 shard,但不保证在同一个Lucene sengment index segment,所以检索性能略低。 此外,每次检索 Elasticsearch 时,都需要从内存关系表中获取数据关联信息。 也需要一定的时间。 嵌套的好处是更新父文档或子文档。 不影响其他文档,所以更新频繁的多级关系使用 parent/children 模式是最合适的。
在 Elasticsearch 中,Join 可以让我们创建 parent/child 关系。Elasticsearch 不是一个 RDMS。通常 join 数据类型尽量不要使用,除非不得已。那么 Elasticsearch 为什么需要 Join 数据类型呢?
在 Elasticsearch 中,更新一个 object 需要 root object 一个完整的 reindex:
- 即使是一个 field 的一个字符的改变
- 即便是 nested object 也需要完整的 reindex 才可以实现搜索
通常情况下,这是完全 OK 的,但是在有些场合下,如果我们有频繁的更新操作,这样可能对性能带来很大的影响。
join 数据类型可以完全地把两个 object 分开,但是还是保持这两者之前的关系。
- parent 及 child 是完全分开的两个文档
- parent 可以单独更新而不需要重新 reindex child
- children 可以任意被添加/串改/删除而不影响 parent 及其它的 children
与 nested 类型类似,父子关系也允许你将不同的实体关联在一起,但它们在实现和行为上有所不同。 与 nested 文档不同,它们不在同一文档中,而 parent/child 文档是完全独立的文档。 它们遵循一对多关系原则,允许你将一种类型定义为 parent 类型,将一种或多种类型定义为 child 类型
即便 join 数据类型给我们带来了方便,但是,它也在搜索时给我带来额外的内存及计算方便的开销。
join 数据类型是一个特殊字段,用于在同一索引的文档中创建父/子关系。 关系部分定义文档中的一组可能关系,每个关系是父(parent)名称和子(child)名称。
一个例子:
PUT my_index
{
"mappings": {
"properties": {
"my_join_field": {
"type": "join",
"relations": {
"question": "answer"
}
}
}
}
}
在这里我们定义了一个叫做 my_index 的索引。在这个索引中,我们定义了一个 field,它的名字是 my_join_field。它的类型是 join 数据类型。同时我们定义了单个关系:question 是 answer 的 parent。
要使用 join 来 index 文档,必须在 source 中提供关系的 name 和文档的可选 parent。 例如,以下示例在 question 上下文中创建两个 parent 文档:
PUT my_index/_doc/1?refresh
{
"text": "This is a question",
"my_join_field": {
"name": "question"
}
}
PUT my_index/_doc/2?refresh
{
"text": "This is another question",
"my_join_field": {
"name": "question"
}
}
更多阅读:Elasticsearch:Join 数据类型,Elasticsearch:在 Elasticsearch 中的 join 数据类型父子关系。
总结
方法一:
- 简单、快速、高性能
- 善于维持一对一的关系
- 无需特别查询
方法二:
- 由于底层存储在同一个 Lucene sengment 中,因此读取和查询性能比较方法更快。
- 更新单个子文档会重建整个数据结构,所以不适合更新频繁嵌套的场景。
- 可以维护一对多和多对多的存储关系
方法三:
- 多关系数据,存储完全独立,但存在于同一个分片中,因此读取和查询性能略低于第二种方式。
- 需要额外内存,维护管理关系表
- 更新文档不会影响其他子文档,适合更新频繁使用的场景。
- 排序和打分操作繁琐,需要额外的脚本函数支持
- 每种方式都有自己适合的应用场景,所以在实践中,我们需要根据实际业务场景选择合适的存储方式