30、ElasticSearch 实战:ES 数据建模的探索与实践
1 什么是数据建模?
数据建模(Data modeling), 是创建数据模型的过程.
数据模型是对真实世界进行抽象描述的一种工具和方法, 实现对现实世界的映射. 比如影视作品、演员、观众评论...
数据建模有三个过程: 概念模型 => 逻辑模型 => 数据模型(第三范式)
数据模型, 需要结合使用的数据库类型, 在满足业务读写性能等需求的前提下, 制定出最终的定义.
2 如何对 ES 中的数据进行建模
ES中的数据建模:
由数据存储、检索等功能需求提炼出实体属性、实体之间的关系 =》形成逻辑模型;
由性能需求提炼制定索引模板、索引Mapping(包括字段的配置、关系的处理) ==》形成物理模型.
ES中存储、检索的基本单位是索引文档(document), 文档由字段(field)组成, 所以ES的建模就是对字段进行建模.
文档类似于关系型数据库中的一行数据, 字段对应关系型数据库中的某一列数据.
2.1 字段类型的建模方案
(1) text 与 keyword 比较:
- text: 用于全文本字段, 文本会被 Analyzer 分词; 默认不支持聚合分析及排序, 设置 "fielddata": true 即可支持;
- keyword: 用于 id、枚举及不需要分词的文本, 比如身份证号码、电话号码,Email地址等; 适用于 Filter(精确匹配过滤)、Sorting(排序) 和 Aggregations(聚合).
- 设置多字段类型:
默认会为文本类型设置成 text, 并设置一个 keyword 的子字段;
在处理人类自然语⾔时, 可以添加“英⽂”、“拼⾳”、“标准”等分词器, 提高搜索结果的正确性.
(2) 结构化数据:
- 数值类型: 尽量选择贴近的类型, 例如可以用 byte, 就不要用 long;
- 枚举类型: 设置为 keyword, 即使是数字, 也应该设置成 keyword, 获取更好的性能; 另外范围检索使用keyword, 速度更快;
- 其他类型: 日期、二进制、布尔、地理信息等类型.
2.2 检索、聚合及排序的建模方案
- 如不需要检索、排序和聚合分析, 则可设置 "enable": false ;
- 如不需要检索, 则可设置 "index": false ;
- 如不需要排序、聚合分析功能, 则可设置 "doc_values": false / "fielddate": false ;
- 更新频繁、聚合查询频繁的 keyword 类型的字段, 推荐设置 "eager_global_ordinals": true .
2.3 额外存储的建模方案
- 是否需要专门存储当前字段数据?
"store": true
, 可以存储该字段的原始内容;一般结合
"_source": { "enabled": false }
进行使用, 因为默认的"_source": { "enabled": true }
, 也就是添加索引时文档的原始 JSON 结构都会存储到_source
中.
- disable_source: 禁用 _source 元字段, 能节约磁盘, 适用于指标型数据 —— 类似于标识字段、时间字段的数据, 不会更新、高亮查询, 多用来进行过滤操作以快速筛选出更小的结果集, 用来支撑更快的聚合操作.
官方建议: 如果更多关注磁盘空间, 那么建议优先考虑增加数据的压缩⽐, 而不是禁用
_source
;无法看到
_source
字段, 就不能做reindex
、update
、update_by_query
操作;目前为止, Kibana 中无法对禁用了
_source
字段的索引进行 Discover 挖掘操作.—— 谨慎禁用
_source
字段, 参考: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html
3 ES 数据建模实例演示
3.1 动态创建映射关系
# 直接写入一本图书信息:
POST books/_doc
{
"title": "Thinking in Elasticsearch 7.2.0",
"author": "Heal Chow",
"publish_date": "2019-10-01",
"description": "Master the searching, indexing, and aggregation features in Elasticsearch.",
"cover_url": "https://healchow.com/images/29dMkliO2a1f.jpg"
}
# 查看自动创建的mapping关系:
GET books/_mapping
# 内容如下:
{
"books" : {
"mappings" : {
"properties" : {
"author" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"cover_url" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"description" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"publish_date" : {
"type" : "date"
},
"title" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
}
}
}
}
}
3.2 手动创建映射关系
# 删除自动创建的图书索引:
DELETE books
# 手动优化字段的mapping:
PUT books
{
"mappings": {
"_source": { "enabled": true },
"properties": {
"title": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 100
}
}
},
"author": { "type": "keyword" },
"publish_date": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyyMMddHHmmss||yyyy-MM-dd||epoch_millis"
},
"description": { "type": "text" },
"cover_url": { index 设置成 false, 不支持搜索, 但支持 Terms 聚合
"type": "keyword",
"index": false
}
}
}
}
说明:_source
元字段默认是开启的, 若禁用后, 就无法对搜索的结果进行展示, 也无法进行 reindex
、update
、update_by_query
操作.
3.3 新增需求 - 添加大字段
- 需求描述: 添加图书内容字段, 要求支持全文搜索, 并且能够高亮显示.
- 需求分析: 新需求会导致 _source 的内容过⼤, 虽然我们可以通过source filtering对要搜索结果中的字段进行过滤:
"_source": {
"includes": ["title"] 或 "excludes": ["xxx"] 排除某些字段, includes 优先级更高
}
但这种方式只是 ES 服务端传输给客户端时的过滤, 内部 Fetch 数据时, ES 各数据节点还是会传输 _source
中的所有数据到协调节点 —— 网络 IO 没有得到本质上的降低.
3.4 解决大字段带来的性能问题
(1)在创建 mapping 时手动关闭 _source
元字段: "_source": { "enabled": false}
;
(2)然后为每个字段设置 "store": true
.
# 关闭_source元字段, 设置store=true:
PUT books
{
"mappings": {
"_source": { "enabled": false },
"properties": {
"title": {
"type": "text",
"store": true,
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 100
}
}
},
"author": { "type": "keyword", "store": true },
"publish_date": {
"type": "date",
"store": true,
"format": "yyyy-MM-dd HH:mm:ss||yyyyMMddHHmmss||yyyy-MM-dd||epoch_millis"
},
"description": { "type": "text", "store": true },
"cover_url": {
"type": "keyword",
"index": false,
"store": true
},
"content": { "type": "text", "store": true }
}
}
}
(3)加数据, 并进行高亮查询:
# 添加包含新字段的文档:
POST books/_doc
{
"title": "Thinking in Elasticsearch 7.2.0",
"author": "Heal Chow",
"publish_date": "2019-10-01",
"description": "Master the searching, indexing, and aggregation features in Elasticsearch.",
"cover_url": "https://healchow.com/images/29dMkliO2a1f.jpg",
"content": "1. Revisiting Elasticsearch and the Changes. 2. The Improved Query DSL. 3. Beyond Full Text Search. 4. Data Modeling and Analytics. 5. Improving the User Search Experience. 6. The Index Distribution Architecture. .........."
}
# 通过 stored_fields 指定要查询的字段:
GET books/_search
{
"stored_fields": ["title", "author", "publish_date"],
"query": {
"match": { "content": "data modeling" }
},
"highlight": {
"fields": { "content": {} }
}
}
查询结果如下:
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 0.5753642,
"hits" : [
{
"_index" : "books",
"_type" : "_doc",
"_id" : "dukLoG0BdfGBNhbF13CJ",
"_score" : 0.5753642,
"highlight" : {
"content" : [
"<em>Data</em> <em>Modeling</em> and Analytics. 5. Improving the User Search Experience. 6."
]
}
}
]
}
}
(4)结果说明:
- 返回结果中不包含 _source 字段;
- 对需要显示的信息, 要在查询中指定 "stored_fields": ["xxx", "yyy"] ;
- 禁⽌ _source 字段后, 仍然支持使用 Highlights API 的使用.
3.5 mapping中字段的常用参数
参考:https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-params.html
- enabled – 设置成 false, 当前字段就只存储, 不支持搜索和聚合分析 (数据保存在 _source 中);
- index – 是否构建倒排索引, 设置成 false, 就无法被搜索, 但还是支持聚合操作, 并会出现在 _source 中;
- norms – 只⽤来过滤和聚合分析(指标数据)、不关心评分的字段, 建议关闭, 节约存储空间;
- doc_values – 是否启用 doc_values, 用于排序和聚合分析;
- field_data – 如果要对 text 类型启用排序和聚合分析, fielddata 需要设置成true;
- coerce – 是否开启数据类型的自动转换 (如: 字符串转数字), 默认开启;
- multifields - 是否开启多字段特性;
- dynamic – 控制 mapping 的动态更新策略, 有 true / false / strict 三种.
doc_values 与 fielddata 比较:
doc_values: 聚合和排序的字段需要开启 —— 默认 为所有非text类型的字段 开启 —— 内存不够时, 会写入磁盘文件中;
fielddata: 是否为text类型开启, 以实现排序和聚合分析 —— 默认关闭 —— 全部加载进内存中.
3.6 mapping 设置小结
(1)支持加入新的字段 (包括子字段)、更换分词器等操作:
可以通过 update_by_query 令旧数据得到清洗.
(2)Index Template: 根据索引的名称匹配不同的 mappings 和 settings;
(3)Dynamic Template: 在一个 mapping 上动态设定字段类型;
(4)Reindex: 如果要修改、删除已经存在的字段, 或者修改分片个数等参数, 就要重建索引.
必须停机, 数据量大时耗时会比较久.
可借助 Index Alias (索引别名) 来实现零停机维护.
4 ES 数据建模最佳实践
4.1 如何处理关联关系
(1)范式化设计:
我们知道, 在关系型数据库中有“范式化设计”的概念, 有 1NF、2NF、3NF、BCNF 等等, 主要目标是减少不必要的更新, 虽然节省了存储空间, 但缺点是数据读取操作可能会更慢, 尤其是跨表操作, 需要 join 的表会很多.
反范式化设计: 数据扁平, 不使用关联关系, 而是在文档中通过 _source
字段来保存冗余的数据拷贝.
优点: 无需处理 join 操作, 数据读取性能好;
缺点: 不适合数据频繁修改的场景.
==》ES 不擅长处理关联关系, 一般可以通过对象类型(object)、嵌套类型(nested)、父子关联关系(child/parent)解决.
具体使用所占篇幅较大, 这里省略.
4.2 避免太多的字段
(1)一个⽂档中, 最好不要有⼤量的字段:
- 过多的字段导致数据不容易维护;
- mapping 信息保存在 Cluster State 中, 数据量过⼤, 对集群性能会有影响 (Cluster State 信息需要和所有的节点同步);
- 删除或修改字段时, 需要 reindex;
(2)ES中单个索引最大字段数默认是 1000, 可以通过参数 index.mapping.total_fields.limt
修改最⼤字段数.
思考:什么原因会导致文档中有成百上千的字段?
ES 是无模式 (schemaless) 的, 默认情况下, 每添加一个字段, ES 都会根据该字段可能的类型自动添加映射关系.
如果业务处理不严谨, 会出现字段爆炸的现象. 为了避免这种现象的发生, 需要制定 dynamic 策略:
- true - 未知字段会被自动加入, 是默认设置;
- false - 新字段不会被索引, 但是会保存到 _source 中;
- strict - 新增字段不会被索引, ⽂档写入失败, 抛出异常.
—— 生产环境中, 尽量不要使用默认的 "dynamic": true .
4.3 避免正则查询
正则、前缀、通配符查询, 都属于 Term 查询, 但是性能很不好(扫描所有文档, 并逐一比对), 特别是将通配符放在开头, 会导致性能灾难.
(1)案例:
- 文档中某个字段包含了 Elasticsearch 的版本信息, 例如 version: "7.2.0" ;
- 搜索某系列的 bug_fix 版本(末位非0的版本号)? 每个主要版本号所关联的文档?
(2)通配符查询示例:
# 插入2条数据:
PUT softwares/_doc/1
{
"version": "7.2.0",
"doc_url": "https://www.elastic.co/guide/en/elasticsearch/.../.html"
}
PUT softwares/_doc/2
{
"version": "7.3.0",
"doc_url": "https://www.elastic.co/guide/en/elasticsearch/.../.html"
}
# 通配符查询:
GET softwares/_search
{
"query": {
"wildcard": {
"version": "7*"
}
}
}
(3)解决方案 - 将字符串类型转换为对象类型:
# 创建对象类型的映射:
PUT softwares
{
"mappings": {
"properties": {
"version": { # 版本号设置为对象类型
"properties": {
"display_name": { "type": "keyword" },
"major": { "type": "byte" },
"minor": { "type": "byte" },
"bug_fix": { "type": "byte" }
}
},
"doc_url": { "type": "text" }
}
}
}
# 添加数据:
PUT softwares/_doc/1
{
"version": {
"display_name": "7.2.0",
"major": 7,
"minor": 2,
"bug_fix": 0
},
"doc_url": "https://www.elastic.co/guide/en/elasticsearch/.../.html"
}
PUT softwares/_doc/2
{
"version": {
"display_name": "7.3.0",
"major": 7,
"minor": 3,
"bug_fix": 0
},
"doc_url": "https://www.elastic.co/guide/en/elasticsearch/.../.html"
}
# 通过filter过滤, 避免正则查询, 大大提升性能:
GET softwares/_search
{
"query": {
"bool": {
"filter": [
{
"match": { "version.major": 7 }
},
{
"match": { "version.minor": 2 }
}
]
}
}
}
4.4 避免空值引起的聚合不准
(1)示例:
# 添加数据, 包含1条 null 值的数据:
PUT ratings/_doc/1
{
"rating": 5
}
PUT ratings/_doc/2
{
"rating": null
}
# 对含有 null 值的字段进行聚合:
GET ratings/_search
{
"size": 0,
"aggs": {
"avg_rating": {
"avg": { "field": "rating"}
}
}
}
# 结果如下:
{
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 2, # 2条数据, avg_rating 结果不正确
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
},
"aggregations" : {
"avg_rating" : {
"value" : 5.0
}
}
}
(2)使用 null_value
解决空值的问题:
# 创建 mapping 时, 设置 null_value:
PUT ratings
{
"mappings": {
"properties": {
"rating": {
"type": "float",
"null_value": "1.0"
}
}
}
}
# 添加相同的数据, 再次聚合, 结果正确:
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 2,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
},
"aggregations" : {
"avg_rating" : {
"value" : 3.0
}
}
}