一句话速览:ES 的核心是倒排索引——“词 → 文档列表”的映射让全文检索从全表扫描变成查字典;但围绕它的绝大多数线上问题都不是”搜不到”本身,而是索引结构(Mapping)被错误创建、数据同步不完整、插件生命周期没管好这三类工程问题。

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P1-04、P1-05;
07-面试问题/验证问题/03-基础设施与中间件问题.md P1-01、P1-02、P1-03;
07-面试问题/验证问题/02-数据与环境问题.md P1-04

最近修订:2026-08-07

目录


技术点 0:ES 底层原理速通 —— 倒排索引 / 写入流程 / 分片

难度:⭐⭐⭐ | 掌握要求:能画出倒排索引结构、写入流程(refresh/flush)、query then fetch

这部分没有对应踩坑记录,但它是理解后面所有坑(为什么 mapping 抢跑会出事故、为什么数据”写进去立刻搜不到”)的地基,放在最前面。

倒排索引:ES 快的根本原因

传统数据库的索引是”文档 → 包含哪些词”(正排),查”哪些文档包含’精装修’”只能全表扫描 LIKE。ES 反过来建索引——倒排索引(Inverted Index)是”词 → 包含它的文档ID列表”

flowchart LR
    subgraph 文档
        D1["doc1: 豪华精装修公寓"]
        D2["doc2: 精装修近地铁"]
        D3["doc3: 毛坯大平层"]
    end
    subgraph 倒排索引
        T1["精装 → [1, 2]"]
        T2["修 → [1, 2]"]
        T3["公寓 → [1]"]
        T4["地铁 → [2]"]
        T5["毛坯 → [3]"]
    end
    D1 --> T1 & T2 & T3
    D2 --> T1 & T2 & T4
    D3 --> T5

搜索”精装修”时,分词器把查询词也拆开,直接查词项字典拿到文档 ID 列表做交集——时间复杂度从 O(全表) 变成 O(词项数),这就是”查字典 vs 翻全书”的区别。词项字典本身用 FST(有限状态转移机)压缩存储,支持前缀快速定位。

写入流程与”近实时”搜索

ES 一条写入请求经历的路径,解释了为什么 ES 叫 Near Real-Time(近实时,NRT) 而不是实时:

flowchart TD
    A[写入请求] --> B[写入内存缓冲区
+ 追加 translog 防丢] B --> C{refresh
默认每秒一次} C --> D[生成新的 segment
进入 OS 文件缓存
**此刻起可被搜索到**] D --> E{flush
translog 满或定时} E --> F[segment 真正 fsync 落盘
清空 translog] F --> G[后台 segment merge
小段合并成大段]

三个机制各管一件事,面试要能分开讲:

  • refresh(默认 1s):把内存数据生成新 segment 放进文件系统缓存,让数据可被搜索。这就是”写入后立刻搜索可能查不到”的原因,也是 POST /index/_refresh 强制刷新的用途(本项目验证搜索功能时如果用写入后立刻查询,应该了解这个参数)。
  • translog:每次写入先追加到 translog,防节点宕机丢内存数据——类似 Redis 的 AOF、MySQL 的 redo log,是同一种”WAL(预写日志)”思想。
  • flush 与 segment merge:segment 是不可变文件(immutable),删除只是打标记(.del 文件),后台合并才真正清理。segment 不可变这个设计带来了缓存友好、无需锁、并发读高效等好处,是 Lucene 的核心取舍。

分片与两阶段查询

  • 分片(Shard):索引被水平拆成多个主分片(primary shard),每个分片是一个独立的 Lucene 索引,可以分布在不同节点上实现水平扩展;副本分片(replica)提供高可用和读扩展。主分片数建索引时定死,不能改(要改只能 reindex),副本数可以随时调——这是建索引规划时最重要的一个决策。
  • Query Then Fetch:一次搜索分两步——① Query 阶段:协调节点把查询广播到所有相关分片,每个分片返回”本分片得分最高的 from+size 个文档ID”;② Fetch 阶段:协调节点归并排序出最终 TopN,再按 ID 去分片取完整文档。这解释了为什么深度分页(from+size 很大)性能差:每个分片都要算并返回大量候选。深分页的正确姿势是 search_afterscroll
  • 相关性评分:现代版本默认 BM25(TF-IDF 的改进版,引入词频饱和和文档长度归一化),match 查询按得分排序,term 精确匹配查询默认不关心评分。

技术点 1:Mapping —— 索引字段类型决定查询行为

难度:⭐⭐ | 掌握要求:text vs keyword 的区别、动态映射的风险、mapping 不可变

核心概念

ES 的字段类型分两大类,查询行为完全不同,这是所有 ES 排查问题的第一认知基础:

  • text 类型:写入时会被分词器拆分成多个 token 存索引,适合全文检索(搜索”精装修”能匹配到”豪华精装修公寓”)。
  • keyword 类型:写入时整个字符串作为一个不可分割的值存索引,适合精确匹配、排序、聚合(比如城市名”上海”必须完整匹配,不能被拆开)。

如果不显式定义 mapping,ES 会在第一次写入数据时自动推断字段类型(动态映射,dynamic mapping),推断规则对字符串默认是 text(外加一个 .keyword 子字段)。这个自动推断的行为是很多”数据存在但搜不到”问题的根源。

我们踩过的坑

验证问题 P1-02:city 字段被自动推断成 text,导致 TermQuery 精确匹配失效

完整的问题链条,是理解这个坑最好的方式:

  1. 第一次启动 search 服务时,IK 分词器插件还没装,代码里定义好的索引 mapping(含自定义分词器配置)创建失败。
  2. 但如果这时候恰好有数据尝试写入 ES(比如调用了 /search/sync),ES 会自动创建一个索引并自动推断 mapping,把 city 这类字符串字段识别成了 text 类型。
  3. 代码里查询用的是 TermQuery(精确匹配,不会对查询词分词,也期望字段本身没有被分词)。但 city 字段是 text 类型,写入”上海”时已经被标准分词器拆开存储,TermQuery 拿着完整的”上海”去匹配被拆碎的索引,自然匹配不上,结果是数据明明存在,搜索却返回 0 条

修复方式是删除这个被自动推断出来的错误索引,让代码里正确定义了 keyword 类型的 ElasticsearchIndexInitializer 重新创建索引:

1
2
curl -X DELETE "http://localhost:9200/livin_houses"
# 重启 search 服务,触发按代码定义的 mapping 重新建索引

必须记住的结论

  • 凡是要做精确匹配/排序/聚合的字段(状态、城市、类型这种枚举/短字符串),必须显式定义成 keyword,绝不能依赖 ES 自动推断
  • 索引的 mapping 必须由代码显式定义并且保证先于任何数据写入生效,一旦被自动推断”抢跑”创建了错误的 mapping,唯一的补救方法是删除索引重建(或 reindex 到新索引),没有办法”修改”已存在字段的类型——这是由”倒排索引建好后不可变”的底层结构决定的。
  • 排查”数据存在但搜不到”的第一反应:GET /{index}/_mapping 看一眼实际的字段类型,跟代码预期的是否一致,这比看业务代码快得多。

技术点 2:IK 中文分词器 —— 中文全文检索的前提

难度:⭐⭐ | 掌握要求:ik_max_word vs ik_smart、为什么索引用细粒度查询用粗粒度

核心概念

ES 官方镜像不自带中文分词能力,默认的 standard 分词器是按空格/标点切分的,对中文基本等于按单字拆分,效果很差。IK 分词器是最常用的中文分词插件,提供两种分词粒度:ik_max_word(细粒度,尽可能多地拆分)和 ik_smart(粗粒度,尽量拆成完整语义的词)。

为什么要两种粒度:索引时用 ik_max_word 拆得越细,能被匹配到的角度越多(召回率高);搜索时用 ik_smart 拆得越整,结果越精准(准确率高)。”索引粗、查询细”则两头不占。这是中文搜索场景的经典搭配:index 用 ik_max_word,search 用 ik_smart(mapping 里分别用 analyzersearch_analyzer 指定)。

我们踩过的坑

验证问题 P1-01:插件缺失导致索引创建直接失败

索引 mapping 里定义了用 ik_max_word 作为自定义 analyzer 的 tokenizer,但插件没装,报错非常明确:

1
Custom Analyzer [ik_max_word_analyzer] failed to find tokenizer under name [ik_max_word]

这类”依赖的组件缺失”报错很明确,反而不难排查,装上插件重启即可:

1
2
3
docker exec livin-es bin/elasticsearch-plugin install --batch \
"https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.13.3.zip"
docker restart livin-es

开发问题 P1-05:插件安装在容器可写层,容器重建后会丢失(这是本项目影响最持久的一个基础设施配置疏漏)

真正麻烦的地方在于:docker exec 装插件是装在容器的可写层里,不是持久化数据卷。只要容器被”重建”(不是简单的 restart,而是 docker compose up --force-recreate 或者删掉容器重新创建),装好的插件就会消失,回到”插件缺失”的状态,之前建好的索引也会因为找不到 tokenizer 变成 red(不可用)状态。

这个坑之所以隐蔽,是因为**”重启”和”重建”看起来是同一类操作,但对容器可写层的影响完全不同**:docker restart 保留容器实例和它的可写层,装过的插件还在;docker compose up --force-recreate(或者删除容器重新 up)是创建一个全新的容器实例,可写层从镜像原始状态重新开始,之前手动装的东西全部消失。

彻底的解决方案是把插件目录也做成持久化数据卷,让插件的生命周期脱离容器实例本身:

1
2
3
4
5
6
7
8
9
volumes:
es-data:
es-plugins: # 插件目录单独持久化,不随容器重建丢失

services:
elasticsearch:
volumes:
- es-data:/usr/share/elasticsearch/data
- es-plugins:/usr/share/elasticsearch/plugins

必须记住的结论

  • 任何通过 docker exec 手动安装/修改的东西(装插件、改配置文件),如果没有对应挂载数据卷,都只存在于这个容器实例的生命周期里,容器一旦被重建就会丢失。这不是 ES 特有的问题,是所有 Docker 容器的通用特性,装完插件后要下意识问自己一句”这个目录持久化了吗”。
  • docker restart(重启现有容器)和 docker compose up --force-recreate / 删除重建(创建新容器)对可写层的影响完全不同,运维/部署脚本里要清楚区分这两种操作。
  • 中文全文检索项目,IK 插件是硬依赖,必须在环境搭建阶段就确认好持久化方案,不要等到某次容器重建后功能突然失效才去排查。
  • 记住分词搭配:索引用 ik_max_word(保召回),搜索用 ik_smart(保准确)

技术点 3:数据同步的完整性 —— 消息驱动同步容易踩的坑

难度:⭐⭐ | 掌握要求:消息体只是通知不是数据源、失败要重试不要写残缺数据

核心概念

用消息队列驱动”业务库变更 → 同步到 ES”是常见架构(属于最终一致性的数据同步,思想同 02 篇的 MQ 最终一致方案),但消息体本身通常只包含”发生了什么变化”的最小信息(比如 houseId + 新状态),而不是完整的业务数据。消费者拿到消息后,往往需要反查一次完整数据再写入 ES,而不能只用消息体里的字段拼一个残缺的文档。

我们踩过的坑

开发问题 P1-04:消费者”简化实现”导致索引文档字段严重缺失

HouseSearchSyncConsumer 的注释写着”简化实现:直接用 payload 中的数据(实际项目需要完整数据)”——这是一句诚实的自我记录,但代码一直没有按注释的提醒去补全。结果是 ES 文档里只有消息体带的 houseId/status/updatedAt 三个字段,索引 mapping 定义的 title/city/price/location(GEO坐标)全部是空的。由于搜索查询用这些字段做过滤条件,效果是搜索功能”能跑但结果不可预期”——不报错,但 city 过滤、GEO 距离过滤、全文检索全部失效。

修复方式是让消费者收到消息后,通过 Feign 反查一次完整的房源详情,再组装出与索引 mapping 完全对齐的文档:

1
2
3
4
5
6
7
8
// 修复后:先反查完整数据,再写入 ES
public void onMessage(Long houseId) {
HouseDetailDTO detail = houseFeignClient.getDetail(houseId).getData();
if (detail == null) {
throw new RuntimeException("查询房源详情失败,触发MQ重试"); // 不写入残缺数据
}
// 用 detail 的完整字段组装 ES 文档,包括 lng/lat 合并成 geo_point
}

这里还有一个值得记住的设计细节:查询失败时选择抛异常触发 MQ 重试,而不是”忍着写一份残缺数据进去”。宁可这条消息暂时没被消费成功(后续重试),也不要让不完整的数据进入索引——半成品数据比”暂时没同步”更难排查,因为它看起来像是”已经同步了”。

验证问题 P1-04:status 字段大小写不一致

搜索代码硬编码只认 status=ONLINE(大写),如果同步过去的数据是 online 或者其他大小写形式,同样会导致”数据存在但搜不到”。这类问题本质上是多个系统间对同一个枚举值的约定没有强制保证一致,业务库里存的状态值和搜索代码里过滤用的字符串必须字面一致。

必须记住的结论

  • 消息驱动的数据同步,消息体只是”变更通知”,不是”完整数据源”。消费者收到通知后应该反查权威数据源拿到完整数据,而不是直接用消息体里的字段拼装。
  • 代码注释里写的”简化实现,后续需要完善”,本质上是显式记录的技术债务,如果这个模块会被下游依赖用来做关键判断(这里是搜索的可用性),这类简化实现要优先偿还,不要等到功能验证时才暴露。
  • 反查数据失败时,raise 触发重试 比 “写入残缺/默认数据” 更安全——半成品数据造成的问题往往比”暂时缺失”更难定位。
  • 跨系统共享的枚举值(状态字段这种),要保证所有写入方和查询方对大小写、具体取值的约定完全一致,最好是通过枚举类型约束,而不是到处手写字符串常量。

技术点 4:GEO 地理位置查询

难度:⭐⭐ | 掌握要求:geo_point 格式、验证距离过滤的正确姿势

核心概念

ES 原生支持 geo_point 类型字段和基于此的距离过滤查询(GeoDistanceQuery),本项目用它实现”搜索我附近 N 公里内的房源”。geo_point 字段的写入格式通常是 {"lat": ..., "lon": ...} 或者 "lat,lon" 字符串,需要跟经纬度原始字段(lng/lat)分开维护,同步时要显式拼装成这个格式。

底层实现值得一提:ES 的地理索引是把地球表面递归划分成网格(GeoHash/Quadtree 思想),把”圆形距离范围”近似成”若干网格的并集”,从而把地理范围查询转化成对网格编码的 term 查询——这就是为什么 geo_distance 能复用倒排索引做到毫秒级,而不是逐文档算球面距离。

我们踩过的坑

这部分没有独立的踩坑记录(阶段8验证时是在修复 P1-04 消费者简化实现的同时一并补上的),但值得记住实现要点:同步到 ES 的文档中,除了保留原始的 lng/lat 字段用于展示,还要单独拼一个 locationgeo_point 字段供 GeoDistanceQuery 使用,两者字段名和数据类型不能混淆。验证 GEO 查询是否真的按距离过滤,最简单的方法是拿一个明显很远的坐标(比如用北京坐标查上海房源,限定 1km 范围)确认返回 0 条,而不是只验证”附近坐标能搜到”——后者不能排除”实际上没做距离过滤、只是全量返回”的可能性。


术语表

术语 含义
倒排索引 “词 → 文档ID列表”的映射结构,全文检索高效的根本原因
NRT(近实时) ES 写入默认 1 秒 refresh 一次才可被搜索,不是严格实时
refresh / flush 生成新 segment 使其可搜索 / 把 segment 真正落盘并清空 translog
translog ES 的预写日志(WAL),防节点宕机丢失内存中的新数据
segment Lucene 的不可变索引文件,删除是打标记,后台 merge 才真正清理
分片(Shard) 索引的水平拆分单位;主分片数建索引后不可改,副本数可调
Query Then Fetch 两阶段查询:各分片先返回候选ID,协调节点归并后再取完整文档
BM25 ES 默认的相关性评分算法,TF-IDF 的改进版
text / keyword 分词全文检索字段 / 整值精确匹配字段
ik_max_word / ik_smart IK 分词器的细粒度(索引用)/ 粗粒度(查询用)模式
geo_point / geo_distance 地理坐标字段类型 / 按距离范围过滤的查询