核心使用
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:本系列最重要的一张表
这两个类型搞混,后面的查询、排序、聚合会一路错下去:
| 对比项 | text | keyword |
|---|---|---|
| 是否分词 | 是,按分析器切词 | 否,整串原样存 |
| 典型用途 | 正文、标题、描述、商品名 | 状态、标签、ID、邮箱、主机名 |
| 适合查询 | match、match_phrase、multi_match | term、terms、prefix、wildcard |
| 排序 | 不支持(除非开 fielddata,别开) | 支持 |
| 聚合 | 不支持(同上) | 支持 |
| 磁盘占用 | 倒排索引 + 位置信息,较大 | 较小,适合 doc_values |
一个字段经常两种需求都有,比如文章标题既要全文搜、又要精确排序,那就用多字段(multi-fields):主字段 text 负责搜,子字段 .keyword 负责排序聚合。上面的例子就是标准写法,查询时分别用 title 和 title.keyword。
报错信息是 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 不算分,所以不需要遍历所有文档算 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 一个请求搞定。
对 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。
数据能写能查了,下一步是把单节点换成真正的集群:多节点配置、分片怎么规划、备份怎么做,下一篇展开。