跳到主要内容

核心使用

Mapping:先把 schema 定对​

Mapping 是索引的 schema,决定了每个字段怎么存、怎么分词、能不能被搜索/排序/聚合。它是整个 ES 使用里最容易留下技术债的地方——字段类型和分片数一样,建索引时就该定死。

先看一个能覆盖大部分业务场景的完整例子:

PUT /articles
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
},
"mappings": {
"dynamic": "strict",
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 }
}
},
"author": { "type": "keyword" },
"content": { "type": "text" },
"status": { "type": "keyword" },
"view_count": { "type": "integer" },
"created_at": { "type": "date" },
"tags": { "type": "keyword" }
}
}
}

text 与 keyword:本系列最重要的一张表​

这两个类型搞混,后面的查询、排序、聚合会一路错下去:

对比项textkeyword
是否分词是,按分析器切词否,整串原样存
典型用途正文、标题、描述、商品名状态、标签、ID、邮箱、主机名
适合查询match、match_phrase、multi_matchterm、terms、prefix、wildcard
排序不支持(除非开 fielddata,别开)支持
聚合不支持(同上)支持
磁盘占用倒排索引 + 位置信息,较大较小,适合 doc_values

一个字段经常两种需求都有,比如文章标题既要全文搜、又要精确排序,那就用多字段(multi-fields):主字段 text 负责搜,子字段 .keyword 负责排序聚合。上面的例子就是标准写法,查询时分别用 title 和 title.keyword。

给 text 字段排序会直接报错

报错信息是 Fielddata is disabled on text fields by default。这不是配置问题,是 ES 在拦你:text 字段开 fielddata 会把分词结果整份加载进堆内存,十个用户就能把节点打挂。正确做法是用 .keyword 子字段,或者加一个 keyword 类型的独立字段。

中文内容记得装 IK 分词器并指定 analyzer(ik_max_word 细粒度、ik_smart 粗粒度),不装的话默认 standard 分析器会把中文按单字切,「搜索引擎」变成「搜」「索」「引」「擎」四个词,召回率上去了、准确率惨不忍睹。

常用字段属性​

除了 type,Mapping 里还有几个参数值得记住:

属性作用什么时候用
index是否为该字段建索引,默认 true只取回不搜索的大文本,设 false 省空间
doc_values是否生成列式存储,默认 true不排序不聚合的字段可关掉,省磁盘
ignore_above超过长度的值不索引keyword 字段必配,防止超长字符串撑爆索引
null_value用指定值替代 null 参与索引需要把 null 当普通值查询时
format日期格式自定义时间格式,或兼容多种格式时
fields给一个字段挂多个子字段同一个值既要分词又要精确匹配

dynamic:动态映射的三档开关​

不写 Mapping 直接写文档,ES 会自动推断类型,这叫动态映射。方便,但生产上很危险:日期字符串被认成 text、小数被认成 float、字段名拼错多出一个新字段,都是这么来的。

dynamic 有三档:

取值行为适用
true(默认)遇到新字段自动加进 Mapping探索期、日志类索引
false新字段存进 _source 但不索引,搜不到字段不可控但不想污染 Mapping
strict遇到未定义字段直接报错,拒绝写入业务索引,强烈推荐

false 有个隐蔽的坑:字段写进去了、_source 里能看到,但 _search 就是搜不到,排查半天才发现是动态映射被关。要么 true 要么 strict,false 只在非常明确的场景用。

改 Mapping 的限制​

ES 的 Mapping 只能加、不能改。具体说:

  • ✅ 可以新增字段;
  • ✅ 可以给已有字段追加 fields 子字段;
  • ✅ 可以调整 ignore_above 这类不影响索引结构的参数;
  • ❌ 不能修改已有字段的 type;
  • ❌ 不能修改已有字段的 analyzer;
  • ❌ 不能删除字段(只能忽略它)。

真要把 text 改成 keyword,只能重建索引。标准流程是「新建索引 → reindex → 切别名」。

第一步,建一个结构正确的新索引:

PUT /articles_v2
{
"settings": { "number_of_shards": 3, "number_of_replicas": 1 },
"mappings": {
"properties": {
"title": { "type": "keyword" },
"created_at": { "type": "date" }
}
}
}

第二步,把数据搬过去(源和目标的字段名要对得上):

POST /_reindex?wait_for_completion=false
{
"source": { "index": "articles_v1" },
"dest": { "index": "articles_v2" }
}

第三步,用别名切换,业务代码只认 articles 这个别名:

POST /_aliases
{
"actions": [
{ "remove": { "index": "articles_v1", "alias": "articles" } },
{ "add": { "index": "articles_v2", "alias": "articles" } }
]
}

_reindex 是个异步任务,大索引一定要加 wait_for_completion=false,然后用 GET /_tasks/任务ID 看进度,别在前台干等。这套「别名 + 版本化索引」的玩法,是 ES 里做零停机变更的标准姿势,值得一开始就养成习惯。

查询 DSL​

ES 的查询是 JSON 结构,核心分两块:query(算相关性得分)和aggs(聚合)。先掌握 query。

查询上下文与过滤上下文​

同一个条件写在不同位置,行为完全不同:

  • 查询上下文(query context):query 里直接写的条件,会计算 _score,影响排序。
  • 过滤上下文(filter context):bool 的 filter、must_not 里的条件,只判断「匹配/不匹配」,不算分,结果可缓存,性能更好。

经验法则:关心相关性用 query,只关心「有没有」用 filter。状态、时间范围、租户 ID 这类精确条件,一律放 filter。

单个条件:match / term / range​

六个条件一次过一遍。下面每条都是完整可执行的请求,注意 match 配 text、term 配 keyword:

# match:text 字段分词匹配,按相关性打分
curl -s "http://localhost:9200/articles/_search?pretty" \
-H 'Content-Type: application/json' \
-d '{"query": {"match": {"title": "Elasticsearch 运维"}}}'

# match_phrase:词序相邻,适合短语精确匹配
curl -s "http://localhost:9200/articles/_search?pretty" \
-H 'Content-Type: application/json' \
-d '{"query": {"match_phrase": {"content": "分片分配失败"}}}'

# multi_match:一个关键词搜多个字段,^ 给字段加权
curl -s "http://localhost:9200/articles/_search?pretty" \
-H 'Content-Type: application/json' \
-d '{"query": {"multi_match": {"query": "快照", "fields": ["title^2", "content"]}}}'

# term:不分词,精确匹配 keyword 字段
curl -s "http://localhost:9200/articles/_search?pretty" \
-H 'Content-Type: application/json' \
-d '{"query": {"term": {"status": "published"}}}'

# terms:一次匹配多个值,类似 SQL 的 in
curl -s "http://localhost:9200/articles/_search?pretty" \
-H 'Content-Type: application/json' \
-d '{"query": {"terms": {"tags": ["ops", "search"]}}}'

# range:范围查询,gte/gt/lte/lt 四个边界
curl -s "http://localhost:9200/articles/_search?pretty" \
-H 'Content-Type: application/json' \
-d '{"query": {"range": {"created_at": {"gte": "2025-01-01", "lt": "2025-07-01"}}}}'

再强调一遍最常见的错误组合:term 查 text 字段(分词后存的是词项,你拿整句去 term 匹配,永远匹配不到)、match 查 keyword 字段(不分词,但 match 会对查询词做分析,行为很怪)。查不到数据先检查这对组合。

bool:组合查询的主武器​

bool 把多个条件拼起来,四个子句各有明确语义:

子句语义算分是否必须满足
must必须匹配,参与打分是是
filter必须匹配,不算分,可缓存否是
should匹配了加分,按 minimum_should_match 决定是否必须是默认不必须
must_not必须不匹配,不算分否是(排除)

一个典型的组合:标题要相关、状态和发布时间要过滤、废弃标签要排除、带 ops 标签的加权:

GET /articles/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "Elasticsearch 运维" } }
],
"filter": [
{ "term": { "status": "published" } },
{ "range": { "created_at": { "gte": "2025-01-01" } } }
],
"must_not": [
{ "term": { "tags": "deprecated" } }
],
"should": [
{ "term": { "tags": "ops" } }
],
"minimum_should_match": 0
}
}
}
filter 为什么快

filter 不算分,所以不需要遍历所有文档算 BM25;同时它的结果可以被 ES 缓存(filter cache),相同条件重复查询时直接复用位图。日志检索、后台列表这类「条件固定、关键词可变」的查询,把固定条件全塞进 filter,性能提升非常明显。

分页、排序与深分页​

GET /articles/_search
{
"from": 40, "size": 20, "track_total_hits": true,
"sort": [ { "created_at": "desc" }, { "_id": "asc" } ]
}
  • from + size:最常用的分页,但 from + size 默认不能超过 10000(index.max_result_window)。这不是随便设的限制:协调节点要把每个分片的前 from+size 条结果都取回内存归并,翻到第 1000 页时,内存和 CPU 开销是灾难性的。
  • sort:可以多字段排序,建议最后加一个唯一字段(如 _id)做兜底,否则排序值相同的文档在翻页时可能重复或漏掉。
  • track_total_hits:默认只精确统计到 10000 条,超过就返回「10000+」。确实需要精确总数时设 true,但代价是全量扫描。

超过 10000 条还要往下翻,有两条正道。

search_after:用上一页最后一条的排序值做游标,推荐用于「加载更多」。

GET /articles/_search
{
"size": 20,
"sort": [ { "created_at": "desc" }, { "_id": "asc" } ],
"search_after": [ "2025-06-01T10:00:00", "a1b2c3d4" ]
}

scroll:一次性导出全量数据时用,不适合实时翻页。

POST /articles/_search?scroll=1m
{
"size": 1000,
"sort": [ "_doc" ]
}

search_after 不支持跳页(只能一页页往后),但内存恒定、性能稳定,是前端「加载更多」的正确实现。scroll 会占住一个快照,导出几百万条数据可以用,别拿它做用户翻页——每个 scroll 上下文都在消耗资源。

聚合分析​

聚合(Aggregations)是 ES 相比「纯搜索引擎」最有价值的部分:一次请求就能把分组、统计、趋势算完,不用把数据拉到应用层再 group by。

基础三件套:terms / date_histogram / avg​

GET /articles/_search
{
"size": 0,
"aggs": {
"by_status": {
"terms": { "field": "status", "size": 10 }
},
"per_month": {
"date_histogram": {
"field": "created_at",
"calendar_interval": "month",
"format": "yyyy-MM"
}
},
"avg_views": {
"avg": { "field": "view_count" }
}
}
}
  • size: 0:只要聚合结果,不要命中文档。聚合查询几乎都该带上,否则白算一遍 hits。
  • terms:按字段值分组,类似 group by,size 控制返回多少个桶(不是全部,桶太多会吃内存)。
  • date_histogram:按时间分桶,calendar_interval 支持 day、week、month、quarter、year,也可以写固定间隔如 30m。
  • avg:求平均,同族的还有 sum、min、max、cardinality(去重计数)、percentiles(分位数)。

嵌套聚合:分组之后再统计​

聚合可以层层嵌套,内层作用在外层的每个桶上,这是它比 SQL 灵活的地方:

GET /articles/_search
{
"size": 0,
"query": { "range": { "created_at": { "gte": "2025-01-01" } } },
"aggs": {
"by_status": {
"terms": { "field": "status", "size": 5 },
"aggs": {
"avg_views": { "avg": { "field": "view_count" } },
"per_month": {
"date_histogram": { "field": "created_at", "calendar_interval": "month" },
"aggs": {
"p95_views": { "percentiles": { "field": "view_count", "percents": [95] } }
}
}
}
}
}
}

返回结构就是嵌套的:每个 status 桶里有 doc_count、平均阅读量,以及按月拆开的趋势和 P95。这种「按状态分组 → 再看月度趋势 → 再看分位数」的需求,用 SQL 要写三层子查询,ES 一个请求搞定。

聚合字段必须是 keyword 或数值

对 text 字段做 terms 聚合,会直接报 fielddata 错误(和排序同一个原因)。要么用 .keyword 子字段,要么在 Mapping 阶段就把该字段设成 keyword。另外聚合结果默认只返回 10 个桶,terms 的 size 别设成几万,桶数过多会触发内存熔断器(circuit breaker),请求直接失败。

批量操作​

bulk:批量写入的正确姿势​

一条条 POST 写入,网络往返和 refresh 开销会把吞吐拖垮。批量写用 _bulk,格式是 NDJSON(每行一个 JSON,行与行之间用换行符,最后一行也必须有换行):

{"index":{"_index":"books","_id":"10"}}
{"title":"Elasticsearch 实战","author":"李四","price":68.0}
{"index":{"_index":"books","_id":"11"}}
{"title":"日志系统设计","author":"王五","price":55.0}
{"update":{"_index":"books","_id":"10"}}
{"doc":{"price":59.0}}
{"delete":{"_index":"books","_id":"11"}}

每两条一组:第一行是动作元数据(index / create / update / delete),第二行是文档内容(delete 除外)。执行:

curl -X POST "http://localhost:9200/_bulk" \
-H 'Content-Type: application/x-ndjson' \
--data-binary @books.ndjson

几个关键点:

  • 单批 5-15MB,太小浪费往返,太大会把节点内存顶高。按文档大小算,一般 1000-5000 条一批。
  • _bulk 返回 200 不代表全部成功。必须检查响应里的 errors 字段,以及 items 数组里每条的 status,部分失败是常态(比如某条 mapping 冲突)。
  • 写入性能敏感时,把 refresh_interval 临时调大(如 30s)、副本设为 0,灌完数据再改回来。
  • 不要用 ?refresh=true 跑 bulk,每批都强制刷新会让段数量爆炸。

_msearch:一次发多个查询​

多个互不相关的查询,用 _msearch 合并成一次请求,省掉往返开销。把下面的内容存成 queries.ndjson,POST 到 /_msearch 即可:

{"index":"books"}
{"query":{"match":{"title":"elasticsearch"}},"size":3}
{"index":"books"}
{"query":{"term":{"author":"张三"}},"size":3}

格式是「一行头 + 一行查询」成对出现,头的 index 可以省略(表示用 URL 里指定的索引)。返回的 responses 数组顺序和请求顺序一一对应。适合仪表盘场景:一个页面要展示四五个不同维度的数据,合并成一次请求比并发发五个更省资源。

小结​

  • Mapping 是地基:text 分词给 match,keyword 精确给 term/排序/聚合;一个字段两种需求就用 fields 子字段。业务索引把 dynamic 设成 strict。
  • Mapping 只能加不能改,类型和分析器定错了只能 reindex;「版本化索引 + 别名切换」是零停机变更的标准做法。
  • 查询 DSL 的核心是 bool:must 算分、filter 不算分可缓存、should 加分、must_not 排除;精确条件放 filter,相关性条件放 must。
  • 分页 from + size 有 10000 上限,深分页用 search_after(翻页)或 scroll(导出)。
  • 聚合三件套 terms / date_histogram / avg,嵌套起来能一次算出分组趋势;size: 0 省掉 hits 开销。
  • 批量写用 _bulk(NDJSON,5-15MB 一批,检查 errors),多查询用 _msearch;批量场景别开 refresh=true。

数据能写能查了,下一步是把单节点换成真正的集群:多节点配置、分片怎么规划、备份怎么做,下一篇展开。