Elasticsearch(简称 ES)是一个基于 Apache Lucene 构建的分布式、RESTful 风格的搜索和分析引擎。它支持近乎实时的全文检索、结构化查询、聚合分析和海量数据的水平扩展,是目前最流行的开源搜索引擎,广泛用于站内搜索、日志分析(ELK/EFK 栈)、监控告警与业务数据检索等场景。
本文从搜索引擎的基本问题出发,梳理 ES 的定位、核心概念与底层原理。
为什么需要搜索引擎
传统的关系型数据库(如 MySQL)用 B+ 树索引擅长等值查询与范围查询,但在处理模糊匹配、分词、相关性排序这类需求时力不从心:
LIKE '%keyword%'无法走索引,只能全表扫描,数据量大时性能极差。- 无法对内容做分词(如按词语、拼音、同义词切分),只能做整串包含匹配。
- 缺少相关性打分能力,无法回答"哪条结果更匹配"。
搜索引擎的核心是倒排索引(Inverted Index),它彻底改变了检索方式:以"词"为索引单位,快速找到包含该词的所有文档。
核心概念与术语
ES 面向用户的抽象模型非常直观,与关系型数据库存在一一对应关系:
| 关系型数据库 (MySQL) | Elasticsearch |
|---|---|
| Database(数据库) | Index(索引) |
| Table(表) | Type(类型,7.x 起废弃) |
| Row(行) | Document(文档) |
| Column(列) | Field(字段) |
| Schema(表结构) | Mapping(映射) |
- Index(索引):一个索引是一组相似文档的集合,相当于逻辑上的"数据库/表"。
- Document(文档):JSON 格式的基本数据单元,一个文档即一条记录。
- Field(字段):文档中的键值对,相当于列。
- Mapping(映射):定义字段类型与分析方式,决定字段如何被索引和查询。
- Shard(分片):一个索引被拆分为多个分片分布在集群节点上,实现数据的水平扩展。
- Primary Shard(主分片):处理写请求,负责数据写入。
- Replica Shard(副本分片):主分片的拷贝,提供读能力与高可用。
- Segment(段):分片内部最小的存储单元,Lucene 将索引文件组织为多个不可变的段。
- Cluster / Node(集群 / 节点):多个节点组成集群,通过分片实现容量与吞吐的扩展。
倒排索引:搜索的核心原理
倒排索引是全文检索的基石。以一段简单的英文文档集合为例:
| Doc ID | 内容 |
|---|---|
| 1 | Elasticsearch is a search engine |
| 2 | search engine based on Lucene |
构建倒排索引的过程:
- 分词(Analyze):将文档内容按规则切分成词项(Term),如
search、engine。 - 归一化(Normalize):统一大小写、去除停用词、还原词干等,如
Search→search。 - 建索引(Index):得到"词项 → 文档列表"的映射表:
| Term | Posting List(倒排列表) |
|---|---|
| search | [1, 2] |
| engine | [1, 2] |
| lucene | [2] |
查询 search 时,只需查找词项表定位到 Posting List,即可快速返回文档 1 和 2,时间复杂度接近 $O(1)$,无需扫描全部文档。
Posting List 的优化
- 跳表(Skip List):对文档 ID 建立跳跃指针,加速多个词项的 AND / OR 合并计算。
- Frame of Reference(FOR):对有序的文档 ID 做差值编码(Delta Encoding)+ 位压缩,大幅减少存储与 IO。
- Roaring Bitmap:将文档 ID 集合转换为压缩位图,用于缓存与高效集合运算。
正是这些压缩与加速技术,让"在大规模数据上做毫秒级检索"成为可能。
回答开头的三个问题
回到文章开头 MySQL 的三大痛点,倒排索引 + ES 的组合恰好逐一破解:
① LIKE '%keyword%' 全表扫描 → 倒排索引定位
MySQL 的 B+ 树只能按前缀匹配走索引,%keyword% 通配符出现在中间,索引直接失效,只能逐行扫描。倒排索引反其道而行:先按"词"组织,再反查文档。查询时在 Term 字典里定位 keyword(FST 查找近似 $O(\text{词长})$),直接拿到包含它的 Posting List——关键词在原文中的位置无关紧要,前面后面是任意内容都能命中。这就是"索引单位从’行’变成’词’“带来的质变。
为什么"前模糊"在倒排索引下也能快速命中?核心在于索引的 key 完全不同:
- MySQL 侧:B+ 树的 key 是整行原文,按字符串前缀排序。
%keyword%要求命中字符串任意中间位置,前缀树无能为力,只能退化成逐行做子串比对——这就是全表扫描。 - ES 侧:分词器在写入时已经把原文切成一个个独立的词,
keyword被剥离出来,成为字典里的一个独立条目。查询keyword本质是"查字典里有没有这个词”(成员查找),而不是"在原文里找子串"——keyword前面是什么、后面是什么,在索引阶段就已被剥离,查询阶段完全不用关心。FST 保证一次字典定位近似 $O(\text{词长})$。
边界澄清:这里的"快"是相对
LIKE '%keyword%'而言——匹配粒度是"整词"。如果需求是真正的任意中缀子串匹配(如要求命中mykeywordabc这种词中间的片段),分词后的倒排索引同样不直接支持,需要 ngram 分词器或wildcard查询。常规关键词搜索场景下,“整词"粒度已经足够,这也是搜索与数据库的匹配语义差异。
② 无法分词 → 分词流水线
MySQL 的 LIKE 只能整串匹配,无法理解"无线机械键盘"和"机械键盘"是相关的。ES 在索引时通过 Analysis 流水线(Character Filters → Tokenizer → Token Filters)把文本切成 Term:
- 按词语切分:
无线机械键盘→[无线, 机械, 键盘],搜任意一个词都能命中。 - 支持拼音 / 同义词扩展:搜
jixie或机械键盘都能命中同一批文档。 - 查询与索引用同一套分析器:两侧对齐,词的不同形态(大小写、时态)也能归一化匹配。
③ 缺少相关性打分 → BM25
MySQL 只能回答"有没有匹配”,回答不了"哪条更匹配"。ES 用 BM25 算法为每个命中文档打分:
- TF(词频):词在文档中出现越多,分数越高(带饱和处理,避免无限增长)。
- IDF(逆文档频率):越稀有的词权重越高,
机械键盘比键盘更能区分文档。 - 文档长度归一化:同样的词,出现在短文档中比长文档中更"重要"。
三者综合后每个文档得到一个 _score,按分数倒序返回——这就是"哪条结果更匹配"的答案,也是搜索排序(召回后精排)的基础。
数据结构
在索引元数据(Mapping / Settings)已经建立的前提下,ES 接收到一个 JSON 文档后,数据处理主要经历集群网络路由层、Lucene 引擎数据结构拆解层以及内存与磁盘持久化层。
Lucene 内部的数据结构构建
文档进入分片后,Lucene 会根据 Mapping 配置,将文档拆解并同步构建多种底层数据结构:
每个字段按类型进入对应的结构,下面逐一展开每种结构的原理。
倒排索引(Inverted Index)
适用类型:text、keyword 等文本字段。
倒排索引回答的问题是:“哪些文档包含某个词?” 它的构建分三步:
Analysis(分词)
原始文本先通过三段流水线切分为 Term 序列:
- Character Filters(字符过滤):清洗原始字符,如去掉 HTML 标签、替换特殊符号。
- Tokenizer(分词器):把文本按规则切成 Token。中文常见
ik_max_word、standard等。 - Token Filters(词项过滤):对 Token 做归一化——转小写、提取词干(
running→run)、删除停用词。
建立字典与倒排列表
分词后,每个 Term 指向一个 Posting List(倒排列表):
Posting List 中除了保存包含该 Term 的 DocID 外,还会记录:
- TF(Term Frequency,词频):该词在文档中出现多少次,参与相关性打分(
_score)。 - Positions(位置):词在文档中的位置序列,用于 Phrase Query(如
"无线 键盘"要求相邻)。 - Offsets(偏移量):词在原文中的字符起止偏移,用于搜索结果高亮。
字典压缩:FST
Term 字典全量加载进内存会非常大,Lucene 用 FST(Finite State Transducer,有限状态转换器) 压缩。FST 本质是一个 DAG,公共前缀的 Term 共享同一条路径:
以上图中 anc 与 ant 共享前缀 an,路径只存一次。FST 查找一个词的时间复杂度近似 $O(\text{词长})$,且天然支持前缀查询(如 an*)和模糊匹配。除了字典查找,FST 还存了每个 Term 在磁盘上的文件偏移,一次定位就能直接读到 Posting List。
Posting List 压缩
Posting List 中的 DocID 是有序整数序列,直接存储空间浪费巨大,Lucene 用两种算法压缩(详见下文 Roaring Bitmap 小节):
- Frame of Reference (FOR):对相邻 DocID 做差值编码(如
[1, 5, 9, 100]→[1, 4, 4, 91]),差值通常很小,可以位压缩存储,然后分块存到磁盘。 - Roaring Bitmaps:把 DocID 集合转换成位图,用于加速 Posting List 之间的交 / 并运算。
倒排索引的存储架构
上面把"分词 → 建立字典 → Posting List → 压缩"按步骤拆开讲了,但它们是层层咬合在一起的。下面以三份商品文档为例,串起"写入 → 落盘 → 查询"的完整链路,看看倒排索引到底是怎么存进磁盘的:
这张图把存储拆成三层,对应上面的样例数据:
- 写入层(①②):三份文档的
title字段分词后得到 Term 集合——「无线机械键盘 87 键」→ [无线, 机械, 键盘, 87, 键]。每个 Term 收集"出现在哪些文档"形成 Posting List。 - 逻辑结构层(③):全部分词结果聚合成一张"Term 字典 → Posting List"的映射。以「机械」为例,它出现在 Doc 1、Doc 2,Posting List 里除了 DocID,还记录了词频(TF)、在文档内的位置(Positions)和起止偏移(Offsets)——偏移用于高亮。
- 磁盘物理层(④):逻辑结构按职责拆分成一组 Segment 物理文件——FST 字典落到
.tim/.tip,DocID+TF 落到.doc(FOR 差值压缩),位置和偏移落到.pos/.pay,文档长度统计落到.nvd(评分用)。ES 查询时只按需读对应文件,例如只看相关性打分不需要.pos。
查询(⑤):match「机械键盘」 分词为 [机械, 键盘],FST 在 $O(\text{词长})$ 内定位到两个 Term 的 Posting List,取交集 [1,2] ∩ [1,2] = [1,2],最终命中 Doc 1 与 Doc 2。
关键:压缩字典是 Segment 粒度的
上面的图简化成了一张"全局字典",实际上每个 Segment 各自持有一份独立的压缩字典(.tim 完整字典 + .tip FST 前缀索引),Segment 之间互不共享:
- Segment 按写入批次划分,而不是按词条划分:每次 Refresh 把 Index Buffer 里攒的文档打包成一个新 Segment,因此每个 Segment 的字典就是"这批文档里出现的所有词条"。很多词条落在同一个字典下是常态,但这不会导致某个 Segment 因词多而特别大——段的大小由写入量决定,与词条数量无关。
- 跨 Segment 查询 = 每段独立查 + 结果合并:协调节点把查询广播到所有相关 Shard;每个 Shard 内并发扫所有 Segment 的 FST,各段自行定位、求交、返回命中文档;最后汇总统一打分排序。这就是"Segment 越多查询越慢"的原因(每个段都要扫一遍),也是后台 Merge 存在的意义——把大量小段合并成大段,减少段数、摊薄扫描成本。
会不会数据倾斜?
- 段大小不均:写入速率波动会产生大小不一的 Segment,这是暂时的,靠后台 Merge 追平,不是真正的倾斜。
- 热点 Term(如"无线"出现在 90% 的商品文档里):它的 Posting List 很长,这只影响查询该词时的扫描 / 评分开销,不影响存储分布。
- 真正的倾斜发生在 Shard 层:文档默认按
_id哈希路由到分片,天然均衡;只有强制用routing路由时,才可能把数据压到少数分片。
Segment 大小真的均匀吗?
稳定状态下是的,但均匀是指"同一量级内",而非严格相等。默认的 Tiered Merge Policy(分层合并) 保证:
- 维护多个层(tier),每层内 Segment 大小相近(比例受
segmentsPerTier控制,默认 10)。 - 每次 Merge 挑选大小相近的几个段合并(受
maxMergedSegmentMB、maxMergeAtOnce约束),合并出的更大段进入更高层。 - 因此段大小呈指数分层(1MB、10MB、100MB…各层若干段),段数有界、每段扫描成本可预期,避免了"一个巨型段 + 无数小段"的失衡。
- 高峰写入时小段会暂时积压,待合并追上后恢复分层结构;
forcemerge可强制合成单个大段,但会牺牲增量写入的灵活性。
对比:倒排索引 vs B+ 树聚簇索引
说到索引,MySQL InnoDB 的 B+ 树聚簇索引是最常被拿来对比的。两者都解决"快速定位数据"的问题,但思路完全不同:
核心差异一句话:B+ 树聚簇索引是**“按主键有序的数据本身”(叶子节点直接存整行,数据即索引);倒排索引是“词 → 文档的映射表”**(数据本体存在 _source,索引只负责定位 DocID)。
| 对比维度 | B+ 树聚簇索引(InnoDB) | 倒排索引(Lucene) |
|---|---|---|
| 组织方式 | 按主键(聚簇键)排序的平衡多叉树 | Term 字典 + 无序 Posting List |
| 叶子节点 | 存整行数据(数据即索引) | 存 DocID 集合(不含原始文档) |
| 等值查询 | 主键/唯一键 $O(\log N)$ 定位 | 字典(FST)近似 $O(\text{词长})$ 定位 |
| 范围查询 | 极强:键有序,叶子链表顺序扫描,天然支持 | 弱:Posting List 无序,数值范围要另建 BKD 树 |
| 前缀/模糊查询 | 仅 LIKE 'abc%' 用最左前缀,%abc% 全表扫 | 极强:FST 前缀遍历、倒排列表编辑距离(模糊) |
| 全文检索 | 不支持(无分词) | 天生为全文设计(分词 + 位置 + 偏移) |
| 多条件组合 | 需设计联合索引,受最左前缀约束 | Posting List 位图交 / 并(Roaring)求交,组合任意 |
| 排序 / 聚合 | 按索引键有序则免排序,其他键要临时表 / filesort | 走独立列存 Doc Values,聚合不碰倒排索引 |
| 更新方式 | 原地更新叶子节点(页分裂 / 页合并) | 写时不可变 Segment,删改靠标记删除 + 后台 Merge |
| 存储膨胀 | 聚簇索引含数据,二级索引需回表 | 一份文档同时进入倒排、Doc Values、_source 等多份结构 |
一句话选型:数据是结构化、按主键查询 / 区间扫描为主(订单表、流水表)→ B+ 树聚簇索引;数据是非结构化文本、需要搜索 / 模糊 / 聚合分析(搜索系统、日志分析)→ 倒排索引。这也是为什么电商订单用 MySQL、商品搜索用 ES 的底层原因。
Point Values(BKD 树)—— 时间范围与数值检索
适用类型:date、long、integer、float、geo_point 等。
为什么需要 BKD 树?
早期的 ES 把数值也当成字符串塞进倒排索引。比如价格 399.00 会变成一个 Term "399",数值有多少种,字典里就有多少个 Term——导致范围查询要遍历海量 Term,效率极低且索引极度膨胀。
Lucene 6 引入 BKD 树(Block K-D Tree),专门解决数值 / 地理坐标的范围查询。
结构特点
BKD 树是 K-D 树与 B 树的结合:内部节点记录切分维度与切分值(一维场景退化为类似 B 树的分块有序树),叶子节点是排好序的 Leaf Block:
范围查询原理(如 price >= 300, price <= 500):
- 查询从根节点出发,与每个内部节点的值域比对:当前块完全落在查询区间外 → 整块剪枝跳过;部分重叠 → 继续下钻;完全命中 → 读取 Leaf Block 内全部 DocID。
- 最终只需扫描少数几个 Leaf Block,检索效率比遍历倒排索引提升数量级。
以日期范围 gte: 2026-01-01, lte: 2026-06-01 为例,日期先转为长整型毫秒值,然后沿 BKD 树逐层剪枝,快速排除半年之外的所有块。
Doc Values(列式存储)
适用类型:除 text 以外的大部分非分析字段(如 keyword、date、long 等)。
为什么需要 Doc Values?
倒排索引是 Term -> DocID,适合回答"哪个文档包含这个词"。但排序、聚合、脚本需要的是相反方向:“给定 DocID,它的字段值是多少?” 如果基于倒排索引做聚合,要把整个倒排列表解压进堆内存(早期 ES 的 Fielddata 机制),数据量大时直接 OOM。
Doc Values 用正排列存解决:写入时就把字段值按列连续存储(DocID -> Field Value):
存储与压缩:
- 字典编码(Dict Encoding):低基数字段(如品牌、状态码)把取值映射成整数 ID,列里只存 ID,值表单独存储。
- 变长压缩 / 固定宽度:数值字段按需要分配 bit 宽度(如 1~4 字节),进一步压缩。
- 列文件通过
mmap挂载到 OS Page Cache,顺序读取时对缓存友好,不占 JVM 堆内存。
核心作用:专门用于 Sort(排序)、Aggregations(聚合) 和 Script(脚本)。这就是为什么聚合排序走 Doc Values 而不是倒排索引——后者需要解压整个倒排列表进堆内存(Fielddata),容易 OOM。
Roaring Bitmap:Posting List 的压缩与加速
Roaring Bitmap 是 Posting List 的核心压缩算法,同时也在查询执行阶段被用来缓存 filter 上下文的结果集。
为什么不用普通位图?
普通位图每个文档 ID 占 1 bit,几十亿文档就要几百 MB;而纯数组存储又太稀疏。Roaring Bitmap 折中:按高 16 位分块(每 65536 个 DocID 一组),块内再按稀疏度选择最省的容器:
- Array Container:块内元素少(< 4096 个)时,用有序数组存储,稀疏场景最省。
- Bitmap Container:块内元素多(≥ 4096 个)时,用 65536 bit(8KB)位图,稠密场景最省。
- Run Container:遇到连续区间(如
[100, 101, 102, ...])时,只存起点和长度,极限压缩。
为什么快:两个 Posting List 做交集 / 并集时,直接对容器做位运算(AND / OR / XOR),数组场景还能用二分查找,配合 SIMD 指令提速。这正是 bool.filter 多条件过滤"秒出结果"的底层原因。
_source 与 Stored Fields(行存存储)
_source 字段
默认开启,ES 会把传入的原始 JSON 作为一个完整的 Byte 数组(经 LZ4 / ZSTD 算法压缩)直接写入 Stored Fields 集合。GET /index/_doc/id 返回完整文档时,就是直接读取并解压该字段,不需要重新组装。
Stored Fields
如果在 Mapping 中配置了 "store": true,该字段会被单独拆出来存入行存文件,用于只需提取少量字段、而不想解压整个 _source 的场景:
行存 vs 列存的取舍:_source 与 Stored Fields 是行存(按文档组织,适合取整条 / 少量字段);Doc Values 是列存(按字段组织,适合跨文档聚合)。两者互补,各司其职。
内存到磁盘的流转过程(Segment 生命周期)
文档经过上述处理生成内存结构后,数据会经历以下四个阶段落盘:
写入 Index Buffer 与 Translog:文档同时写入内存中的 Index Buffer 和磁盘上的 Translog(Write-Ahead Log,预写日志,防止宕机丢失)。
Refresh(可被搜索):默认每 1 秒(或 Buffer 满时),Index Buffer 中的数据被写入 OS Page Cache,生成一个新的 In-memory Segment。一旦生成 Segment,该 Segment 内的数据就可以被客户端 Search 到(这就是 ES 近实时搜索 (NRT) 的原因)。
Flush & Commit(持久化落盘):默认每 30 分钟 或 Translog 达到阈值(如 512MB)时,触发 Flush。调用 OS
fsync将 Page Cache 中的 Segments 强制刷入物理磁盘,生成 Lucene Commit Point,同时清空并重置 Translog。Segment Merge(后台合并):频繁 Refresh 会产生大量小 Segment 文件。ES 后台会有异步线程按照段大小进行 Merge,将小 Segment 合并为大 Segment,同时彻底物理删除已被标记删除(
.del)的文档。
一个例子:商品文档如何用上所有数据结构
以电商商品为例,一条商品文档长这样:
{
"product_id": "P-1001",
"title": "无线机械键盘 87 键 蓝牙 2.4G 三模热插拔",
"description": "Gasket 结构,RGB 背光,支持 Win / Mac 双系统切换",
"brand": "Keychron",
"category": "电脑外设",
"price": 399.00,
"stock": 128,
"on_sale": true,
"created_at": "2026-07-01T10:30:00+08:00",
"tags": ["机械键盘", "无线", "办公"]
}
对应的 Mapping 片段:
{
"mappings": {
"properties": {
"title": { "type": "text" },
"description": { "type": "text", "store": true },
"brand": { "type": "keyword" },
"category": { "type": "keyword" },
"price": { "type": "double" },
"stock": { "type": "integer" },
"on_sale": { "type": "boolean" },
"created_at": { "type": "date" },
"tags": { "type": "keyword" }
}
}
}
写入这条文档时,ES 会同步构建所有数据结构,各自负责不同的查询需求:
| 数据结构 | 覆盖字段 | 典型查询 |
|---|---|---|
| 倒排索引 | title(分词后)、brand、tags | 搜索"机械键盘"、精确匹配 brand = Keychron |
| Roaring Bitmap | Posting List、filter 上下文缓存 | bool.filter 快速过滤 + 结果集位图缓存 |
| BKD 树 | price、stock、created_at | 价格区间 price 300~500、上架时间范围 |
| Doc Values | brand、price、created_at | 按价格排序、按品牌聚合统计销量 |
_source | 整条 JSON | GET /products/_doc/P-1001 返回完整文档 |
| Stored Fields | description(store: true) | 只取描述字段,避免解压整个 _source |
再来对应几个真实查询,看看它们分别走了哪条路:
// ① 关键词搜索 → 倒排索引
GET /products/_search
{ "query": { "match": { "title": "机械键盘" } } }
// ② 价格范围 → BKD 树
GET /products/_search
{ "query": { "range": { "price": { "gte": 300, "lte": 500 } } } }
// ③ 聚合统计各品牌数量 → Doc Values
GET /products/_search
{ "aggs": { "by_brand": { "terms": { "field": "brand" } } } }
// ④ 多条件过滤 → Roaring Bitmap(filter 上下文结果集缓存为位图)
GET /products/_search
{ "query": { "bool": { "filter": [ { "term": { "on_sale": true } }, { "terms": { "tags": ["无线", "办公"] } } ] } } }
// ⑤ 只取描述字段 → Stored Fields(无需解压 _source)
GET /products/_doc/P-1001
{ "stored_fields": ["description"] }
补充一点:Roaring Bitmap 其实有两处用武之地——在倒排索引里它和 Frame of Reference 一起负责压缩 Posting List(高基数词项用 FOR,低基数/稀疏时切换为 Roaring Bitmap);在查询执行时,
filter上下文的匹配结果会被缓存成位图,多个 filter 之间用位图 AND/OR 快速求交集,复杂过滤条件秒出结果。
这样一条商品数据从写入到被各种查询命中,六种机制各司其职:倒排索引管"搜得准"、Roaring Bitmap 管"滤得快"、BKD 树管"范围快"、Doc Values 管"排得顺/聚得稳"、_source 与 Stored Fields 管"取得到"。
写入与查询流程
写入流程(Write Path)
- 客户端将文档请求路由到某个 主分片。
- 主分片先将操作写入 Translog(预写日志,保证崩溃恢复时不丢数据)。
- 文档被写入内存中的 Index Buffer,此时不可被搜索。
- 当 Index Buffer 满或达到
refresh_interval(默认 1s)时,生成一个新的 Segment 并写入文件系统缓存,此时文档变为可搜索(这就是"近实时"的含义)。 - Segment 会定期通过
flush落盘,并随时间合并成更大的段(Segment Merge)。
查询流程(Search Path)
- 查询请求到达协调节点(Coordinating Node)。
- 协调节点将请求广播到索引的所有分片(Query Phase)。
- 各分片在本地执行检索与打分,返回 Top N 结果。
- 协调节点对结果归并排序,再向相关分片取回完整文档(Fetch Phase)返回客户端。
搜索是"两阶段"过程:Query 阶段只汇总各分片的 top-N 文档 ID 与分数,Fetch 阶段才拉取完整数据,以最小化跨节点数据传输量。
为什么"近实时"(Near Real-Time)
ES 的默认 refresh_interval 是 1 秒,即文档写入后大约 1 秒才能被搜索到。这由两个设计决策决定:
- Segment 不可变:写入先进内存缓冲,定期批量生成新段,避免频繁的小型磁盘写入。
- 牺牲少许实时性换取吞吐:批量刷新比逐条落盘高效得多,适合日志、监控等高写入场景。
若需要更高的实时性(如搜索引擎内刚发布的文章),可以调小 refresh_interval;反之在批量导入数据时可临时调大甚至关闭刷新以提升写入速度。
分布式机制
分片与路由:一篇文档怎么落到分片
写入一条文档时,ES 的协调节点(Coordinating Node)先计算它属于哪个主分片:
- 路由公式:
shard = hash(_id) % num_primary_shards(默认对_id取哈希;也可通过routing参数指定自定义路由值,如?routing=user_42把同一用户的文档归到同一分片)。正因为取模依赖num_primary_shards,主分片数量在索引创建后不可修改——改了会导致所有路由结果错位(副本数量可随时调整)。 - DocID 与
_id是两回事:_id是用户指定的文档主键(如P-1001),用于路由和按 ID 查询;而倒排索引、.doc文件里的 DocID 是 Lucene 段内分配的整数编号——文档进入某个分片后,Lucene 在该分片的每个 Segment 内从0开始递增编号(Doc 0、Doc 1、Doc 2…)。同一篇文档在不同 Segment、不同分片里的 DocID 没有全局关系,所以用_id定位文档、用段内 DocID 做检索定位,各司其职。 - 查询是广播到所有分片:因为文档被
hash(_id)均匀打散到各分片,查询无法预知目标在哪个分片,只能广播到全部分片(Query Phase),各分片返回 Top N 后归并排序(Fetch Phase)。
数据热点与倾斜
分片级倾斜才是真正需要警惕的问题,分两种情况:
- 写入倾斜:强制
routing路由时,若路由 key 分布不均(如按user_id路由而某大客户集中),数据会压到少数分片。 - 查询热点:由于查询广播到全部分片,热点词(如"无线"被大量命中)不造成分片倾斜,只增加该词的 CPU / IO 开销。
常见的应对手段:
| 手段 | 适用场景 |
|---|---|
| 合理设置主分片数 | 创建索引时按数据量预估(一般每分片 20–50GB),主分片数不可改 |
慎用自定义 routing | 路由 key 要足够分散;可用 routing_partition_size 把同一路由值摊到多个分片 |
| 滚动索引 / ILM | 时间序列数据按天 / 月滚动索引(Index Lifecycle Management),避免单索引无限膨胀——这是最大的写入热点来源 |
| 增加副本分散读负载 | 读热点为主时,副本越多读吞吐越高 |
forcemerge | 只读索引合并为少数大段,减少查询扫描的段数 |
| 分片 Rebalance | ES 自动在节点间均衡分配分片,节点加入 / 移除时触发 |
- 高可用:主分片与其副本不能位于同一节点,节点故障时副本自动提升为主分片。
- 集群发现:通过 Zen Discovery(旧版)或基于投票的选主机制维护集群状态与节点故障感知。
- 水平扩展:增加节点即可自动分配分片,容量与吞吐随节点线性扩展。
选型与适用场景
适合使用 ES 的场景
- 全文检索:站内搜索、电商商品检索、文档检索。
- 日志与指标分析:ELK 栈(Elasticsearch + Logstash + Kibana)是日志分析的事实标准。
- 聚合分析:对大规模数据进行分组、统计、求最值等聚合查询。
- 模糊匹配与相关性排序:需要打分、同义词、拼音搜索的场景。
不适合 / 需要权衡的场景
- 强事务与强一致性:ES 是最终一致模型,不适合作为核心业务数据库。
- 复杂联表查询:ES 的 Join / Nested 能力有限,适合"宽表"扁平化设计。
- 精确的账务类存储:涉及金额、账目的系统仍需关系型数据库兜底。
典型应用场景:订单查询
电商平台的订单系统是 ES 的经典落地场景之一。订单数据量大、查询维度多(用户、店铺、状态、时间区间)、且需要复杂的组合过滤与排序,直接用 MySQL 很难扛住。
摘要数据与详情数据分离
订单往往包含两部分差异巨大的数据:
- 摘要数据(摘要表 / 列表查询用):订单号、用户 ID、店铺、金额、状态、下单时间等少量核心字段。
- 详情数据(详情表 / 详情页用):收货地址、商品明细、优惠信息、发票等体积大、访问频次低的字段。
不要在 ES 里存全部字段。 通常只在 ES 中建立"订单索引",存放摘要字段供列表检索、过滤、排序;详情页查询时用订单号回源 MySQL 获取完整详情。
这样做的好处:
- 索引体积小:倒排索引与 Doc Values 只针对摘要字段建立,磁盘与内存占用大幅降低,检索更快。
- 写入快:同步到 ES 的文档很小,对 MySQL 主库压力可控。
- 可靠性兜底:ES 只是检索索引而非唯一数据源,即使 ES 数据异常,详情仍可从 MySQL 恢复重建。
冷热数据分离
订单随时间推移,访问热度急剧下降:
- 热数据:近期订单(如 3~6 个月内),查询频率极高,要求毫秒级响应。
- 冷数据:历史订单,访问极少,但仍需支持"翻历史订单"等低频查询。
常用手段:
- 索引按时间分桶(Index per Time Period):如按月或按季度建索引(
orders-2025-01、orders-2025-02),配合**索引别名(Alias)**对外提供统一访问入口。 - ILM(Index Lifecycle Management):自动按策略将热索引保留在 SSD 节点上,到期后迁移到冷节点(
warm/cold),或自动关闭、删除,实现存储成本的自动优化。 - 冷热节点分离:热节点用高性能 SSD、更多内存;冷节点用大容量机械盘,单副本降低成本。
典型应用场景:搜索与推荐(召回与精排)
搜索推荐系统通常遵循一个漏斗:召回(Recall)→ 粗排(Coarse Ranking)→ 精排(Fine Ranking)→ 重排。越靠前,候选集越大、对延迟要求越严苛。
- 大召(召回):从全量候选(百万 ~ 亿级)中快速捞出可能相关的 Top 几千 / 几万条,是决定"天花板"的一环。
- 精排(小召/排序):只对召回结果(几千条量级)用更复杂的模型精细打分,再取 Top N 展示。
ES 最擅长、也最常被用在召回阶段:
- 倒排索引天然适合"大召":在亿级文档中,用关键词匹配、过滤条件毫秒级捞出候选集,这是 ES 的核心强项。
- 复杂的查询 DSL:
match、bool、filter、terms、range、function_score等组合,可快速实现多条件召回与初筛。 - 粗排也能用:对召回结果用
function_score结合业务分(销量、热度、新鲜度)做粗排序,再交给精排模型(如深度学习排序)做二次打分。
典型分工:
| 阶段 | 候选量级 | 主要载体 |
|---|---|---|
| 召回(大召) | 百万 ~ 亿级 | Elasticsearch(倒排索引) |
| 粗排 | 万级 | ES function_score / 轻量模型 |
| 精排 | 千级 | 深度模型 / LTR 排序模型 |
| 重排 | 百级以内 | 业务规则 / 多样性打散 |
核心思路:用 ES 解决"把候选捞出来"的海量检索问题,把"谁排在前面"的精细打分交给上层模型,各司其职。
小结
- ES 以倒排索引为核心,解决了传统数据库难以处理的全文检索与相关性排序问题。
- 通过分片 + 副本实现分布式水平扩展与高可用。
- 以"近实时“换取写入吞吐,通过 Translog、Segment、Refresh 等机制平衡性能与可靠性。
- 理解 Index / Document / Mapping / Segment / Translog 等核心概念,是深入 ES 内部机制的第一步。