Elasticsearch 核心概念深度解析:从倒排索引到字段存储
涵盖:倒排索引、正排索引、Doc Values、Fielddata、
_source、store、null_value
前言
在使用 Elasticsearch 的过程中,很多开发者会遇到一些看似矛盾的设计:为什么 text 字段不能排序?为什么明明存了数据却搜不到 null?_source 和 store 到底什么关系?
本文将通过问答式 的讲解,结合具体数据示例,帮你彻底搞懂 ES 核心存储与索引机制。
一、倒排索引:搜索的基石
1.1 什么是倒排索引?
倒排索引是 ES 搜索快的根本原因。它的本质是:词项 → 文档ID 的映射表。
传统方式(正排):文档 → 词
文档1: "elasticsearch入门" → 包含词["elasticsearch","入门"]
文档2: "java入门" → 包含词["java","入门"]
要查"入门",需要扫描所有文档 → O(n)
倒排索引方式:词 → 文档
"elasticsearch" → [文档1]
"入门" → [文档1, 文档2]
"java" → [文档2]
要查"入门",直接取列表 → O(1)
1.2 text 字段的倒排索引是怎么做的?
关键点:入库时就生成,不是查询时才做!
写入文档时(indexing time):
原始文本:"elasticsearch 入门教程"
↓ 分词
词项列表:["elasticsearch", "入门", "教程"]
↓ 建索引
倒排索引:
"elasticsearch" → [文档1]
"入门" → [文档1]
"教程" → [文档1]
查询时(searching time):
查询词:"入门"
↓ 分词
词项:["入门"]
↓ 查索引
匹配到文档1 ✅
text vs keyword 倒排索引对比:
| 维度 | keyword | text |
|---|---|---|
| 索引时的输入 | 整个字符串 | 分词后的词项列表 |
| 词项数量 | 少(≈文档数) | 多(文档数×平均词长) |
| 存储位置信息 | 不需要 | 需要(支持短语查询) |
二、正排索引(Doc Values):排序/聚合的支撑
2.1 什么是正排索引?
正排索引是文档ID → 字段值的映射,用于排序和聚合。
具体长什么样?
数值字段(price):
文档ID: 1 2 3 4
price: [2999, 5999, 3999, 1000]
存储方式:一个连续数组,下标即文档ID,不需要单独存ID列。
字符串字段(status):
第1步:建立字典
0 → "published"
1 → "draft"
第2步:按文档ID顺序存编码
文档ID: 1 2 3
编码: [0, 1, 0]
第3步:字典也存下来
2.2 正排索引怎么和文档ID关联?
数组下标就是文档ID(每个Segment内部独立编号):
数组下标: 0 1 2
┌──────┬──────┬──────┐
price: │ 2999 │ 5999 │ 3999 │
└──────┴──────┴──────┘
↑ ↑ ↑
文档ID: 1 2 3
用户的 _id(如 "user_123")通过另一个倒排索引映射成这个下标。
2.3 排序时正排索引会变吗?
不会! 排序只是产生一个排好序的文档ID列表。
原始正排索引(不动):
文档ID: 1 2 3 4
price: 2999 5999 3999 1000
排序结果(新列表):
位置: 第1名 第2名 第3名 第4名
文档ID: 4 1 3 2
正排索引从头到尾没变过,只是多了一个排序后的ID数组。
2.4 Doc Values vs Fielddata
| 维度 | Doc Values | Fielddata |
|---|---|---|
| 构建时机 | 索引时(写入时建好) | 查询时(第一次用时建) |
| 存储位置 | 磁盘 | JVM堆内存 |
| 内存占用 | 低(OS管理) | 高(容易OOM) |
| 适用场景 | 生产环境推荐 | 禁用,仅调试 |
Fielddata 内存中的样子:
文档ID 0 → ["elasticsearch", "入门", "教程"]
文档ID 1 → ["java", "入门", "精通"]
文档ID 2 → ["elasticsearch", "高级", "实战"]
2.5 为什么对 text 排序/聚合没有意义?
text 是一个整体一句话,对它排序没有业务含义:
- 按第一个词的首字母?不直观
- 按字符串字典序?"入门教程"和"实战"谁大谁小?
正确做法:需要排序/聚合的字段用 keyword,或用 multi-field:
json
{
"title": {
"type": "text",
"fields": {
"raw": { "type": "keyword" }
}
}
}
// 搜索用 title,排序用 title.raw
三、_source:原始文档存储
3.1 _source 是什么?
_source 存储你写入的原始 JSON 文档。
json
PUT my_index/_doc/1
{
"name": "张三",
"age": 25
}
// ES 实际存储
{
"_id": "1",
"_source": { // 👈 这就是 _source
"name": "张三",
"age": 25
}
}
3.2 _source 的作用
| 作用 | 说明 |
|---|---|
| 返回原始文档 | 查询时,_source 里的内容就是你要的数据 |
| 重新索引(Reindex) | 从一个索引复制到另一个索引 |
| 更新(Update) | 部分更新需要读取旧值 |
| 高亮(Highlight) | 需要从 _source 截取片段 |
3.3 能禁用 _source 吗?
可以,但后果严重:
json
PUT my_index
{
"mappings": {
"_source": { "enabled": false }
}
}
禁用后:查询返回空、无法 reindex、无法 update、无法高亮。
唯一禁用的理由 :节省磁盘空间(约15-30%),但生产环境几乎永远不要禁用。
3.4 安全的最佳实践:查询时过滤
与其禁用 _source,不如在查询时控制返回内容:
json
// 只返回 name 和 age
GET my_index/_search
{
"_source": ["name", "age"],
"query": { "match_all": {} }
}
// 排除敏感字段
GET my_index/_search
{
"_source": {
"excludes": ["email", "phone"]
}
}
// 完全不返回 _source
GET my_index/_search
{
"_source": false
}
四、store:字段级单独存储
4.1 store 是什么?
store 是字段级别的属性,决定该字段是否单独存储一份 ,独立于 _source。
json
PUT my_index
{
"mappings": {
"properties": {
"title": {
"type": "text",
"store": true // 单独存储
}
}
}
}
4.2 store vs _source 的关系
_source |
store: true 字段 |
|---|---|
| 存整个文档 | 存单个字段 |
| 默认开启 | 默认关闭 |
| 查询返回整个文档 | 查询只返回该字段 |
| 占用一份空间 | 额外占用空间 |
4.3 什么时候需要 store: true?
场景1:禁用了 _source,但又想返回某些字段
json
PUT my_index
{
"mappings": {
"_source": { "enabled": false },
"properties": {
"user_id": { "type": "keyword", "store": true }
}
}
}
GET my_index/_search
{
"stored_fields": ["user_id"]
}
场景2:大文档只取小字段
文档很大,但只需要返回 title 和 author,开启 store 可以避免读取整个 _source。
4.4 总结
| 问题 | 答案 |
|---|---|
| 默认值 | false |
| 什么时候用 | 禁用 _source 时;或大文档只取小字段 |
| 怎么查询 | stored_fields 参数 |
| 最佳实践 | 99% 场景不需要,默认用 _source 过滤就够了 |
五、null_value:让 null 可以被搜索
5.1 问题背景
ES 默认行为:字段值为 null 时,不建立索引。
json
PUT my_index/_doc/1
{ "status": null } // status 不会被索引
PUT my_index/_doc/2
{ "status": "active" }
倒排索引中只有 "active" → [2],没有文档1。
后果:无法搜索"status 为 null"的文档。
5.2 解决方案:null_value
设置 null_value,把 null 替换成占位值进行索引。
json
PUT my_index
{
"mappings": {
"properties": {
"status": {
"type": "keyword",
"null_value": "NULL" // 👈 遇到 null 就存成 "NULL"
}
}
}
}
写入后,倒排索引:
"NULL" → [文档1]
"active" → [文档2]
现在可以搜索了:
json
GET my_index/_search
{
"query": { "term": { "status": "NULL" } }
}
// 返回文档1 ✅
5.3 限制与注意事项
| 限制 | 说明 |
|---|---|
不改变 _source |
_source 里存的还是 null |
| 类型必须匹配 | 数值字段用数值占位,不能用字符串 |
| 占位值可能冲突 | 建议用 __NULL__ 等特殊值 |
| 替代方案 | must_not + exists 查询空值(不需要 null_value) |
json
// 不设 null_value 也能查空值
GET my_index/_search
{
"query": {
"bool": {
"must_not": [{ "exists": { "field": "status" } }]
}
}
}
5.4 使用场景
场景:聚合中包含 null
json
PUT orders
{
"mappings": {
"properties": {
"status": {
"type": "keyword",
"null_value": "__NULL__"
}
}
}
}
GET orders/_search
{
"aggs": {
"by_status": {
"terms": { "field": "status" }
}
}
}
// 返回分组包括:__NULL__、active、pending、completed
六、概念对比总表
| 概念 | 存什么 | 用途 | 默认 | 查询方式 |
|---|---|---|---|---|
| 倒排索引 | 词项 → 文档ID | 全文搜索 | ✅ 自动 | match / term |
| Doc Values | 文档ID → 字段值 | 排序、聚合 | keyword/数值 ✅ | 自动使用 |
| Fielddata | 文档ID → 词项列表 | text排序/聚合 | ❌ 禁用 | 需手动开启 |
_source |
原始 JSON | 返回文档、reindex | ✅ 开启 | 自动返回 |
store |
单个字段值 | 绕过 _source 取字段 | ❌ 关闭 | stored_fields |
null_value |
null 替换成占位值 | 让 null 可搜索 | ❌ 不设置 | term 查询 |
七、生产环境最佳实践
- 倒排索引:放心用,ES 自动管理
- 排序/聚合:用 keyword 字段,不要对 text 排序
- Fielddata :永远不要在生产环境开启
_source:保持开启,查询时用过滤控制返回内容store:99% 场景不需要,禁用_source时才考虑null_value:需要把 null 作为聚合分组时使用;仅查空值用exists查询
八、常见问题速查
| 问题 | 答案 |
|---|---|
| text 字段为什么不能排序? | 对一句话排序没有业务意义 |
| 怎么让 text 字段能排序? | 用 multi-field 加 keyword 子字段 |
| Fielddata 能开吗? | 生产环境不要开,会 OOM |
_source 能禁吗? |
几乎不要,除非你接受无法 reindex/update |
store 和 _source 什么关系? |
store 是单独存一份,_source 是存整个文档 |
| null 为什么搜不到? | 默认不索引 null,用 null_value 解决 |
| 怎么查 null 值? | must_not + exists 或设置 null_value |
本文基于 Elasticsearch 7.x/8.x 版本编写