wth2jhl.online

Back

“Elasticsearch is a distributed search and analytics engine, scalable data store and vector database optimized for speed and relevance on production-scale workloads.” —— elastic/elasticsearch README 第一句

📦 项目信息

  • 仓库:elastic/elasticsearch ↗(main 分支 @ 2026-08-31)
  • 版本:9.6.0(捆绑 Lucene 10.5.1、JDK 26.0.2)
  • 规模:47,533 个文件 / 31,805 个 Java 文件 / ~650 万行 Java
  • 授权:AGPL-3.0 + SSPL v1 + Elastic License 2.0 三重授权(x-pack 仅 ELv2)
  • 分析方式:浅克隆后纯源码阅读,未运行任何服务,读完即删

一、为什么我会去翻这个仓库#

之前拆的项目都是”小而美”:go-redis 几万个 star 但源码就几 MB,go-sql-driver/mysql 干脆只有 35 个文件。这次想反过来——找一个大家都用过、但几乎没人真读过的巨型仓库,看看”工业级”到底长什么样。

Elasticsearch 是最合适的样本:日志、监控、全文检索、向量召回,绕不开它;但 650 万行的体量又让人天然畏惧。

浅克隆下来 745MB、47,533 个文件。先看几个让我停下来的数字:

目录          Java 文件数      代码行数(含测试)
server/         8,337         2,008,865
x-pack/        18,413         3,766,112
modules/        1,856           387,508
libs/           1,070           183,158
test/             805           158,763
plugins/          233            22,310
distribution/     120            17,602
─────────────────────────────────────────────
合计           31,805         ~6,540,000
text

光是 server/src/test 下就有 2,824 个测试文件,rest-api-spec 里有 1,723 个 YAML REST 测试、603 个 API endpoint 定义,server 模块里 grep 出的 Setting 配置项有 600+ 个。

我给的判断:Elasticsearch 不是一个”搜索应用”,而是三个硬核问题焊在一起——分布式一致性(谁当 master、集群状态怎么同步)、存储引擎(怎么写盘、怎么不丢数据)、查询引擎(怎么在几十上百个 shard 上做归并)。想读懂它,就得按这三条线拆。


二、骨架:650 万行是怎么组织的#

顶层目录的职责划分,仓库根目录的 AGENTS.md(后面细说)里写得比我清楚:

两个值得注意的边界设计:

  1. server 对依赖极度克制。核心里只有 Lucene 和一小撮库,网络层(Netty4)是 modules 插件,S3 存储库也是插件。核心 = 接口,实现全部插件化。这是它能撑 15 年没烂掉的原因之一。
  2. REST API 用 JSON spec 描述(rest-api-spec/),所有官方客户端从这份 spec 生成。API 契约不写在代码里,写在数据里。

三重授权:一段开源史的化石#

LICENSE.txt 开头:

“Source code in this repository is covered by (i) a triple license under the ‘GNU Affero General Public License v3.0 only’, ‘the Server Side Public License, v 1’, and the ‘Elastic License 2.0’… Code that is licensed solely under the ‘Elastic License 2.0’ is found only in the x-pack folder.”

2021 年 Elastic 把授权从 Apache 2.0 改成 SSPL/ELv2 双授权,AWS 顺势 fork 出 OpenSearch;2024 年又加回 AGPL。现在每个源文件头部都印着这段三重授权声明——开源商业化路线之争,直接物化成了文件头注释。

仓库里住着一份 AI 结对编程说明书#

翻目录时我撞见一个 2026 年的新物种:根目录有一份 179 行的 AGENTS.md,专门写给 AI coding agent 看——工具链快照、./gradlew spotlessJavaCheck 这类验证命令、目录结构导览,一应俱全。而 CLAUDE.md 只有一行 @AGENTS.md(Claude Code 的引用语法)。

连 Elastic 这种级别的仓库都在给 AI 写”入职文档”了。这个细节本身就是个时代注脚。


三、写入路径:一个文档的旅程#

写入的核心在 server/src/main/java/org/elasticsearch/index/engine/InternalEngine.java——4,384 行,每个 shard 一个实例。index() 方法的骨架是这样的:

每一步都有讲究:

① 按 _uid 加锁。并发写同一个文档时,版本冲突检测必须在锁内做,否则两个并发更新可能都读到旧版本。锁的粒度是单文档,不是 shard 级。

② 写入限流(throttle)。恢复(recovery)流量不限流,正常写入限流——这是背压机制,防止 indexing 把 merge IO 挤死。

③ plan 模式。indexingStrategyForOperation 返回一个策略对象:写 Lucene?当 stale op 处理?还是预检直接失败?先算清楚再动手,所有分支收敛成一个不可变的 plan。

④ 先写 Lucene,⑤ 再写 translog。注意顺序——translog 不是”先写日志再落盘”的传统 WAL 位置,它只记录尚未 Lucene commit 的操作。崩溃恢复时重放 translog 补齐 commit 之后的数据。

最让我意外的是两个细节:

细节一:auto-generated ID 的 append-only 优化。代码里有一段长达 30 行的注释解释它:如果 ID 是自动生成的,就可以跳过版本查询直接 addDocument(比 updateDocument 快)。但重试场景会破坏这个假设,于是用 autoGeneratedIdTimestamp 建立一个 happens-before 关系——注释里甚至举了 doc A / doc B 并发重发的例子。性能优化的前提条件、边界情况、降级策略,全部写在注释里,这种文档密度在大仓库里是稀缺品。

细节二:写失败也要记 NoOp。文档级失败(比如 mapping 冲突)时,会往 translog 和 Lucene 里写一条 NoOp,占住这个 seq_no:

} else if (indexResult.getSeqNo() != UNASSIGNED_SEQ_NO) {
    // if we have document failure, record it as a no-op in the translog
    // and Lucene with the generated seq_no
    final NoOp noOp = new NoOp(indexResult.getSeqNo(), ...);
java

为什么?因为主分片的序列号必须连续,副本靠 seq_no 追数据,中间挖个洞,副本的同步校验就乱了。失败也是一种进度。

refresh:1 秒的”近实时”#

IndexSettings 里 DEFAULT_REFRESH_INTERVAL = new TimeValue(1, TimeUnit.SECONDS)。写入进 Lucene 内存 buffer 后并不立刻可见,等 refresh 打开新 searcher 才可查——这就是”near real-time”的准确含义:默认可见性延迟 1 秒,换来的是一致性读和写入吞吐的解耦。

顺带一提 IndexShardState 这个枚举也很有意思:

public enum IndexShardState {
    CREATED((byte) 0), RECOVERING((byte) 1), POST_RECOVERY((byte) 2),
    STARTED((byte) 3),
    // previously, 4 was the RELOCATED state
    CLOSED((byte) 5);

    static {
        ...
        IDS[4] = STARTED; // for backward compatibility reasons (this was the RELOCATED state)
    }
}
java

状态 4(RELOCATED)被废弃了,但编号永远留着,反序列化时映射到 STARTED。滚动升级期间新老节点共存,序列化兼容性就是靠这种”化石编号”维持的。


四、Translog:教科书级的 WAL#

index/translog/Translog.java(2,376 行)的类注释本身就值得一读:

“A Translog is a per index shard component that records all non-committed index operations in a durable manner.”

拆开看它的四个设计:

1. UUID 绑定 Lucene commit。从 ES 2.0 起,每次 Lucene commit 都会写入 TRANSLOG_UUID_KEY,保证 translog 和它所属的 Lucene 索引强关联——防止你不小心拿 A 引擎的日志去恢复 B 引擎。灾难恢复工具最怕的就是”看起来能恢复,恢复出一堆错数据”,这个 UUID 就是防呆栓。

2. 单写文件 + 代数(generation)。任意时刻只有一个 translog 文件可写,写满阈值(或 primary term 变更需要切断新旧操作)就转为只读、开新文件。每个只读 generation 配一份 translog-${gen}.ckp——已冻结的日志连元数据都是 fsync 过的,灾难恢复时能精确知道每个文件写到哪。

3. translog.ckp 设计成恰好一个磁盘块。注释原话:“designed to fit in a single disk block such that a write of the file is atomic”。checkpoint 记录当前 generation、操作数、已 fsync 的偏移量——原子性不靠锁、不靠日志,靠物理写宽度。这是我见过最朴素的崩溃一致性方案。

4. checkpoint 的写盘三步:写临时文件 → fsync → 原子 rename,再 fsync 父目录。IOUtils.fsync(targetPath.getParent(), true) 连目录都 fsync——rename 的持久性在很多文件系统上需要目录落盘才保证,大多数人会漏掉这一步。

index.translog.durability 默认 request(每次写请求都 fsync),可改 async 换吞吐。金融级不丢和日志级高吞吐,一个配置切换。


五、集群协调:被 TLA+ 验证过的共识#

这是我认为整个仓库最硬核的部分。

cluster/coordination/CoordinationState.java 的类注释只有两行,但分量极重:

“The core class of the cluster state coordination algorithm, directly implementing the formal model ↗”

共识算法的正确性不是靠测试测出来的,是先在 TLA+ 里建模、形式化验证,再照着写实现的。 老版本 Zen 选举有脑裂问题(minimum_master_nodes 配错就裂),7.0 重写整套协调层时,Elastic 直接把算法写成了形式化模型。这种”先证明再实现”的做法,在工业界代码库里一只手数得过来。

配套的类各司其职:

Coordinator.java (2,333 行)      顶层编排,实现 ClusterStatePublisher
CoordinationState.java           纯状态机,无 IO,可单测、可对着模型写
LeaderChecker.java               follower 探活 leader
FollowersChecker.java            leader 反向探活 follower
ElectionSchedulerFactory.java    选举定时(带随机抖动防惊群)
ElectionStrategy.java            可插拔的 quorum 约束
JoinHelper / JoinValidationService   入伙验证(新节点加入要校验历史)
text

LeaderChecker 的注释说明了容错哲学:

“We are fairly lenient, possibly allowing multiple checks to fail before considering the leader to be faulty, to allow for the leader to temporarily stand down on occasion.”

宁可多等几轮心跳,也不轻易换主——换主的代价(重新选举、发布状态)远大于多等几秒。

集群状态的两阶段发布#

master 选出后,所有变更走 MasterService(2,097 行)汇聚成一次集群状态发布:先 publish(两阶段:先 commit 再 apply,见 ApplyCommitRequest),默认 cluster.publish.info_timeout=10s、publish.timeout=30s。

ClusterState 本身是个不可变对象,字段一目了然:

private final long version;
private final GlobalRoutingTable routingTable;   // 分片在哪
private final DiscoveryNodes nodes;              // 谁活着
private final Metadata metadata;                 // 索引/模板/配置
private final ClusterBlocks blocks;              // 全局/索引级只读锁
public class ClusterState implements ChunkedToXContent, Diffable<ClusterState>
java

注意 Diffable<ClusterState>——状态按 diff 传播。集群状态是全量快照模型,但传输是增量的,不然千节点集群每次变更都广播全量状态,网络先炸了。javadoc 还特意叮嘱:“Security-sensitive data such as passwords or private keys should not be stored in the cluster state”——因为集群状态会暴露在各种 API 里。安全边界写在注释里。

Version.java:一部活的兼容史#

server 根下有个 Version.java,235 个版本常量从 V_7_0_0 一路排到 V_9_6_0:

public static final Version V_7_0_0 = new Version(7_00_00_99);
...
public static final Version V_9_6_0 = new Version(9_06_00_99);
java

每个跨版本通信的序列化分支都拿它做判断。滚动升级时,9.6 的节点收到 7.17 节点的字节流,靠这些常量决定用哪套编解码。一个文件,五年线协议兼容史。


六、读路径:query-then-fetch 只是骨架#

action/search/TransportSearchAction.java(2,807 行)是搜索入口,transport action 名字叫 indices:data/read/search。真正的执行链比教科书上的”query 然后 fetch”长得多:

CanMatchPreFilterSearchPhase    先用轻量 can_match 排除不可能命中的 shard
        ↓
DfsQueryPhase / QueryPhase      各 shard 本地执行 query,返回 top-N doc id + 分数
        ↓
RankFeaturePhase                跨 shard 重排(rank 类查询)
        ↓
FetchSearchPhase                按合并后的最终排名回源取 _source/字段
        ↓
ExpandSearchPhase               展开(如 collapse 的 inner hits)
FetchLookupFieldsPhase          补查 lookup 字段
text

三个亮点:

1. pre-filter 是第一道闸。action.search.pre_filter_shard_size 控制:当候选 shard 数超过阈值,先发一轮 CanMatchNodeRequest——用 min/max 时间范围这类元信息判断”这个 shard 有没有可能命中”,直接跳过不可能的。在千 shard 集群上,这一轮预筛能把参与查询的 shard 数砍掉几个数量级。

2. 归并在协调节点。SearchPhaseController 负责把各 shard 的 partial 结果做 reduce——top-N 合并、聚合树合并。查询是 scatter,归并是 gather,内存压力全在协调节点,所以有独立的 SEARCH_COORDINATION 线程池。

3. 跨集群/跨项目搜索。代码里已经出现 CrossProjectIndexResolutionValidator、RemoteClusterActionType——搜索请求可以 fan-out 到远程集群。ES 正在把”一个集群内的搜索引擎”变成”跨组织的查询联邦层”。


七、工程哲学:最戳我的三个细节#

1. 在源码里明着拒绝 CompletableFuture#

ActionListener(ES 全局的异步回调抽象)的 javadoc 里有一段罕见的”反向选型说明”:

“Note that we explicitly avoid java.util.concurrent.CompletableFuture and other similar mechanisms as much as possible… futures support blocking while waiting for a result, but this is almost never appropriate in Elasticsearch’s production code where threads are such a precious resource. Moreover if something throws an Error then the JVM should exit pretty much straight away, but CompletableFuture can catch an Error which delays the JVM exit…”

两条理由:(a) future 支持阻塞等待,而 ES 里线程是稀缺资源,阻塞几乎永远是错的;(b) CompletableFuture 会捕获 Error——但 Error(OOM、StackOverflow)出现时 JVM 应该尽快退出,吞掉它会延迟死亡。

所以 ES 全库用 ActionListener<Response>:只有 onResponse 和 onFailure,结构上就不可能写出阻塞等待、也不可能吞掉 Error。用类型系统封死一类 bug,比写 100 条 code review 规范都管用。

2. Releasable + try-with-resources 贯穿一切#

index() 开头那两行 try (var ignored1 = acquireEnsureOpenRef()) 不是个例。ES 把”资源释放”统一抽象成 Releasable 接口,锁、引用计数、限流配额、临时内存全部走 try-with-resources。没有 finally 手动释放,就没有漏释放。4,000 行的 InternalEngine 里,异常路径的正确性基本靠这个模式兜底。

3. 线程池按”业务 + 系统”双维隔离#

ThreadPool 里定义了 24+ 个命名线程池:

write / search / get              用户流量
write_coordination / search_coordination / cluster_coordination   协调流量
flush / refresh / warmer / merge / force_merge                    后台维护
snapshot / snapshot_meta                                           快照
system_read / system_write / system_critical_read / system_critical_write  系统内部
text

最值得注意的是 system_* 一族——内部操作(比如集群状态持久化、安全配置的读写)有专属线程池,不被用户查询饿死。隔离做到这个粒度,说明他们真的在生产上被”搜索洪峰把内部心跳饿死”这类问题咬过。


八、演进方向:从 codec 到 inference#

Codec:每个大版本一层”自己的 Lucene”#

index/codec/ 目录是一部压缩格式进化史:

Elasticsearch814Codec → 816 → 900 → 900Lucene101 → 92Lucene103 → 93Lucene104
text

每个 ES 大版本都在 Lucene 之上叠一层定制 codec,旁边还挂着特化实现:tsdb(时序库的 ordinal 优化,ES95TSDBDocValuesFormat)、zstd(存储字段压缩)、bloomfilter(_id 查询加速)、vectors(向量量化)、columnar(列存)。Lucene 是地基,但每一层的”房型”都是 ES 自己改的。这也是为什么它能一边跟着 Lucene 升级、一边做出 logsdb / tsdb 这种差异化索引模式。

inference 包:AI 抽象进了 server 核心#

server/src/main/java/org/elasticsearch/inference/ 里已经躺着一整套推理服务抽象:

InferenceService / InferenceServiceRegistry    服务注册与发现
TaskType / InputType                            任务类型(embedding / completion / rerank…)
ChunkingSettings / ChunkingStrategy             长文本切块策略
RerankingInferenceService / TopNProvider        重排
Model / ModelConfigurations / ModelSecrets      模型配置与密钥分离
UnifiedCompletionRequest                        统一补全请求
VectorType                                      向量类型
text

README 第一句就自称 “vector database”,ModelSecrets 把密钥从配置里单独拆出来——RAG 时代,ES 想当那个”离数据最近的推理入口”。推理不再只是插件,而是核心抽象。

ES|QL 的 datasource 矩阵:往”查询联邦”走#

x-pack/plugin/ 下最扎眼的是一排 esql-datasource-*:

esql-datasource-parquet / iceberg / orc / csv / ndjson     文件格式
esql-datasource-s3 / gcs / azure / http / grpc             存储与协议
esql-datasource-gzip / zstd / lz4 / snappy / brotli / bzip2  压缩
text

ES|QL(ES 的查询语言)正在接各种外部数据源——查 ES 和查 Iceberg 湖仓表,语法逐渐统一。再叠加 ent-search、ccr(跨集群复制),方向很清楚:从”单集群搜索引擎”长成”数据访问层”。


九、我学到了什么#

读完 650 万行里最有代表性的几千行,我的三个结论:

1. 大而不乱的秘密是”接口在核心、实现在插件”。 server 几乎零第三方依赖,Netty、S3、云发现全是插件。核心只定义契约。这让 15 年、5 个大版本、三重授权风波之后,主干依然能编译、能滚动升级。

2. 正确性是被”结构”保证的,不是被”自觉”保证的。 拒绝 CompletableFuture 用类型封死阻塞和吞 Error;translog checkpoint 靠”恰好一个磁盘块”保证原子;废弃的枚举编号永远留坑;TLA+ 先验证再实现共识。每一个都是在用结构消除”人可能犯错”的空间。

3. 注释密度是工程成熟度的度量。 auto-generated ID 优化的 30 行注释、LeaderChecker 的容错哲学、ActionListener 的选型说明、translog 的磁盘块设计——关键决策的”为什么”都写在了代码旁边。很多项目只有”是什么”,ES 连”当时为什么这么选、边界在哪”都交代了。

如果只带走一个模式,我选 ActionListener 式的”用接口排除一整类 bug”——与其写规范禁止阻塞调用,不如让阻塞在类型上不可表达。

最后说一句:这种体量的仓库,浅克隆 + 按主题抽样读是唯一现实的读法。745MB 全量历史没必要拉,核心是找到”每条主线最硬的那个类”——InternalEngine、Translog、CoordinationState、TransportSearchAction,四个类读完,骨架就立起来了。

读完即删,不留副本。下一站想拆一个更偏算法的仓库,评论区提提名。

Elasticsearch 源码深拆:650 万行 Java 里的分布式搜索引擎骨架
https://wth2jhl.online/blog/elasticsearch-source-deep-dive/
Author 小吴同学
Published at August 31, 2026