4、ES数据模型:文档、映射、字段类型、动态映射

好,咱们今天聊聊ES的数据模型。说实话,很多新手刚接触ElasticSearch时,最容易犯的错就是把它当成普通数据库来用。我当年也踩过这个坑——把ES当MySQL使,结果查询性能惨不忍睹。后来才明白,ES的数据模型设计,直接决定了你的搜索体验和集群稳定性。

4.1 文档(Document):ES里的“一行数据”

文档是ES中最基本的数据单元。你可以把它理解成JSON对象,但又不完全是。

文档长什么样?

{
  "_index": "users",
  "_id": "1",
  "_source": {
    "name": "张三",
    "age": 28,
    "bio": "资深Java开发,热爱开源"
  }
}

每个文档都有几个关键属性:

  • _index:文档属于哪个索引,相当于数据库里的表名
  • _id:文档的唯一标识,你可以自己指定,也可以让ES自动生成
  • _source:真正的数据本体,就是那个JSON对象
我的习惯:生产环境中,我一般会自己指定_id。比如用业务主键(用户ID、订单号)作为_id,这样去重和更新都方便。自动生成的_id是26位UUID,可读性差,排查问题时很头疼。

文档有几个重要特性:

  • 不可变性:文档一旦写入,就不能部分修改。你看到的“更新”操作,其实是先删除旧文档,再写入新文档
  • 扁平化结构:ES内部会把JSON拍平。比如嵌套的 {"address": {"city": "北京"}} 会变成 address.city: 北京
  • 元数据:除了_source,每个文档还携带_version(版本号)、_seq_no(序列号)等元数据,用于并发控制
注意:_source字段默认是开启的,会占用存储空间。如果你只关心搜索,不需要返回原始数据,可以关闭_source。但我不建议这么做——我曾经在一个日志系统里关了_source,结果排查问题时拿不到原始日志,后悔莫及。

4.2 映射(Mapping):定义文档的“骨架”

映射就是ES里的“表结构定义”。它告诉ES:你的文档里有哪些字段,每个字段是什么类型,该怎么索引和存储。

映射的核心作用:

  • 定义字段名称和类型
  • 控制字段是否被索引(能不能搜到)
  • 控制字段是否存储(能不能返回)
  • 指定分词器(中文分词还是英文分词)

举个例子,创建一个带映射的索引:

PUT /my_index
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "price": {
        "type": "float"
      },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss"
      }
    }
  }
}

这里我定义了三个字段:title是文本类型,用IK分词器;price是浮点数;created_at是日期,指定了格式。

关键点:映射一旦创建,就不能随意修改。ES不像MySQL那样可以随便ALTER TABLE。所以,设计映射时一定要想清楚。我见过太多人上线后才发现字段类型不对,只能重建索引。

4.3 字段类型详解

ES的字段类型非常丰富,但日常开发中,最常用的就这几种。咱们一个一个说。

4.3.1 text 类型

text类型用于全文搜索。它会对内容进行分词,然后建立倒排索引。

特点:

  • 支持模糊搜索、短语搜索、高亮显示
  • 不能用于聚合、排序(除非开启fielddata)
  • 需要指定分词器
{
  "description": {
    "type": "text",
    "analyzer": "standard",  // 标准分词器,按空格和标点切分
    "search_analyzer": "standard"  // 搜索时的分词器
  }
}
避坑指南:我曾经在一个电商项目中,把商品标题设成了text类型,结果用户搜“手机壳”时,匹配到了“手机”和“壳”两个词,返回了大量不相关的结果。后来我改用keyword类型做精确匹配,才解决了问题。记住:需要精确匹配的字段,别用text

4.3.2 keyword 类型

keyword类型用于精确匹配、聚合、排序。它不会分词,整个字符串作为一个词条存入倒排索引。

适用场景:

  • ID、邮箱、标签、状态码
  • 需要做terms聚合的字段
  • 需要排序的字段
{
  "email": {
    "type": "keyword"
  },
  "status": {
    "type": "keyword"
  }
}

keyword类型有一个重要参数:ignore_above。它控制超过多长的字符串会被忽略。默认是2147483647(几乎不限),但实际使用中,我建议设置一个合理值,比如256或512,避免超大字符串撑爆内存。

注意:keyword字段默认只支持精确匹配(大小写敏感)。如果你需要模糊匹配,可以用wildcard查询,但性能较差。更好的做法是:同时保留一个text字段用于搜索,一个keyword字段用于聚合。

4.3.3 date 类型

date类型用于存储日期和时间。ES内部会把日期转换成毫秒时间戳(long类型)存储。

支持的格式:

  • 字符串:"2024-01-15""2024/01/15 10:30:00"
  • 时间戳:1705300000000(毫秒)
  • 自定义格式:通过format参数指定
{
  "publish_date": {
    "type": "date",
    "format": "yyyy-MM-dd HH:mm:ss||epoch_millis"
  }
}

这里用了双竖线||,表示支持多种格式。ES会依次尝试解析。

我的经验:日期格式一定要统一。我见过一个系统,有的数据用"2024-01-15",有的用"2024/01/15",还有的用时间戳。结果做范围查询时,ES解析失败,数据丢失。后来我强制所有数据都用yyyy-MM-dd'T'HH:mm:ss'Z'(ISO 8601格式),再也没出过问题。

4.3.4 nested 类型

nested类型是ES里处理“数组中的对象”的解决方案。为什么需要它?因为ES默认会把JSON对象拍平。

举个例子:

// 原始数据
{
  "user": "张三",
  "orders": [
    {"product": "手机", "price": 5000},
    {"product": "耳机", "price": 200}
  ]
}

// ES内部拍平后
{
  "user": "张三",
  "orders.product": ["手机", "耳机"],
  "orders.price": [5000, 200]
}

看到了吗?拍平后,orders.productorders.price变成了两个独立的数组。如果你搜索“手机且价格=200”,它会匹配到——因为“手机”在product数组里,“200”在price数组里,但这两个值其实不属于同一个订单。这就是对象关系丢失问题。

nested类型怎么解决?

{
  "mappings": {
    "properties": {
      "user": {"type": "keyword"},
      "orders": {
        "type": "nested",
        "properties": {
          "product": {"type": "keyword"},
          "price": {"type": "integer"}
        }
      }
    }
  }
}

使用nested类型后,ES会把每个内部对象独立存储,保持它们之间的关联。查询时要用nested查询:

GET /my_index/_search
{
  "query": {
    "nested": {
      "path": "orders",
      "query": {
        "bool": {
          "must": [
            {"term": {"orders.product": "手机"}},
            {"term": {"orders.price": 200}}
          ]
        }
      }
    }
  }
}
性能警告:nested类型虽然解决了对象关系问题,但性能开销很大。每个nested对象在底层都是一个独立的Lucene文档。如果你有大量nested数据,查询和索引都会变慢。我建议:nested对象数量控制在100以内,超过这个数,考虑用父子文档或拆分索引。

4.4 动态映射:ES的“自动类型推断”

动态映射是ES的一大特色。你不需要事先定义映射,直接写入数据,ES会自动推断字段类型。

动态映射的规则:

JSON类型 ES推断类型
字符串(可解析为日期) date
字符串(数字格式) float 或 long
普通字符串 text + keyword(多字段)
整数 long
浮点数 float
布尔值 boolean
对象 object
数组 取第一个元素的类型

动态映射的三种模式:

  • true(默认):自动检测并添加新字段
  • false:忽略新字段,不索引也不存储
  • strict:遇到新字段直接报错,拒绝写入
PUT /my_index
{
  "mappings": {
    "dynamic": "strict",  // 严格模式
    "properties": {
      "name": {"type": "text"}
    }
  }
}
我的建议:生产环境一定要用strict模式。为什么?因为动态映射太“聪明”了,聪明到会犯错。我遇到过:一个字段本来存的是字符串"123",后来某条数据存了数字123,ES自动把字段类型从text改成了long,导致之前的查询全部失效。这种问题排查起来非常痛苦。

动态映射的模板(Dynamic Templates):

如果你既想享受动态映射的便利,又想控制字段类型,可以用动态模板。比如:

PUT /my_index
{
  "mappings": {
    "dynamic_templates": [
      {
        "strings_as_keyword": {
          "match_mapping_type": "string",
          "mapping": {
            "type": "keyword"
          }
        }
      },
      {
        "longs_as_integer": {
          "match_mapping_type": "long",
          "mapping": {
            "type": "integer"
          }
        }
      }
    ]
  }
}

这个模板把所有字符串都映射成keyword,所有长整型都映射成integer。这样既保留了动态映射的灵活性,又避免了类型推断错误。

4.5 总结与最佳实践

好了,咱们把ES数据模型的核心内容过了一遍。最后给你几个我总结的“铁律”:

  1. 先设计映射,再写入数据。别偷懒用动态映射,除非是临时测试
  2. text和keyword要分清楚。需要搜索的用text,需要精确匹配和聚合的用keyword
  3. 日期格式要统一。推荐ISO 8601格式,兼容性好
  4. nested类型慎用。能用扁平结构就别嵌套,性能差距很大
  5. 动态映射用strict模式。宁可报错,也别让ES自作主张

记住一句话:ES的数据模型设计,决定了你未来三年的运维成本。设计时多花一小时,运维时少花一周。嗯,今天就到这里,下节课咱们聊聊ES的索引生命周期管理。