先说一个我常被问到的问题:Elasticsearch 底层到底是怎么存数据的?为什么放着成熟的行存储不用,也不去学列存储,非要自己搞一套倒排索引?
这个问题的背景很常见。后端开发但凡接触过 MySQL,都知道 InnoDB 的 B+ 树叶子节点把一行行数据串起来,这叫行存储;后来大数据火了,ClickHouse、Parquet 这类列式存储又成了数据分析的标配。到了 Elasticsearch 这儿,情况似乎变得"拧巴"了——它既不像 MySQL 那样一行一行存,也不像 ClickHouse 那样一列一列存,而是把每个字段拆成词条,再反过来建立"词条 → 文档ID"的映射。
这篇文章我打算从存储结构的设计逻辑讲起,把行存储、列存储、倒排索引三者放在同一个坐标系里对比,再把倒排索引为什么在搜索场景下"不可替代"这件事拆开讲透。适合正在学 ES 的后端开发、数据工程师,以及所有被"ES 为什么快""ES 为什么不用 B+ 树"这类问题困扰过的人。看完你会明白,ES 不是不会用行存和列存,而是在不同场景下把三种存储思维都用了进去,只是倒排索引才是它真正的灵魂。
1. 三种存储模型的核心差异:先搞懂它们到底在"存什么"
1.1 行存储:以"记录"为单位,服务事务型查询
行存储这个词大家最不陌生。MySQL 的 InnoDB、PostgreSQL,底层都是典型的行式存储。所谓行存储,就是物理上把一条记录的多个字段连续地存放在一起,磁盘上先写完 ID,紧接着写完 title,再写完 price,这一行才算结束。
这种布局天然为"取整行"设计。比如执行SELECT * FROM products WHERE id = 123,数据库只要定位到这条记录在 B+ 树叶子节点的位置,一次 I/O 就能把整行数据全部读出来。这也正是 OLTP 场景最需要的能力:高频、小事务、按主键或索引点查、频繁更新。
但行存储有个明显的软肋:当数据量上去以后,如果要按某个非索引字段做统计,就必须把整张表都扫一遍。MySQL 里一个SELECT COUNT(*) FROM orders WHERE status = 'PAID'如果没命中索引,可能要把所有行都读进内存,虽然 InnoDB 有 buffer pool 兜底,但磁盘 I/O 仍然逃不掉。本质上是因为行存储"把不相干的字段捆在一起",而统计查询通常只需要一两个字段,大量无关数据被白白读了出来。
1.2 列存储:以"字段"为单位,牺牲点查换聚合效率
列存储的思路完全反过来。它不按记录组织数据,而是把一个字段的所有值连续放在一起。也就是说,id列的数据是一个独立文件,title列是另一个独立文件,price列又是一个独立文件。
这种做法的好处在分析型查询里非常明显。ClickHouse 跑一个SELECT category, AVG(price) FROM products GROUP BY category,它只需要读取category和price两列的数据,其他字段碰都不碰。加上列存天然适合压缩——同一列的数据类型一致、取值范围相近,压缩率能做到行存的 5 到 10 倍甚至更高——所以 OLAP 场景几乎被列存储统治。
代价也很直白:你要想根据某个条件把整行数据拼出来,就得去各个列文件里分别查找再组装。对于SELECT * FROM products WHERE id = 123这种点查需求,列存反而可能比行存更慢,因为每列都要一次随机读取再合并。这也是为什么 ClickHouse 这类系统在设计上就更偏向聚合分析,而不是高频点查。
1.3 倒排索引:以"词条"为单位,彻底改变查找方向
倒排索引和前面两种不在一个维度上。行存储和列存储回答的问题是"数据在物理上怎么排列",倒排索引回答的问题是"怎么快速从词条找到包含它的文档"。
举个例子。假设有三篇文档:
- 文档1:Elasticsearch 入门教程
- 文档2:Elasticsearch 实战笔记
- 文档3:数据库索引原理
对文本做分词后,倒排索引大致长这样:
| 词条 | 文档ID列表 |
|---|---|
| elasticsearch | [1, 2] |
| 入门 | [1] |
| 教程 | [1] |
| 实战 | [2] |
| 笔记 | [2] |
| 数据库 | [3] |
| 索引 | [3] |
当你搜索"Elasticsearch"时,系统根本不需要把三篇文档全文扫描一遍,只需在词条字典里找到"elasticsearch"这个词,然后拿出文档列表[1, 2],两步就完成了匹配。
这就是倒排索引和行存储、列存储的本质区别:行存储关心记录完整性,列存储关心字段聚合效率,倒排索引只关心"从词到文档的定位速度"。它的存在不是为了替代行存或列存,而是为了解决一个前两者都很难高效处理的问题——全文检索。
2. 为什么 ES 不直接采用行存储:场景根本不匹配
2.1 ES 的定位不是事务处理,而是"搜索 + 分析"
很多刚开始学 ES 的人会拿它跟 MySQL 对比,这其实是个误区。MySQL 解决的核心问题是"数据的一致性读写",它的设计目标围绕 ACID 展开,要让千万级用户同时在线上做增删改查不出乱子。
ES 解决的却是另一类问题:在大量非结构化或半结构化数据中快速找到"相关"的内容。比如电商平台搜"华为手机",用户期望的是把标题、描述、品牌属性里包含相关词的商品快速捞出来,还要按相关度排序。这种查询往往还带着分词、同义词、模糊匹配、多字段组合等复杂条件,用 SQL 的LIKE '%华为手机%'去怼,数据量稍微一大就完了。
我用 MySQL 做过一个实验:一张 500 万行的商品表,字段包括标题、描述、品牌、类目,用LIKE '%手机%'搜索标题,典型的全表扫描,耗时在 1 秒以上;同样的数据量导入 ES,标准 match 查询,返回结果在 10 毫秒以内。这个量级的差距不是靠加索引或调参能弥补的,根子上就是存储结构不同。
2.2 行存储在全文检索场景的三个致命伤
第一个致命伤是全表扫描逃不掉。行存储按记录组织数据,当查询条件无法命中索引时,数据库只能把整行整行的数据读出来逐一匹配。MySQL 的 B+ 树索引对等值查询、范围查询很友好,但遇到"字段某个位置包含某个词"这种条件,普通索引直接失效,只能用LIKE '%xx%',前缀未知,索引树也无从查起。
第二个致命伤是相关性排序做不了。行存储的查询结果要么按主键排序,要么按指定字段排序,它没有办法回答"哪条结果跟搜索词更相关"这个问题。搜索引擎里常见的 TF-IDF、BM25 打分机制,在行存模型里根本没地方落——因为要计算相关度,必须先知道词在文档里出现了几次、出现在什么位置、占整篇文档多大比重,这些信息在"一行记录"里是无从谈起的。
第三个致命伤是模糊匹配的性能。即使不考虑相关度,行存储也很难支撑"用户输入关键词实时联想""拼写纠错"这类交互式搜索需求。这些能力需要倒排索引配合专门的数据结构才能做到。
2.3 一个直观的对比:MySQL 和 ES 处理搜索请求的路径差异
MySQL 处理WHERE title LIKE '%手机%'时,执行计划往往是全表扫描,逐行读取 title 字段,做字符串匹配,匹配通过再返回整行。数据量从 100 万涨到 1000 万,耗时基本线性增长。
ES 处理同样的需求时,分词器先把"华为手机"拆成"华为"和"手机"两个词条,倒排索引直接定位包含这两个词的文档 ID 集合,再用 BM25 算法对每个文档打分,按分数排序返回。这个过程扫描的文档数量通常远小于全表,而且索引结构本身支持高效的合并运算,所以数据量翻倍,查询时间往往只增加一点点。
这就是为什么 ES 宁可维护一个复杂的倒排索引,也不愿意用看似"成熟稳定"的行存储——在搜索这个赛道上,行存储的短板是结构性的,不是靠加机器就能解决的。
3. 为什么 ES 也不选列存储:分析虽强,却丢了"定位"能力
3.1 列存储的优势 ES 心里门儿清:doc_values 就是列存
如果你以为 ES 完全不用列存储,那就错了。ES 里的doc_values本质上就是一种列式存储结构。
在 ES 中,每个字段除了可以建倒排索引,还可以开启doc_values(默认对大部分字段开启)。doc_values 会按列把字段值组织起来,专门服务于排序、聚合、脚本计算这些操作。比如你要统计所有商品的平均价格,ES 就会从 doc_values 里把 price 这一列连续读出来做计算,而不是去倒排索引一条一条翻。
这个设计和 ClickHouse 的列存储思路如出一辙:同一列的数据放一起,读得快,压缩率高。所以 ES 并不是不懂列存的优势,它只是把列存储放在了"辅助"的位置,而不是主存储结构。
3.2 列存储解决不了"从词到文档"的问题
列存储的问题在于,它擅长对已知范围内的数据做统计,但不擅长先找到"哪些文档满足条件"。
假设你要找出所有标题里包含"手机"的商品,列存储怎么处理?它只能把 title 这一列全部读出来,逐条判断是否包含"手机"。这本质上还是一个扫描过程,只是扫描的列变少了,并没有从根本上改变"线性遍历"的本质。
而倒排索引的查询路径是:词条字典查找 "手机" 这个词 → 拿出对应的文档 ID 列表 → 再去取这些文档的详情。扫描的是索引,不是数据本身。在千万级文档的场景下,这两种路径的性能差距可以达到几个数量级。
打个比方。行存储是图书馆里按书柜整理的藏书,你想找某类主题的书,得一本本翻;列存储是把所有书的目录页拆下来装订在一起,你想统计数据确实方便,但想找"哪些书提到了某个词"还是得从目录里翻;倒排索引则是一本按关键词编排的索引手册,输入关键词,直接看到页码列表,效率完全不在一个级别。
3.3 搜索系统的标准架构:倒排索引负责检索,列存负责聚合
实际上,Elasticsearch 采用的是一种非常务实的混合策略:
- 倒排索引负责"定位文档":告诉系统哪些文档 ID 匹配查询条件;
doc_values负责"处理字段":对命中的文档做排序、聚合、计算;_source负责"还原原貌":存储完整的原始 JSON 文档,用于查询结果的展示。
这个架构在现代搜索引擎里几乎是标配。比如电商场景下,用户搜索"手机"后,系统先用倒排索引找出所有包含这个词的商品 ID,然后用 doc_values 对结果按价格排序,再按品牌或类目聚合出筛选面板,最后从_source里取出商品标题、图片、价格等展示信息。每一层都有对应的存储结构,各司其职。
所以"ES 不用列存储"这个说法本身是错的。更准确的说法是:ES 只用列存储解决分析问题,但不会用它来解决检索问题。检索是倒排索引的地盘。
4. 倒排索引的三个不可替代优势:拆开揉碎了讲
4.1 优势一:词条到文档的定位是常数级,不随数据量线性恶化
倒排索引最核心的优势是查找效率。ES 内部使用 FST(有限状态转换器)来维护词条字典,这是一种紧凑的、支持前缀查找的哈希表结构。查找一个词条的复杂度基本可以认为是 O(len(term)),和文档总量几乎没有关系。
拿到词条之后,还需要读取这个词对应的文档 ID 列表。这个列表可能很长,但 ES 不会傻乎乎地顺序读,它使用跳表(skip list)来加速查找,配合 Roaring Bitmap 对文档 ID 做压缩和集合运算。
我用一个具体的例子说明差距。假设索引里有 1 亿篇文档,搜索词"elasticsearch"命中了 10 万篇。用 MySQL 的LIKE查询,需要扫描最多 1 亿行;用 ES,找到词条后直接拿到 10 万个文档 ID,而且如果只需要前 10 条最相关的结果,甚至连完整的 10 万条都不用遍历完,算完打分直接取 top 10 就行。
这个特性决定了 ES 在数据量增长时,查询性能不会像关系型数据库那样快速衰减。很多团队从 MySQL 迁移到 ES 之后,最大的感受就是"数据量翻了好几倍,查询速度反而没怎么变",原因就在这里。
4.2 优势二:压缩率惊人,信息密度远超原始数据
很多人想象不到,倒排索引虽然多维护了一层映射关系,但它在磁盘上占用的空间往往比原始数据还小。原因在于几个关键的压缩手段:
- 差值增量编码:文档 ID 列表通常是有序的,ES 存储的不是完整的 ID,而是相邻 ID 的差值。比如文档 ID 是 [1, 5, 9, 17],存储时变成 [1, 4, 4, 8],这些差值本身更小,需要的比特数更少。
- Roaring Bitmap:对于文档 ID 分布比较稠密的场景,ES 会用位图的方式压缩,用一个二进制位代表一篇文档是否命中,存储密度极高。
- 词条去重:同一个词在一篇文档里可能出现多次,但在倒排索引里只记录一次,配合词频信息一起存储,天然去掉了大量重复数据。
这些压缩手段叠加起来效果非常可观。我维护过的一个日志索引,原始 JSON 每天约 50GB,迁移到 ES 后磁盘占用只有 15GB 左右,倒排索引加上 doc_values 的压缩功不可没。
相比之下,行存储很难做这么激进的压缩,因为一行里什么类型的数据都有,热门的字符串字段和冷门的数值字段被迫放在一起,压缩算法无从下手。列存储的压缩率虽然也很高,但它在压缩时更注重通用性,不像倒排索引有专门的文档 ID 编码技巧。
4.3 优势三:天然支持相关性打分与多条件布尔组合
第三个优势往往是最被低估的。倒排索引在记录"哪些文档包含这个词"的同时,还记录了词频、词在文档中出现的位置等信息。这些元信息是相关性排序的基础。
ES 默认的 BM25 算法就是基于这些数据来计算分数的。它综合考虑了词频(一个词在文档里出现多少次)、逆文档频率(一个词在整个索引里有多稀有)、字段长度归一化(一篇文档越长,出现某个词的"价值"越低)三个因素。这些计算在得到文档 ID 列表的同时就能完成,因为所有需要的数据都跟着索引结构一起存好了。
多条件组合也是倒排索引的强项。比如电商搜索"华为手机 银色 128G",ES 会把查询拆成多个词条,分别从倒排索引里取出各自对应的文档 ID 集合,然后做集合的交、并、差运算。配合跳表和位图,这些运算在毫秒级就能完成。
如果用行存储实现同样的逻辑,要么写数十行复杂的 SQL 加多个LIKE条件,要么在应用层取回大量数据再内存过滤,效率和 ES 完全不在一个数量级。列存储虽然能做部分条件过滤,但要做"词条级别"的相关度排序时,同样拿不出词频、词位置这类信息。
提示:这也是为什么"ES 慢"的排查方向里,最常见的一句话是"查询没走倒排索引"。一旦查询条件里出现
wildcard、regexp、script这些无法利用词条字典的操作,ES 就不得不退化为扫描,性能立刻打回原形。后面我会专门讲这个坑。
为了更直观,我把三种存储模型的核心差异整理成一张表:
| 维度 | 行存储 | 列存储 | 倒排索引 |
|---|---|---|---|
| 核心结构 | B+树 / 堆表 | 列文件 / 稀疏索引 | 词条字典 + 文档ID列表 |
| 代表系统 | MySQL InnoDB | ClickHouse、Parquet | Elasticsearch、Lucene |
| 擅长场景 | 事务点查、频繁更新 | 聚合分析、数据仓库 | 全文检索、相关性排序 |
| 定位能力 | 主键/索引定位 | 按列扫描定位 | 词条到文档ID直接定位 |
| 排序能力 | 字段排序 | 字段排序 | 相关性打分排序 |
| 压缩效率 | 低 | 高 | 高(靠增量、位图) |
| 典型瓶颈 | 模糊查询全表扫描 | 条件检索仍需扫列 | 词条字典内存占用 |
5. 实操复盘:ES 里如何用好"三种存储"的组合拳
5.1 一个电商搜索的真实案例
我之前做过一个电商搜索服务,索引里有大约 8000 万个商品文档,每个文档的字段包括商品标题、描述、品牌、类目、价格、库存、上架时间、销量等。刚开始搭建的时候,团队里有人提议直接按 MySQL 的思路设计索引——把商品所有字段都放进 ES,然后搜索走倒排索引。
这个思路说起来简单,但真正跑起来后发现了很多问题。首当其冲的是存储成本:如果把每个字段的倒排索引都建立起来,磁盘占用会非常高。后来我们对照查询场景做了字段级的精细规划:
- 标题、品牌、类目:这三个字段参与搜索匹配,必须建倒排索引。标题用标准分词器,品牌和类目用
keyword类型,支持精确匹配。 - 价格、销量、上架时间:这三个字段不参与关键词搜索,但用于排序和筛选。它们不需要倒排索引,但一定要开启
doc_values,否则排序和聚合会非常慢。 - 商品详情、图片地址等长文本:只用于展示,不参与搜索,直接放在
_source里。这些字段彻底关闭倒排索引,节省大量存储。
这样调整之后,索引的磁盘占用下降了约 40%,查询平均延迟从调整前的 80 毫秒降到了 30 毫秒。核心逻辑就一句话:搜索走倒排索引,分析走列存,展示走原始文档,三者各管一摊。
5.2 Windows 环境跑 ES 的实操注意事项
既然是实践类话题,把 ES 在 Windows 上跑起来的环境问题也一并说下。很多人最开始学 ES 是在 Windows 上,踩的最多的坑是版本环境不匹配。
ES 是 Java 写的,和 JDK 版本强绑定。我在 Windows 上试过 9.x 版本搭配 JDK 17,同时也有同事在 8.x 版本下用 JDK 11,基本都能正常启动。但如果你用新版本的 ES 配老版本的 JDK,或者反过来用老 ES 配新 JDK,启动时大概率会报Unsupported major.minor version这类错误,这其实就是 class 文件版本和 JVM 版本对不上。
Windows 上启动 ES 的正确姿势,我总结下来就几条:
- 确认 JDK 版本:ES 9.x 对应 JDK 17 以上,建议直接用官方规定的最低版本,别高太多也别低太多。
- 下载对应版本的 ZIP 包后,解压到路径里没有中文、没有空格的目录,比如
D:\elasticsearch。 - 修改
config/elasticsearch.yml,把path.data和path.logs指向独立的目录,不要把数据放在解压目录里,避免后续升级时被覆盖。 - 打开
bin/elasticsearch.bat启动,看到started日志就说明成功了。 - 浏览器访问
http://localhost:9200,能看到集群信息 JSON 就是正常状态。
注意:Windows 上如果启用了系统代理,访问 localhost 有时会被代理拦截。如果启动成功后浏览器访问不了 9200 端口,先检查代理设置,把本地地址加入直连列表。
5.3 字段映射设计里的"反直觉"经验
还有一个容易被忽略的坑是 ES 的动态映射。ES 默认会自动识别字段类型并建立索引,但这个"自动"在真实场景里经常帮倒忙。
比如商品 ID 这个字段,如果值是纯数字,ES 会自动映射成long类型。这本身没问题,但如果你期望用商品 ID 做精确匹配和聚合,long类型也能用。麻烦的是另一类场景:某个字段动态映射成了text类型(因为首次写入时值是字符串),而text类型默认会被分词,导致你在做精确匹配时永远等不到预期结果。
我踩过一个特别典型的坑:一个订单号字段,因为某一天有脏数据混入了字母,ES 直接把字段映射从keyword动态改成了text。结果线上所有按订单号精确查询的接口全部失效,排查了半天才发现是 mapping 被动态修改了。
经验就一句:在正式环境使用前,把索引的 mapping 用显式定义写死,关掉或严格限制动态映射。用"dynamic": "strict"虽然有点激进,但对核心索引来说,宁可启动时报错也不要在线上被悄悄改 mapping。
6. 常见问题与排查技巧:倒排索引失效的几种典型情况
6.1 为什么我加了索引还是慢?多半是查询没走倒排索引
ES 里一个非常典型的误操作是使用wildcard查询。比如:
{ "query": { "wildcard": { "title": "*手机*" } } }这个查询的语义和 MySQL 的LIKE '%手机%'几乎一样,本质上是对title字段做遍历匹配。wildcard只有在通配符不出现在词条开头时才有机会利用倒排索引的字典结构,一旦以*开头,ES 就不得不扫描整个词条字典甚至整个字段的所有值,性能急剧下降。
如果确实要做"包含某词"的模糊搜索,正确做法是确保字段被正确分词,然后用match查询。match会将查询词也分词,然后走倒排索引查找每个词条,性能比wildcard高几个数量级。
我把常见的"看似在搜索、实则不走倒排索引"的操作列了一下:
| 查询类型 | 是否走倒排索引 | 性能表现 | 替代方案 |
|---|---|---|---|
match(text字段) | 是 | 快 | 无 |
term(keyword/数值字段) | 是 | 快 | 无 |
wildcard(以*开头) | 否 | 极慢 | match、ngram分词 |
regexp | 否 | 极慢 | 尽量避免 |
script过滤 | 否 | 极慢 | 把计算逻辑提前到写入时 |
exists大量使用 | 部分 | 中等 | 精简索引字段 |
6.2 聚合为什么慢?先检查 doc_values 是否开启
聚合查询是 ES 的一大强项,但前提是参与聚合的字段开启了doc_values。如果你在 mapping 里手动关闭了doc_values,聚合时 ES 会退化为从_source里取出所有文档再内存计算,速度会断崖式下跌。
我还见过一种更隐蔽的情况:字段被映射成text类型,而text类型默认不支持doc_values聚合。要对字符串字段做聚合,正确做法是同时定义一个keyword子字段。比如:
{ "mappings": { "properties": { "product_name": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } } }这样既可以用product_name做全文检索,又可以用product_name.keyword做聚合排序。很多新手在聚合时报错Fielddata is disabled on text fields by default,就是没建这个子字段。
6.3 启动常见问题速查:从 JVM 内存到端口占用
Windows 上跑 ES 的问题,其实有一套很成熟的排查顺序。我把经常遇到的按概率排序:
- 端口被占用:默认占用 9200(HTTP)和 9300(内部通信)两个端口。如果启动日志显示
Address already in use,优先查这两个端口被谁占了。Windows 下用netstat -ano | findstr 9200就能查到进程 PID,再去任务管理器里确认。 - JVM 内存不足:ES 默认堆内存是 1GB,如果你的索引数据量比较大,启动后很快会 OOM。Windows 上可以修改
config/jvm.options里的-Xms和-Xmx,建议设置为物理内存的一半左右,且两个值要一致,防止运行时堆大小动态调整带来的性能损耗。 - 文件句柄或线程数限制:Linux 上有这个问题,Windows 上相对少见,但如果你跑的是 Docker Desktop 里的 ES,也可能会碰到。
- Max virtual memory areas 错误:Linux 环境常见,Windows 上一般不用管。
每次排查完我都会同步一个经验:别一上来就怀疑 ES 本身有问题,99% 的启动失败都能通过看日志找到答案。启动日志会明确告诉你哪个环节出错,比到处搜索求助要高效得多。
6.4 实战排错的一个完整案例
举个例子。有次我在 Windows 上帮同事启动 ES 9.5.3,启动日志一直报failed to obtain node locks。这个错误看上去像权限问题,其实真正原因是上次 ES 进程没有正常退出,数据目录里的锁文件没被释放。
解决方法是先确认 9200 端口没有进程占用,然后删掉数据目录下的node.lock文件,重新启动就好了。需要注意的是,如果当时 ES 服务还活着,强行删锁文件可能导致数据损坏,所以顺序一定是先检查进程、确认停止、再删锁、再启动。
还有一次,ES 启动正常但GET _cluster/health返回yellow。这个状态说明主分片都到位了,但部分副本分片没法分配。最常见的修复方式是检查节点数是否足够——单节点集群没有地方放副本分片,把副本数改成 0 或再启动一个节点就能恢复。这个状态在开发环境其实不影响读写,但很多新手看到yellow就慌了,其实没必要。
7. 写在最后:对存储选型的一点个人体会
做搜索相关的项目到现在,我最大的感受是:选存储结构不能只看"哪种技术更先进",要看你的核心查询模式到底是什么。行存储适合事务,列存储适合分析,倒排索引适合检索,没有谁比谁绝对高级,只有合不合适。
Elasticsearch 之所以在搜索领域站稳脚跟,不是因为它发明了某种神奇的数据结构,而是因为它把倒排索引、列存储、原始文档存储结合到了一起,让每个环节都用最合适的工具。理解了这一点,你在设计索引 mapping 的时候就会更从容——知道哪些字段该建索引,哪些字段该开 doc_values,哪些字段该关掉倒排索引,而不是一股脑全塞进去。
踩过几次wildcard查询的坑、经历过 mapping 被动态修改的教训之后,我已经养成了一个习惯:任何一次 ES 调优,先盯着查询计划看我有没有真的走倒排索引,再谈别的。这些细节在官方文档里都会写,但只有自己踩过一遍,才会真正记住。