索引设计与文档操作

上一篇笔记我们介绍了ES中的核心概念,这篇笔记我们将开始具体操作和实践,学习ES中如何创建索引和操作文档。

索引操作

之前章节我们介绍过,ES中索引(Index)是存储文档的容器,创建索引时我们可以同时指定分片数、副本数等配置和具体的字段Mapping信息。下面例子中,我们创建了一个包含产品信息的索引,它可以在Kibana的Dev Tools中执行。

PUT /products
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "stock": {
        "type": "integer"
      },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
      }
    }
  }
}

settings中的number_of_shards是主分片数,主分片数一旦索引创建后就无法修改;number_of_replicas是副本数,副本数可以随时调整。mappings中定义了具体的字段类型。其中产品名是字符串类型,它特别指定使用ik_max_word分词器;价格的ES中的scaled_float缩放浮点类型,我们知道浮点数本身有精度问题,不适合存储和统计资金相关数值,而ES的缩放浮点类型本质上还是会将数值以整数存储,scaling_factor指定了缩放因子,让数值对外表现的还是像小数,这种类型特别适合存储价格;库存我们使用了整数类型;创建时间使用了日期类型。

查看已创建的索引信息可以使用如下命令。

GET /products

查看全部索引命令如下。

GET /_cat/indices?format=json

这里查看全部索引使用的其实是Cat API。实际上,如果你直接用GET /_cat/indices,ES会返回一个类似表格的纯文本而非JSON,这是Cat API的特点,它是设计给人类阅读而不是程序解析的,不过它也允许我们添加参数format=json来以可读性更好的JSON形式输出。

如下是删除索引命令,该命令会删除索引及其中存储的文档,注意删除是不可逆的,生产操作需谨慎。

DELETE /products

Mapping索引字段类型设计

Mapping是ES中对文档字段的结构定义,它类似关系型数据库中的表结构(Schema),Mapping决定了每个字段以何种方式存储和检索。ES 8.x中,常用的字段类型如下表格。

类型 说明
text 全文检索字段,会经过分词器处理,适合标题、描述、文章内容等
keyword 精确值字段,不分词,适合ID、状态码、标签、枚举值、邮箱等,支持聚合、排序
wildcard 支持通配符和正则高效查询的字段(适合日志、trace_id等)
integer 32位整型
long 64位整型
short 16位整型
byte 8位整型
float 单精度浮点型
double 双精度浮点型
half_float 半精度浮点型(占用更少空间)
scaled_float 缩放浮点型,底层会通过scaling_factor转为long存储
boolean 布尔类型
date 日期类型,支持多种格式,内部以UTC毫秒时间戳存储
date_nanos 纳秒级日期类型(Elasticsearch 7.0+ 引入)
binary 二进制数据的Base64编码,不参与搜索和聚合
object 普通嵌套JSON对象,默认会进行扁平化存储
nested 嵌套文档类型,支持独立查询数组中每个对象,避免跨对象匹配问题
flattened 专门为大、动态嵌套JSON对象设计的字段类型,将整个JSON对象展平为单个字段,适合标签、配置等动态字段
geo_point 地理坐标点经纬度,支持地理距离、地理边界查询
geo_shape 地理形状(包括多边形、线、多点等),支持复杂地理查询
point Cartesian坐标点(x, y)
shape Cartesian形状(矩形、多边形等)
dense_vector 稠密向量字段,用于向量搜索(KNN、语义检索)
sparse_vector 稀疏向量字段(8.11+ 增强支持)
ip IPv4和IPv6地址
completion 前缀补全字段,专为自动补全、搜索建议设计,针对短文本或关键词推荐場景,底层用FST存储,查询非常快

我们这里不会逐个讲解,具体用法用到时参考文档即可,下面我们只介绍一些其中最常用和容易混淆的字段类型。

text vs keyword

textkeyword是使用频率最高也最容易被混淆的两种类型,理解它们的区别至关重要。text会经过分词处理、建立倒排索引,适用于全文检索,而keyword不经过分词处理,存储的是原始值,用于精确匹配、聚合和排序。

一个常见的业务场景是:商品名称既需要支持全文检索(用户输入“iPhone 16”能搜到),又需要支持精确匹配和聚合统计。此时可以使用fields多字段特性,将字段同时映射为textkeyword

{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "ik_max_word",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      }
    }
  }
}

上面的映射定义中,name字段用于全文检索,name.keyword字段用于精确匹配和聚合,两者互不干扰。

date类型的格式配置

date类型默认使用的是ISO 8601格式,但在实际业务中经常需要兼容多种格式,这可以通过format参数配置。

"created_at": {
  "type": "date",
  "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
}

多个格式之间用||分隔,ES会依次尝试解析。

object vs nested

假设订单文档中有一个订单商品列表items数组字段,使用object类型时,ES内部会将数组中所有对象的同名字段展平合并存储。

{
  "items": [
    { "name": "手机", "price": 3999 },
    { "name": "耳机", "price": 299 }
  ]
}

展平后内部实际存储如下。

items.name: ["手机", "耳机"]
items.price: [3999, 299]

这会导致跨字段的关联关系丢失。查询“价格为299的手机”时,因为name=手机price=299都在同一文档中存在,这就会导致错误地命中这条数据,即使它们实际上来自不同的数组元素。

nested类型会将每个数组元素作为独立的隐藏文档存储并保留字段间的关联关系,查询时正确的使用nested查询语法能在单个元素内部进行条件匹配。

"items": {
  "type": "nested",
  "properties": {
    "name": { "type": "keyword" },
    "price": { "type": "double" }
  }
}

不过nested类型的代价是存储和查询开销更大,如果不需要在数组元素内部做关联查询,使用object即可。

动态映射和显式映射

ES默认开启动态映射(Dynamic Mapping),当写入一个ES中不存在的字段时,ES会自动推断其类型并添加到Mapping中。这在快速开发原型时很方便,但在生产环境下存在巨大隐患,自动推断的类型可能不符合预期,后续用它做聚合查询时就会报错。动态映射的类型推断规则如下。

JSON 值类型 ES 自动推断类型
字符串 text + keyword 子字段
整数 long
小数 float
布尔值 boolean
日期格式字符串 date(需符合日期格式)
对象 object
数组 取决于数组第一个元素的类型

不过,生产环境下还是强烈建议使用显式映射(Explicit Mapping),显式映射意味着在创建索引时明确定义每个字段的类型和配置,不依赖ES自动推断,这样可以避免类型推断错误导致的一系列问题。

对于不需要被检索的字段,可以设置index: false来关闭索引,减少存储开销;对于不需要在查询结果中返回的字段,可以设置 store: false(默认值,数据从 _source 中读取)。

创建索引时,我们可以通过dynamic参数控制动态映射行为,将其设置为strict表示关闭动态映射,且遇到未知字段直接报错,强制所有字段必须提前定义。

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "name": { "type": "text" }
    }
  }
}

文档操作

掌握了索引和Mapping设计之后,我们继续学习文档(Document)的基本增删改查操作。这里我们仍基于Kibana的Dev Tools手动调用ES的Restful API,这些操作看起来可能比较繁琐,不过这里我们只是为了展示与ES交互的基础方式,实际开发中我们通常使用代码和ES的Java API Client等客户端来实现这些交互。

新增文档

新增文档分为指定ID和不指定ID两种形式。指定ID新增使用PUT方法,如果文档不存在则创建,如果已存在则整体替换(注意并非部分更新)。基于我们之前创建的products索引,下面例子展示了指定ID新增的写法。

PUT /products/_doc/1
{
  "name": "华为 Mate 70 Pro",
  "price": 5999,
  "stock": 100,
  "created_at": "2024-11-20 10:00:00"
}

自动生成ID写入文档使用POST方法,ES会自动生成唯一的文档ID。

POST /products/_doc
{
  "name": "苹果 iPhone 16",
  "price": 6999,
  "stock": 50,
  "created_at": "2024-11-20 10:00:00"
}

ES中自动生成的文档ID是形如3EdEEJ4BdikuOx43gqyK的字符串,如果我们需要在MySQL中记录这个ID作为关联,那有一个点要尤其注意。MySQL中utf8mb4的默认排序规则通常是utf8mb4_general_ciutf8mb4_0900_ai_ci(MySQL 8.x),这两个排序规则都是不区分大小写的!也就是说,搜索AAA,字段值AaaaaA都能匹配!如果你用这些排序规则ES的文档ID,就可能出现查到的结果实际并非想要的数据的奇怪情况。此时一种方法是使用MySQL的utf8mb4_bin排序规则,另一种方式则是彻底不要用ES自动生成的ID,而是我们手动指定ID。

此外,使用_create端点可以实现只创建不覆盖的写入,如果文档ID已存在则报错而非覆盖。

PUT /products/_create/1
{
  "name": "华为 Mate 70 Pro",
  "price": 5999
}

查询文档

根据ID查询单个文档写法如下。

GET /products/_doc/1

返回结果例子如下。

{
  "_index": "products",
  "_id": "1",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "name": "华为 Mate 70 Pro",
    "price": 5999,
    "stock": 100,
    "created_at": "2024-11-20 10:00:00"
  }
}

返回结果中,_source字段包含文档的原始内容,_version是文档版本号,found标识文档是否存在。

如果只需要确认文档是否存在而不需要返回内容,可以使用HEAD方法,响应状态码200表示存在,404表示不存在。

HEAD /products/_doc/1

如果只需要返回部分字段,可以通过_source_includes参数过滤。

GET /products/_doc/1?_source_includes=name,price

更新文档

ES中文档是不可变的,所谓的更新本质上是写入一个新版本的文档并将旧版本标记为删除,真正的物理删除发生在段合并时。ES中更新操作有两种方式,全量替换和部分更新。全量替换使用PUT方法,这会用新文档完整替换旧文档,旧文档中所有字段都会被新文档覆盖,未出现在新文档中的字段会消失。

PUT /products/_doc/1
{
  "name": "华为 Mate 70 Pro",
  "price": 5888,
  "stock": 80,
  "created_at": "2024-11-20 10:00:00"
}

部分更新使用POST方法和_update端点,它只更新指定字段,其它字段保持不变。

POST /products/_update/1
{
  "doc": {
    "price": 5888,
    "stock": 80
  }
}

_update还支持使用Painless脚本进行更复杂的更新操作,例如对数值字段做原子性的增减。

POST /products/_update/1
{
  "script": {
    "source": "ctx._source.stock -= params.quantity",
    "params": {
      "quantity": 5
    }
  }
}

Upsert操作是指如果文档存在则更新,不存在则插入,这在同步数据场景中比较实用。

POST /products/_update/100
{
  "doc": {
    "name": "小米 15 Pro",
    "price": 4999
  },
  "doc_as_upsert": true
}

删除文档

根据ID删除单个文档写法如下,删除同样是标记删除,物理删除在段合并时发生。

DELETE /products/_doc/1

并发更新

在高并发场景下,写入可能出现多个请求同时修改文档的情况,如果不加以控制就会出现数据竞争问题,ES提供了基于版本的乐观并发控制机制来解决这个问题。

ES中每个文档都有序列号_seq_no和主分片任期_primary_term两个元数据字段,文档每被修改一次序列号加1,序列号用来保证要修改的这个文档自从查询后有没有被别人改过;至于主分片任期则是分片发生故障、主分片切换时加1,它是用来保证你操作的是当前最新的主分片,避免旧节点和旧分片数据干扰用的。序列号和主分片任期共同标识文档的版本状态,在更新时携带这两个参数,ES会校验当前文档的版本是否与请求中携带的一致,如果不一致说明文档已被其他请求修改过,本次更新会失败并返回409 Conflict

正确的使用流程是先查询文档。

GET /products/_doc/1

返回信息可能类似如下,可以看到其中包含了_seq_no_primary_term

{
  "_index": "products",
  "_id": "1",
  "_version": 3,
  "_seq_no": 3,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "name": "华为 Mate 70 Pro",
    "price": 5888,
    "stock": 75,
    "created_at": "2024-11-20 10:00:00"
  }
}

更新时,我们把查询到的参数填进去。

PUT /products/_doc/1?if_seq_no=3&if_primary_term=1
{
  "name": "华为 Mate 70 Pro",
  "price": 5500
}

如果更新成功返回200,如果期间有其他人修改了该文档,ES会返回409 Conflict。此时业务代码中,我们可以重新读取最新文档,然后带上新的seq_noprimary_term再次尝试,这样就实现了乐观锁重试逻辑,这与数据库乐观锁的使用方式一致。

Bulk批量操作

在需要批量写入或更新文档时,每次操作发一个HTTP请求的效率非常低,ES提供了Bulk API来支持在一次请求中执行多个操作。Bulk请求的格式非常特殊,它不是标准JSON格式文本,Bulk请求体中有多行数据,每个操作第1行是操作描述,第2行的文本内容(删除操作则没有第2行)。下面是一个批量操作的例子。

POST /_bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "name": "华为 Mate 70 Pro", "price": 5999, "stock": 100 }
{ "index": { "_index": "products", "_id": "2" } }
{ "name": "苹果 iPhone 16", "price": 6999, "stock": 50 }
{ "update": { "_index": "products", "_id": "1" } }
{ "doc": { "price": 5888 } }
{ "delete": { "_index": "products", "_id": "3" } }

Bulk支持的操作类型有四种:index为创建或替换;create为只创建,存在则报错;update是部分更新;delete是删除。

如果所有操作都针对同一个索引也可以在路径中指定索引名,此时请求体内就不需要重复写_index了。

POST /products/_bulk
{ "index": { "_id": "1" } }
{ "name": "华为 Mate 70 Pro", "price": 5999 }
{ "index": { "_id": "2" } }
{ "name": "苹果 iPhone 16", "price": 6999 }

Bulk操作的响应里每个子操作都有独立的结果,整体不会因为某一个子操作失败而全部回滚,因此通常都需要在代码中逐一检查每个操作的result字段和error字段。

{
  "took": 10,
  "errors": true,
  "items": [
    { "index": { "_id": "1", "result": "created", "status": 201 } },
    { "index": { "_id": "2", "result": "created", "status": 201 } },
    { "update": { "_id": "99", "status": 404, "error": { "type": "document_missing_exception" } } }
  ]
}

errors字段为 true时说明有部分操作失败,此时需要遍历items列表检查每一条。

当然,Bulk请求也并不是越大越好,请求体过大会占用大量内存并拖慢响应,通常建议单次Bulk请求的数据量控制在5MB到15MB之间,或每批1000到5000条文档,具体还是需要根据文档大小和服务器性能测试调整。

索引模板

在实际项目中,我们有时需要创建多个结构相同的索引,例如按月分割的日志索引logs-2024-01logs-2024-02等,如果每次都手动创建并配置Mapping既繁琐又容易出错。ES内置支持了索引模板能用来解决这个问题,它允许我们预先定义索引的Settings和Mapping,当新创建的索引名称匹配模板的规则时,ES会自动应用模板配置。

下面命令创建了一个叫logs_template的索引模板,用于创建名字符合service-logs-*的索引。

PUT /_index_template/logs_template
{
  "index_patterns": ["service-logs-*"],
  "priority": 100,
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1
    },
    "mappings": {
      "dynamic": "strict",
      "properties": {
        "timestamp": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" },
        "level": { "type": "keyword" },
        "service": { "type": "keyword" },
        "message": { "type": "text", "analyzer": "ik_max_word" },
        "trace_id": { "type": "keyword" }
      }
    }
  }
}

index_patterns支持通配符,上述模板会自动应用于所有以service-logs-开头的索引。priority是模板优先级,当多个模板都匹配同一个索引名时,优先级高的模板会生效。此时创建service-logs-2024-11service-logs-2024-12等索引时,无需再手动指定Mapping模板会自动应用。

PUT /service-logs-2024-11

如果你认为在业务代码中判断索引是否存在,不存在则创建也一样能实现这个功能,那你就低估索引模板的强大了。我们甚至可以不用预先创建索引直接写入数据,ES识别到试图写的索引不存在但能够匹配一个索引模板时,索引也会自动创建。这意味着业务代码可以放心大胆的直接写数据。

PUT service-logs-app-2024-11-01/_doc/1
{
  "timestamp": "2024-11-01 12:00:00",
  "level": "INFO",
  "service": "user-service",
  "message": "用户登录成功",
  "trace_id": "trace-123456"
}

下面命令可以查看我们之前创建的索引模板。

GET /_index_template/logs_template

如果想查看全部索引模板或是匹配搜索模板,可以使用类似GET /_index_templateGET /_index_template/logs*的写法。

下面命令可以删除索引模板。

DELETE /_index_template/logs_template

别名机制

别名(Alias)是ES中另一个非常实用的功能,它允许我们为一个或多个索引创建虚拟名称,这意味着像上面索引模板的写日志场景中,我们可以对外统一暴露日志索引的别名,应用代码始终通过别名来操作,屏蔽底层索引名称的变化。

下面命令我们为products_v1创建了别名products

POST /_aliases
{
  "actions": [
    { "add": { "index": "products_v1", "alias": "products" } }
  ]
}

创建索引时也可以直接指定别名。

PUT /products_v1
{
  "aliases": {
    "products": {}
  }
}

别名还有一个非常重要的使用场景是实现零停机的索引切换。我们知道ES的Mapping一旦创建后许多配置就无法修改,当需要“修改”索引的Mapping时,通常需要新建一个索引并将数据重新导入然后无缝切换流量,这个操作就可以依赖别名来实现。下面例子我们将别名products指向的索引从products_v1换成了products_v2,其中actions中的多个操作是原子执行的,不会出现别名短暂不可用的情况,它对应用完全透明。

POST /_aliases
{
  "actions": [
    { "remove": { "index": "products_v1", "alias": "products" } },
    { "add":    { "index": "products_v2", "alias": "products" } }
  ]
}

上面也是生产环境中进行Mapping变更的标准做法,我们的业务代码中应始终使用别名而非具体的索引名,这也是一个工程最佳实践。

此外,别名其实同时可以指向多个索引。当别名指向多个索引时,读操作会同时检索所有索引,但写操作需要明确指定1个写入目标,否则会报错。这可以通过is_write_index参数指定写索引。

POST /_aliases
{
  "actions": [
    { "add": { "index": "logs-2024-10", "alias": "logs", "is_write_index": false } },
    { "add": { "index": "logs-2024-11", "alias": "logs", "is_write_index": true } }
  ]
}

这样查询logs别名时会同时查询两个月的数据,而写入始终只写到最新的logs-2024-11中。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。