十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Elasticsearch存储结构解析:倒排索引、行存与列存如何协同

Elasticsearch存储结构解析:倒排索引、行存与列存如何协同 先说一个我常被问到的问题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 倒排索引以词条为单位彻底改变查找方向倒排索引和前面两种不在一个维度上。行存储和列存储回答的问题是数据在物理上怎么排列倒排索引回答的问题是怎么快速从词条找到包含它的文档。举个例子。假设有三篇文档文档1Elasticsearch 入门教程文档2Elasticsearch 实战笔记文档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 列表的同时就能完成因为所有需要的数据都跟着索引结构一起存好了。多条件组合也是倒排索引的强项。比如电商搜索华为手机 银色 128GES 会把查询拆成多个词条分别从倒排索引里取出各自对应的文档 ID 集合然后做集合的交、并、差运算。配合跳表和位图这些运算在毫秒级就能完成。如果用行存储实现同样的逻辑要么写数十行复杂的 SQL 加多个LIKE条件要么在应用层取回大量数据再内存过滤效率和 ES 完全不在一个数量级。列存储虽然能做部分条件过滤但要做词条级别的相关度排序时同样拿不出词频、词位置这类信息。提示这也是为什么ES 慢的排查方向里最常见的一句话是查询没走倒排索引。一旦查询条件里出现wildcard、regexp、script这些无法利用词条字典的操作ES 就不得不退化为扫描性能立刻打回原形。后面我会专门讲这个坑。为了更直观我把三种存储模型的核心差异整理成一张表维度行存储列存储倒排索引核心结构B树 / 堆表列文件 / 稀疏索引词条字典 文档ID列表代表系统MySQL InnoDBClickHouse、ParquetElasticsearch、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高几个数量级。我把常见的看似在搜索、实则不走倒排索引的操作列了一下查询类型是否走倒排索引性能表现替代方案matchtext字段是快无termkeyword/数值字段是快无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 的问题其实有一套很成熟的排查顺序。我把经常遇到的按概率排序端口被占用默认占用 9200HTTP和 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 调优先盯着查询计划看我有没有真的走倒排索引再谈别的。这些细节在官方文档里都会写但只有自己踩过一遍才会真正记住。
返回列表