凌晨 1 点多,告警群炸了。
业务那边说搜索接口超时,TP99 从 50ms 飙到 3 秒,用户开始疯狂刷退款单。
我上去一看,ES 集群 CPU 飘红,节点负载打满,第一反应就是加机器、扩分片。结果倒好,分片一加,查询更慢了,差点把整个集群拖崩。
后来硬着头皮啃了三天源码,翻了十几篇官方文档,把 ES 的核心原理从倒排索引到段合并、从写流程到查询流程全撸了一遍,才慢慢摸清楚这东西到底是怎么工作的。
今天这篇文章,我把 ES 底层那些绕来绕去的东西掰开揉碎讲清楚,尽量少画饼,多上干货。建议先收藏,因为你大概率不会看第二遍。

很多人觉得 ES 就是个搜索引擎,其实不太准确。
ES 底层用的是 Lucene,这点大家都知道。但它跟 Lucene 不一样的地方在于,Lucene 是个 Java 库,你得自己写代码去调;ES 把它包装成分布式的服务,HTTP 协议一开,RESTful 接口一暴露,用起来就跟调 API 一样简单。
但简单归简单,坑是真的多。
比如你往里写一条数据,默认情况下 1 秒之后才能搜到。很多人不知道这个点,写完数据立刻查,查不到就以为写失败了,结果疯狂重试,把集群打爆。
这事儿我干过,别笑。
所以理解 ES 原理,不只是为了面试,更是为了别在生产环境翻车。下面我挑几个最核心的模块讲,尽量讲人话。

传统的数据库,比如 MySQL,做模糊查询的时候用的是 LIKE '%xxx%',这种查询是全表扫描,数据量一上百万就开始拉垮。
ES 之所以快,核心就在于倒排索引。
啥叫倒排索引?我举个真实的例子你就懂了。
假设我们有三条文档:
doc1: 运维躬行录 是一个 干货 公众号
doc2: 分享 运维 实战 经验
doc3: 公众号 分享 干货 内容
传统的正排索引是:文档 -> 词项。
doc1 -> [运维躬行录, 是, 一个, 干货, 公众号]
doc2 -> [分享, 运维, 实战, 经验]
doc3 -> [公众号, 分享, 干货, 内容]
倒排索引反过来,是:词项 -> 文档。
运维躬行录 -> [doc1]
干货 -> [doc1, doc3]
公众号 -> [doc1, doc3]
分享 -> [doc2, doc3]
运维 -> [doc2]
实战 -> [doc2]
经验 -> [doc2]
内容 -> [doc3]
这样一来,你搜 "干货" 的时候,ES 不用扫描所有文档,直接从倒排表里拿到 [doc1, doc3] 就完事了。这就是为啥 ES 搜得快。
但这里面有个细节,很多人不知道。
ES 在建倒排索引的时候,不是简单地把词项存起来,它还记录了词频(TF)和位置信息(Position)。这就是为什么 ES 支持短语查询、高亮、相关性打分这些高级功能。
如果你想做精确匹配,可以用 keyword 类型;如果你想分词搜索,用 text 类型。这两种类型在建索引的时候走的是完全不同的路径,性能差距非常大。生产环境里,keyword 用于精确过滤,text 用于全文搜索,两者结合用,查询效率能提升好几倍。
这是 ES 最难理解的部分之一,也是很多线上事故的根源。
ES 的数据不是直接写入磁盘的,而是先写到一个内存缓冲区(In-Memory Buffer),同时记录到 Translog(事务日志)。
这个内存缓冲区会被分成一个个段(Segment)。你可以把段想象成一个个小文件,每个段都是一份完整的、不可变的数据集。
那什么时候段会落盘呢?有两个关键操作:
第一个是 refresh。
默认情况下,ES 每 1 秒执行一次 refresh。refresh 干了啥呢?它把内存缓冲区里的数据生成一个新的段,写到文件系统缓存(不是磁盘!),然后这个段就可以被搜索了。
这就是为啥 ES 是**近实时(NRT, Near Real-Time)**的,而不是实时。1 秒的延迟,是默认配置,你可以改小,但不建议,太小会影响写入性能。
第二个是 flush。
flush 才是真正把数据写磁盘的动作。它干了三件事:
Translog 是干嘛的?你可以理解为 MySQL 的 binlog。ES 写入数据的时候,先写 Translog,再写内存。这样万一机器宕机,内存里的数据丢了,还能从 Translog 恢复。
但这里有个坑,我之前踩过。
我们有个业务场景,写入量特别大,默认的 Translog 配置扛不住,导致节点频繁重启。后来查了文档才知道,Translog 有个参数 index.translog.flush_threshold_size,默认是 512mb,这个值在大写入场景下明显偏小。还有 index.translog.durability,默认是 request,意思是每次写请求都要 fsync 一次,性能很差。改成 async 之后性能能提升不少,但代价是宕机时可能丢几秒的数据。
这种 trade-off,你得根据业务场景来选。
段这玩意儿有个问题:它不可变。
这意味着数据一旦写入段,就不能修改了。如果你更新了一条文档,ES 不是去改原来的段,而是生成一个新的段,把更新后的文档放进去,同时在原来的段里标记那条文档为"已删除"。
这就引出了一个问题:随着时间推移,段会越来越多,越来越大,查询性能会越来越差。
所以 ES 有一个后台线程,专门干一件事:段合并(Segment Merging)。
合并的逻辑很简单:把多个小的段合并成一个大的段,同时把标记为"已删除"的文档真正清理掉。
这个过程是自动的,但你可以通过 index.merge.policy 来调优。比如:
index.merge.policy.floor_segment:小于这个值的段会被优先合并,默认 2mbindex.merge.policy.max_merge_at_once:一次最多合并多少个段,默认 10index.merge.policy.segments_per_tier:每层多少个段,默认 10段合并对查询性能影响很大,但合并过程中会消耗大量的 IO 和 CPU。生产环境里经常出现这种情况:业务低峰期没啥事,一到高峰期查询就慢,监控一看,节点 IO 打满,全在合并段。
遇到这种情况怎么办?
第一,调大 refresh_interval,从默认的 1 秒改成 5 秒或者 10 秒,减少段生成的数量。
第二,把 merge 操作限流。ES 有个参数 indices.store.throttle.type,可以设置成 none 或者 merge,但这玩意儿我一般不敢乱动,除非你很清楚后果。
第三,也是最重要的,业务低峰期做 force merge。调用 _forcemerge API,把段合并成一个,对只读索引特别有效,比如日志场景。
ES 集群是怎么分布数据的?靠的是分片(Shard)。
一个索引在创建的时候可以指定分片数,比如 5 个主分片。数据写入的时候,会根据文档 ID 的 hash 值路由到对应的分片上。
每个主分片可以有多个副本分片,副本分片是主分片的完整拷贝。
这里有个很多人搞混的概念:主分片数量创建后不能改,副本分片数量可以随时改。
如果你想改主分片数量,只能用 reindex,也就是把数据从老索引搬到新索引。这个操作在大集群上执行起来非常麻烦,资源消耗也大。所以生产环境创建索引的时候,一定要提前规划好分片数。
我之前就吃过这个亏。一个索引创建的时候分片数设了 3,后来数据量涨到几百 GB,每个分片都有 100 多 G,查询性能开始下降。这时候想加分片?不好意思,加不了,只能 reindex 到一个新索引。reindex 跑了 8 个小时,期间业务搜索就特别慢。
经验之谈:单个分片控制在 10-50GB 之间是最佳实践。如果你的索引最终规模在 200GB 左右,分片数可以设 5 个或者 8 个。分片数太少,单个分片太大,查询慢;分片数太多,集群元数据管理开销大,查询协调也慢。
副本的作用主要是两个:高可用和读扩展。
主分片挂了,副本分片会自动升级为主分片,服务不中断。读扩展的意思是,搜索请求可以同时打到主分片和副本分片上,提升查询吞吐。
但这里有个细节你可能不知道:副本分片数的设置会影响写入性能。每写一条文档,都要同步到所有副本,等所有副本返回成功才算写入完成。副本越多,写入越慢。所以对于写入密集型的场景,副本数保持默认的 1 就行;查询密集型的场景,可以适当增加副本。
ES 的查询流程是分两阶段的:query 阶段和 fetch 阶段。
Query 阶段干的事是:
preference 参数控制)Fetch 阶段干的事是:
为啥要分两阶段?这是个性能优化。
你想啊,如果 query 阶段就返回完整文档,数据量得多大,网络传输得多慢。通过两阶段,query 阶段只传 ID 和排序值,数据量小得多;fetch 阶段只拉需要的文档(默认 10 条),效率高得多。
但这里有个坑:深度分页。
ES 默认的 from + size 分页,from + size 不能超过 index.max_result_window,默认是 10000。超过这个值,ES 直接报错。
为啥要限制?因为 ES 每次分页都要把 from 之前的数据全取出来,然后丢掉。如果你 from = 10000,size = 10,那 ES 得从每个分片取 10010 条数据,然后排序丢弃前 10000 条。这性能,崩得很快。
解决深度分页有几个方案:
我们业务上主要用 Search After,配合 PIT 效果很好,几十万页的数据翻起来也不卡。
最后讲一个让我印象深刻的事。
之前有个业务反馈,说数据写入 ES 之后,查不到。但监控显示写入成功了。查了半天才发现,是一致性级别的问题。
ES 写入的时候可以指定 consistency 参数,有三个值:
one:只要主分片写入成功就返回quorum:大多数分片写入成功才返回(默认)all:所有分片都写入成功才返回我们集群当时刚好有一个节点网络抖动,主分片写入成功,但副本同步失败,quorum 级别等不到大多数分片的确认,写入请求一直挂着。后来用户重试,写入了新的文档,但因为 ID 一样(业务自己生成 ID),旧版本把新版本覆盖了,导致数据错乱。
这个 case 教会我一件事:不要用业务自增 ID,用 ES 自动生成的 UUID。如果一定要用业务 ID,要么用 op_type=create 防止覆盖,要么在业务层做幂等。
整理几个生产环境最常见的坑,建议保存:
index.search.slowlog.threshold.query.warn: 10s,超过 10 秒的慢查询会自动打到日志里,定期分析能发现很多潜在问题。ES 这东西,学起来不难,用起来全是坑。网上很多教程只讲怎么用,不讲为啥这么用,结果一上生产就各种问题。
我自己也是踩了无数坑之后,才慢慢搞明白这玩意儿的设计思路。ES 的作者 Shay Banon 当年写这个东西的时候,目标是做一个分布式、可扩展、近实时的搜索引擎,所以它的很多设计都是为了这个目标服务的。
理解这些设计原则,比死记硬背参数有用得多。
比如你知道 ES 是近实时的,就不会在写入后立刻查;你知道段是不可变的,就知道为啥要合并;你知道分片数不可改,就会在建索引的时候多花点时间规划。
这些东西,文档里都有,但只有你真的踩过坑,才能体会其中的深意。