Elasticsearch与Lucene核心机制详解

11 阅读6分钟

Elasticsearch与Lucene核心机制详解

一、基础概念:ES与Lucene的分工

1.1 核心比喻

  • Elasticsearch = 图书管理员 + 图书馆管理系统
  • Lucene = 图书索引卡片柜(最核心的检索技术)

1.2 具体职责分工

功能Lucene负责Elasticsearch负责
索引存储单个索引文件格式分布式分片管理
分词分词算法实现分词器配置管理
相关性TF-IDF/BM25计算查询语句解析
存储字段原始值存储集群数据分布
查询单个分片内检索分布式查询协调
事务单个索引段变更集群级事务一致性
APIRESTful API、客户端SDK

1.3 关键理解点

  1. Lucene是库,ES是服务

    • Lucene:Java库,需要编程调用
    • ES:开箱即用的搜索服务
  2. Lucene不懂分布式

    • Lucene只处理单个索引(分片)
    • ES负责把数据分布到多个Lucene索引上
  3. ES是Lucene的"集群管理器"

    一个ES节点 = Lucene实例 + 集群协调模块
    整个ES集群 = 多个Lucene实例 + 分布式协调系统
    

二、Lucene倒排索引详细结构

2.1 简化表示 vs 真实结构

简化表示(便于理解):

词项: "小米" -> 位置: [name字段, tags字段]

真实的多层结构:

Level 1: 词项字典(Term Dictionary)
├── "小米" → 指向倒排列表A
├── "手机" → 指向倒排列表B
├── "2999" → 指向倒排列表C
└── ... (按字典序排序,使用FST压缩存储)

Level 2: 倒排列表(Posting List)
倒排列表A(对应词项"小米"):
┌───────────────────────────────────────┐
│ Header:                               │
│   • DocFreq = 2 (文档频率)            │
│   • TotalTermFreq = 3 (总词频)        │
├───────────────────────────────────────┤
│ DocId List (使用FOR编码压缩):         │
│   文档1 → [位置信息指针]               │
│   文档4 → [位置信息指针]               │
├───────────────────────────────────────┤
│ 位置信息 (存储在每个文档项中):         │
│   文档1:                              │
│     • 字段: name                      │
│       - 位置: 0 (在字段中的偏移)       │
│       - 起始字符: 0                   │
│       - 结束字符: 2                   │
│     • 字段: brand                     │
│       - 位置: 0                       │
│       - 起始字符: 0                   │
│       - 结束字符: 2                   │
└───────────────────────────────────────┘

2.2 倒排索引的核心组件

  1. 词项字典(Term Dictionary)

    • 所有词项的排序列表
    • 使用FST(有限状态转换器)压缩存储
    • 支持前缀查询和模糊查询
  2. 倒排列表(Posting List)

    • 存储包含该词项的文档列表
    • 包含:文档ID、词频、位置信息
    • 使用FOR(Frame Of Reference)编码压缩
  3. 位置信息(Positions)

    • 词项在文档中的具体位置
    • 包括:字段名、偏移量、起止字符
    • 用于短语查询和高亮显示

三、索引段(Segment)机制

3.1 什么是段?

段是Lucene索引的最小独立单元,每个段都是一个完整的、不可变的倒排索引

3.2 段的产生过程

graph TB
    A[文档写入] --> B[内存Buffer]
    B --> C{达到阈值?}
    C --否--> D[等待Refresh]
    C --是--> E[创建新段]
    
    E --> F[Segment_1]
    F --> G[Refresh后<br>段可搜索]
    
    G --> H{后台Merge触发?}
    H --是--> I[选择小段合并]
    I --> J[创建更大段]
    J --> K[删除旧段]
    
    style F fill:#e1f5fe
    style J fill:#f3e5f5

3.3 段生命周期示例

时间线发展:
T0: 索引为空
T1: 写入文档1-3 → 内存Buffer
T2: 写入文档4,触发refresh → 创建Segment_1[文档1-4]
T3: 写入文档5-7 → 内存Buffer
T4: 写入文档8,触发refresh → 创建Segment_2[文档5-8]
T5: 写入文档9-10 → 内存Buffer
T6: refresh → 创建Segment_3[文档9-10]
T7: 触发合并 → Segment_1+Segment_2合并为Segment_4[文档1-8]
最终:Segment_4 + Segment_3

3.4 段的物理文件结构

Segment_1/
├── .si (段信息文件)
├── 倒排索引相关:
│   ├── .tim (词项字典)
│   ├── .tip (词项索引)
│   ├── .doc (倒排列表)
│   └── .pos (位置信息)
├── 正排存储:
│   ├── .fdt (存储字段值)
│   ├── .fdx (存储字段索引)
│   └── .fdm (存储字段元数据)
├── 列式存储:
│   ├── .dvd (DocValues数据)
│   └── .dvm (DocValues元数据)
└── 其他元数据文件

3.5 段的关键特性

  1. 不可变性:一旦创建就不能修改
  2. 独立性:每个段都是完整索引
  3. 渐进合并:小段合并成大段
  4. 删除标记:删除文档时只标记,合并时才物理删除

3.6 为什么要有段机制?

  1. 写入性能:可以批量写入,减少磁盘IO
  2. 删除效率:通过标记删除,避免立即重写索引
  3. 缓存友好:小段可以完全载入内存
  4. 并发控制:读操作不影响写操作
  5. 故障恢复:translog + 段机制保证数据安全

四、10文档ES集群存储与查询实例

4.1 示例文档

文档1: {"id":1,"name":"小米手机","brand":"小米","price":2999,"category":"手机"}
文档2: {"id":2,"name":"苹果手机","brand":"苹果","price":8999,"category":"手机"}
文档3: {"id":3,"name":"华为平板","brand":"华为","price":3999,"category":"平板"}
文档4: {"id":4,"name":"小米电视","brand":"小米","price":4999,"category":"电视"}
文档5: {"id":5,"name":"联想笔记本","brand":"联想","price":5999,"category":"电脑"}
文档6: {"id":6,"name":"苹果笔记本","brand":"苹果","price":12999,"category":"电脑"}
文档7: {"id":7,"name":"华为手机","brand":"华为","price":3999,"category":"手机"}
文档8: {"id":8,"name":"小米笔记本","brand":"小米","price":6999,"category":"电脑"}
文档9: {"id":9,"name":"三星电视","brand":"三星","price":5999,"category":"电视"}
文档10: {"id":10,"name":"苹果平板","brand":"苹果","price":5999,"category":"平板"}

4.2 集群部署结构

3节点集群,3主分片+1副本:
节点1: 分片P0(主), 分片P1(副本), 分片P2(副本)
节点2: 分片P1(主), 分片P2(副本), 分片P0(副本)
节点3: 分片P2(主), 分片P0(副本), 分片P1(副本)

4.3 文档分布(按_id哈希路由)

文档1 → 分片1  文档6 → 分片0
文档2 → 分片2  文档7 → 分片1
文档3 → 分片0  文档8 → 分片2
文档4 → 分片1  文档9 → 分片0
文档5 → 分片2  文档10 → 分片1

最终分布

  • 分片0: 文档3,6,9
  • 分片1: 文档1,4,7,10
  • 分片2: 文档2,5,8

4.4 搜索流程详解

场景1:搜索"小米"
1. ES接收请求:GET /products/_search?q=小米
2. 转换为内部查询:match "_all": "小米"
3. 向所有主分片广播查询:
   - 节点2查询分片1
   - 节点3查询分片2
   - 节点1查询分片0
4. 各分片Lucene并行查询:
   - 分片1: [文档1:0.85, 文档4:0.85]
   - 分片2: [文档8:0.70]
   - 分片0: []
5. ES归并结果:[文档1, 文档4, 文档8]
6. 返回给用户
场景2:范围查询"price>5000"
各分片本地查询:
- 分片1: price DocValues遍历 → 文档10(5999)匹配
- 分片2: 文档2(8999)、5(5999)、8(6999)匹配
- 分片0: 文档6(12999)、9(5999)匹配
全局结果:文档2,5,6,8,9,10
场景3:聚合统计"按品牌分组"
分片本地聚合:
- 分片1: 小米×2, 华为×1, 苹果×1
- 分片2: 苹果×1, 联想×1, 小米×1
- 分片0: 华为×1, 苹果×1, 三星×1
全局归并:
苹果:3, 小米:3, 华为:2, 联想:1, 三星:1

4.5 写入过程数据流

1. 客户端 → ES协调节点
2. 路由计算:_id哈希 → 确定分片
3. 转发到主分片节点
4. 主分片写入:
   a. 写入translog(保证持久化)
   b. Lucene内存buffer添加文档
   c. 返回成功给客户端
5. 异步复制到副本分片
6. refresh(默认1秒):
   a. Lucene buffer生成新段
   b. 索引可搜索
7. flush(每30分钟/512MB):
   a. 将内存数据写入磁盘
   b. 清理translog

五、关键配置参数

# 段相关配置
index.refresh_interval: "1s"     # 刷新间隔
index.merge.policy.max_merged_segment: "5gb"  # 最大段大小
index.merge.policy.segments_per_tier: 10      # 每层段数
index.merge.scheduler.max_thread_count: 1     # 合并线程数

# 索引配置
index.number_of_shards: 3        # 主分片数
index.number_of_replicas: 1      # 副本数
index.codec: "Lucene87"          # 压缩编解码器