
1. 从搜索引擎选型说起大概两年前我接手了一个日志检索项目数据量倒不算变态但每天几个亿条访问日志MySQL 里 LIKE 查询已经慢到没法看。当时团队里有人提议上 Elasticsearch也有人建议用 ClickHouse还有人觉得先凑合用 Solr。折腾了一圈之后我还是选了 Elasticsearch原因很简单我们不仅要查得快还要支持多维聚合分析而且搜索相关性的调优空间得够大。说实话当时我对 Elasticsearch 的了解也仅限于“会用 PUT 传个 JSON、能用 Kibana 画个图”真正把它内部逻辑吃透是后来在排查一次分片恢复卡死问题时被逼着读源码读出来的。这篇笔记不是教材而是我结合项目实践对整个 Elasticsearch 体系的梳理重点放在架构设计、底层原理、Lucene 之间的关系这三个方向。如果你也想从“会用”进阶到“懂它为什么这么设计”这份笔记应该能帮你少走不少弯路。如果你是纯新手先从第 2 节看起理解几个核心概念再往后读效果会好很多。2. Elasticsearch 到底是什么为什么是它2.1 必须先建立的概念倒排索引理解 Elasticsearch 之前我建议先忘掉 MySQL 那种“数据存表里、按行查”的思维。Elasticsearch 的核心数据结构叫倒排索引。正排索引是“文档 ID - 字段内容”倒排索引反过来是“词项 - 包含它的文档 ID 列表”。这就像书的目录和书末尾的索引页目录是正排的索引页是倒排的——你想找“分布式”这个词出现在哪些页直接翻索引页比从第一页挨着翻高效得多。倒排索引的具体结构可以拆成三部分Term Dictionary词项字典所有被分词后的词项按字典序排序存放做查询时能快速二分定位。Posting List倒排列表每个词项后面挂着一个文档 ID 列表有的还会带上词频TF和词位置信息用于相关度计算与短语查询。Segment段Lucene 把索引文件拆成多个段每个段内部就是一套完整的倒排索引结构段之间相互独立合并时再统一处理。这套设计让“搜索一个词”变成了“在字典里找一个词 拉取文档 ID 列表”两步操作比全表扫描快出数量级。代价是写入时维护成本较高但 Elasticsearch 用近实时机制把这个代价平摊掉了后面我会细说。2.2 Elasticsearch 与 Lucene 的关系很多人一上来就搞混 Elasticsearch 和 Lucene觉得这是两套并列的搜索引擎。实际上 Elasticsearch 底层就是 LuceneLucene 是一个 Java 编写的全文检索引擎库提供倒排索引、分词、查询解析、相关度评分这些最底层能力但它本身不是独立服务没有网络接口、没有分布式能力、也没有 JSON 解析。Elasticsearch 在 Lucene 之上做的事可以概括成三件分布式协调把数据分片把分片分布到多台节点自动完成路由、副本同步、故障转移。RESTful API 封装让你用 HTTP JSON 就能读写数据不需要直接在 Java 代码里操作 Lucene API。运维能力索引生命周期管理、冷热分层、监控告警、跨集群复制这些生产级特性全是 Lucene 不提供的。所以你可以把 Lucene 想象成一台发动机Elasticsearch 是把它装上了变速箱、方向盘、仪表盘的车。纯用 Lucene 写应用不是不行但你要自己处理集群和容灾工程成本极高绝大多数团队没必要这么做。3. Elasticsearch 核心架构拆解3.1 节点角色与职责划分Elasticsearch 集群里的节点按角色可以分成几类生产环境千万不要一个节点身兼数职。我这里结合自己踩过的坑逐一说明Master 节点负责集群级操作比如创建/删除索引、管理分片分配、维护集群状态。它不参与文档级读写请求所以压力相对可控。集群状态里包含每个索引的分片分布、映射信息等元数据所有节点都会有一份副本。Data 节点真正存数据、执行查询和写入的节点。如果节点角色是data那么它的 CPU、磁盘、内存都会被占用得很高扩容主要就是扩这类节点。Ingest 节点负责数据预处理比如管道里做 grok 解析、日期格式转换、字段重命名。数据量小时可以复用 data 节点数据量大时建议单独拆出来。Coordinating 节点每个节点都有协调能力负责接收客户端请求、分发到分片、汇总结果。如果集群压力集中在查询汇总上可以部署专用 coordinating 节点。Machine Learning 节点跑异常检测、数据帧分析等机器学习任务不是所有集群都需要。我在生产环境的标准做法是3 个 master 节点防止脑裂、一批 data 节点按热温冷分层、专门的 ingest 节点处理日志管道、2 个 coordinating 节点对接业务查询。这样职责分离一个节点挂了影响面可控。3.2 分片与副本的设计逻辑Elasticsearch 分布式能力的基石就是分片。一个索引的数据会被切到多个分片里每个分片本质上就是一个独立的 Lucene 索引可以运行在集群中的任意 data 节点上。分片设计有两个核心参数number_of_shards主分片数决定索引数据的水平切分粒度。number_of_replicas副本分片数用于高可用和读写负载分摊。这里有个关键点主分片数在索引创建后不能修改。原因很简单文档路由规则是routing hash(_id) % number_of_primary_shards一旦主分片数变了同一批文档会落到不同的分片Lucene 索引段没法做局部搬迁只能重建索引。所以项目初期就要估算好数据量级宁可多设一些分片也不要后续因分片数不足而重建索引。我遇到过一个真实案例某业务索引创建时设了 3 个主分片半年后数据量暴涨单分片超过 50GB查询和写入都开始抖动。最后只能新建一个 15 分片的索引用 reindex 把历史数据迁移过去整个过程花了快 8 小时。如果一开始就按“单分片 30GB 左右”的容量规划这个迁移成本完全可以省掉。副本分片则灵活得多可以随时调整。它的价值有两层主分片所在节点宕机时副本分片能晋升为主分片保证数据不丢。查询请求可以在主分片和副本分片之间负载均衡提升吞吐。副本不是越多越好因为每个副本都要占用磁盘和内存而且副本之间需要同步写入。一般生产环境设 1 个副本足够极端高并发读的场景可以临时加到 2。3.3 集群发现与脑裂问题Elasticsearch 节点之间通过Zen Discovery7.x 之前或基于 Quorum 的集群引导机制7.x 之后发现彼此并选举主节点。7.0 版本起ES 引入了集群引导配置需要显式指定可能的 master 节点列表避免节点启动后互相选举导致脑裂。脑裂是分布式系统里最经典的故障因为网络分区两个节点都认为对方失联各自选出了自己的 master导致集群状态不一致。Elasticsearch 解决脑裂的手段是“最少法定人数”规则选举 master 需要满足minimum_master_nodes (master候选节点数 / 2) 1这个条件。这个值配小了会脑裂配大了在真故障时集群可能无法选出 master。比如 3 个 master 候选节点时这个值就必须是 2。注意生产环境我强烈建议把所有主节点候选都设置为仅 master 角色不要同时承担 data 角色。数据节点 GC 停顿或磁盘繁忙时容易导致 master 选举超时触发不必要的重新选举。3.4 写路径与读路径的完整链路一次写入请求的完整链路是这样的客户端向任意节点发起 index/update/delete 请求这个节点扮演 coordinating 角色。协调节点根据文档_id计算目标分片把请求转发给主分片所在节点。主分片执行写入写入 Lucene 的 translog 和内存 buffer同时把请求并行转发给所有副本分片。副本分片各自执行写入并返回确认。主分片收到所有副本确认后向协调节点返回成功。协调节点向客户端返回结果。这里有几个性能关键点同步写副本是延迟来源。如果业务对写入延迟要求极高可以把replication设为async但这样做会有数据丢失窗口我只在离线写入场景使用过。refresh 间隔决定可见性。默认refresh_interval为 1 秒也就是说数据写入后最多 1 秒内可被搜索到。把 refresh 设为-1关闭可以大幅提升写入吞吐适合批量导入场景。translog 是数据安全兜底。Lucene 的段数据在内存 buffer 积累到一定大小后才会落盘如果节点宕机内存数据会丢。translog 负责记录未落盘的所有操作节点恢复时重放 translog 就能找回数据。读路径相对简单协调节点把查询请求广播到所有相关分片主分片和副本都行各分片在本地 Lucene 索引上执行查询返回结果集的 Top N协调节点做全局排序后返回客户端。这个流程需要注意深分页问题——比如查询第 10000 条到 10100 条每个分片都要先取前 10100 条再归并坐标节点内存压力很大。生产环境我用search_after或 PIT 替代传统深分页后面常见问题章节会展开。4. Lucene 底层索引机制深入4.1 分段存储与合并机制Lucene 把索引分为多个 segment每个 segment 是完整独立的索引。新文档先写入内存触发 refresh 时生成一个新的 segment。这种“append-only 不可变性”的设计带来了几个好处每次写入不需要修改旧 segment避免了加锁的写竞争。segment 不可变可以被 OS 页缓存有效利用读性能很稳定。通过定期合并 segment可以清理旧版本数据、减少文件句柄数提升查询效率。但不可变性也有代价更新和删除文档不能直接改 segment只能通过写入新的删除标记或新版本文档来处理所以删除文档后的磁盘空间不会立刻回收要等 segment merge 完成后才能真正释放。Lucene 的 merge 策略默认是TieredMergePolicy分层合并。它的核心逻辑是根据 segment 的 size 按层归并让分层的 segment 数量大致相同使得合并成本与搜索性能之间尽量平衡。如果你的索引主要场景是写入密集可以调节index.merge.scheduler.max_thread_count和index.merge.policy.segments_per_tier来控制合并频率避免 merge 阶段 IO 飙升拖垮查询。4.2 内存 Buffer 与 FST 词项字典Lucene 每个分片内部有一个IndexWriter维护内存 buffer。新文档不会直接落盘而是先写入 buffer 和 translog。当内存 buffer 超过阈值默认 16MB或者 refresh 间隔到时会生成一个新的 segment。词项字典的查询性能很大程度上依赖一种叫FSTFinite State Transducer有限状态转换器的数据结构。FST 把词项字典按公共前缀压缩存储既能节省内存又能以 O(长度) 的复杂度完成查找。这个词项字典占用的内存往往比倒排列表本身还大FST 就是 Lucene 能在几百 GB 索引上保持毫秒级词的查询速度的关键。这里有一个容易踩的坑分片数量过多时每个分片都有自己的 FST它们分别加载到 JVM 堆内。当你集群里有几千个分片时FST 占用的堆内存可能超过数据本身。我见过一个极端案例60 个节点、每个节点分配 30GB 堆结果堆内存用了 80%全是 FST 和 filter cache。排查半天才发现是某个索引分了太多分片后来把分片从 40 降到 10堆内存立刻降了一半。4.3 分词器的工作流程分词是把一段文本拆成词项的过程。Elasticsearch 里分词器通常由三部分组成Character Filters字符过滤器在分词前处理原始文本比如去除 HTML 标签、替换特定字符。Tokenizer分词器把文本切分成词元流这是最核心的部分。Token Filters词元过滤器对词元做进一步处理比如小写化、停用词过滤、同义词替换、拼音转换等。以我常用的ik_max_word中文分词器为例它对“我是中国人”进行分词会尽量切出最多组合词得到“我”“是”“中国”“中国人”等词元。另一个ik_smart模式则倾向切出最粗粒度的词比如“中国人”作为一个整体。选哪个取决于场景搜索建议类适合ik_max_word精确匹配类适合ik_smart。分词器选不对会让查询结果离谱。之前有个电商搜索项目商品名称是“美的空调”如果分词器把“美的”当成一个独立的词用户搜“美丽”时可能会匹配到“美的空调”因为“美”和“丽”分别匹配了某个词项。这种问题可以通过自定义分词器、加同义词词典、或者用match_phrase查询来规避。4.4 相关度评分TF-IDF 与 BM25Lucene 默认的相关度评分算法在 5.x 以后是BM25取代了早期的 TF-IDF。BM25 的核心思想仍然是三个量TF词频词在文档中出现越多次得分越高但有一个饱和机制——词频超过一定阈值后权重增长变缓防止长文档靠词频堆分。IDF逆文档频率词在越少文档出现区分度越高权重越大。比如“的”这种词几乎每篇都有IDF 接近 0。字段长度归一化字段越长匹配到的词越容易偶然出现所以长字段的匹配分数会被拉低。BM25 有两个可调参数k1控制词频饱和曲线默认 1.2b控制字段长度归一化幅度默认 0.75。如果业务文档普遍很长可以降低b让长度惩罚变得缓和如果短文本匹配更重要可以升高b。我用过一个比较有效的调参思路离线构建一个查询日志集合用标注好的相关文档集做评测在开发环境用k1从 0.8 到 1.6、b从 0.5 到 1.0 逐步扫描能找到一组最适合当前业务的参数。不要盲目照搬别人的调参结果不同语料分布下最优参数差别很大。5. Elasticsearch 分布式架构中的关键机制5.1 数据路由与分片分布文档写入哪个分片取决于路由公式shard hash(_routing) % number_of_primary_shards。默认_routing就是文档_id你可以在写入时指定自定义 routing 值让同一类文档落在同一分片提升查询效率。路由自定义在 join 类型查询中特别有用。Elasticsearch 官方并不推荐使用_parent字段做父子关系因为性能很差但如果你必须根据某个业务维度做聚合比如按用户 ID 把该用户所有记录路由到同一分片查询这个用户的数据时就只查一个分片而非全索引性能提升非常明显。但注意自定义 routing 也有代价如果 routing 分布不均匀某些分片的数据量会远大于其他分片造成“数据倾斜”。我有一次把 routing 设为“订单所属城市”结果一线城市分片数据量是三四线城市的 20 倍那几个分片就成了瓶颈查询延迟从 30ms 飙到 3 秒。5.2 分布式查询的协调流程一个标准查询在分布式环境下的执行流程可以拆成两个阶段查询阶段Query Phase协调节点向每个分片发起查询各分片在本地找出 Top N 匹配文档的 ID 和得分返回给协调节点。取回阶段Fetch Phase协调节点拿到所有分片返回的 Top N 后做全局排序确定最终 Top N 文档 ID再向各分片发起“取这些文档详情”的请求最后组成完整结果返回客户端。你会发现两个阶段里每个分片都会先查“它自己的 Top N”。如果有 5 个分片每个分片返回 Top 10最终合并时可能某条文档在第 3 个分片上排第 11结果就错过了。如果需要精确的全局 Top 10需要让每个分片返回更多候选这就是为什么深分页代价那么高——翻页越深每个分片要返回的候选数就越多。理解了这条链路你就明白为什么 Elasticsearch 官方文档反复强调“不要在集群里做大分页查询”。生产环境我遇到过一页一页翻到第 200 页的用户拖垮了整个集群。后来强制限制from size不超过 10000并用search_after方案替换问题迎刃而解。5.3 集群状态分发的可靠性Elasticsearch 集群里master 节点维护一个“集群状态”对象内容包括索引列表、每个索引的分片分配、节点列表、持久化配置等。集群状态通过内部发布机制同步到所有节点任何分片分配决策都以集群状态为基础。集群状态体积也是有代价的。如果你创建了几千个索引每个索引都定义了几百个字段的 mapping集群状态可能膨胀到几十 MB。每次集群状态变化比如节点加入、索引变更都要全节点广播状态越大广播延迟越高极端情况下会导致集群不稳定。我参与过一个项目因为运维脚本每半小时自动创建带随机后缀的索引一年下来索引数量超过 2 万个集群状态到了 100MB 多每次新节点加入耗时接近 20 分钟。后来清理了历史索引把字段映射精简集群状态降到 5MB 以下稳定性立竿见影。6. 从零搭建一个 ES 集群的实操记录6.1 环境准备与配置清单这里我以 3 台 8 核 16GB 云主机为例记录一次完整的 Elasticsearch 8.x 集群搭建过程。操作系统是 CentOS 7Java 版本 17ES 8.x 需要 JDK 17。关键配置项# elasticsearch.yml cluster.name: my-es-cluster node.name: node-1 node.roles: [master, data, ingest] network.host: 0.0.0.0 discovery.seed_hosts: [10.0.0.11, 10.0.0.12, 10.0.0.13] cluster.initial_master_nodes: [node-1, node-2, node-3] path.data: /data/elasticsearch path.logs: /var/log/elasticsearch bootstrap.memory_lock: true这里有几个坑必须说network.host设置为0.0.0.0后ES 会自动切换到生产模式要求bootstrap.memory_lock: true或手动调 vm.max_map_count否则启动失败。必须预先在/etc/security/limits.conf里设置elasticsearch soft memlock unlimited并执行ulimit -l unlimited不然bootstrap.memory_lock: true会直接报错。Java 堆内存设置不要超过物理内存的一半而且要留给 OS page cache 足够的空间因为 Lucene 大量利用 mmap 做文件读取。6.2 启动与验证启动前先调内核参数sysctl -w vm.max_map_count262144 echo vm.max_map_count262144 /etc/sysctl.conf然后使用 systemd 启动systemctl daemon-reload systemctl enable elasticsearch systemctl start elasticsearch检查节点是否正常curl http://10.0.0.11:9200看到带cluster_name和version的 JSON 响应就成功了一大半。再用curl http://10.0.0.11:9200/_cluster/health如果status为green说明三个节点组成了一个健康集群所有主分片和副本分片都分配正常。6.3 索引生命周期管理策略生产环境一定要配置ILMIndex Lifecycle Management索引生命周期管理否则时间序列索引会无限膨胀。我用日志场景举例PUT _ilm/policy/log_ilm_policy { policy: { phases: { hot: { min_age: 0ms, actions: { set_priority: { priority: 100 } } }, warm: { min_age: 3d, actions: { read_only: {} } }, cold: { min_age: 15d, actions: { freeze: {} } }, delete: { min_age: 30d, actions: { delete: {} } } } } }这套策略的含义是索引创建后前 3 天处于 hot 阶段支持读写3 天后进入 warm 阶段只读并降低副本数15 天后进入 cold 阶段冻结索引减少内存占用30 天后自动删除。我之前的日志集群从“无策略、人工清数据”切换到这套 ILM 后运维成本直线下降磁盘再也没报警。6.4 生产环境的内存与磁盘调优ES 堆内存设置经验值是-Xms和-Xmx设置为相同大小避免 JVM 动态扩容引发不必要的 GC 停顿。堆内存大小一般控制在物理内存的 50% 以内比如 16GB 机器给 ES 堆分配 8GB剩下 8GB 留给 OS page cache。磁盘方面我强烈建议使用 SSD并把索引放在独立磁盘上不要和系统盘共用。日志型场景可以配置两个数据路径把热索引和冷索引物理隔离。如果预算有限至少保证 hot 阶段的索引全部落在 SSD 上。Linux 下还需要关闭 swap 或设置vm.swappiness1避免 JVM 堆被换出到磁盘导致 GC 时卡顿。检查节点配置是否生效可以调用curl http://10.0.0.11:9200/_nodes/process关注mlockall字段是否为true。7. 常见问题与排查技巧实录7.1 写入延迟突然飙升现象原本写入 2ms某天开始涨到 200ms 以上查询也慢。排查路径先看节点磁盘 IO 使用率segment merge 是常见元凶。执行GET /_nodes/hot_threads看是否有LuceneMerge线程堆积。查看GET /_cluster/health是否有relocating_shards不为 0分片搬迁也会抢占 IO。再检查 GC 日志老年代回收频繁说明堆不够或者内存里缓存占太多。如果以上都正常看是否索引有大量更新/删除操作导致 segment 合并一直跟不上。如果是调大index.merge.scheduler.max_thread_count或者减小segments_per_tier。我实际处理过一个数据修复任务一天内导入了近 20 亿条数据merge 线程把磁盘 IO 打满业务查询全卡住。临时做法PUT /index/_settings把refresh_interval调为-1并调用POST /index/_forcemerge?max_num_segments1手动触发合并等合并完成后再恢复自动 refresh。7.2 分片恢复卡在 INITIALIZING现象某个节点重启后集群状态显示大量分片INITIALIZING长时间不能变绿。常见原因有两个节点间数据传输速度慢。检查网络吞吐确认日志里没有slow_operation。磁盘空间不足导致分片副本无法分配。我在生产环境遇到过类似场景某台 data 节点磁盘剩余 3%exporter 监控没报警结果 ES 在初始化分片时直接返回“no space left on device”集群卡住。解决方式是用POST /_cluster/reroute?retry_failedtrue手动重试分片分配。如果重试仍然失败必须看该节点磁盘是否真的满了。另外集群设置了cluster.routing.allocation.enable: none时即使重启节点也不会自动恢复分片新手容易踩。7.3 查询相关性结果不理想场景用户搜“苹果手机”返回的第一条是“苹果园水果”而不是 iPhone。这类问题我处理过多次通常需要从这几个层面去排查分词器确认索引 mapping 用的分词器跟查询时一致。如果不一致同一个词在索引和查询时切出来的词元不同匹配就会错位。打分参数按前面 BM25 调参流程找出一组合适的k1和b。查询类型全文搜索用match做短语保序用match_phrase但match_phrase会把匹配要求变严格过度使用会导致召回率下降。文档权重商品标题和描述字段不同权重。可以用boost给标题字段乘以 2给描述字段乘以 1让标题匹配优先。我自己的经验是不要一开始就调 BM25 参数先把 mapping 和 query 结构优化好再看剩余问题是否值得调参。调参是锦上添花不是救命稻草。7.4 深分页导致的协调节点 OOM用户反馈从第 1000 页开始查询超时甚至集群无响应。原因前面提过from size的深分页协调节点需要收集所有分片的大量候选文档。比如 10 个分片from10000, size10每个分片都要返回前 10010 条10 个分片就是 10 万条文档在海量排序内存和 CPU 消耗呈线性增长。解决方案有三种限制 fromsize 不超过 10000强制手段。使用 search_after需要提供一个游标值比如按时间戳ID 排序每次都从上一批最后一条的位置继续往后查。这个方案在日志场景最常用。使用 Scroll API适合大量数据的导出场景不适合给用户实时翻页因为它会保持一个搜索上下文快照占用内存。我实际改造过一个报表系统的翻页接口把from翻页全部换成search_after之后接口响应时间从 8 秒降到了 120ms效果立竿见影。8. 我在实践中的一点体会这套 Elasticsearch 架构和 Lucene 原理看起来知识点散实际上有一条主线贯穿始终一切都是为了在“海量数据”和“毫秒级查询”之间取得平衡。分布式分片让数据可以横向扩展倒排索引让查找从扫描变成字典查询segment 不可变让读性能稳定而近实时写入机制又巧妙平衡了写入吞吐和查询可见性。我踩过的坑、调过的参数、读过的源码都让我越来越认同一个判断Elasticsearch 不是银弹但它适合做“全文搜索 聚合分析 海量日志处理”这类场景。真正把它用好核心还是理解分片设计、路由策略、查询链路和 Lucene 段合并机制这些底层逻辑。如果你现在正在部署 ES 集群或者准备改造现有搜索架构我的建议是先在测试环境严格按照这篇文章里的架构设计建一个小集群把分片、副本、ILM 都配好再导一部分真实数据做压测。不要直接拿生产环境试错代价太大。这是一套值得投入时间的知识体系等你在生产环境真正遇到分片恢复卡死或者深分页拖垮节点的时候你会发现今天理解的每个细节都是在帮你省钱省时间。