Elasticsearch 深度指南
Elasticsearch 深度指南
本文面向有一定 Elasticsearch 使用经验、准备高级/资深 Java 岗位面试的同学。目标不是罗列 API,而是讲清楚"为什么这么设计""生产上会踩什么坑""业界怎么解决"。每个知识点尽量给出:原理 → 机制细节 → 现实案例 → 代码/配置。
目录
每节标题保持简短,具体结论以 要点提示放在每节正文开头,方便扫读记忆。
- 一、Elasticsearch 是什么,能解决什么问题
- 二、倒排索引原理
- 三、集群架构:Node / Cluster / Index / Shard / Replica
- 四、写入流程:refresh / flush / translog
- 五、查询与打分机制
- 六、深度分页问题
- 七、集群高可用与常见故障
- 八、与业务数据库的数据同步
- 结语:面试回答的通用框架
一、Elasticsearch 是什么,能解决什么问题
要点:ES 的核心能力是"基于倒排索引的分布式全文检索 + 近实时聚合分析",本质是把 Lucene 单机的全文检索能力,通过分片和副本机制扩展成可以水平扩容、高可用的分布式搜索引擎。
Elasticsearch(下文简称 ES)是一个基于 Apache Lucene 构建的分布式搜索和分析引擎。理解这句话要抓住两层意思:
- Lucene 是单机的全文检索库,提供了倒排索引的构建、查询、打分这套核心能力,但它只是一个 Java 库,不提供分布式、不提供 HTTP 接口、不提供集群管理。
- ES 在 Lucene 之上包了一层分布式外壳:把数据分片(Shard)分布到多个节点,每个分片本质就是一个独立的 Lucene 索引;再叠加副本、集群协调、RESTful API、聚合分析框架,最终对外呈现为一个可以水平扩展、高可用的搜索引擎。
1.1 全文检索场景的核心诉求
要点:全文检索要解决的不是"查得到",而是"查得快、查得准、还能按相关度排序"——这三点恰好是传统关系型数据库索引结构不擅长的。
全文检索场景通常同时有下面几个诉求,传统关系型数据库很难同时满足:
- 分词匹配:用户搜"红色连衣裙",应该能匹配到"连衣裙 红色 收腰款"这样的商品标题,而不要求关键词连续、顺序一致。
- 相关度排序:不是"能匹配上就返回",而是要按匹配程度(关键词命中数、位置、字段权重等)给结果排序,最相关的排前面。
- 海量数据下的低延迟查询:数据量到千万、亿级别时,查询仍要保持毫秒到百毫秒级响应。
- 模糊匹配、同义词、拼音纠错等语义层面的能力:这些都需要专门的分词器和查询能力支撑。
这几点恰恰是 MySQL 这类以 B+ 树索引为核心设计的关系型数据库的弱项(具体原因见第二节)。
1.2 典型应用场景
要点:ES 的两大主流场景是"业务内的全文检索"(如商品搜索)和"海量日志/指标的存储与分析"(ELK/EFK 技术栈),二者对 ES 的诉求侧重点不同。
- 电商商品搜索:按标题、类目、品牌做多字段加权全文检索,同时结合价格区间、库存、销量等结构化字段做过滤,再叠加聚合做"品牌/价格区间"分面导航(Faceted Search)。这是"全文检索 + 结构化过滤 + 聚合分析"三者结合的典型场景。
- 日志分析(ELK/EFK 技术栈):Logstash/Filebeat/Fluentd 采集应用日志写入 ES,Kibana 做可视化查询和看板。这个场景下 ES 承担的更多是"海量时间序列数据的存储 + 聚合统计",查询模式偏向按时间范围过滤 + 关键字检索 + 聚合(如统计每分钟错误数)。
- APM / 可观测性:链路追踪数据(Trace/Span)、监控指标写入 ES,做故障排查和性能分析。
- 风控 / 安全(SIEM):海量行为日志的实时检索与异常模式匹配。
现实案例
电商详情页/搜索页背后,通常是 MySQL 存交易强一致的核心数据(订单、库存扣减),ES 只存用于检索的冗余副本(商品标题、属性、价格快照等),两边通过异步同步保持最终一致(详见第八节)。这是"ES 不适合做系统的唯一数据源(Source of Truth)"这条工程共识的典型落地——ES 擅长检索,不擅长强一致事务。
二、倒排索引原理
2.1 正排索引与倒排索引
要点:正排索引是"文档 → 词"的映射(类似书的目录按页码找内容),倒排索引是反过来"词 → 文档"的映射(类似书末尾的关键词索引,按词直接找到出现的页码),全文检索要靠后者才能做到"不逐条扫描就能定位包含某个词的所有文档"。
- 正排索引(Forward Index):以文档 ID 为主键,存储该文档包含的完整内容/分词结果。查询"这篇文档有哪些词"很快,但反过来查询"哪些文档包含某个词"就得遍历所有文档,效率是 O(N)。
- 倒排索引(Inverted Index):以词(Term)为主键,存储"哪些文档包含这个词、出现在什么位置"。查询"包含某个词的文档"直接查这个词对应的列表即可,不需要遍历全部文档。
原始文档:
Doc1: "红色连衣裙 收腰款"
Doc2: "黑色连衣裙 显瘦款"
Doc3: "红色半身裙"
分词后建立倒排索引:
词项(Term) -> 倒排列表(Posting List)
"红色" -> [Doc1, Doc3]
"连衣裙" -> [Doc1, Doc2]
"收腰款" -> [Doc1]
"黑色" -> [Doc2]
"显瘦款" -> [Doc2]
"半身裙" -> [Doc3]
查询"红色连衣裙":
分别查"红色"和"连衣裙"两个词对应的倒排列表 -> [Doc1,Doc3] 和 [Doc1,Doc2]
取交集/并集(取决于查询逻辑) -> Doc1 命中两个词,相关度最高排最前
2.2 倒排索引的构建过程
要点:构建过程是"分词 → 归一化 → 生成词典和倒排表",词典部分 Lucene 用类似 FST(有限状态转换器)的结构做前缀压缩,倒排表里除了文档 ID,还记录词频、位置信息用于打分和短语查询。
一个字段的内容写入 ES 后,大致经历这几步:
- 分词(Tokenizer):把整段文本切分成一个个词项,如中文用 IK 分词器把"红色连衣裙"切成"红色"/"连衣裙"。
- 归一化(Token Filter):转小写、去停用词、词干提取(英文场景,如 running → run)、同义词展开等。
- 写入词典(Term Dictionary):所有出现过的词项按序组织成词典,供查询时快速定位。
- 写入倒排表(Posting List):每个词项对应一个列表,记录包含它的文档 ID,以及词频(该词在这篇文档中出现几次)、位置信息(用于短语查询
match_phrase)、偏移量(用于高亮)。
Lucene 底层为了让词典查找也做到高效(词典本身可能有几十万上百万个词项),内部用了类似 FST(Finite State Transducer) 的结构做前缀共享压缩,兼顾内存占用和查找速度;倒排表则通过跳表结构加速大列表的求交集运算。这部分是 Lucene 内部实现细节,面试作为"知道倒排索引不是简单的 HashMap,Lucene 在词典和列表压缩上做了专门优化"这个认知即可,具体数据结构演进以官方文档/源码为准。
2.3 为什么全文检索不能用 MySQL LIKE
要点:
LIKE '%关键词%'前导通配符导致 B+ 树的有序性完全用不上,只能全表扫描;即使不考虑性能,MySQL 也没有分词、相关度打分能力,无法满足"模糊匹配 + 排序"的全文检索诉求。
MySQL 的普通索引基于 B+ 树,本质是靠"数据有序"实现快速定位——LIKE 'abc%'(前缀匹配)能利用这个有序性走索引区间扫描,但 LIKE '%abc%' 或 LIKE '%abc'(内容中间/结尾包含)没有一个固定前缀可供定位,索引的有序性完全用不上,MySQL 只能退化成全表扫描,逐行做字符串匹配,数据量一大性能急剧下降。
即使抛开性能不谈,LIKE 只能做"包含/不包含"的二元判断,没有相关度概念——无法回答"这几个匹配结果里哪个更相关",也没有分词能力(不能把"红色连衣裙"理解成两个独立词项分别匹配)。
MySQL 本身也提供了 FULLTEXT 全文索引(InnoDB 5.6+ 开始支持),底层同样是倒排索引思路,但生态成熟度、分词器/相关度算法的丰富程度、以及水平扩展能力,都无法和专门为全文检索设计的 ES/Lucene 相比,工业界重度全文检索场景基本不会依赖 MySQL 的 FULLTEXT 索引。
现实案例
早期不少系统用
LIKE '%关键词%'做商品名称搜索,数据量小的时候(几万条)勉强能用,一旦商品量涨到百万级,一次搜索请求就是几十上百毫秒起步的全表扫描,且并发一高直接拖垮数据库——这是"要不要引入 ES"这个技术选型问题最常见的现实触发点。
三、集群架构:Node / Cluster / Index / Shard / Replica
3.1 几个核心概念的关系
要点:Cluster 由多个 Node 组成,一个 Index(逻辑上的数据集合)被水平切分成多个 Shard(真正存数据、单机 Lucene 索引),每个主分片(Primary Shard)可以有若干副本分片(Replica Shard)分布在不同节点上做高可用。
Cluster(集群,靠 cluster.name 区分)
├── Node1(节点,一个 ES 进程)
│ ├── Index"products" 的 Shard0(主分片)
│ └── Index"products" 的 Shard1 的副本(副本分片)
├── Node2
│ ├── Index"products" 的 Shard1(主分片)
│ └── Index"products" 的 Shard0 的副本(副本分片)
└── Node3
└── ...
- Cluster:一个或多个 Node 组成的集合,靠相同的
cluster.name互相识别,对外表现为一个整体。 - Node:一个 ES 进程实例,按角色可以分为:
- master-eligible node:有资格参与选主,选出的 master 节点负责维护集群元数据(索引、mapping、分片分配情况)并广播给其它节点,本身不承担实际的索引/查询数据读写压力。
- data node:真正存储分片数据、承担索引写入和查询计算。
- ingest node:请求写入前做预处理(如日志字段解析),可选。
- coordinating node(协调节点,任何节点默认都可以承担这个角色):接收客户端请求,转发到相关分片所在节点,再汇总结果返回——理解第五节"两阶段查询"的关键角色。
- Index:逻辑上的数据集合(类似关系型数据库的"表"),有自己的 mapping(字段类型定义)。7.x 之前一个 Index 下还有 Type 的概念(类似"表下面再分子表"),7.x 逐步废弃、8.x 完全移除,现在一个 Index 只对应一类文档结构。
- Shard:Index 的物理切分单位,每个 Shard 本质就是一个独立、完整的 Lucene 索引,有自己的倒排索引、段文件等。分为主分片(Primary)和副本分片(Replica)。
- Replica:主分片的副本,数据内容与主分片一致,承担两个职责:故障时的高可用(主分片所在节点挂了,副本可以被提升为新主分片)和分摊查询压力(查询可以命中主分片也可以命中副本分片)。
3.2 分片如何提升写入和查询吞吐
要点:分片把一个 Index 的数据和计算量拆分到多个节点上并行处理,写入靠"多分片并行接收不同文档"提升吞吐,查询靠"多分片并行检索各自数据再汇总"缩短单次查询耗时;副本额外提供了"查询可以在主/副本之间做负载均衡"的读扩展能力。
- 写入吞吐:一个文档写入时,ES 用
shard = hash(routing) % number_of_primary_shards(默认routing就是文档_id)计算出该文档归属哪个主分片,不同文档会被路由到不同分片。多个分片分布在不同节点上,就能把整体写入压力分摊到多台机器上并行处理,而不是所有写入都挤到一台机器的一个 Lucene 索引上。 - 查询吞吐:一次查询默认会被广播到该 Index 涉及的所有主分片(或其副本之一),每个分片在自己持有的这部分数据上独立执行查询和打分,最后由协调节点汇总排序(见第五节的"两阶段查询")。数据被拆得越分散,单个分片需要扫描的数据量越小,整体查询延迟也越低——这本质是"分而治之",用多机并行换取单次查询的响应时间。
- 副本对读的额外收益:查询请求可以在主分片和它的副本之间做负载均衡,副本数越多,能承载的并发查询吞吐越高(但不会加速单次查询的延迟,因为一次查询还是命中某一个分片副本,不会同时用主+副本一起算这一次查询)。
现实案例
日志类 Index 通常按天/按小时滚动创建新索引(如
logs-2026.07.16),一方面控制单个 Index 的数据量和分片大小在合理范围(官方建议单分片大小控制在几十 GB 量级,过大会拖慢查询和恢复速度),另一方面老日志索引可以整体删除/归档,比在一个巨大索引里做范围删除高效得多。
3.3 为什么分片数量创建后很难修改
要点:主分片数量在索引创建时就通过路由公式(
hash(routing) % 主分片数)和数据绑定死了,事后改变分片数会让已有文档的路由结果全部错位;副本数量则没有这个问题,可以随时动态调整。
前面写入吞吐部分提到的路由公式 shard = hash(routing) % number_of_primary_shards 里,主分片数量本身是这个公式的一个乘数。如果索引创建之后直接修改主分片数量,所有已经写入的文档按新公式重新计算,绝大部分都会得到和之前不同的分片编号——相当于数据和它"应该在哪个分片"的映射关系全部错乱,无法简单地"加几个分片"了事。这也是为什么 ES 要求主分片数量必须在创建索引时就规划好。
副本数量则完全没有这个问题——副本只是主分片的完整拷贝,不参与路由计算,number_of_replicas 可以随时通过 _settings API 动态调整,增加副本只是让 ES 在其它节点上多复制几份数据。
如果确实需要调整主分片数,ES 提供了两个有条件限制的 API,而不是简单地"改个数字":
- Split(拆分):把索引拆成更多主分片,但目标分片数必须是原分片数的倍数关系(具体的倍数约束以官方文档为准),且原索引需要先设为只读。
- Shrink(收缩):把索引的主分片合并成更少的数量,同样有整除关系的限制。
实际工程里更通用、也更常见的做法是:创建一个新的、分片数符合新规划的索引,用 Reindex API 把数据搬过去,搬完后把业务读写的别名(Alias)切到新索引上,这样不受 Split/Shrink 的倍数关系限制,代价是需要额外的存储空间和一次全量数据搬迁的时间窗口。
四、写入流程:refresh / flush / translog
4.1 一次写入请求的完整链路
要点:一条写入请求会同时做两件事——写入内存缓冲区(等待 refresh 变得可搜索)和写入 translog(保证宕机不丢数据),这两件事职责完全不同,是理解"近实时"和"数据持久性"的关键分界线。
客户端写入请求(Index/Update)
|
v
协调节点路由到目标主分片所在节点
|
v
主分片处理:
① 写入内存中的 indexing buffer(此时数据还不可被搜索到)
② 同时把这次操作写入 translog(顺序追加,用于故障恢复)
|
v
主分片处理成功后,把写入请求转发给所有副本分片,
副本分片重复①②两步,副本确认成功后,主分片才向协调节点返回成功
|
v
(异步,默认每1秒)refresh:
把 indexing buffer 中的数据生成一个新的 Lucene 段(segment),
写入文件系统缓存(不强制刷盘),这个新段自此可以被搜索到
|
v
(周期性或达到阈值触发)flush:
把内存中的段真正 fsync 到磁盘,做一次 Lucene commit,
这次 commit 之前的 translog 内容已确保落盘,可以清空/滚动 translog
4.2 为什么 ES 是近实时搜索
要点:文档写入后并不是立刻可搜索,而是要等下一次 refresh(默认最长 1 秒)把内存缓冲区的数据变成一个新的 Lucene 段之后才可见——这个"写入到可搜索之间有个小延迟"的特性,就是 near real-time(近实时)说法的由来。
写操作在写入 indexing buffer 的那一刻就已经"写成功"了(客户端会收到成功响应),但这些数据此时还只是内存里的中间状态,并不在任何一个可供搜索的 Lucene 段里,所以立刻发起查询是查不到刚写入的这条数据的。
只有等到下一次 refresh 发生——默认每 index.refresh_interval(默认 1 秒)触发一次——才会把 indexing buffer 里积累的数据构建成一个新的、不可变的 Lucene 段,并写入操作系统的文件系统缓存(page cache),使其对搜索可见。这个新段这时候还没有 fsync 到磁盘,只是操作系统层面可读,但已经足以让搜索请求查到它。
这就是"近实时"而不是"实时"的原因:写入和可搜索之间,天然存在一个最长约等于 refresh 间隔的延迟窗口。批量导入数据时,如果不关心这个延迟,通常会把 refresh_interval 临时调大甚至设为 -1(关闭自动 refresh)以提升写入吞吐,导入完成后再改回默认值或手动触发一次 refresh。
4.3 translog 保证的是什么
要点:translog 是一个只追加写的操作日志,作用类似关系型数据库的 WAL——它保证的是"数据不丢",而不是"数据马上可搜索";节点异常宕机重启时,靠重放 translog 把还没来得及 flush 到磁盘段文件的数据恢复回来。
refresh 只是把数据放进了文件系统缓存,让它可搜索,但这份数据本身还没有 fsync 到磁盘,如果这时候机器掉电或进程被强杀,文件系统缓存里的内容可能丢失。真正保证"写入确认成功的数据不会丢"的,是 translog:每次写操作在写入 indexing buffer 的同时,也会顺序追加写入 translog 这个日志文件。
- translog 的落盘策略由
index.translog.durability控制:默认request,即每次写请求都会等 translog fsync 落盘后才向客户端返回成功,最安全但有一定延迟开销;也可以设置成async,按固定间隔(默认每 5 秒)批量 fsync,吞吐更高但极端情况下有丢失这几秒内数据的风险。 - flush 会清空/滚动 translog:flush 操作把当前内存段真正 fsync 到磁盘、完成一次 Lucene commit 之后,这次 commit 之前的数据已经安全地落在磁盘段文件里,不再需要 translog 兜底,对应的 translog 可以清空(滚动出新的 translog 文件)。flush 由 translog 大小达到阈值(默认 512MB)或周期性时间触发。
- 故障恢复:节点异常重启时,ES 会先加载磁盘上最后一次 flush(commit)的段文件,再重放这次 commit 之后 translog 里记录的所有操作,把状态恢复到宕机前那一刻——这套机制本质上就是数据库里 WAL(Write-Ahead Log) 思路的落地。
现实案例
如果把
index.translog.durability从默认的request改成async来追求更高写入吞吐,要清楚这是"用可能丢失几秒数据换取性能"的权衡,一般只在能接受少量数据丢失的场景(如非核心日志采集)才这样配置,涉及订单、支付相关的核心业务数据同步到 ES,不建议做这个取舍。
五、查询与打分机制
5.1 一次查询的两阶段执行
要点:一次查询分为 Query 阶段(各分片各自查询打分,只返回文档 ID 和分数)和 Fetch 阶段(协调节点汇总排序出最终 Top N 后,再去对应分片取回完整文档内容),这样设计是为了避免把所有分片的全部文档内容都传输一遍再排序的浪费。
① Query 阶段:
协调节点把查询广播给该 Index 涉及的所有分片(主分片或其副本之一)
每个分片在本地执行查询、计算打分,只返回"文档ID + 分数"这种轻量结果(不含文档正文)
协调节点收集所有分片的结果,在内存里做全局排序,确定最终 Top N 是哪几个文档
② Fetch 阶段:
协调节点只需要对 Top N 对应的少数几个分片发起"取文档内容"的请求
拿到完整文档(_source等字段)后拼装成最终响应返回给客户端
这样"先只传分数排序、后按需取内容"的两阶段设计,避免了让每个分片把所有匹配到的文档全文都传给协调节点(可能匹配了几十万条,但用户只要前 10 条)——只在最终确定 Top N 之后,才对少数几个分片发起"取具体内容"的请求,大幅减少了网络传输量。
5.2 BM25 打分的直觉理解
要点:BM25(ES 5.0 起替代 TF-IDF 成为默认相关度算法)本质上还是围绕"词频、逆文档频率、字段长度"这三个直觉去打分,不需要记完整公式,理解每个因子在鼓励什么、抑制什么即可。
BM25 是 ES 默认的相关度打分算法(早期版本默认是 TF-IDF,ES 5.0 之后默认切换为 BM25,二者思路接近,BM25 做了工程上的改进)。不需要死记公式,抓住三个因子的直觉:
- 词频(TF, Term Frequency):一个词在这篇文档里出现得越多,说明这篇文档跟这个词越相关,分数应该越高。但 BM25 对词频做了饱和处理——出现 10 次和出现 100 次,对分数的提升不是线性放大,而是边际效益递减(通过参数
k1,默认约 1.2 控制饱和曲线),避免"疯狂堆砌关键词"就能无限刷高分数。 - 逆文档频率(IDF, Inverse Document Frequency):一个词在所有文档里出现得越普遍(比如"的"、"是"这种词,或者语料库里几乎每篇都有的词),说明它区分度低,对相关度的贡献应该被降权;反之,一个很少见的词一旦命中,说明匹配得很精准,应该给更高权重。
- 字段长度归一化(Field-Length Norm):同样命中一个关键词,出现在一篇很短的标题里(比如商品标题)通常比出现在一篇很长的正文里更能说明"这篇文档就是讲这个的",所以字段越短、命中权重相对越高。这个影响程度由参数
b(默认约 0.75)控制,b越大,长度差异对分数的影响越大。
一句话总结面试答法:BM25 综合考虑"这个词在这篇文档里有多突出(词频,且有饱和上限)"、"这个词在整个语料库里有多稀有(逆文档频率)"、"这篇文档本身长不长(长度归一化)"三者,算出一个综合相关度分数。具体的 k1、b 默认值和公式细节以官方文档为准,面试更多考察这三个因子的直觉理解而非精确推导。
六、深度分页问题
6.1 为什么 from 和 size 深度分页慢
要点:
from+size分页时,每个分片都必须先返回from+size条结果给协调节点,协调节点再合并排序、丢弃前面的from条——请求的页码越靠后,每个分片要计算和传输的数据量越大,且这些开销和"你实际只要这一页 10 条"完全不成正比。
from 和 size 是最直观的分页参数:from=100&size=10 表示跳过前 100 条,取第 101~110 条。但结合第五节的两阶段查询就能看出问题——协调节点要拿到全局正确排序的第 101~110 条,必须先让每个分片各自返回它本地排序前 from+size(这里是 110)条结果,因为无法预先知道这 110 条里到底有多少条落在哪个分片。
协调节点收到所有分片各自的 110 条结果后(假设有 5 个分片,就是 5×110=550 条),在内存里做全局排序,再丢弃前 100 条,只留最后 10 条返回。页码越往后翻,from 越大,每个分片需要排序和传输的数据量也越大,这个开销是随着深度线性增长的,而用户实际要看的永远只有 size 条。这既浪费 CPU(排序)也浪费内存和网络带宽(传输)。
出于这个原因,ES 默认设置了 index.max_result_window(默认 10000),from+size 超过这个值会直接报错拒绝执行,而不建议简单粗暴地调大这个参数了事。
6.2 search_after 怎么解决
要点:
search_after用上一页最后一条文档的排序值作为"书签"传给下一次查询,每个分片只需要基于这个书签定位起点、返回size条即可,不需要像from+size那样重复计算前面所有页的数据。
search_after 的思路是用游标代替偏移量:查询时除了正常的排序条件,额外带上上一页最后一条文档的排序字段值(比如按时间排序,就是最后一条的时间戳),下次查询时告诉 ES "从这个值之后开始找"。
因为每个分片只需要基于这个"书签"位置继续往后取 size 条,不需要像 from+size 那样每次都从头算出 from+size 条再丢弃前面的,所以性能不会随着翻页深度增加而下降。
使用上有两个要注意的点:
- 排序字段必须包含一个能保证唯一性的 tie-breaker(比如在按时间排序基础上加一个
_id或_doc作为次级排序字段),否则遇到排序值相同的文档,翻页结果可能重复或遗漏。 - 只能"一页一页往后翻",不能跳页——不像
from+size那样可以直接跳到第 50 页,search_after必须拿着上一页的书签才能取下一页,天然适合"无限滚动加载"这类只会向后翻页的场景,不适合"用户输入页码直接跳转"的传统分页 UI。
较新版本的 ES(7.10 起,具体行为以官方文档为准)还提供了 Point in Time(PIT) 配合 search_after 使用,能在翻页过程中固定住一个一致的数据快照视图,避免翻页期间有新文档写入导致的排序错位。
6.3 scroll 和 search_after 该怎么选
要点:
scroll会在发起查询的那一刻创建一个数据快照并一直保持到滚动结束,适合"一次性导出全量数据"这种批处理场景;不适合用户实时翻页——它看到的是查询发起那一刻的固定视图,之后的新增/修改都不会体现,而且长时间占用的 scroll 上下文本身也消耗集群资源。
scroll 更早期,用来解决"要一次性取出几十万上百万条全部数据"(比如批量导出、离线重建索引)这种诉求:第一次请求会创建一个 scroll_id,这个 scroll_id 背后绑定的是查询发起那一刻的一份数据快照(依赖当时的段文件视图),后续带着这个 scroll_id 持续翻页,每次拿一批,直到取完。
scroll 有两个明显的代价:
- 快照期间数据不实时:
scroll期间即使原索引有新增、修改、删除,滚动过程看到的还是发起那一刻的旧视图,这对"取全量数据做批处理"没问题,但完全不适合"用户翻页看最新数据"的场景。 - 资源占用:每个存活的 scroll 上下文都要占用集群资源(保留对应的段文件不被合并/清理,消耗内存),如果大量并发的 scroll 请求没有及时清理,会拖累集群整体性能,官方也因此不建议把
scroll当作用户交互式分页的常规方案。
两者该怎么选,一句话总结:
| 场景 | 推荐方案 |
|---|---|
| 用户交互式的常规分页(前几页) | from + size |
| 用户"无限滚动"翻页,或需要深度分页且要看到较新数据 | search_after(可结合 PIT) |
| 一次性批量导出全量数据、离线重建索引 | scroll |
七、集群高可用与常见故障
7.1 脑裂问题是怎么产生的
要点:脑裂是指网络分区把集群切成互不相通的两部分,如果两部分各自都能选出 master,就会出现两个"合法"的集群同时独立处理写入,产生数据分歧;ES 7.0 之前靠人工配置
minimum_master_nodes这个多数派门槛来防止脑裂,配置不当(比如设成 1)就可能真的出现两个 master。7.0 引入的新集群协调层不再需要手工配置这个参数,大幅降低了误配置导致脑裂的风险。
脑裂(Split Brain)指的是:集群因为网络分区被切成两部分,如果两部分都误以为自己是"完整的、有效的集群",各自选出一个 master 节点独立对外提供服务,就会导致同一份数据出现两份互不知情、各自演进的版本,网络恢复后难以合并,可能造成数据错乱或丢失。
ES 7.0 之前(基于 Zen Discovery),需要人工配置 discovery.zen.minimum_master_nodes,其含义是"必须凑够多少个 master 候选节点达成一致才能选出新 master",标准取值是 (master候选节点数 / 2) + 1(多数派门槛)。如果这个值配置过小(比如误配成 1),网络分区后,两部分即使各自都不到半数节点,也可能各自都满足这个偏低的门槛,从而各自选出一个 master,形成脑裂。
ES 7.0 引入了新的集群协调子系统(替代 Zen Discovery),采用类似 Raft 的共识协议自动管理"投票配置"(voting configuration),不再需要人工配置 minimum_master_nodes 这个参数,多数派判断由集群自动维护和调整,大幅降低了因为人工配置错误导致脑裂的概率。具体的协调协议实现细节、版本间的行为差异,建议以官方文档为准。
需要说明的是,"降低脑裂概率"不等于"彻底不可能出现任何形式的数据不一致"——网络分区这类问题本质上是分布式系统 CAP 权衡的体现,理解这一点比记住某个具体参数名更重要。
7.2 集群健康状态 green / yellow / red
要点:green 表示所有主分片和副本分片都正常分配,yellow 表示主分片都正常但有副本没分配上(数据不丢但抗风险能力下降),red 表示有主分片没有分配上(对应索引的部分数据不可读写,需要立即处理)。
ES 集群健康状态是最基础也是最高频的运维/面试问题,通过 GET _cluster/health 可以查看:
- Green:该索引/集群下所有主分片和全部副本分片都已经正常分配到某个节点上,一切正常。
- Yellow:所有主分片都已正常分配,但至少有一个副本分片没有被分配(比如持有副本的节点挂了,或者单节点测试环境里设置了
number_of_replicas > 0但根本没有第二个节点可分配)。这个状态下数据本身不丢(主分片都在),但容错能力下降——如果此时唯一持有数据的主分片所在节点也出问题,就会直接变成 Red。 - Red:至少有一个主分片没有被分配(比如持有该主分片的节点永久性丢失、且没有可用副本能被提升为主分片)。这意味着这个分片对应的那部分数据当前不可读写,是需要立即介入处理的严重故障状态。
现实案例
单节点的本地开发/测试环境,新建索引默认
number_of_replicas通常不是 0(依赖具体版本和模板配置),因为只有一个节点,副本分片永远没有第二个节点可以分配,集群健康状态会一直是 Yellow——这是开发环境常见的"正常但看起来报警"的情况,不代表数据有问题,生产多节点环境才需要认真对待 Yellow 状态背后代表的副本缺失风险。
八、与业务数据库的数据同步
8.1 全量同步怎么做
要点:全量同步通常在首次接入 ES,或者 Mapping 结构变更需要重建索引时使用,做法是分批扫描源表全部数据、转换后批量写入 ES,重点是控制批次大小和写入速率,避免对源库和 ES 造成瞬时压力。
全量同步一般用于两个时机:索引第一次上线,或者索引 Mapping 结构变化需要重建(比如字段类型改了,ES 不支持原地修改已有字段类型,通常需要建一个新索引重新写入全量数据再切换别名)。
常见做法:
- 分批扫描 + Bulk 写入:用主键或自增 ID 分页扫描源表(避免用
OFFSET深度分页,思路类似第六节讨论的深度分页问题,通常用"上次扫描到的最大 ID"作为下一批的起点),每批转换成 ES 文档格式后调用 Bulk API 批量写入,而不是一条条Index请求(Bulk 能大幅减少网络往返次数,是官方推荐的批量写入方式)。 - 借助现成工具:如 Logstash 的 JDBC input 插件定时轮询源表增量/全量数据同步到 ES,或者用通用 ETL 工具(如 DataX)做全量搬迁。
- 全量同步期间的注意事项:写入 ES 时可以临时把
refresh_interval调大(甚至设为-1)、副本数临时设为 0,全量导入完成后再改回正常配置——减少全量导入期间的 refresh 和副本同步开销,加快导入速度。
8.2 增量同步:基于 binlog 的方案
要点:生产环境增量同步的主流做法是基于 MySQL binlog 的 CDC(变更数据捕获)方案——用 Canal(或 Debezium 等同类工具)伪装成 MySQL 从库解析 binlog,把变更事件投递到消息队列,再由消费者写入 ES,好处是对业务代码零侵入、不依赖应用层显式双写。
增量同步的目标是:业务数据库(如 MySQL)发生的每一次增删改,都能尽快、可靠地同步反映到 ES 里。业界主流做法是基于 binlog 的 CDC(Change Data Capture,变更数据捕获):
MySQL 主库(binlog) --> Canal/Debezium(伪装成MySQL从库拉取binlog并解析) --> MQ(Kafka/RocketMQ)
|
v
消费者服务:解析变更事件
-> 转换成 ES 文档格式
-> 调用 Bulk API 更新/删除 ES 对应文档
- Canal(阿里开源)的原理是模拟 MySQL 的 slave 节点,向 MySQL 主库发起 dump 协议请求,MySQL 会把它当成一个真正的从库,将 binlog 事件流推送给它,Canal 解析出行级别的增删改事件,转发到下游(通常是 MQ)。这种方式对业务应用代码完全无侵入——业务代码该怎么读写 MySQL 还是怎么读写,不需要额外调用任何同步逻辑。
- 这类方案相比"应用层显式双写"(业务代码在写 MySQL 的同时手动调用 ES 写入)的优势在于:不会因为 ES 写入失败/超时而影响主业务事务,两边解耦;缺点是多引入了 binlog 采集、消息队列这条链路,同步链路更长,需要单独监控这条链路本身的健康状况(消费延迟、消费失败等)。
- Kafka 生态里对应的同类方案通常是 Debezium + Kafka Connect,思路一致。
8.3 同步延迟带来的一致性问题
要点:binlog 到 ES 的同步链路是异步的,天然存在延迟窗口,可能导致"刚写完 MySQL 立刻查 ES 查不到"(读己之写不一致)以及"并发更新在 ES 侧乱序覆盖"两类问题,分别靠"关键读走数据库"和"用业务时间戳做乐观版本控制"来缓解。
异步同步链路不可避免会有同步延迟,由此带来两类典型一致性问题:
- 读己之写不一致(Read-after-Write):用户刚提交了一次修改(比如修改了商品标题),MySQL 已经写成功,但 binlog 采集 -> MQ -> 消费写入 ES 这条链路还没跑完,此时立刻搜索会查到旧数据。缓解思路:对"用户刚编辑完,马上要看到自己修改结果"这类强诉求的场景,直接从 MySQL 读取而不是从 ES 读取(比如订单详情页读订单主库,只有搜索场景才依赖 ES);或者在应用层做短暂的展示层特殊处理(如提交成功后本地缓存这次修改结果)。
- 并发更新导致的乱序覆盖:如果同一条数据被连续快速修改两次,理论上消息应该按顺序被消费和写入 ES,但如果消息队列的分区/并发消费机制没有保证同一条数据的多次变更严格有序(比如两条消息被路由到不同分区、由不同消费者并发处理),有可能后发生的修改先被写入 ES,先发生的修改后写入,导致 ES 里最终停留的是一个"旧"版本。缓解思路:保证同一个文档 ID 的变更事件始终路由到 MQ 的同一个分区(用文档 ID 做分区键),从消息层面保证同一条数据的处理顺序;同时可以在写入 ES 时使用外部版本号(
version_type=external,传入 MySQL 的更新时间戳或 binlog 位点作为版本号),让 ES 自动拒绝"版本号比当前已存储的还旧"的写入,双重兜底防止乱序覆盖。
现实案例
生产实践中,binlog 同步链路本身也可能出故障(Canal 进程挂了、MQ 消费堆积或消费者长时间下线),如果没有监控告警,这类问题往往是"用户反馈搜索结果对不上最新数据"才被发现,具体一查发现同步链路已经断了很久。所以除了同步链路本身,通常还会配一个周期性的全量/增量比对补偿任务(定时抽样比对 MySQL 和 ES 的数据差异并修复),作为异步同步链路失效时的兜底手段,而不是完全依赖实时同步链路"永远不出问题"这个假设。
结语:面试回答的通用框架
回答 ES 相关问题时,比较容易拿分的框架是:先讲清楚这个机制解决了什么具体问题、朴素方案为什么不够(比如为什么不能直接用 MySQL LIKE、为什么深度分页 from+size 会慢),再讲 ES/Lucene 具体怎么设计解决这个问题,最后结合一个真实业务场景说明这个设计在生产上会遇到什么权衡(近实时的延迟窗口、同步链路的一致性问题、脑裂的容错取舍)。ES 面试真正想考察的往往不是"记住了多少 API 参数名",而是"你是否理解每个设计背后的工程权衡,并且知道它在生产环境里会踩什么坑"。

浙公网安备 33010602011771号