在现实的应用场景中,关系型模型是普遍存在的,例如书本与作者的关系,用户和用户对书本评价的关系。在传统的关系型数据库领域,想要表达关系型模型是非常自然的,但在 ES 里要处理这个事情就并不那么简单了。

在 ES 中可以保存关系型模型数据的方式主要有以下两种:

  1. nested:在这种方式中,会将一对多的关系保存在同一个文档中。
  2. join(Parent / Child) :通过维护文档的父子关系,将两个对象分离。

上述的这两种方式都可以描述一对多的关系,今天我们就来了解一下这两种实现关系型模型的方式和它们各种的优缺点和适用场景。

一、nested(嵌套类型)

nested 类型是一种特别的 object 数据类型,其允许数组中的对象可以被单独索引,使它们可以被独立地检索。下面的示例是使用普通的 object 数组来保存书本与作者的一对多关系,我们看看会产生什么问题。

  1. # 创建 Mapping
  2. PUT books_index
  3. {
  4. "mappings": {
  5. "properties": {
  6. "book_id": { "type": "keyword" },
  7. "author": {
  8. "properties": {
  9. "first_name": { "type": "keyword" },
  10. "last_name": { "type": "keyword" }
  11. }
  12. }
  13. }
  14. }
  15. }
  16. # 写入书本数据
  17. PUT books_index/_doc/1
  18. {
  19. "book_id": "1234",
  20. "author": [
  21. { "first_name": "zhang", "last_name": "san" },
  22. { "first_name": "wang", "last_name": "wu" }
  23. ]
  24. }

如上面的示例,我们创建了 books_index 索引,其中 author 字段是一个对象,包含了 first_name 和 last_name 两个属性。并且我们写入数据的时候,书本的作者有两个(描述了一对多的关系):zhangsan 和 wangwu。下面我们来执行一段查询:

  1. GET books_index/_search
  2. {
  3. "query": {
  4. "bool": {
  5. "must": [
  6. { "term": { "author.first_name": "zhang" } },
  7. { "term": { "author.last_name": "wu" } }
  8. ]
  9. }
  10. }
  11. }

如上查询示例,你会发现我们的数据中是没有 zhangwu 这个作者的,但是这个查询却可以命中文档 1,跟我们预期的不一样。

为什么呢?因为 object 被扁平化处理后,其丢失了 first_name 和 last_name 之间的关系,变成了下面这样的关系:

  1. {
  2. "book_id": "1234",
  3. "author.first_name": ["zhang", "wang"],
  4. "author.last_name": ["san", "wu"]
  5. }

对于这个扁平化数组,原先 first_name 和 last_name 间的对应关系已经不复存在了。所以我们的查询语句在 author.first_name 中匹配了 “zhang”,在 author.last_name 匹配了 “wu”,自然而然就命中了文档 1。

那有什么办法可以维护这个关系吗?答案是使用 nested 数据类型。

使用 nested 数据类型可以使对象数组中的对象被独立索引,这样 first_name 和 last_name 间的对应关系就不会丢失了。下面示例我们修改一下 Mapping,把 author 的类型定义为 nested:

  1. # 删除索引
  2. DELETE books_index
  3. # 创建索引,author 类型为 nested
  4. PUT books_index
  5. {
  6. "mappings": {
  7. "properties": {
  8. "book_id": { "type": "keyword" },
  9. "author": {
  10. "type": "nested", # author 定义为 nested 类型的对象
  11. "properties": {
  12. "first_name": { "type": "keyword" },
  13. "last_name": { "type": "keyword" }
  14. }
  15. }
  16. }
  17. }
  18. }

如上示例,我们在 author 中指定了这个对象的类型为 nested,在内部 nested 类型将数组中的每个对象索引为单独的隐藏文档,这样数组中的每个对象就可以被单独检索了。nested 数据类型的检索示例如下:

  1. # nested 数据类型的查询
  2. GET books_index/_search
  3. {
  4. "query": {
  5. "nested": { # 使用 nested 关键字
  6. "path": "author", # path 关键字指定对象名字
  7. "query": {
  8. "bool": {
  9. "must": [
  10. { "term": { "author.first_name": "zhang" } },
  11. { "term": { "author.last_name": "san" } }
  12. ]
  13. }
  14. }
  15. }
  16. }
  17. }

如上示例,使用 nested 关键字指定一个 nested 对象的查询,使用 path 指定 nested 对象的名字。

从上面的示例来看,nested 通过冗余的方式将对象和文档存储在一起,所以查询时的性能是很高的,但在需要更新对象信息的时候需要更新所有包含此对象的文档,例如某个作者的信息更改了,那么所有这个作者的书本文档都要更新。所以 nested 适合查询频繁但更新频率低的场景。

二、Parent / Child(文档的父子关系)

除了提供 nested 来描述一对多关系外,ES 还提供了 join 数据类型来表达关系型数据模型。

join 数据类型允许在一个索引中的文档创建父子关系,通过维护父子文档的关系独立出来两个对象。父文档和子文档是相互独立的,通过类似引用的关系进行绑定,所以当父文档更新时,不需要更新子文档,而子文档可以被任意添加、修改、删除而不会影响到父文档和其他子文档。

需要注意的是,为了维护父子文档的关系需要占用额外的内存资源,并且读取性能相对较差。但由于父子文档是互相独立的,所以适合子文档更新频率高的场景

下面通过一个例子来进一步理解 join 数据类型,更多关于 join 的使用示例可以参考官方文档

1. 在 Mapping 中定义 join 数据类型

在使用 join 前,先进行字段的定义,Mapping 的定义如下:

  1. PUT join_books_index
  2. {
  3. "mappings": {
  4. "properties": {
  5. "book_id": { "type": "keyword" },
  6. "name": { "type": "text" },
  7. "book_comments_relation": { # 定义字段名字
  8. "type": "join", # 此字段为 join 类型
  9. "relations": { # 声明 Parent / Child 的关系
  10. "book": "comment" # book 是 Parent 的名称,comment 是 Child 的名称
  11. }
  12. }
  13. }
  14. },
  15. "settings": {
  16. "number_of_shards": 3, # 定义 3 个主分片
  17. "number_of_replicas": 1
  18. }
  19. }

如上示例,book_comments_relation 是字段的名字,使用 join 关键字定义此字段的类型为 join 类型。relations 处声明了 Parent / Child 的关系,其中 book 是 Parent 的名称,comment 是 Child 的名称。

2. 索引父文档

在定义了 Mapping 后,我们写入父文档的数据。

  1. PUT join_books_index/_doc/11
  2. {
  3. "book_id": "1234",
  4. "name": "java book",
  5. "book_comments_relation": {
  6. "name": "book"
  7. }
  8. }

这里父文档的 ID 为 11,其中 book_comments_relation 声明了文档类型为 book(即我们概念里的父文档)。

3. 索引子文档

索引子文档的示例如下:

  1. PUT join_books_index/_doc/21?routing=11
  2. {
  3. "comment": "a good book!!",
  4. "user_name": "fork",
  5. "book_comments_relation": {
  6. "name": "comment",
  7. "parent": "11"
  8. }
  9. }

如上示例,book_comments_relation 中声明了文档的类型为 comment(即我们概念里的子文档),并且使用 parent 字段指定父文档的 ID。

为了确保查询时的性能,父文档和子文档必须在同一个分片,所以需要强制使用 routing 参数,并且其值为父文档的 ID(如果写入父文档的时候也用 routing 参数,那么需要保证它们的值是一样的)。

4. 数据检索

Ok,在索引了父子文档的数据后,下面来做几个搜索。

获取父文档的信息

  1. # 获取父文档
  2. GET join_books_index/_doc/11
  3. # 结果
  4. {
  5. "_id" : "11",
  6. "_source" : {
  7. "book_id" : "1234",
  8. "name" : "java book",
  9. "book_comments_relation" : {
  10. "name" : "book"
  11. }
  12. }
  13. }

如上示例,可以看到获取的父文档的数据是不包含子文档的信息的,因为父子文档是相互独立的。

获取子文档的信息

  1. # 获取子文档信息
  2. GET join_books_index/_doc/21
  3. # 结果,失败的
  4. {
  5. "_index" : "join_books_index",
  6. "_type" : "_doc",
  7. "_id" : "21",
  8. "found" : false
  9. }
  10. # 获取子文档信息,需要加入 routing 参数
  11. GET join_books_index/_doc/21?routing=11

如上示例,在获取子文档时,如果不加 routing 参数是无法找到对应的子文档的。routing 参数的值为父文档的 ID。

Parent Id 查询

如果我们要查询一本书的评价列表,可以用 Parent Id 进行查询。

  1. POST join_books_index/_search
  2. {
  3. "query": {
  4. "parent_id": {
  5. "type": "comment",
  6. "id": "11"
  7. }
  8. }
  9. }

如上示例,parent_id 字段里,我们查询了父文档 ID 为 11 并且 comment 类型的文档。

Has Child 查询

如果我们想查询用户 “fork” 评论了哪些书本,可以使用 Has Child 查询。Has Child 查询将在子文档中进行条件匹配,然后返回匹配文档对应的父文档的信息。

  1. # Has Child 查询
  2. POST join_books_index/_search
  3. {
  4. "query": {
  5. "has_child": {
  6. "type": "comment", # 在评论中查询
  7. "query": {
  8. "term": {
  9. "user_name": "fork"
  10. }
  11. }
  12. }
  13. }
  14. }
  15. # 结果
  16. {
  17. "_id" : "11",
  18. "_source" : {
  19. "book_id" : "1234",
  20. "name" : "java book",
  21. "book_comments_relation" : {
  22. "name" : "book"
  23. }
  24. }
  25. }

如上示例,使用 has_child 字段来声明一次 Has Child 查询,其中我们查询文档类型(type 字段的值)为 comment(子文档) 的文档,条件为用户名字为 “fork”,而返回结果则为父文档的列表。

Has Parent 查询

那如果我们想查询 java 相关书籍的评论时,可以使用 Has Parent 查询。 Has Parent 查询会在父文档中进行匹配,然后返回匹配文档对应的子文档的信息。

  1. # Has Parent 查询
  2. POST join_books_index/_search
  3. {
  4. "query": {
  5. "has_parent": {
  6. "parent_type": "book",
  7. "query": {
  8. "term": {
  9. "name": "java"
  10. }
  11. }
  12. }
  13. }
  14. }
  15. # 结果
  16. {
  17. "hits" : {
  18. "hits" : [
  19. {
  20. "_id" : "21",
  21. "_source" : {
  22. "comment" : "a good book!!",
  23. "user_name" : "fork",
  24. "book_comments_relation" : {
  25. "name" : "comment",
  26. "parent" : "11"
  27. }
  28. }
  29. }
  30. ]
  31. }
  32. }

如上示例,使用 has_parent 字段 开启一次 Has Parent 查询,parent_type 的值设置为 book(父文档),查询条件为书名中带有 “java” 的文档,最终返回结果则为子文档列表。

三、总结

今天为你介绍了如何在 ES 中保存关系型模型的数据。

我们先从一个实例开始,得出了普通的 object 类型数组是不能满足需求的,因为对象数组在存储的时候被扁平化了,导致对象字段间的关系丢失,从而影响搜索的精准度。

而 nested 类型的数据允许数组中的对象可以被单独索引,使它们可以被独立地检索,从而解决了普通 object 产生的问题。因为 nested 通过冗余的方式将对象和文档存储在一起, 所以适合查询频繁但更新频率低的场景。

另外,我们还介绍了 join 数据类型,利用独立的父子文档间的“绑定”关系来达到保存关系型数据的目的。为了维护父子文档的关系需要占用额外的内存资源,并且读取性能相对较差,因为父子文档是互相独立的,所以适合子文档更新频率高的场景。

最后我们用一张表格来对比一下 nested 和 join 数据类型的优缺点:

Nested 类型 Join 类型
缺点 文档变更时,需要更新所有引用这个文档信息的文档 占用额外的内存资源来维护父子关系,并且读取性能相对较差
优点 对象与文档存储在一起,读取性能高 父子文档互相独立
使用场景 适合查询频繁但更新频率低的场景 适合子文档更新频率高的场景

好了今天的内容到此为止,更多关于 nested 的使用方式可以参考官方文档