“Elasticsearch is a distributed search and analytics engine, scalable data store and vector database optimized for speed and relevance on production-scale workloads.” ——
elastic/elasticsearchREADME 第一段
一、为什么我会去翻这个仓库#
之前拆的都是 Go 项目:go-redis v9、grpc-go、kratos、go-mysql-driver。Go 生态的深拆文写多了,我总想找个 Java 的大家伙试试——要那种「人人都用过、几乎没人翻过源码」的。
Elasticsearch 是最合适的候选:日志平台的心脏、搜索基础设施的事实标准,而且 README 第一句已经把自己重新定义为 vector database——2026 年的 ES 早就不是「那个全文搜索框」了。
我 git clone 下来(浅克隆 745MB):
| 指标 | 数值 |
|---|---|
| 文件总数 | 47,533 |
| Java 文件 | 31,805 |
| Java 总行数 | ~650 万(含测试) |
| main 分支版本 | 9.6.0(未发布) |
| 最新发行版 | 9.5.1 |
| 捆绑 Lucene | 10.5.1 |
| 捆绑 JDK | 26.0.2 |
650 万行 Java 是什么概念?grpc-go 全仓库不到 20 万行。这一个仓库等于 30 个 grpc-go。
我给的判断:ES 是「分布式系统披着搜索的外衣」。它真正的价值不在倒排索引(那是 Lucene 的事),而在于把单机搜索引擎塞进一个自管理集群所需的全部基础设施——一致性、复制、资源隔离、故障恢复。这篇文章分两部分:先带你 5 分钟把 ES 跑起来亲手摸一遍,再深入源码拆它的五脏六腑。
二、先上手:5 分钟跑起一个 ES 9#
读源码之前先有手感。一台有 Docker 的机器就够了(内存 2GB+)。
2.1 启动单节点#
docker run -d --name es9 \
-p 9200:9200 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \
docker.elastic.co/elasticsearch/elasticsearch:9.5.1bash两个说明:
- 9.x 默认开启安全认证(启动时自动生成密码和证书)。本地试玩用
xpack.security.enabled=false关掉最省事;生产环境千万别关,用ELASTIC_PASSWORD环境变量走官方流程。 ES_JAVA_OPTS限制堆内存 1GB,笔记本也能跑。
等 10 秒,验证:
curl -s localhost:9200 | python3 -m json.toolbash会返回节点信息,version.number 是 9.5.1,lucene_version 是 10.5.1——第一眼的证据:ES 的版本故事永远和 Lucene 绑在一起。
再看集群健康:
curl -s 'localhost:9200/_cluster/health?pretty'
# "status" : "green", "number_of_nodes" : 1, ...bash2.2 建索引:一次把 mapping 写对#
我们建一个电影索引,故意塞进三种典型字段——全文、结构化、向量:
curl -s -X PUT localhost:9200/movies -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"title": { "type": "text" },
"genre": { "type": "keyword" },
"year": { "type": "integer" },
"rating": { "type": "float" },
"embedding": { "type": "dense_vector", "dims": 3, "index": true, "similarity": "cosine" }
}
}
}'bashtext 会分词进倒排索引(能全文搜索),keyword 不分词(能精确过滤和聚合),dense_vector 是向量字段(能 kNN)。这三种字段类型对应 ES 的三种身份:搜索引擎、分析引擎、向量数据库,一个 mapping 就看全了。
2.3 批量写数据#
curl -s -X POST localhost:9200/movies/_bulk -H 'Content-Type: application/json' --data-binary '
{"index":{"_id":"1"}}
{"title":"The Matrix","genre":"sci-fi","year":1999,"rating":8.7,"embedding":[0.10,0.20,0.30]}
{"index":{"_id":"2"}}
{"title":"Inception","genre":"sci-fi","year":2010,"rating":8.8,"embedding":[0.12,0.22,0.28]}
{"index":{"_id":"3"}}
{"title":"The Godfather","genre":"crime","year":1972,"rating":9.2,"embedding":[0.80,0.10,0.10]}
{"index":{"_id":"4"}}
{"title":"Heat","genre":"crime","year":1995,"rating":8.3,"embedding":[0.75,0.15,0.12]}
{"index":{"_id":"5"}}
{"title":"Spirited Away","genre":"animation","year":2001,"rating":8.6,"embedding":[0.40,0.50,0.30]}
'bash坑预警:写完立刻搜,可能一条都搜不到。这不是 bug——ES 是近实时(NRT)搜索,默认 refresh_interval=1s,数据写入后要等 refresh 才能被搜到。急性子可以 POST /movies/_refresh,或者写入时带 ?refresh=true。为什么是这个设计?第四节拆源码时揭晓。
2.4 五种查询姿势#
① 全文搜索 + 高亮:
curl -s -X GET localhost:9200/movies/_search -H 'Content-Type: application/json' -d '{
"query": { "match": { "title": "matrix" } },
"highlight": { "fields": { "title": {} } }
}'bashmatch 会先对 “matrix” 分词再查倒排索引,命中 The Matrix,_score 来自 BM25 相关性打分。
② 复合查询:相关性与精确过滤分离:
curl -s -X GET localhost:9200/movies/_search -H 'Content-Type: application/json' -d '{
"query": {
"bool": {
"must": [ { "match": { "genre": "sci-fi" } } ],
"filter": [ { "range": { "year": { "gte": 2000 } } } ]
}
}
}'bash注意 must 和 filter 的区别:filter 不参与打分、结果可以缓存——「先过滤再算分」是 ES 查询优化的第一原则,生产查询里能用 filter 就别用 must。
③ 聚合:分析引擎的一面:
curl -s -X GET localhost:9200/movies/_search -H 'Content-Type: application/json' -d '{
"size": 0,
"aggs": {
"by_genre": {
"terms": { "field": "genre" },
"aggs": { "avg_rating": { "avg": { "field": "rating" } } }
}
}
}'bashsize: 0 表示不要文档只要聚合结果。terms 桶 + 子聚合嵌套,这就是 Kibana 所有图表的底层。
④ 向量搜索:向量数据库的一面:
curl -s -X GET localhost:9200/movies/_search -H 'Content-Type: application/json' -d '{
"knn": {
"field": "embedding",
"query_vector": [0.11, 0.21, 0.29],
"k": 2,
"num_candidates": 10
}
}'bash查询向量 [0.11, 0.21, 0.29] 会命中 The Matrix 和 Inception(余弦距离最近)。真实场景里这个向量是 embedding 模型生成的;9.x 还有 semantic_text 字段,把模型调用内置进写入链路。k 是最终返回数,num_candidates 是每个分片上的候选池——先粗筛再精排,又是两阶段思想。
⑤ 彩蛋:ES|QL,ES 自己的查询语言:
curl -s -X POST localhost:9200/_query -H 'Content-Type: application/json' -d '{
"query": "FROM movies | WHERE year >= 1990 | STATS avg_rating = AVG(rating) BY genre | SORT avg_rating DESC"
}'bash管道式语法,像 SPL/KQL 那一系。ESQL 是 9.x 的头号工程(源码 125 万行,第三节细说),目标是让 ES 从「拼 JSON DSL」进化成「跑查询引擎」。
2.5 三个运维必会命令#
curl -s 'localhost:9200/_cat/indices?v' # 所有索引:分片数、文档数、占用空间
curl -s 'localhost:9200/_cat/nodes?v' # 节点:堆内存、CPU、负载
curl -s 'localhost:9200/_cat/shards?v' # 分片分布:谁在哪个节点上bash_cat 系列是人类可读的运维视图。玩完收工:
docker rm -f es9bash手感有了。接下来进源码——上面每一个「理所当然」的行为,底层都有对应的设计。
三、仓库骨架:650 万行是怎么分布的#
先看顶层:
elasticsearch/
├── server/ 113M 核心服务器(本次深拆的主角)
├── x-pack/ 333M 商业功能开源化后的全部代码
├── modules/ 43M 随发行版默认携带的非核心模块
├── libs/ 24M 内部公共库(logging / x-content 等)
├── docs/ 48M asciidoc 文档
├── plugins/ 6.2M 可选插件(discovery-ec2 等)
├── test/ 12M 测试基础设施(ESTestCase 等)
├── qa/ 4.7M 滚动升级 / 混合集群集成测试
├── rest-api-spec/ 7.2M REST API 的 JSON 规约(测试用)
└── build-tools*/ Gradle 构建插件text按 Java 行数统计:
| 模块 | 文件数 | 行数(含测试) |
|---|---|---|
| x-pack | 18,413 | 3,766,112 |
| server | 8,337 | 2,008,865 |
| modules | 1,856 | 387,508 |
| libs | 1,070 | 183,158 |
| test | 805 | 158,763 |
| plugins | 233 | 22,310 |
三个反直觉的事实:
1. x-pack 比 server 大近一倍。 2018 年 Elastic 把商业功能(security、ML、ESQL、inference…)开源进主仓库后,x-pack/ 就成了代码量大头。单看子模块更夸张——光 x-pack/plugin/esql 一个查询引擎就 125 万行(就是第二节那个彩蛋的实体),inference 32 万行,security 27 万行。ESQL 单独拎出来比绝大多数独立数据库项目的代码都多。
2. server 的「核心」其实很克制。 server/src/main 只有 4,959 个文件、98 万行,测试文件却有 2,824 个——测试代码和主代码几乎 1:1。仓库自带的 AGENTS.md(对,这个 2010 年的老仓库现在专门给 AI 编码代理写了导航文档)解释了分层原则:
“
server: The core Elasticsearch server. Few third-party dependencies (Lucene plus a handful of small libraries).”
server 的第三方依赖几乎只有 Lucene 加几个小库。网络层(Netty4)、脚本语言(Painless)、仓库后端(S3/GCS/Azure)全部做成 modules/ 可插拔实现。依赖面小 = 安全审计面小 = 升级风险小,这是 650 万行还能持续演进的前提。
3. 许可证已经不是 Apache 2.0。 随便打开一个源文件,头部是三选一许可:
“Licensed under the Elastic License 2.0, the GNU Affero General Public License v3.0 only, and the Server Side Public License v 1, at your election.”
2021 年 ES 退出 Apache 2.0 引发 AWS 分叉出 OpenSearch,2024 年 Elastic 用「三证可选」的方式部分回归开源(AGPL 是 OSI 认证许可)。用代码之前先看许可证,这是深拆任何仓库的第一步。
四、Lucene 是地基,但 ES 在上面盖了自己的房子#
4.1 三层数据模型#
ES 的数据组织是三层:
Index(逻辑表)
└── Shard(分片 = 一个独立的 Lucene 索引)
└── Segment(Lucene 段 = 不可变的倒排索引文件组)text每个分片就是一个完整的 Lucene Directory。ES 的分布式 = 把无数个 Lucene 索引分布到节点上,再提供统一视图——理解这一点,后面所有设计都能推导出来。
4.2 段的生命周期:refresh / flush / merge#
第二节那个「写完搜不到」的坑,答案在这里:
| 操作 | 做了什么 | 节奏 |
|---|---|---|
| refresh | 把内存 buffer 的文档写成新段,打开新 searcher → 文档变得可搜索 | 默认 refresh_interval=1s |
| flush | Lucene commit(段持久化到磁盘)+ 裁剪 translog | 按 translog 大小触发 |
| merge | 后台把小段合并成大段,物理清除已删除文档 | 持续进行 |
index/IndexSettings.java 里:
public static final TimeValue DEFAULT_REFRESH_INTERVAL = new TimeValue(1, TimeUnit.SECONDS);java「近实时」的「近」就是这 1 秒。这是吞吐量与可见性延迟的经典交换:批量日志场景可以把 refresh_interval 调到 30s 换吞吐,搜索场景保持 1s。
merge 由 MergeSchedulerConfig 控制并发(maxThreadCount / maxMergeCount),实现有 ElasticsearchConcurrentMergeScheduler 和新的 ThreadPoolMergeScheduler。merge 是 ES 磁盘 IO 的大头,也是写入太快时段数量爆炸、性能劣化的根源——_cat/segments 是排查这类问题的第一站。
4.3 ES 自己维护一整条 codec 演化线#
翻 server/index/codec/ 能看到 ES 对 Lucene 存储格式的持续魔改:
Elasticsearch814Codec
Elasticsearch816Codec
Elasticsearch900Codec ← ES900AdaptivePointsFormat(自适应点编码)
Elasticsearch92Lucene103Codec
Elasticsearch93Lucene104Codec ← 当前最新text最新这个的类注释:
“Elasticsearch codec as of 9.3 relying on Lucene 10.4. This extends the Lucene 10.4 codec to compressed stored fields with ZSTD instead of LZ4/DEFLATE.”
ES 在 Lucene 官方 codec 之上换了 stored fields 的压缩算法——ZSTD 比 LZ4 压缩率高、比 DEFLATE 快,这是拿真实压缩比/延迟数据换来的决策。旁边还有 Elasticsearch900AdaptivePointsFormat(BKD 树点数据的自适应编码),以及 index/codec/tsdb/es95/ 下一整套为时序数据定制的 doc-values 编码(ordinal 压缩,专门优化 TSDB 的低基数字段)。
命名规则也值得学:codec 名带版本号且只增不改——老索引永远用写入时的 codec 读,新写入用新 codec。数据格式的演化不靠迁移,靠兼容层。
五、写入路径:一个 4,384 行的 InternalEngine#
server/index/engine/InternalEngine.java 是每个文档的必经之路。index() 方法的骨架(简化):
public IndexResult index(Index index) throws IOException {
try (var ignored1 = acquireEnsureOpenRef()) { // ① 读引用,防止并发关闭
try (
Releasable ignored = versionMap.acquireLock(index.uid()); // ② per-uid 锁
Releasable indexThrottle = doThrottle ? throttle.acquireThrottle() : () -> {} // ③ 背压
) {
final IndexingStrategy plan = indexingStrategyForOperation(index); // ④ 先规划
// ⑤ 执行:写 Lucene
indexResult = indexIntoLucene(index, plan);
// ⑥ 写 translog(如果来源不是 translog 重放)
if (index.origin().isFromTranslog() == false) {
location = translog.add(new Translog.Index(index, indexResult));
}
// ⑦ 更新内存版本映射
versionMap.maybePutIndexUnderLock(index.uid(), new IndexVersionValue(...));
}
}
}java五层设计一层都不能少:
① acquireEnsureOpenRef()——引用计数 + 开闭状态检查的合体。引擎关闭和写入并发时,不靠一把大锁,靠「持有引用期间不可关闭」的语义。这个模式贯穿整个 server(ensureOpen() 在仓库里出现几千次)。
② versionMap.acquireLock(uid)——并发控制粒度到文档级。两个线程写同一个 _id 会串行,写不同 _id 完全并行。对比整段加锁的存储引擎,这是吞吐的关键。versionMap 同时承担版本仲裁:乐观并发控制(if_seq_no + if_primary_term 参数)就是在这里比对的——客户端带着上次读到的序列号来更新,中途被人改过就拒绝,无锁化的 CAS 语义。
③ throttle.acquireThrottle()——写入背压。注意 index.origin().isRecovery() == false 才限流:恢复流量不限速(它本来就是追赶进度),用户流量才限。
④ plan 模式——indexingStrategyForOperation 先算出「策略」(要不要写 Lucene、版本号怎么定、是否提前返回错误),再执行。把决策和执行分开,分支复杂度全部收敛在 plan 里,执行路径是直的。
⑤⑥⑦ 顺序——先 Lucene、后 translog、最后 versionMap。注意第 ⑥ 步:文档写失败时也会往 translog 里记一条 no-op 并附上生成的 seq_no,保证主分片序列号严格连续——副本重放时序号对得上。
最让我惊讶的是 ④ 里 append-only 优化的注释(节选):
“if we have an autoGeneratedID that comes into the engine we can potentially optimize and just use addDocument instead of updateDocument and skip the entire version and index lookupVersion across the board. Yet, we have to deal with multiple document delivery… We use the documents timestamp for this…”
自动生成的 ID 可以跳过版本查找直接 addDocument,但重复投递会破坏这个优化。ES 用文档的 autoGeneratedIdTimestamp 建立 happens-before 关系:重试的文档时间戳如果早于已见的 maxUnsafeAutoIdTimestamp,就退化为 updateDocument。注释里还手写了 doc A / doc B 乱序到达的推演过程——用单调时间戳替代分布式一致性判断,典型的工程近似:最坏情况只是少数文档退化慢路径,永远正确、几乎总是快。
这也解释了官方为什么推荐「日志场景不指定 _id」——不只是省事,是能走这条快路径。
六、复制链路:主从一致性的 1,655 行#
单分片写完不算完。action/support/replication/TransportReplicationAction.java 负责把操作复制到所有副本,类注释把流程写得很清楚:
“Base class for requests that should be executed on a primary copy followed by replica copies… The action samples cluster state on the receiving node to reroute to node with primary copy and on the primary node to validate request before primary operation followed by sampling state again for resolving nodes with replica copies.”
注意「两次采样集群状态」:第一次找到 primary 在哪,第二次(执行前)再确认副本集合。两次采样之间集群可能变了——分片迁移、节点掉线——用「采样-验证-再采样」代替全局锁,这是无锁分布式系统的典型手法。
同类里还藏着两个设计开关:
protected enum PrimaryActionExecution {
RejectOnOverload, // 常规操作:排队满了就拒绝(背压)
Force // 特殊操作:绕过队列和写入压力检查,强制执行
}java什么操作有资格 Force?集群内部的元数据维护操作。又一次看到那个主题:资源隔离的终点是保护系统自己(第十一节线程池还会再见一次)。
6.1 序列号、primary term 与全局检查点#
每次写操作带两个单调递增的数:
seq_no:主分片的操作序号,第五节里由generateSeqNoForOperationOnPrimary生成;primary_term:主分片每换一次(故障恢复、主切换),term 加一。旧主复活时带着旧 term,写入会被拒绝——这是防脑裂双写的核心机制。
写完后主分片执行 maybeSyncGlobalCheckpoint("post-operation"):global checkpoint = 所有活跃副本都已持久化的最高序号。它直接决定三件事:
- translog 可以安全裁剪到哪里(第七节);
- 副本恢复时从哪里开始增量追赶(而不是全量复制);
- 跨集群复制(CCR)的进度基线。
6.2 分片状态机#
index/shard/IndexShardState.java 只有 5 个状态:
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);java注意那行注释和配套处理:4 曾经是 RELOCATED 状态,后来被删除,但序列化兼容数组里保留了 IDS[4] = STARTED 的映射——老版本节点发来的状态 4 按 STARTED 处理。一个枚举的序列化都留着十几年的兼容痕迹,滚动升级不是一句口号。
七、Translog:类级注释就是一份设计文档#
index/translog/Translog.java(2,376 行)的类注释值得全文背诵,它把 WAL 的每个设计决策都交代了:
“Each Translog has only one translog file open for writes at any time referenced by a translog generation ID. This ID is written to a
translog.ckpfile that is designed to fit in a single disk block such that a write of the file is atomic.”
三个细节:
translog.ckp检查点文件小于一个磁盘块——利用磁盘单块写入的原子性,不用加锁、不用双写。崩溃恢复时读到的 ckp 要么全新要么全旧。- UUID 绑定:每次 Lucene commit 都把
TRANSLOG_UUID_KEY写进 commit 数据。注释原话是防止 “accidental recovery from a transaction log that belongs to a different engine”——把别人的 translog 错接过来恢复,这种事故在运维手动搬数据目录时真的会发生。 - generation 轮转:文件到达阈值或 primary term 变更时,当前文件转只读、开新文件,每个只读 generation 都有自己已 fsync 的
.ckp副本。
Durability 枚举只有两个值:
public enum Durability {
REQUEST, // 每次写请求都 fsync——默认,不丢数据
ASYNC // 周期 fsync——更快,崩溃丢一个窗口
}java这就是 ES 的持久性菜单:REQUEST 是 WAL 标准语义,ASYNC 是给「日志丢几秒无所谓」场景的性能开关。整个持久性模型就两个词,没有第三种含糊状态。
把五、六、七节连起来,一次写入的完整链路是:
客户端 → 协调节点路由到 primary
→ InternalEngine.index()(per-uid 锁 → Lucene → translog)
→ 并行复制到各 replica(同样走 InternalEngine)
→ 全部成功 → 推进 local checkpoint
→ 异步同步 global checkpoint → translog 可裁剪
→ 1 秒后 refresh 可见 → 攒够量后 flush 落盘text八、选主:一个被 TLA+ 形式化验证过的算法#
cluster/coordination/CoordinationState.java 的类注释只有一句话,但信息量极大:
“The core class of the cluster state coordination algorithm, directly implementing the formal model” —— 链接指向
elastic/elasticsearch-formal-models仓库的ZenWithTerms.tla
ES 的集群协调算法是有 TLA+ 形式化模型的,代码是模型的直接实现。ES 7 之前的 Zen 选主有 split-brain 黑历史,7.0 换掉的这套(基于 quorum + term,思想接近 Raft 但走「发布完整集群状态」而非「复制日志」的路线)是先写数学证明再写代码的。工业界愿意给共识算法做形式化验证的项目,一只手数得过来。
配套的 ElectionSchedulerFactory 实现了防惊群的选举调度,四个可调参数:
"cluster.election.initial_timeout" // 首次超时
"cluster.election.back_off_time" // 退避增量
"cluster.election.max_timeout" // 超时上限
"cluster.election.duration" // 选举总时长java超时随机化 + 指数退避:网络分区恢复后,节点不会同时发起选举,而是错开重试。
选主成功后的集群状态分发走 CoordinatorPublication(两阶段):先广播元信息(publish.info_timeout 默认 10s),收齐 quorum 承诺后再提交完整状态。活性监控由 LeaderChecker(master 看 follower)和 FollowersChecker(follower 看 master)双向心跳完成——单向心跳无法区分「对端挂了」还是「我这边网络断了」,双向才能。
集群状态本身(cluster/ClusterState.java,1,426 行)是不可变对象:每次变更 = 基于旧状态构建新状态,走 MasterService 的任务队列批量应用。不可变意味着任何节点拿到的快照都自洽,读状态永远不用加锁。
九、搜索路径:一条流水线,九个阶段#
action/search/TransportSearchAction.java(2,807 行)是搜索请求的总入口。看 action/search/ 目录下的类名,整个流水线的骨架就出来了:
CanMatchPreFilterSearchPhase 预过滤:哪些分片可能匹配?
↓
DfsQueryPhase(可选) 全局词频统计(提高小数据集打分一致性)
↓
查询阶段 各分片返回 docId + 分数
↓ QueryPhaseResultConsumer 协调节点归并排序
RankFeaturePhase(可选) 重排序特征
↓
FetchSearchPhase 只向相关分片拉完整文档
↓
ExpandSearchPhase(可选) 展开折叠结果text四个不经典的地方:
1. canMatch 预过滤。查询真正下发前,协调节点先问每个分片「你可能匹配吗」——基于分片的 min/max timestamp、字段存在性等元数据判断。时序索引上这能跳过 90% 以上的分片,是 ES 敢在几万分片的集群上跑搜索的底气。
2. 分片内也是并发的。search/query/QueryPhase.java(312 行)用的是 Lucene 的 CollectorManager——一个分片内的多个段可以多线程并行搜索。并行度从「节点级」压到了「段级」。
3. 批量化的查询阶段。SearchService 里:
public static final Setting<Boolean> BATCHED_QUERY_PHASE = Setting.boolSetting(
"search.batched_query_phase", true, ...);java默认开启:同一分片上排队的多个查询合并成一批执行,共享一次分片上下文的构建。协调层的归并成本也不含糊——SearchPhaseController 专门负责跨分片的 TopDocs 归并。
4. 深分页的三选一。from + size 在协调节点归并时要收集 from+size 条,深翻页成本平方级上涨,所以 ES 提供 search_after(游标式翻页,推荐)和 scroll(快照遍历,批量导出用)。这不是 API 设计洁癖,是归并复杂度逼出来的。
十、线程池:25 个池子和资源隔离哲学#
threadpool/ThreadPool.java 里的 THREAD_POOL_TYPES 是我见过最直白的资源隔离声明(节选):
entry(Names.WRITE, FIXED) entry(Names.GENERIC, SCALING)
entry(Names.SEARCH, FIXED) entry(Names.MANAGEMENT, SCALING)
entry(Names.SEARCH_COORDINATION, FIXED) entry(Names.FLUSH, SCALING)
entry(Names.GET, FIXED) entry(Names.REFRESH, SCALING)
entry(Names.WRITE_COORDINATION, FIXED) entry(Names.MERGE, SCALING)
entry(Names.SYSTEM_READ, FIXED) entry(Names.SYSTEM_WRITE, FIXED)
entry(Names.SYSTEM_CRITICAL_READ, FIXED) entry(Names.SYSTEM_CRITICAL_WRITE, FIXED)
entry(Names.AUTO_COMPLETE, FIXED) ...java25 个池子,两条规则:
- 延迟敏感的用户路径(write/search/get)用 FIXED——线程数与 CPU 数挂钩,队列有限,满了就拒绝(背压直接给到客户端);
- 后台维护任务(flush/refresh/merge/warmers)用 SCALING——空闲 5 分钟回收,跟用户路径抢不到资源也不影响查询。
9.x 有两个值得注意的新增:
SEARCH_COORDINATION / WRITE_COORDINATION——把「协调」从「执行」里拆出来单独成池。第九节的归并成本就是动因:大聚合查询的协调节点 CPU 会被归并打满,如果不隔离,连累同节点上所有分片的查询执行。
SYSTEM_CRITICAL_READ/WRITE——系统关键操作专用池。用户把 write 队列打满拒绝服务时,集群内部的状态持久化、健康检查仍然走得通。资源隔离的终点是保护系统自己不被用户流量杀死——第六节的 Force 和这里是同一个思想的两处实现。
还有个细节:public static final double searchAutoscalingEWMA = 0.1;——搜索池开始按负载 EWMA(指数加权移动平均)自动伸缩。固定池不再是纯固定,但平滑系数 0.1 说明扩缩是慢变量,避免抖动。
十一、网络层与工程实践#
11.1 网络层:Netty4 做成可插拔模块#
HTTP 和节点间传输共用 modules/transport-netty4——40 个文件、7,181 行,薄得惊人。因为重的部分(编解码、连接管理、线程模型)全交给 Netty,ES 这层只做协议适配。这也呼应第三节:网络层不在 server 里,是模块——理论上可以整体换掉。
11.2 工程实践里值得抄的四件事#
1. 依赖供应链校验。 gradle/verification-metadata.xml 给每一个第三方依赖记录了校验和,构建时不匹配直接失败。7.2M 的 rest-api-spec 同理——REST API 的 JSON 规约本身就是测试夹具,API 兼容性的破坏会在 CI 里立刻现形(仓库还专门有 REST_API_COMPATIBILITY.md 讲兼容策略)。
2. flaky 测试隔离机制。 根目录 muted-tests.yml:
- class: org.elasticsearch.xpack.apmdata.APMYamlTestSuiteIT
method: test {yaml=/10_apm/Test template reinstallation}
issue: https://github.com/elastic/elasticsearch/issues/116445yaml不稳定的测试被显式静音,但必须挂 issue 链接——静音不是删除,修复有账可查。几万测试的仓库里,这是让 CI 信号保持可信的唯一办法。
3. 随机化测试基建。 test/framework 里的 ESTestCase 有 3,399 行,基于 com.carrotsearch.randomizedtesting:每个测试用随机种子跑(随机数据、随机分片数、随机配置),失败时打印种子可复现。AbstractBWCSerializationTestCase 这类基类专门测「新版本序列化的数据老版本能不能读」——滚动升级的兼容性不是测一次,是每个对象都测。
4. 给 AI 代理的导航文档。 根目录同时有 AGENTS.md 和 CLAUDE.md,写清楚工具链快照、构建命令、目录职责(本文第三节的引用即出自此)。15 年历史的 Java 仓库,已经把「AI 协作者」当作一等公民来接待。另外 spotlessApply 统一格式、build-conventions/formatterConfig.xml 定义 formatter——650 万行保持风格一致靠的不是自觉,是工具。
十二、结论#
翻完这 650 万行(抽样深读),我的判断:
- ES 的核心竞争力不在搜索,在分布式基建。Lucene 谁都能包,但 translog 的单磁盘块检查点、TLA+ 验证的选主、25 池资源隔离、
TransportVersion支撑的滚动升级——这些才是 15 年护城河。 - 同一个思想反复出现在不同层:背压(写入限流 / 队列拒绝 /
RejectOnOverload)、两阶段(查询-抓取 / 粗筛-精排 / 发布-提交)、保护系统自己(Force/SYSTEM_CRITICAL_*)、只增不改的演化(codec 版本 / 枚举序列化兼容)。读懂一层,其他层触类旁通。 - 代码注释是设计文档。
InternalEngine的 append-only 优化注释、Translog的类注释、CoordinationState的模型链接,全都把「为什么」写在「怎么做」旁边。650 万行的仓库敢这么写注释,是因为注释真的在维护。
如果你也想上手:第二节的 Docker 命令 + 五条查询就是全部入场券;如果你的系统也有「单机组件 → 分布式集群」那一跳要做,ES 的 server/ 目录依然是市面上最好的免费教材。
本文基于 elasticsearch main 分支(9.6.0)浅克隆源码分析,体验部分对应最新发行版 9.5.1;行数统计含测试代码。