ElasticSearch 基础知识这个系列在开篇里讲完了下载、单机启动、索引与文档的基本概念,这篇正好接上,聊一聊真正把数据放进 ES 之后,你马上会遇到的那几个基础问题。先说清楚,这依然是入门向的第二篇,不碰复杂的性能调优和分布式运维,重点解决三件事:数据怎么写才稳、字段映射怎么设计才准、查询和聚合为什么往往和直觉不一样。
这个系列适合已经装好 ES、甚至能用 REST 接口建索引的人,但一遇到"明明写了为什么查不到""这个字段不能排序""聚合结果怎么才 10 条"就卡壳的开发者。我尽量把原理和实操放在一起讲,每说一个知识点都给你能直接复制到控制台验证的示例。写技术基础最怕的就是背概念,所以我刻意把顺序排成"先写,再建结构,再查,再统计,最后补底层",对应你在真实项目里踩坑的顺序。
1. 从"能用"到"会用":文档级操作才是基本功
1.1 写入文档前,先决定这三件事
第一件事是_id由谁决定。如果你不指定,ES 会自动生成一串 20 位的随机 ID。好处是你不用操心主键冲突,坏处是当你想按业务主键去重、重复写入时自动更新同一条文档,没有稳定 ID 会非常别扭。实际项目里我习惯用业务唯一键直接做_id,比如用户 ID、订单号。这样重复写入同一个_id时 ES 天然覆盖,相当于做了一层幂等保护,不用额外写查重逻辑。
第二件事是版本控制。ES 内部用_version字段标记文档版本,每次更新它都会加一。你可以在更新请求里带上if_seq_no和if_primary_term实现乐观锁,防止两个客户端同时操作同一条记录时互相覆盖。理解方式可以参考文档编辑冲突:你先打开把价格改成 100,另一边同时把库存改成 50,后提交者会把先提交者的结果覆盖掉;如果不做条件校验,总有一个修改会悄无声息地丢。生产环境做余额变更、库存扣减这类场景,这个机制迟早会用上。
第三件事是refresh。ES 写入后默认 1 秒左右才可被搜索,这个时间由refresh_interval控制,它不代表数据已经落盘,只是把内存里的 segment 标记为可读。低延迟场景可以把refresh_interval调低,但代价是 IO 压力变大;批量导入场景我建议直接调成-1,也就是关掉自动刷新,导完再手动刷一次,整体速度能快好几倍。很多人一开始不懂为什么刚写入就查不到,其实就是卡在这里。
需要额外提醒的是:update和index不一样。index是整篇覆盖,update的前半段是先读取原文档、合并字段再写回,本质上是"读-改-写"三步,而且要求目标文档已存在。update的请求体支持用doc传入部分字段,也支持用script做更复杂的原子更新。比如固定金额累加:
POST /orders/_update/1 { "script": { "source": "ctx._source.amount += params.delta", "params": { "delta": 50 } } }这个操作如果换成先 GET 再 index,中间就可能丢更新。
1.2 批量操作不是简单循环,bulk 的每行格式最容易错
单条写入一条条 POST 是最直观的,但数据量上来之后必须用_bulk。_bulk把多个操作打包在一个 HTTP 请求里,格式是两行一组:第一行是 action 类型,第二行是文档数据本身。具体长这样:
POST /orders/_bulk {"index":{"_id":"1"}} {"order_no":"A001","amount":100.5} {"update":{"_id":"2"}} {"doc":{"amount":200.0}}常见错误有两个:第一,少换行或者多换行都会导致解析异常;第二,第二行的 JSON 必须压成一行,不能写成带缩进的漂亮格式,否则_bulk解析器会把换行后的内容当成新请求理解,直接报错。
拿一次插入 1000 条数据来算:逐条index请求需要 1000 次 HTTP 往返,_bulk只需要 1 次。性能差异是数量级的,这也是批量写接口存在的意义。但_bulk不是越大越好,官方建议单批控制在 10~15MB 左右。如果单条文档很大,建议几百条就试一下请求耗时,太长容易触发客户端超时和内存压力。
我在本地调试的习惯是先用 Kibana 的 Dev Tools 手写两三条,确认 mapping 和数据格式没问题,再上脚本批量跑;脚本里每批控制在 1000~5000 条之间,返回结果里逐个检查items数组,哪条失败就单拎出来处理,不要因为一两条脏数据把整批重推。
2. Mapping:字段类型决定查询命运
2.1 动态映射是隐性地雷,写数据之前先 GET 一下
新建索引时不指定 mapping,ES 会根据第一条文档自动推断字段类型。这个机制上手很舒服,但坑也最多。最经典的是时间字段:你第一次写入 1687572800 这种秒级时间戳,ES 推断成long;下一次你改成传2024-05-10 12:00:00这种字符串,它又会判断成date。同一个字段两种格式混着传,后面查范围、排序都会出幺蛾子。
更隐蔽的是数值字段。客户端传一个字符串"100",ES 会自动把它映射成long;如果后来某条数据混入了"100.5",下一次写入就会因为类型不一致直接报解析异常。你以为是数据的问题,其实是 mapping 已经被第一条脏数据锁死了,后面来什么好数据都进不来。
所以只要环境稍微正式一点,我强烈建议先手工建索引、把 mapping 定义好,再开始写数据。就算图省事想靠动态映射,也至少做一次确认动作:GET /your_index/_mapping跑一下,看看 ES 实际推出来的字段类型。这个检查十秒钟就够,能省掉后面半夜排查问题的几个钟头。
顺带补一个基础知识点:ES 底层使用不可变的 segment 文件,这也是为什么大部分 mapping 参数创建后不可修改。索引一旦建立,字段类型基本就定了,真要调整只能重建索引,使用别名切换。所以初期花十分钟想清楚字段类型,比后期做数据迁移划算得多。
2.2 text 和 keyword 到底怎么选
这是面试常问、也是线上最容易搞混的一对。text会经过分词器切成一个个词项,支持全文检索和模糊匹配,但默认不能用于排序、聚合。keyword不切词,整体作为一个词项存储,支持精确匹配、排序、聚合,但如果把整篇日志塞进keyword,字段会变得超长,索引膨胀而且性能下降。
正确的做法通常是"多字段"。一个字段既要全文搜索又要精确过滤时,映射成text加keyword子字段:
PUT /orders { "mappings": { "properties": { "order_no": { "type": "keyword" }, "remark": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "amount": { "type": "double" }, "created_at": { "type": "date" } } } }上面这个 mapping 里,order_no用keyword,因为订单号永远做精确匹配;remark用text加keyword多字段,全文搜索用remark,去重、排序、聚合用remark.keyword,两个用途互不干扰。
还有几个容易被忽略的参数:ignore_above表示长度超过阈值就不索引该值,默认keyword上限是 256;index: false表示该字段只存不索引,适合只展示但不查询的字段;doc_values默认开启,支持聚合和排序,如果你确认某字段永远不做这两个操作,关掉它能省不少堆内存。设计 mapping 时刻意提醒自己:不是把所有字段都索引,只索引你要查的字段。
3. 分词器:中文搜索最容易被忽略的一环
3.1 内置标准分词器为什么让中文"搜不准"
ES 默认的standard分词器是给英文设计的:按空格和标点切词,英文单词转小写。但中文字和字之间没有空格,于是"中华人民共和国成立了"会被整体塞进一个 token。你用搜索词"中华"去匹配,根本查不到这条文档,因为倒排索引里存的是"中华人民共和国成立了"整段,而不是切出来的"中华"。
这就解释了一个新手最常见的困惑:"数据明明写进去了,为什么搜索像是失灵?"数据一点没丢,只是你和分词器对"词"的理解不一样。倒排索引的核心是"词项到文档 ID"的映射,分词这步没把句子拆成可检索的最小单位,后面查询表现当然不对。遇到搜索不到结果,第一反应不应该是检查数据有没有写进去,而是拿同样的文本跑一遍_analyze,看它到底被切成了什么样。
可以先在 Kibana Dev Tools 里跑这个命令:
POST /_analyze { "analyzer": "standard", "text": "中华人民共和国成立了" }返回结果里 token 大概率是一整句,这时候问题的源头就清楚了。标准分词器不算 bug,只是不适合中文场景,选对分析器是关键。
3.2 换中文分析器,并用 _analyze 验证效果
生产里中文场景用得最多的是 IK 分词器。它有ik_max_word和ik_smart两种模式:前者做最大粒度切分,分出尽可能多的词,适合建索引;后者只保留粗粒度结果,分词少、索引小,适合查询时使用。常见的组合是写入时用ik_max_word,查询时在search_analyzer里指定ik_smart,兼顾"查得全"和"查得准"。
判断一个分词器合不合适,不需要把数据全导进来验证,直接用_analyze接口看 token 列表。比如:
POST /_analyze { "analyzer": "ik_max_word", "text": "北京烤鸭" }返回结果能看到"北京""烤鸭"这些可用的词项,对照一下业务期望,再决定 mapping 里的analyzer怎么配。这个调试工具是整个分词排障的起点,定位问题的准确率比瞎猜高太多。
再往深一步说,自定义分析器就是"字符过滤器 + 分词器 + token 过滤器"三件套的组合。比如你想把全角符号转半角、去掉无意义的邮编、把同义词扩展进索引,都是在自定义分析器的 filter 里串起来的。基础阶段不急着写自定义分析器,但你要知道有这条路,遇到"产品名搜不到别名"这类需求时,方向就不会跑偏。
4. 查询不是把句子原样交给 ES
4.1 match 和 term,一个拆词一个不拆词
排查查询问题,我第一件事永远是问:你用的是match还是term?这两个的区别是理解 ES 查询的分水岭。
match是全文检索,查询词会经过同样的分词器拆成若干词项,再去匹配倒排索引;term是精确词项查询,查询词不会分词,它拿你输入的完整字符串去倒排索引里找完全一样的词项。所以用term去查text字段是踩坑重灾区:text字段里存的词项已经被切碎,而term带的是没被切过的整句字符串,两边根本对不上。
最典型的现象是"我搜一个精确的订单号,明明存在却搜不到"。订单号如果被映射成了text,索引里存的是切分后的片段,用term查整串就命中不了。解决办法有两个方向:要精确查,就把字段映射成keyword,或者用text.keyword子字段;要模糊查,就用match,让分词器先拆一把再匹配。
用生活例子类比:图书馆有目录和正文两种检索方式。term像按目录标题精确翻,书名差一个字都找不到;match像在正文里逐句找,内容沾边就能命中。两种方式用的"词"本身就不一样大,混用必出事。
4.2 filter 和 must:差在算分,还差在缓存
bool查询里的must和filter行为差别很容易被忽略:must中的条件参与相关性算分,filter里只做条件过滤不计分。看起来只是少算一个分数,实际影响很大:filter子句的查询结果可以进入缓存,同样的过滤条件反复用,性能会明显占优;must每次查询都要重新计算文档和查询词的相关度,成本高不少。
所以在构造查询时,我的建议是"能 filter 就别 must"。状态码、时间范围、用户 ID 这类非文本精确条件放 filter,真正影响排名的关键词匹配才放 must。举个例子:
GET /orders/_search { "query": { "bool": { "must": [ { "match": { "remark": "加急" } } ], "filter": [ { "term": { "order_no": "A001" } }, { "range": { "created_at": { "gte": "2024-01-01" } } } ] } }, "_source": ["order_no", "amount", "status"], "size": 20 }这个查询里"加急"只在备注里做相关度匹配,订单号和时间范围都只是过滤条件,不计分也不影响排序。bool里的should是"或"的意思,当不存在must时至少满足一个should才会返回;当存在must时,should的作用变成命中后额外加分。这块逻辑用真数据跑两遍,比背文档记得牢。
再补一个返回字段控制:默认查询会把整个_source返回,如果线上只需要其中两三个字段,用_source指定返回内容能省下不少带宽和解析开销。分页上也别只顾着from加size,页数深时性能下降非常明显,深度分页要用search_after这类方案。基础阶段可以先知道边界在哪,遇到再深入。
5. 聚合:ES 不只是搜索,还是干统计的
5.1 metrics 和 bucket:一个是结果,一个是分组
聚合常被简单理解成"统计",但至少要分清两种角色。metrics聚合如avg、max、sum是对字段做数值计算;bucket聚合如terms、range则是把文档分桶,每个桶里可以再套子聚合。先把文档分成若干组,再对每一组算指标,这是把 ES 当 SQL 用的第一步。
举一个具体例子:统计每个订单状态的平均金额。terms按状态字段分桶,桶内加一个avg:
GET /orders/_search { "size": 0, "aggs": { "by_status": { "terms": { "field": "status", "size": 5 }, "aggs": { "avg_amount": { "avg": { "field": "amount" } } } } } }注意这个查询里size: 0,意思是搜索结果文档本身一条都不要,只关心聚合结果。如果字段是text,这里会直接报错:不支持 text 字段聚合。这个报错几乎每个新手都会遇到一次,所以又回到第二节强调 mapping 设计的原因。聚合专用的字段,从一开始就要keyword类型或多字段。
5.2 聚合结果默认只有 10 个桶,以及 size 的误区
terms聚合有一个很容易被忽略的默认值:结果只返回 top 10 个桶。不管你有几千个状态值,不设置size它就是 10 个,想多看几个必须显式调大。有人为了"保证全量"直接把size设成 0 或者上百万,这在数据量大时非常危险,内存可能直接被打爆,生产环境几乎就是事故。
还要分清两个size的用途:控制聚合返回桶数用的是聚合体里的size;控制搜索结果文档数用的是查询外层的size。两者作用在不同层级,别改错位置。shard_size则是每个分片上额外多收集一些桶,用来提升跨分片统计的准确性,调它不能替代最终的返回桶数。
做聚合之前,我的建议是先在GET /your_index/_mapping里确认字段能聚合,再估算一下数据量。宁可先取少量桶跑通流程,再逐步放宽;别一上来就追求全量,那是把调优问题留给了内存。
6. 别把数据写坏了:分片与写入流程的底层逻辑
6.1 主分片为什么定死了不能改
ES 把一个索引的数据分成多个分片,每个文档落到哪个分片由routing = hash(_id) % number_of_shards决定。这意味着主分片数一旦创建就不能改:改了之后同一个文档的取模结果就可能落不到原来的分片上。生产环境创建索引前必须想清楚未来的数据体量,这步做不好,后面所有扩展都很被动。
有人问:集群加节点不是能水平扩展吗?对,副本分片可以加,主分片数却不能动。副本分片是主分片的拷贝,主要作用是高可用和分摊读请求;主分片数是索引固定的骨架。合理的做法是一开始按偏大的体量规划,或者早期就用索引别名,后面通过别名平滑迁移数据。理解了主分片不可变,也就顺带理解了为什么 mapping 不能改:segment 文件不可变,旧数据已经按旧结构落盘了,改字段类型等于让旧数据和新定义打架。
分片数不是越多越好。分片太多会带来大量的文件句柄和后台 merge 压力,单分片单副本的小业务反而跑得很轻快。我的建议是单分片数据量控制在 20~50GB 之间,具体按业务灵活调整。
6.2 从"写进去"到"能查到",中间发生了什么
一条新增文档到达主分片后,先写进内存 buffer,同时追加写 translog 保证可靠性;每秒或者 buffer 满了之后,生成一个新 segment,segment 写完后才能被搜索;之后小 segment 会在后台合并成大 segment,这个过程叫 merge。所以 ES 的搜索是近实时的,不是实时的,默认间隔 1 秒。
理解这条写入路径能解释很多现象:刚写入的文档搜不到,因为还没 refresh;断电后数据不丢,因为 translog 可以把没落盘的写入重放;频繁 refresh 会让写入变慢,因为小 segment 文件太多,merge 压力上来了。调优的常见思路就是写入峰值时把refresh_interval调大或者临时关掉,导入完成后再恢复。
另外要说并发控制。ES 提供_seq_no和_primary_term组合做乐观锁:更新请求带上最后一次读到的值,如果提交时发现版本已经被别人改过,服务端返回版本冲突,业务方再决定重试还是放弃。这在库存、余额这类频繁更新的场景非常关键,适用于你那怕一个人单机跑也值得养成习惯。
7. 把常见报错和排查手段做成一张速查表
7.1 按现象找排查入口
基础阶段最大的痛苦不是不会操作,而是报错看不懂。我把最常遇到的几类现象整理成了一张表,按现象直接找对应动作就好。
| 现象 | 常见原因 | 排查动作 |
|---|---|---|
| 写入报类型冲突 | 同字段不同数据格式混入 | GET index/_mapping看字段类型 |
| 搜索不到刚写入的数据 | 还没到 refresh 时间 | 检查 refresh_interval,必要时手动 refresh |
| 能查全文但无法排序 | 字段是 text 类型 | 改用 keyword 或多字段 |
| term 查不到精确值 | text 分词导致词项不完整 | 检查字段类型和分词结果 |
| 聚合报 text 字段错误 | 聚合字段必须是 keyword | 重建 mapping 或使用子字段 |
| 版本冲突 409 | 并发条件更新未通过 | 重读数据后重试,确认业务幂等逻辑 |
| bulk 部分失败 | 个别文档格式或类型异常 | 遍历 items 数组定位失败行 |
| 中文搜不到 | 分词器不支持中文切分 | _analyze验证后更换分析器 |
这张表虽然简单,但足够覆盖入门阶段 80% 的"看起来像 bug 其实是基础知识没对齐"的问题。遇到新错先别急着搜资料,按现象归类,再打开对应命令看输出,很多时候答案就摆在那。
7.2 三个最有价值的防御习惯
第一个习惯是建索引前先写 mapping,写数据前先跑GET index/_mapping确认类型。这个动作把大量潜在问题挡在门外,我花五秒钟做的检查,经常能避免后续几个小时的返工。
第二个习惯是任何分词相关的问题都先交给_analyze验证。不要凭直觉猜,直接把同样的文本丢进去,看分词器切出的 token 是不是业务期望的粒度,再决定是换分析器还是调整查询语句。
第三个习惯是学会读返回体里的报错信息。ES 的报错通常会把错误类型、索引名、字段名写得比较明确,哪怕不完全理解,先把报错里的英文关键词复制下来,也比只截图问别人有效率得多。养成这三个习惯,你会发现所谓"ES 很难"的很多部分,其实是文档没看细。
这篇系列我刻意没有讲深奥的调优技巧,因为这些基础概念里任何一个没对齐,后面看再多的深入文章也是空中楼阁。如果你刚开始接触 ES,建议把文中的_analyze、_mapping、_bulk三个命令在 Dev Tools 里各跑一遍,尤其是构造一个"故意查不到"的查询,再通过分词结果理解为什么查不到,这个体验比背十页文档都深刻。最后留一句我自己排错的心得:凡是遇到查不到、聚合报错、类型冲突三类问题,先别急着改代码,第一反应应该是GET index/_mapping和POST index/_analyze各跑一遍,大部分基础坑都藏在这两个命令的输出里。