在使用关系数据库进行开发的过程中,你可能会经常使用外键来表示父表和子表之间的关联关系,在Elasticsearch中,有哪些方法可以用来让开发者解决索引之间一对多和多对多的关联关系的问题呢
你可以很方便地把一个对象以数组的形式放在索引的字段中。下面的请求将建立一个订单索引,里面包含对象字段“goods”,它用来存放订单包含的多个商品的数据,从而在订单和商品之间建立起一对多的关联。
PUT order-obj-array
{
"mappings": {
"properties": {
"orderid": {
"type": "integer"
},
"buyer": {
"type": "keyword"
},
"order_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"goods": {
"properties": {
"goodsid": {
"type": "integer"
},
"goods_name": {
"type": "keyword"
},
"price": {
"type": "double"
},
"produce_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
}
}
}
}
}
}
现在,向这个索引中添加一条订单数据,里面包含两个商品的数据。
PUT order-obj-array/_doc/1
{
"orderid": "1",
"buyer": "tom",
"order_time": "2020-11-04 00:00:00",
"goods": [
{
"goodsid": "1",
"goods_name": "milk",
"price": 5.2,
"produce_time": "2020-10-04 00:00:00"
},
{
"goodsid": "2",
"goods_name": "juice",
"price": 8.2,
"produce_time": "2020-10-12 00:00:00"
}
]
}
这样做虽然可以把商品数据关联到订单数据中,但是在做多条件搜索的时候会出现问题,比如下面的布尔查询相关代码,其中包含两个简单的match搜索条件。
POST order-obj-array/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"goods.goods_name": "juice"
}
},
{
"match": {
"goods.produce_time": "2020-10-04 00:00:00"
}
}
]
}
}
}
从业务的角度讲,由于“juice”的生产日期是“2020-10-12 00:00:00”,所以这一搜索不应该搜到订单数据,然而实际上却能搜到,代码如下。
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 1.3616575,
"hits" : [
{
"_index" : "order-obj-array",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.3616575,
"_source" : {
"orderid" : "1",
"buyer" : "tom",
"order_time" : "2020-11-04 00:00:00",
"goods" : [
{
"goodsid" : "1",
"goods_name" : "milk",
"price" : 5.2,
"produce_time" : "2020-10-04 00:00:00"
},
{
"goodsid" : "2",
"goods_name" : "juice",
"price" : 8.2,
"produce_time" : "2020-10-12 00:00:00"
}
]
}
}
]
}
之所以会产生这种效果,是因为Elasticsearch在保存对象数组的时候会把数据展平,产生类似下面代码的效果。
"goods.goods_name" : [ "milk", "juice" ],
"goods.produce_time" : [ "2020-10-04 00:00:00", "2020-10-12 00:00:00" ]
这导致的直接后果是你无法将每个商品的数据以独立整体的形式进行检索,使得检索结果存在错误。
PUT order-join
{
"settings": {
"number_of_shards": "5",
"number_of_replicas": "1"
},
"mappings": {
"properties": {
"orderid": {
"type": "integer"
},
"buyer": {
"type": "keyword"
},
"order_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"goodsid": {
"type": "integer"
},
"goods_name": {
"type": "keyword"
},
"price": {
"type": "double"
},
"produce_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"my_join_field": {
"type": "join",
"relations": {
"order": "goods"
}
}
}
}
}
可以看出这个映射包含订单父文档和商品子文档的全部字段,并且在末尾添加了一个名为my_join_field的join字段。在relations属性中,定义了一对父子关系:order是父关系的名称,goods是子关系的名称。
由于父文档和子文档被写进了同一个索引,在添加索引数据的时候,需要指明是在为哪个关系添加文档。先添加一个父文档,它是一条订单数据,在这条数据中把join字段的关系名称指定为order,表明它是一个父文档。
PUT order-join/_doc/1
{
"orderid": "1",
"buyer": "tom",
"order_time": "2020-11-04 00:00:00",
"my_join_field": {
"name":"order"
}
}
然后,为该订单数据添加两个子文档,也就是商品数据。
PUT order-join/_doc/2?routing=1
{
"goodsid": "1",
"goods_name": "milk",
"price": 5.2,
"produce_time": "2020-10-04 00:00:00",
"my_join_field": {
"name": "goods",
"parent": "1"
}
}
PUT order-join/_doc/3?routing=1
{
"goodsid": "2",
"goods_name": "juice",
"price": 8.2,
"produce_time": "2020-10-12 00:00:00",
"my_join_field": {
"name": "goods",
"parent": "1"
}
}
在添加子文档时,有两个地方需要注意。一是必须使用父文档的主键作为路由值,由于订单数据的主键是1,因此这里使用1作为路由值,这能确保子文档被分发到父文档所在的分片上。如果路由值设置错误,搜索的时候就会出现问题。
二是在join字段my_join_field中,要把name设置为goods,表示它是一个子文档,parent要设置为父文档的主键,类似于一个外键。由于join字段中每个子文档是独立添加的,你可以对某个父文档添加、删除、修改某个子文档,嵌套对象则无法实现这一点。由于写入数据时带有路由值,如果要修改主键为3的子文档,修改时也需要携带路由值,代码如下。
POST order-join/_update/3?routing=1
{
"doc": {
"price": 18.2
}
}
由于join类型把父、子文档都写入了同一个索引,因此如果你需要单独检索父文档或者子文档,只需要用简单的term查询就可以筛选出它们。
POST order-join/_search
{
"query": {
"term": {
"my_join_field": "goods"
}
}
}
可见,整个搜索过程与普通的索引过程没有什么区别。但是包含join字段的索引支持一些用于检索父子关联的特殊搜索方式。例如,以父搜子允许你使用父文档的搜索条件查出子文档,以子搜父允许你使用子文档的搜索条件查出父文档,父文档主键搜索允许使用父文档的主键值查出与其存在关联的所有子文档。接下来逐个说明。
以父搜子指的是使用父文档的条件搜索子文档,例如,你可以用订单的购买者数据作为条件搜索相关的商品数据。
POST order-join/_search
{
"query": {
"has_parent": {
"parent_type": "order",
"query": {
"term": {
"buyer": {
"value": "tom"
}
}
}
}
}
}
在这个请求体中,把搜索类型设置为has_parent,表示这是一个以父搜子的请求,参数parent_type用于设置父关系的名称,在查询条件中使用term query检索了购买者tom的订单,但是返回的结果是tom的与订单关联的商品列表,如下所示。
"hits" : [
{
"_index" : "order-join",
"_type" : "_doc",
"_id" : "2",
"_score" : 1.0,
"_routing" : "1",
"_source" : {
"goodsid" : "1",
"goods_name" : "milk",
"price" : 5.2,
"produce_time" : "2020-10-04 00:00:00",
"my_join_field" : {
"name" : "goods",
"parent" : "1"
}
}
},
{
"_index" : "order-join",
"_type" : "_doc",
"_id" : "3",
"_score" : 1.0,
"_routing" : "1",
"_source" : {
"goodsid" : "2",
"goods_name" : "juice",
"price" : 18.2,
"produce_time" : "2020-10-12 00:00:00",
"my_join_field" : {
"name" : "goods",
"parent" : "1"
}
}
}
]
需要记住,以父搜子的时候提供的查询条件用于筛选父文档,返回的结果是对应的子文档。如果需要在搜索结果中把父文档也一起返回,则需要加上inner_hits参数。
POST order-join/_search
{
"query": {
"has_parent": {
"parent_type": "order",
"query": {
"term": {
"buyer": {
"value": "tom"
}
}
},
"inner_hits": {}
}
}
}
以子搜父跟以父搜子相反,提供子文档的查询条件会返回父文档的数据。例如:
POST order-join/_search
{
"query": {
"has_child": {
"type": "goods",
"query": {
"match_all": {}
}
}
}
}
上面的请求把搜索类型设置为has_child,在参数type中指明子关系的名称,它会返回所有子文档对应的父文档。但是如果一个父文档没有子文档,则其不会出现在搜索结果中。相关代码如下。
"hits" : [
{
"_index" : "order-join",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"orderid" : "1",
"buyer" : "tom",
"order_time" : "2020-11-04 00:00:00",
"my_join_field" : {
"name" : "order"
}
}
}
]
你还可以根据子文档匹配搜索结果的数目来限制返回结果,例如:
POST order-join/_search
{
"query": {
"has_child": {
"type": "goods",
"query": {
"match_all": {}
},
"max_children": 1
}
}
}
上述代码表示,如果子文档在query参数中指定的搜索结果数量大于1,就不返回它对应的父文档。你还可以使用min_children参数限制子文档匹配数目的下限。
父文档主键搜索只需要提供父文档的主键就能返回该父文档所有的子文档。例如,你可以提供订单的主键返回该订单所有的子文档。
POST order-join/_search
{
"query": {
"parent_id": {
"type": "goods",
"id": "1"
}
}
}
其中,type用于指定子文档的关系名称,id表示父文档的主键,该查询请求会搜出订单号为1的所有商品的数据,如下所示。
join字段有两种专门的聚集方式,一种是children聚集,它可用于统计每个父文档的子文档数据;另一种是parent聚集,它可用于统计每个子文档的父文档数据。
你可以在一个父文档的聚集中嵌套一个children聚集,这样就可以在父文档的统计结果中加入子文档的统计结果。为了演示效果,下面再添加两条测试数据。
POST order-join/_doc/4
{
"orderid": "4",
"buyer": "mike",
"order_time": "2020-12-04 00:00:00",
"my_join_field": {
"name":"order"
}
}
POST order-join/_doc/5?routing=4
{
"goodsid": "5",
"goods_name": "milk",
"price": 3.6,
"produce_time": "2020-11-04 00:00:00",
"my_join_field": {
"name": "goods",
"parent": "4"
}
}
然后发起一个聚集请求,统计出每个购买者购买的商品名称和数量。
POST order-join/_search
{
"query": {
"match_all": {}
},
"aggs": {
"orders": {
"terms": {
"field": "buyer",
"size": 10
},
"aggs": {
"goods_data": {
"children": {
"type": "goods"
},
"aggs": {
"goods_name": {
"terms": {
"field": "goods_name",
"size": 10
}
}
}
}
}
}
}
}
可以看到,这个请求首先对buyer做了词条聚集,它会得到每个购买者的订单统计数据,为了获取每个购买者购买的商品详情,在词条聚集中嵌套了一个children聚集,在其中指定了子文档的关系名,然后继续嵌套一个词条聚集统计每个商品的数据,得到每个购买者的商品列表。结果如下。
"aggregations" : {
"orders" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "mike",
"doc_count" : 1,
"goods_data" : {
"doc_count" : 1,
"goods_name" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "milk",
"doc_count" : 1
}
]
}
}
},
{
"key" : "tom",
"doc_count" : 1,
"goods_data" : {
"doc_count" : 2,
"goods_name" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "juice",
"doc_count" : 1
},
{
"key" : "milk",
"doc_count" : 1
}
]
}
}
}
]
}
}
parent聚集跟children聚集相反,你可以在子文档的聚集中嵌套一个parent聚集,就能得到每个子文档数据对应的父文档统计数据。例如:
POST order-join/_search
{
"aggs": {
"goods": {
"terms": {
"field": "goods_name",
"size": 10
},
"aggs": {
"goods_data": {
"parent": {
"type": "goods"
},
"aggs": {
"orders": {
"terms": {
"field": "buyer",
"size": 10
}
}
}
}
}
}
}
}
上面的请求首先在goods_name字段上对子文档做了词条聚集,会得到每个商品的统计数据,为了查看每个商品的购买者统计数据,在词条聚集中嵌套了一个parent聚集,需注意该聚集需要指定子关系的名称,而不是父关系的名称。最后在parent聚集中,又嵌套了一个词条聚集,以获得每种商品的购买者统计数据,结果如下。
"aggregations" : {
"goods" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "milk",
"doc_count" : 2,
"goods_data" : {
"doc_count" : 2,
"orders" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "mike",
"doc_count" : 1
},
{
"key" : "tom",
"doc_count" : 1
}
]
}
}
},
{
"key" : "juice",
"doc_count" : 1,
"goods_data" : {
"doc_count" : 1,
"orders" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "tom",
"doc_count" : 1
}
]
}
}
}
]
}
最后来总结一下join字段在解决父子关联时的优缺点。它允许单独更新或删除子文档,嵌套对象则做不到;建索引时需要先写入父文档的数据,然后携带路由值写入子文档的数据,由于父、子文档在同一个分片上,join关联查询的过程没有网络开销,可以快速地返回查询结果。但是由于join字段会带来一定的额外内存开销,建议使用它时父子关联的层级数不要大于2,它在子文档的数量远超过父文档的时比较适用。
所谓在应用层关联数据,实际上并不使用任何特别的字段,直接像关系数据库一样在建模时使用外键字段做父子关联,做关联查询和统计时需要多次发送请求。这里还是以订单和商品为例,需要为它们各建立一个索引,然后在商品索引中添加一个外键字段orderid来指向订单索引,代码如下。
PUT orders
{
"mappings": {
"properties": {
"orderid": {
"type": "integer"
},
"buyer": {
"type": "keyword"
},
"order_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
}
}
}
}
PUT goods
{
"mappings": {
"properties": {
"goodsid": {
"type": "integer"
},
"goods_name": {
"type": "keyword"
},
"price": {
"type": "double"
},
"produce_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"orderid": {
"type": "integer"
}
}
}
}
然后向两个索引中添加数据。
PUT orders/_doc/1
{
"orderid": "1",
"buyer": "tom",
"order_time": "2020-11-04 00:00:00"
}
PUT goods/_bulk
{"index":{"_id":"1"}}
{"goodsid":"1","goods_name":"milk","price":5.2,"produce_time":"2020-10-04 00:00:00","orderid":1}
{"index":{"_id":"2"}}
{"goodsid":"2","goods_name":"juice","price":8.2,"produce_time":"2020-10-12 00:00:00","orderid":1}
此时,如果你想获得以父搜子的效果,就得发送两次请求,例如搜索tom的所有订单以及它们包含的商品,先使用term查询tom的所有订单数据。
POST orders/_search
{
"query": {
"term": {
"buyer": {
"value": "tom"
}
}
}
}
然后使用搜索结果返回的orderid去搜索商品索引。
POST goods/_search
{
"query": {
"terms": {
"orderid": [
"1"
]
}
}
}
可以看到,这样做也能达到目的,但是如果第一次搜索返回的orderid太多就会引起性能下降甚至出错。总之,在应用层关联数据的优点是操作比较简单,缺点是请求次数会变多,如果用于二次查询的条件过多也会引起性能下降,在实际使用时需要根据业务逻辑来进行权衡。