八、ElasticSearch的数据建模
1.数据建模简介
英文为Data Modeling,即创建数据模型的过程。
数据模型:是对现实世界进行抽象描述的一种工具和方法,通过抽象的实体及实体间联系的形式,去描述业务规则。从而实现对现实世界的映射。其建模过程如下:
1)概念模型(10%)。确定系统的核心需求和范围边界,实际实体与实体之间的关系。 基础。
2)逻辑模型(60-70%)。进一步梳理业务需求,确定每个实体间的属性,关系和约束等。 核心。
3)物理模型(20-30%)。结合具体的数据库产品,在满足业务读写性能等需求的前提下确定最终的定义。 落地实现。
2.ES中的数据建模
1)ES是基于Luence以倒排索引为基础实现的存储体系,不遵循关系型数据库中的范式约定。
2)Mapping字段相关设置
A、enabled。 true/false。仅存储,不做搜索或聚合分析是设为false。
B、index。 true/false。是否构建倒排索引。不需进行字段的检索的时候设为false。
C、index_options。 docs/freqs/positions/offsets。确定存储倒排索引的哪些信息。
D、norms。 true/false。是否存储归一化相关系数,若字段仅用于过滤和聚合分析,则可关闭。
E、doc_values。 true/false。是否启用doc_values,用于排序和聚类分析。默认开启。
F、field_data。 true/false。是否设text类型为fielddata,实现排序和聚合分析。默认关闭。
G、store。 false/true。是否存储该字段。
H、coerce。 true/false。 是否开启数值类型转换功能,如:字符串转数字等。
I、multifields。 多字段。灵活使用多字段特性来解决多样业务需求。
J、dynamic。 true/false/strict。控制mapping自动更新。
K、date_detection。 true/false。是否启用自定识别日期类型,一般设为false,避免不必要的识别字符串中的日期。
3)Mapping字段属性设定流程
A、是何种类型?
a、字符串类型。
需要分词,则设为text,否则设为keyword。
b、枚举类型。
基于性能考虑,设为keyword,即便该数据为整型。
c、数值类型。
尽量选择贴近的类型,如byte即可表示所有数值时,即用byte,而不是所有都用long。
d、其他类型。
如:布尔型,日期类型,地理位置类型等。
B、是否需要检索?
a、完全不需要检索、排序、聚合分析的字段。
enabled设为false。
b、不需检索的字段。
index设为false。
c、需检索的字段,可通过如下配置设定需要的存储粒度。
index_options 结合需要设定。
norms 不需归一化数据时可关闭。
C、是否需要排序和聚合分析?
当不需要排序和聚合分析功能时:
doc_values设为false。
field_data设为false。
D、是否需要另行存储?
store设为true。即可存储该字段的原始内容(且与_source无关),一般结合_source的enabled设为false时使用。
3.一个ES建模的实例
针对博客文章设定索引blog_index,包含字段:
标题:title;
发布日期:publish_data;
作者:author;
摘要:abstract;
网址:url。
1)一个简易的数据模型:
#简易模型blog_index
PUT blog_index
{
"mappings":{
"doc":{
"properties":{
"title":{
"type":"text", #title设为text,包含自字段keyword。支持检索、排序、聚合分析
"fields":{
"keyword":{
"type":"keyword"
}
}
},
"publish_data":{ #publish_data设为date,支持检索、排序、聚合分析
"type":"date"
},
"author":{
"type":"keyword" #author设为keyword,支持检索、排序、聚合分析
},
"abstract":{
"type":"text" #abstract设为text,支持检索、排序、聚合分析
},
"url":{
"enabled":false #url设为date,不需进行检索
}
}
}
}
}
2)如果在blog_index中加入一个内容字段content:
#为blog_index增加content字段
PUT blog_index
{
"mappings":{
"doc":{
"_source":{
"enabled":false
},
"properties":{
"title":{
"type":"text", #title设为text,包含自字段keyword。支持检索、排序、聚合分析
"fields":{
"keyword":{
"type":"keyword"
}
},
"store":true #对数据进行存储
},
"publish_data":{ #publish_data设为date,支持检索、排序、聚合分析
"type":"date",
"store":true #对数据进行存储
},
"author":{
"type":"keyword", #author设为keyword,支持检索、排序、聚合分析
"store":true #对数据进行存储
},
"abstract":{
"type":"text", #abstract设为text,支持检索、排序、聚合分析
"store":true #对数据进行存储
},
"content":{
"type":"text", #content设为text,支持检索、排序、聚合分析
"store":true #对数据进行存储
},
"url":{
"type":"keyword", #url设为keyword
"doc_values":false, #url不支持排序和聚合分析
"norms":false, #url也不需要归一化数据
"ignore_above":100, #预设内容长度为100
"store":true #对数据进行存储
}
}
}
}
}
3)在搜索时增加高亮:
在此时,content里面的数据会存储大量的内容数据,数据量可能达到上千、上万,甚至几十万。那么在搜索的时候,根据search机制,如果还是像之前一样进行_search搜索,并只显示其他字段的话,其实依然还是每次获取了content字段的内容,影响性能,所以,使用stored_fields参数,控制返回的字段。节省了大量资源:
#使用stored_fields返回指定的存储后的字段
GET blog_index/_search
{
"stored_fields":["title","publish_data","author","Abstract","url"],
"query":{
"match":{
"content":"world" #依然进行content搜索,但是不返回所有的content字段
}
},
"highlight":{ #针对content字段进行高亮显示
"fields":{
"content":{}
}
}
}
注意:GET blog_index/_search?_source=title 虽然只显示了title,但是search机制决定了,会把所有_source内容获取到,但只是显示title。
4.ES中关联关系处理
ES不擅长处理关系型数据库中的关联关系,因为底层使用的倒排索引,如:文章表blog和评论表comment之间通过blog_id关联。在ES中可以通过两种方式变相解决:
①Nested Object;
②Parent/Child。
现在新增一个评论索引:comment,包含:文章ID:blog_id、评论人:username、评论日期:date、评论内容:content。
1)使用Nested Object:
#使用Nested Object处理关联关系
Blog1
{
"title":"Blog Number One",
"author":"alfred",
"comments":[
{
"username":"lee",
"date":"2017-01-02",
"content":"awesome acticle!"
},
{
"username":"fax",
"date":"2018-04-02",
"content":"thanks!"
}
]
}
查询:用户lee评论了thanks的blog文档:
#查询:用户lee评论了thanks的blog文档
GET blog_index/_search
{
"query":{
"bool":{
"must":[
{
"match":{
"comments.username":"lee"
}
},
{
"match":{
"comments.content":"thanks"
}
}
]
}
}
}
返回结果会直接返回包含lee和包含thanks的文档,而不是同时包含lee和thanks的文档。此时,comments默认是Object Array,存储结构类似于以下形式:
#存储结构1:
{
"title":"Blog Number One",
"author":"alfred",
"comments.username":[
"lee",
"fax"
],
"comments.date":[ #此时结构为一个Object
"2017-01-02",
"2017-04-02"
],
"comments.content":[
"awesome article!",
"thanks!"
]
}
使用Nested Object可以解决这个问题:
#使用Nested Object
PUT blog_index_nested
{
"mappings":{
"doc":{
"properties":{
"title":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":100
}
}
},
"publish_date":{
"type":"date"
},
"author":{
"type":"keyword",
"ignore_above":100
},
"abstract":{
"type":"text"
},
"url":{
"enabled":false
},
"comments":{ #将comments放在blog_index的内部,作为一个字段进行存储
"type":"nested", #此时设置内部的comments的类型为nested
"properties":{
"username":{
"type":"keyword",
"ignore_above":100
},
"date":{
"type":"date"
},
"content":{
"type":"text"
}
}
}
}
}
}
}
那么在进行查询的时候,使用nested关键词:
#使用nested关键词的查询
GET blog_index/_search
{
"query":{
"nested":{ #使用nested关键词
"path":"comments", #与之前的查询的不同点
"query":{
"bool":{
"must":[
{
"match":{
"comments.username":"lee"
}
},
{
"match":{
"comments.content":"thanks"
}
}
]
}
}
}
}
}
返回结果会直接返回同时包含lee和thanks的文档,而不是包含lee和包含thanks的文档。此时,comments是Nested Object Array,存储结构类似于以下形式:
#Nested Object Array存储结构:
{
title","Blog Number One",
"authoe":"alfred"
}
{
"comments.username":"lee",
"comments.date":"2017-01-02",
"comments.content":"awesome article!" #此时Nested Object独立存在
}
{
"comments.username":"fax",
"comments.date":"2017-04-02",
"comments.content":"thanks!" #两个Object
}
2)使用Parent/Child:
ES还提供了类似于关系型数据库中join的实现的方式:使用数据类型join:
#使用Parent/Child实现类似join的方式:
PUT blog_index_parent_child
{
"mappings":{
"doc":{
"properties":{
"join":{ #字段名
"type":"join", #指明类型
"relations":{ #指明父子类型
"blog":"comment" #前为父类型,后为子类型
}
}
}
}
}
}
#创建父文档:
PUT blog_index_parent_child/doc/1
{
"title":"blog",
"join":"blog" #指明父类型
}
#创建子文档:
PUT blog_index_parent_child/doc/comment-1?routing=1 #指明routing值,确保父子文档在一
{ #个shard上,一般使用父文档的id
"comment":"comment world",
"join":{ #字段名
"name":"comment", #指明子类型
"parent":1 #指明父文档id
}
}
常见query语法包括以下几种:
①parent_id:返回某父文档的子文档;
②has_child:返回包含某子文档的父文档;
③has_parent:返回包含某父文档的子文档
1)parent_id查询:
#返回某父文档的子文档
GET blog_index_parent_child/_search
{
"query":{
"parent_id":{
"type":"comment", #指定子文档类型
"id":1 #指明父文档id
}
}
}
2)has_child查询:
#返回包含某子文档的父文档
GET blog_index_parent_child/_search
{
"query":{
"has_child":{
"type":"comment", #指明子文档类型
"query":{
"match":{
"comment":"world" #指明查询条件
}
}
}
}
}
3)has_parent查询:
#返回包含某父文档的子文档
GET blog_index_parent_child/_search
{
"query":{
"has_parent":{
"parent_type":"blog", #指明父文档类型
"query":{
"match":{
"title":"blog" #指明查询条件
}
}
}
}
}
Nested Object 对比 Parent/Child:
对比 | Nested Object | Parent/Child |
优点 | 文档存储在一起,因此读写性能较高 | 父子文档可独立更新,二者互不影响 |
缺点 | 更新父子文档需要同时更新整个文档 | 为了维护join的关系,需占用部分内存,读写性能较差 |
使用场景 | 子文档偶尔更新,且查询频繁 | 子文档更新频繁 |
建议:尽量使用Nested Object来解决问题,避免使用Parent/Child。
5.ES中的reindex
reindex:指重建所有数据的过程,一般发生在一下情况:
①mapping设置变更,如:字段类型变化,分词器字典更新等;
②index设置变更,如:分片数变化;
③迁移数据。
ES提供了线程的api用于完成数据重建:
_update_by_query:在现有索引上重建;
_reindex:在其他索引上重建。
#将blog_index中所有文档重建一遍:
POST blog_index/_update_by_query?conflicts=proceed #如果遇到版本冲突,依然执行。
#此时如果blog_index中没有store的数据,则会报错
1)使用_update_by_query,更新文档的字段值和部分文档:
#更新文档的字段值及部分文档
POST blog_index/_update_by_query
{
"script":{ #更新文档的字段值
"source":"ctx._source.likes++", #代码
"lang":"painless" #ES自带script语法
},
"query":{ #更新部分文档
"term":{
"user":"tom"
}
}
}
在reindex发起后进入的文档,不会参与重建,类似于快照的机制。因此:一般在文档不再发生变更时,进行文档的reindex。
2)使用_reindex,重建数据:
#使用_reindex:
POST _reindex
{
"source":{ #被重建索引
"index":"blog_index"
},
"dest":{ #目标索引
"index":"blog_new_index"
}
}
数据重建时间,受到索引文档规模的影响,此时设定url参数wait_for_completion为false,来异步执行。
ES通过task来描述此类执行任务,并提供了task api来查看任务的执行进度和相关数据:
#使用task api
POST blog_index/_update_by_query?comflicts=proceed&wait_for_completion=false
#使用返回的taskid,查看任务的执行进度和相关数据
GET _tasks/<返回的task id>
6、其他建议:
1)对mapping进行版本管理:
要么写文件/注释,加入到Git仓库,一眼可见;
要么增加metadata字段,维护版本,并在每次更新mapping设置的时候加1。
"metadata":{
"version":1
}
2)防止字段过多:
index.mapping.total_fields_limit,默认1000个。一般是因为没有高质量的数据建模导致,如:dynamic设为true。此时考虑查分多个索引来解决问题。