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(聚合).

  • 设置多字段类型:

(2) 结构化数据:

  • 数值类型: 尽量选择贴近的类型, 例如可以用 byte, 就不要用 long;
  • 枚举类型: 设置为 keyword, 即使是数字, 也应该设置成 keyword, 获取更好的性能; 另外范围检索使用keyword, 速度更快;

  • 其他类型: 日期、二进制、布尔、地理信息等类型.

2.2 检索、聚合及排序的建模方案

  • 如不需要检索、排序和聚合分析, 则可设置 "enable": false ;
  • 如不需要检索, 则可设置 "index": false ;

  • 如不需要排序、聚合分析功能, 则可设置 "doc_values": false / "fielddate": false ;

  • 更新频繁、聚合查询频繁的 keyword 类型的字段, 推荐设置 "eager_global_ordinals": true .

2.3 额外存储的建模方案

  • 是否需要专门存储当前字段数据?
  • disable_source: 禁用 _source 元字段, 能节约磁盘, 适用于指标型数据 —— 类似于标识字段、时间字段的数据, 不会更新、高亮查询, 多用来进行过滤操作以快速筛选出更小的结果集, 用来支撑更快的聚合操作.

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 元字段默认是开启的, 若禁用后, 就无法对搜索的结果进行展示, 也无法进行 reindexupdateupdate_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) 结果说明:

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 比较:

3.6 mapping 设置小结

(1) 支持加入新的字段 (包括子字段)、更换分词器等操作:

(2) Index Template: 根据索引的名称匹配不同的 mappings 和 settings;

(3) Dynamic Template: 在一个 mapping 上动态设定字段类型;

(4) Reindex: 如果要修改、删除已经存在的字段, 或者修改分片个数等参数, 就要重建索引.

4 ES 数据建模最佳实践

4.1 如何处理关联关系

(1) 范式化设计:

我们知道, 在关系型数据库中有“范式化设计”的概念, 有 1NF、2NF、3NF、BCNF 等等, 主要目标是减少不必要的更新, 虽然节省了存储空间, 但缺点是数据读取操作可能会更慢, 尤其是跨表操作, 需要 join 的表会很多.

反范式化设计: 数据扁平, 不使用关联关系, 而是在文档中通过 _source 字段来保存冗余的数据拷贝.

==》ES 不擅长处理关联关系, 一般可以通过对象类型(object)、嵌套类型(nested)、父子关联关系(child/parent)解决.

具体使用所占篇幅较大, 这里省略.

4.2 避免太多的字段

(1) 一个⽂档中, 最好不要有⼤量的字段:

(2) ES中单个索引最大字段数默认是 1000, 可以通过参数 index.mapping.total_fields.limt 修改最⼤字段数.

思考: 什么原因会导致文档中有成百上千的字段?

4.3 避免正则查询

正则、前缀、通配符查询, 都属于 Term 查询, 但是性能很不好(扫描所有文档, 并逐一比对), 特别是将通配符放在开头, 会导致性能灾难.

(1) 案例:

(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
    }
  }
}
10-08 02:41