【设计】elasticsearch - 处理关系型数据

不同于关系型数据库, 我们使用es, 一般出于性能、弹性、近实时搜索、大数据量的分析等目的。
然而, 在构建es数据模型时, 免不了会涉及到关系型数据的问题。关系型的数据在实际应用中广泛存在, 关系型数据库对此比较在行, 比如ACID支持、join查询等。es并不擅长这些,es的使用场景, 不是作为关系型数据库而存在的。

当然, 反过来关系型数据库也有不足的地方,比如比较弱的全文搜索、昂贵的join搜索开销、聚合分析等,这些却是es的专业领域。

es和大部分NoSQL数据库一样, 把数据视为平的, 不能够rollback到index之前的状态。但平的数据模型有他的好处, 比如无锁的快速索引、搜索、大数据量的节点间拓展等, 都非常符合es的使用场景。

处理关系型数据


但是, 并不是说关系型不重要,因为即使是在es中,当然无可避免要管理关系型的数据,一般来说,es 主要通过以下的方式或技巧管理:

  • 应用端join
  • 反规范化
  • 嵌套对象
  • parent/child 关联

常见的最终解决方案也来自于这些方法及其混合的使用,接下来分别介绍这些方式:

应用端join


我们可以模拟数据库的join, 把操作移到了客户端进行。

比如有以下数据:

PUT /my_index/user/1
{
"name": "John Smith",
"email": "john@smith.com",
"dob": "1970/10/24"
}

PUT /my_index/blogpost/2
{
"title": "Relationships",
"body": "It's complicated...",
"user": 1
}

如果要找用户名为“John”的博客,首先获取user.id:

GET /my_index/user/_search
{
"query": {
"match": {
"name": "John"
}
}
}

这时获取到user.id后,再去查询这个user的blogposts:

GET /my_index/blogpost/_search
{
"query": {
"filtered": {
"filter": {
"terms": { "user": [1] }
}
}
}
}

可以发现,这里的数据仍旧是规范化的,只不过要在客户端进行join,因此搜索需要进行额外的请求。
而且,上面的例子有个问题,由于现实中叫做“John”的user有很多,所以第二次请求的搜索和结果可能很大。
因此这种方法适合于第一个entity比较少的情况,最好修改变动比较少。

反规范化


如果要获得更好的性能,正如es所追求的,可以在索引的时候反模式化你的数据,使用冗余数据来避免join。

接着上面的例子,我们可以把name写入到blogpost的document中:


PUT /my_index/blogpost/2
{
"title": "Relationships",
"body": "It's complicated...",
"user": {
"id": 1,
"name": "John Smith"
}
}

这时查询就可以用一个请求了:

GET /my_index/blogpost/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "relationships" }},
{ "match": { "user.name": "John" }}
]
}
}
}

这种方式最大的好处就是速度,因为避免了昂贵开销的join操作。

当然,这种方式也有缺点,一个是index的size会相对大一点,这是当然的,因为你冗余了数据。这个倒不是大问题,因为写到硬盘的数据都是高速压缩的,而且es的易拓展的。

另一个要关注的问题是,当修改时,冗余数据也需要更新。比如你改了user.name,那么blogpost中的user.name也需要更新。这个业务场景还好,因为一个用户的博客数很少超过几千篇的,使用这些这些批量更新的接口(scan-scrollbulk)也不需要一秒。

嵌套对象


因为一个document的创建更改删除都是原子操作,那么把紧密的实体关联写入到同一个document也是有好处的。
前面我们提到,blogpost中可以冗余一个user的对象,这个user类型其实属于object(和这一节介绍的不一样),它的实现其实是user.id作为一个key,由key-value对的列表组成document来保存,那如果一个数组作为一个field是怎样索引的呢 ?

假如一个blopost可以有多个comments:

PUT /my_index/blogpost/1
{
"title": "Nest eggs",
"body": "Making your money work...",
"tags": [ "cash", "shares" ],
"comments": [
{
"name": "John Smith",
"comment": "Great article",
"age": 28,
"stars": 4,
"date": "2014-09-01"
},
{
"name": "Alice White",
"comment": "More like this please",
"age": 31,
"stars": 5,
"date": "2014-10-22"
}
]
}

因为没有不指定comments的类型, 它将会是object类型,索引里大概是这样的:

{
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ],
"comments.name": [ alice, john, smith, white ],
"comments.comment": [ article, great, like, more, please, this ],
"comments.age": [ 28, 31 ],
"comments.stars": [ 4, 5 ],
"comments.date": [ 2014-09-01, 2014-10-22 ]
}

这样我们搜索某个comments.name的值时,就无法查询到对应哪个comments了,比如Alice White31的关联就丢失了。所以我们需要使用一个叫nested的类型,来代替object作为comments的field type,具体操作可参考——Nested Object Mapping

使用nested类型之后,每一个nestd的对象都会被索引成一个hidden separate document,类似这样:

{
"comments.name": [ john, smith ],
"comments.comment": [ article, great ],
"comments.age": [ 28 ],
"comments.stars": [ 4 ],
"comments.date": [ 2014-09-01 ]
}
{
"comments.name": [ alice, white ],
"comments.comment": [ like, more, please, this ],
"comments.age": [ 31 ],
"comments.stars": [ 5 ],
"comments.date": [ 2014-10-22 ]
}
{
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ]
}

虽然把nested object分开索引了,fields还保持了他们间的联系,可以进行inner object不能做的comment查询。不仅如此,nested document和root document的join非常快,接近同一个document中的速度。
这些额外的nested document是隐藏的,不能够直接访问,修改增加删除都需要重新索引整个document。

parent-child relationships


nested objects, 所有实体在同一document中(json 角度看),但使用 parent-child 的 parent 和 children 是分开的 documents.

parent-child 对比 nested objects 的好处:

  • parent document可以被update而无需重新索引children
  • Child documents 可以被 added, changed, or deleted,而不会影响parent和其他children。
  • Child documents 可以在搜索结果中被返回

由于es维护了一个映射:哪些 parents 关联了哪些 children,也多亏了这个映射,使查询时的 joins 速度是比较快的,但是也有一个限制:parent document 及其 children 必须在同一个shard中。

这个 parent-child 的映射被保存在Doc Values(documents到terms的映射)中, 当fully hot in memory时可以快速地执行,当太大时将写回磁盘。

不过因为 Parent-child 使用了 global ordinals 来加速 joins,不管使用了 in-memory cache 或者 on-disk doc values, 当索引改变时 global ordinals 需要重新构建。shard的parents越多,rebuild所花的时间就越多。刷新后的第一次 parent-child query or aggregation之后,会触发global ordinals构建,这时可能触发一个比较明显的延时,可以使用eager_global_ordinals来优化查询时间,把开销转移到 refresh time。

因此 parent-children 比较适合这种场景,parent 有许多 children, 而不是很多 parents 和很少children。

最后


上面只是介绍了几种处理关系型数据的方式及优缺点,具体还是要结合实际的应用场景,来选择最适合的方式。

参考


https://www.elastic.co/guide/en/elasticsearch/guide/current/parent-child-performance.html
https://www.elastic.co/guide/en/elasticsearch/guide/current/relations.html
https://www.elastic.co/guide/en/elasticsearch/guide/1.x/relations.html
preload-fielddata - https://www.elastic.co/guide/en/elasticsearch/guide/current/preload-fielddata.html#global-ordinals
Doc Values - https://www.elastic.co/guide/en/elasticsearch/guide/current/docvalues.html
Inner Objects - https://www.elastic.co/guide/en/elasticsearch/guide/current/complex-core-fields.html#inner-objects
Complex Core Field Types - https://www.elastic.co/guide/en/elasticsearch/guide/current/complex-core-fields.html#object-arrays
Nested datatype - https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html
Doc Values Intro - https://www.elastic.co/guide/en/elasticsearch/guide/current/docvalues-intro.html