IndexLib(2):Tablet 与 Segment:索引的组织方式

24 分钟阅读

发布于:

在上一篇文章中,我们介绍了 IndexLib 的整体架构和核心概念。本文将继续深入,详细解析 Tablet 和 Segment 的组织方式,这是理解 IndexLib 索引机制的关键。

1. Tablet 与 Segment 的关系

Tablet 和 Segment 的组织关系是 IndexLib 索引机制的核心。让我们通过类图来理解它们的关系:

classDiagram
    class Tablet {
        - TabletData _tabletData
        - TabletSchema _schema
        - TabletOptions _options
        + Open(string) Status
        + Build(IDocumentBatch) Status
        + Flush() Status
        + Seal() void
        + Commit() Status
        + GetTabletReader() TabletReader
        + Reopen() Status
    }
    
    class TabletData {
        - Version _onDiskVersion
        - vector~shared_ptr~Segment~~ _segments
        - shared_ptr~ResourceMap~ _resourceMap
        + CreateSlice(SegmentStatus) vector~Segment~
        + GetSegment(segmentid_t) Segment
        + GetSegmentWithBaseDocid(docid_t) Segment
        + UpdateVersion(Version) void
        + GetSegmentCount() size_t
    }
    
    class Segment {
        <<abstract>>
        # segmentid_t _segmentId
        # SegmentStatus _status
        + GetSegmentId() segmentid_t
        + GetDocCount() uint32_t
        + GetSegmentStatus() SegmentStatus
        + GetIndexer(string) IIndexer
        + GetBaseDocId() docid_t
    }
    
    class MemSegment {
        - map~string,IIndexer~ _indexers
        + Build(IDocumentBatch) Status
        + NeedDump() bool
        + CreateSegmentDumpItems() vector~SegmentDumpItem~
        + Seal() void
        + EvaluateCurrentMemUsed() size_t
    }
    
    class DiskSegment {
        - map~string,IIndexer~ _indexers
        + Open(string) Status
        + Reopen() Status
        + GetIndexer(string) IIndexer
    }
    
    Tablet "1" --> "1" TabletData : 管理
    TabletData "1" *-- "many" Segment : 包含多个有序Segment
    Segment <|-- MemSegment : 继承
    Segment <|-- DiskSegment : 继承
    
    note for Tablet "索引表的完整抽象<br/>管理索引的构建和查询"
    note for TabletData "管理Segment列表和版本<br/>提供Segment访问接口"
    note for Segment "抽象基类<br/>定义Segment通用接口"
    note for MemSegment "内存段<br/>实时写入和构建"
    note for DiskSegment "磁盘段<br/>持久化存储和查询"

组织关系

  • 一个 Tablet 包含多个 Segment:通过 TabletData 管理有序的 Segment 列表
  • Segment 有序排列:按照 SegmentId 排序,保证 DocId 映射的正确性
  • Segment 类型:分为 MemSegment(内存段)和 DiskSegment(磁盘段)

1.1 整体组织架构

Tablet 是索引表的完整抽象,而 Segment 是索引的基本存储单元。一个 Tablet 包含多个 Segment,这些 Segment 按照时间顺序组织,共同构成完整的索引。

通过阅读源码,我们可以看到 Tablet 和 Segment 的关系定义在 framework/TabletData.h 中:

// framework/TabletData.h
class TabletData : private autil::NoCopyable
{
private:
    Version _onDiskVersion;                               // 磁盘版本
    std::vector<std::shared_ptr<Segment>> _segments;     // Segment 列表(有序)
    std::shared_ptr<ResourceMap> _resourceMap;           // 共享资源
};

关键设计

  • 有序列表_segments 是一个有序的 Segment 列表,按照 SegmentId 排序
  • 版本管理_onDiskVersion 记录哪些 Segment 已持久化
  • 共享资源:多个 Segment 共享 ResourceMap(内存池、缓存等)

1.2 Segment 的 ID 分配机制

Segment 的 ID 分配有特殊的规则,定义在 framework/Segment.h 中:

// framework/Segment.h
class Segment {
public:
    // Segment ID 的掩码定义
    static constexpr segmentid_t RT_SEGMENT_ID_MASK = (segmentid_t)0x1 << 30;      // 实时 Segment
    static constexpr segmentid_t MERGED_SEGMENT_ID_MASK = (segmentid_t)0x0;         // 合并 Segment
    static constexpr segmentid_t PUBLIC_SEGMENT_ID_MASK = (segmentid_t)0x1 << 29;   // 公共 Segment
    static constexpr segmentid_t PRIVATE_SEGMENT_ID_MASK = (segmentid_t)0x1 << 30; // 私有 Segment

    // 判断 Segment 类型
    static bool IsRtSegmentId(segmentid_t segId) { 
        return (segId & RT_SEGMENT_ID_MASK) > 0; 
    }
    
    static bool IsMergedSegmentId(segmentid_t segId) {
        return segId != INVALID_SEGMENTID && 
               (segId & (PUBLIC_SEGMENT_ID_MASK | PRIVATE_SEGMENT_ID_MASK)) == 0;
    }
};

Segment ID 的分类

Segment ID 采用位掩码机制,通过不同的位来区分 Segment 的类型和属性。这种设计使得 ID 分配和类型判断都非常高效:

graph TD
    A[Segment ID: 32位整数] --> B{检查第30位}
    B -->|第30位=1| C[RT Segment<br/>实时Segment]
    B -->|第30位=0| D{检查第29位}
    D -->|第29位=1| E[Public Segment<br/>公共Segment]
    D -->|第29位=0| F[Merged Segment<br/>合并Segment]
    
    C --> G[用于实时写入]
    E --> H[用于公共数据]
    F --> I[用于合并后的数据]
    
    style C fill:#e3f2fd
    style E fill:#fff3e0
    style F fill:#e8f5e9

Segment ID 分配规则

  • 实时 Segment(RT Segment):ID 的第 30 位为 1(0x40000000),用于实时写入
    • 特点:支持实时写入,转储后变为 DiskSegment
    • 用途:接收实时数据,提供低延迟写入能力
  • 合并 Segment(Merged Segment):ID 的第 29、30 位都为 0,用于合并后的 Segment
    • 特点:由多个 Segment 合并而成,只读
    • 用途:优化索引结构,减少 Segment 数量,提高查询性能
  • 公共/私有 Segment:通过第 29 位区分
    • Public Segment:第 29 位为 1(0x20000000),用于公共数据
    • Private Segment:第 29 位为 0,用于私有数据

设计优势

  • 快速判断:通过位运算快速判断 Segment 类型,时间复杂度 O(1)
  • ID 空间利用:32 位 ID 可以支持 40 亿个 Segment,足够使用
  • 类型安全:通过类型判断避免误操作(如对 Merged Segment 进行写入)

2. Segment 的元数据:SegmentMeta 与 SegmentInfo

2.1 SegmentMeta:Segment 的元数据

SegmentMeta 记录 Segment 的元数据信息,定义在 framework/SegmentMeta.h 中:

// framework/SegmentMeta.h
struct SegmentMeta {
    segmentid_t segmentId;                                    // Segment ID
    std::shared_ptr<indexlib::file_system::Directory> segmentDir;  // Segment 目录
    std::shared_ptr<SegmentInfo> segmentInfo;                  // Segment 信息
    std::shared_ptr<indexlib::framework::SegmentMetrics> segmentMetrics;  // Segment 指标
    std::shared_ptr<config::ITabletSchema> schema;            // Schema
    std::string lifecycle;                                     // 生命周期标签
};

SegmentMeta 的组成

SegmentMeta 是 Segment 的元数据容器,包含了 Segment 的所有元信息。让我们通过类图来理解其结构:

classDiagram
    class SegmentMeta {
        + segmentid_t segmentId
        + Directory segmentDir
        + SegmentInfo segmentInfo
        + SegmentMetrics segmentMetrics
        + ITabletSchema schema
        + string lifecycle
    }
    
    class SegmentInfo {
        + uint64_t docCount
        + int64_t timestamp
        + schemaid_t schemaId
        + Locator locator
        + uint32_t shardId
        + bool mergedSegment
    }
    
    class Directory {
        + CreateFileReader()
        + CreateFileWriter()
        + ListDir()
    }
    
    class SegmentMetrics {
        + map_string_double metrics
        + GetMetric()
    }
    
    SegmentMeta --> SegmentInfo : 包含
    SegmentMeta --> Directory : 使用
    SegmentMeta --> SegmentMetrics : 包含

字段详解

  • segmentId:Segment 的唯一标识,用于区分不同的 Segment
  • segmentDir:Segment 的目录,用于文件操作(读取索引文件、写入转储文件等)
  • segmentInfo:Segment 的详细信息(文档数、Locator、分片信息等)
  • segmentMetrics:Segment 的指标信息(内存使用、IO 统计等),用于监控和调优
  • schema:Segment 使用的 Schema(支持 Schema 演进,每个 Segment 可以有不同的 SchemaId)
  • lifecycle:生命周期标签,用于数据管理(如冷热数据分离、数据归档等)

设计原理

  • 元数据分离:将元数据与数据分离,便于管理和查询
  • Schema 演进:每个 Segment 记录自己的 SchemaId,支持 Schema 变更
  • 生命周期管理:通过 lifecycle 标签实现数据的分层存储和管理

2.2 SegmentInfo:Segment 的详细信息

SegmentInfo 记录 Segment 的详细信息,定义在 framework/SegmentInfo.h 中:

// framework/SegmentInfo.h
class SegmentInfo : public autil::legacy::Jsonizable
{
public:
    // 基本信息
    volatile uint64_t docCount = 0;              // 文档数量
    int64_t timestamp = INVALID_TIMESTAMP;      // 时间戳
    schemaid_t schemaId = DEFAULT_SCHEMAID;     // Schema ID
    
    // Locator 信息
    Locator GetLocator() const;
    void SetLocator(const Locator& locator);
    
    // 分片信息
    uint32_t shardId = INVALID_SHARDING_ID;      // 分片 ID
    uint32_t shardCount = 1;                    // 分片数量
    
    // 其他信息
    bool mergedSegment = false;                 // 是否合并 Segment
    uint32_t maxTTL = 0;                        // 最大 TTL
    std::map<std::string, std::string> descriptions;  // 描述信息
};

SegmentInfo 的关键字段

flowchart TD
    A[SegmentInfo<br/>Segment元数据信息] --> B[基础信息]
    A --> C[位置信息]
    A --> D[分片信息]
    A --> E[状态信息]
    
    subgraph Basic["基础信息"]
        B1[segmentId<br/>Segment唯一标识]
        B2[directory<br/>目录路径]
        B3[schemaId<br/>Schema版本]
        B4[docCount<br/>文档数量<br/>用于DocId映射]
        B --> B1
        B --> B2
        B --> B3
        B --> B4
    end
    
    subgraph Location["位置信息"]
        C1[Locator<br/>数据位置信息<br/>用于增量更新]
        C2[timestamp<br/>时间戳]
        C3[concurrentIdx<br/>并发索引]
        C --> C1
        C1 --> C2
        C1 --> C3
    end
    
    subgraph Shard["分片信息"]
        D1[shardId<br/>当前分片ID]
        D2[shardCount<br/>总分片数]
        D3[支持分片存储<br/>水平扩展]
        D --> D1
        D --> D2
        D1 --> D3
        D2 --> D3
    end
    
    subgraph Status["状态信息"]
        E1[mergedSegment<br/>合并标识<br/>是否为合并Segment]
        E2[segmentStatus<br/>Segment状态<br/>ST_BUILT/ST_BUILDING等]
        E --> E1
        E --> E2
    end
    
    style Basic fill:#e3f2fd
    style Location fill:#fff3e0
    style Shard fill:#f3e5f5
    style Status fill:#e8f5e9
  • docCount:Segment 中的文档数量,用于 DocId 映射
  • Locator:数据位置信息,用于增量更新
  • shardId/shardCount:分片信息,支持分片存储
  • mergedSegment:标识是否为合并 Segment

3. DocId 映射机制

3.1 全局 DocId 与局部 DocId

IndexLib 使用两级 DocId 机制:

  • 全局 DocId:在整个 Tablet 范围内唯一的文档 ID
  • 局部 DocId:在单个 Segment 内的文档 ID(从 0 开始)

DocId 映射关系

IndexLib 使用两级 DocId 机制,这是理解索引查询和构建的关键。让我们通过流程图来理解 DocId 的映射关系:

flowchart TD
    subgraph Write["写入路径:分配DocId"]
        W1[文档写入<br/>IDocumentBatch]
        W2[获取当前MemSegment<br/>_normalBuildingSegment]
        W3[获取BaseDocId<br/>前面所有Segment的docCount之和]
        W4[分配LocalDocId<br/>从0开始递增]
        W5[计算GlobalDocId<br/>GlobalDocId = BaseDocId + LocalDocId]
        W6[写入Indexer<br/>使用GlobalDocId]
        
        W1 --> W2
        W2 --> W3
        W3 --> W4
        W4 --> W5
        W5 --> W6
    end
    
    subgraph Query["查询路径:转换DocId"]
        Q1[查询请求<br/>GlobalDocId]
        Q2[遍历TabletData中的Segment]
        Q3[计算每个Segment的BaseDocId<br/>累加前面Segment的docCount]
        Q4{GlobalDocId在范围内?<br/>BaseDocId <= GlobalDocId < BaseDocId + docCount}
        Q5[计算LocalDocId<br/>LocalDocId = GlobalDocId - BaseDocId]
        Q6[在Segment内查询<br/>使用LocalDocId]
        Q7[返回查询结果]
        
        Q1 --> Q2
        Q2 --> Q3
        Q3 --> Q4
        Q4 -->|是| Q5
        Q4 -->|否| Q2
        Q5 --> Q6
        Q6 --> Q7
    end
    
    subgraph Example["示例:3个Segment"]
        E1[Segment1: docCount=1000<br/>BaseDocId=0<br/>GlobalDocId范围: 0-999]
        E2[Segment2: docCount=2000<br/>BaseDocId=1000<br/>GlobalDocId范围: 1000-2999]
        E3[Segment3: docCount=1500<br/>BaseDocId=3000<br/>GlobalDocId范围: 3000-4499]
        
        E1 --> E2
        E2 --> E3
    end
    
    style Write fill:#e3f2fd
    style Query fill:#fff3e0
    style Example fill:#f5f5f5

DocId 映射示例

假设有 3 个 Segment:

  • Segment 1:docCount=1000,baseDocId=0,LocalDocId 范围 [0, 999]
  • Segment 2:docCount=2000,baseDocId=1000,LocalDocId 范围 [0, 1999]
  • Segment 3:docCount=1500,baseDocId=3000,LocalDocId 范围 [0, 1499]

那么:

  • Segment 1 的 GlobalDocId 范围:[0, 999]
  • Segment 2 的 GlobalDocId 范围:[1000, 2999]
  • Segment 3 的 GlobalDocId 范围:[3000, 4499]

从代码中可以看到,TabletData 提供了获取 Segment 及其基础 DocId 的方法:

// framework/TabletData.h
class TabletData {
public:
    // 获取 Segment 及其基础 DocId
    // 返回:(Segment 指针, 基础 DocId)
    std::pair<SegmentPtr, docid64_t> GetSegmentWithBaseDocid(segmentid_t segmentId);
};

DocId 计算逻辑(通过代码分析):

// 伪代码:计算全局 DocId
docid64_t globalDocId = baseDocId + localDocId;

// 其中:
// - baseDocId:前面所有 Segment 的文档数之和
// - localDocId:当前 Segment 内的局部 DocId

3.2 BaseDocId 的计算

BaseDocId 是 Segment 的全局 DocId 起始值,等于前面所有 Segment 的文档数之和:

BaseDocId 计算流程

sequenceDiagram
    participant Writer as TabletWriter
    participant TabletData as TabletData
    participant Seg1 as Segment 1
    participant Seg2 as Segment 2
    participant Seg3 as Segment 3
    
    Writer->>TabletData: GetSegmentWithBaseDocid(segId=1)
    TabletData->>Seg1: GetDocCount()
    Seg1-->>TabletData: 1000
    TabletData-->>Writer: (Segment1, baseDocId=0)
    
    Writer->>TabletData: GetSegmentWithBaseDocid(segId=2)
    TabletData->>Seg1: GetDocCount()
    Seg1-->>TabletData: 1000
    TabletData->>Seg2: GetDocCount()
    Seg2-->>TabletData: 2000
    TabletData-->>Writer: (Segment2, baseDocId=1000)
    
    Writer->>TabletData: GetSegmentWithBaseDocid(segId=3)
    TabletData->>Seg1: GetDocCount()
    Seg1-->>TabletData: 1000
    TabletData->>Seg2: GetDocCount()
    Seg2-->>TabletData: 2000
    TabletData->>Seg3: GetDocCount()
    Seg3-->>TabletData: 1500
    TabletData-->>Writer: (Segment3, baseDocId=3000)

计算示例

  • Segment 1:docCount=1000,baseDocId=0(前面没有 Segment)
  • Segment 2:docCount=2000,baseDocId=1000(Segment 1 的 docCount)
  • Segment 3:docCount=1500,baseDocId=3000(Segment 1 + Segment 2 的 docCount)

代码实现逻辑(通过阅读源码理解):

// TabletData 内部维护 Segment 列表
// 计算 baseDocId 时,遍历前面的 Segment,累加 docCount
docid64_t baseDocId = 0;
for (auto& seg : _segments) {
    if (seg->GetSegmentId() == segmentId) {
        break;
    }
    baseDocId += seg->GetDocCount();
}

4. TabletData 的 Segment 管理

4.1 Segment 的添加与移除

TabletData 通过 Init() 方法初始化 Segment 列表:

// framework/TabletData.h
class TabletData {
public:
    // 初始化:设置版本和 Segment 列表
    Status Init(Version onDiskVersion, 
                std::vector<SegmentPtr> segments,
                const std::shared_ptr<ResourceMap>& resourceMap);
};

Segment 列表的维护

flowchart TD
    Start[TabletData<br/>管理Segment列表] --> Operations{操作类型}
    
    Operations --> Init[Init<br/>初始化]
    Operations --> Add[AddSegment<br/>添加Segment]
    Operations --> Remove[RemoveSegment<br/>移除Segment]
    Operations --> Reopen[Reopen<br/>更新Segment列表]
    
    Init --> InitDetail[设置初始Segment列表<br/>设置Version和ResourceMap<br/>建立Segment有序列表]
    
    Add --> AddDetail[新Segment通过TabletWriter创建<br/>添加到_segments列表末尾<br/>保持SegmentId有序]
    
    Remove --> RemoveDetail[合并后移除旧Segment<br/>从_segments列表中删除<br/>释放Segment资源]
    
    Reopen --> ReopenDetail[加载新Version<br/>更新Segment列表<br/>重新建立Segment视图]
    
    InitDetail --> End[Segment列表已更新]
    AddDetail --> End
    RemoveDetail --> End
    ReopenDetail --> End
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Operations fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Init fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Add fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Remove fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style Reopen fill:#fce4ec,stroke:#c2185b,stroke-width:2px
    style InitDetail fill:#fff9c4,stroke:#f57f17,stroke-width:1px
    style AddDetail fill:#c8e6c9,stroke:#388e3c,stroke-width:1px
    style RemoveDetail fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style ReopenDetail fill:#f8bbd0,stroke:#c2185b,stroke-width:1px
    style End fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
  • 初始化:通过 Init() 设置初始 Segment 列表
  • 添加:新 Segment 通过 TabletWriter 创建后添加到列表
  • 移除:合并后,旧 Segment 从列表中移除
  • 更新Reopen() 时更新 Segment 列表

4.2 Slice 机制:按状态筛选 Segment

Slice 是 TabletData 提供的 Segment 视图机制,可以按状态筛选 Segment:

// framework/TabletData.h
class TabletData {
public:
    class Slice {
        // 提供迭代器,可以遍历筛选后的 Segment
        auto begin() { return _cBegin; }
        auto end() { return _cEnd; }
        auto rbegin() { return _cRbegin; }
        auto rend() { return _cRend; }
    };
    
    // 创建 Slice:按状态筛选
    Slice CreateSlice(Segment::SegmentStatus segmentStatus) const;
};

Slice 的使用场景

Slice 机制是 TabletData 的核心设计,提供了灵活的 Segment 筛选能力。让我们通过流程图来理解不同场景下的使用:

graph TD
    A[TabletData] --> B[CreateSlice]
    B --> C{使用场景}
    
    C -->|查询| D[ST_BUILT]
    C -->|写入| E[ST_BUILDING]
    C -->|合并| F[ST_BUILT]
    C -->|监控| G[ST_DUMPING]
    C -->|全部| H[无筛选]
    
    D --> I[获取所有已构建的Segment<br/>用于查询]
    E --> J[获取构建中的Segment<br/>用于写入]
    F --> K[获取需要合并的Segment<br/>用于合并]
    G --> L[获取转储中的Segment<br/>用于监控]
    H --> M[获取所有Segment<br/>用于管理]
    
    style D fill:#e3f2fd
    style E fill:#fff3e0
    style F fill:#f3e5f5
    style G fill:#e8f5e9

使用场景详解

  1. 查询时CreateSlice(ST_BUILT) 获取所有已构建的 Segment
    • 目的:只查询已持久化的 Segment,保证数据一致性
    • 性能:跳过构建中的 Segment,减少不必要的查询
  2. 写入时CreateSlice(ST_BUILDING) 获取构建中的 Segment
    • 目的:获取当前正在构建的 MemSegment,用于写入
    • 场景:检查是否需要创建新的 MemSegment
  3. 合并时CreateSlice(ST_BUILT) 获取需要合并的 Segment
    • 目的:获取所有已构建的 Segment,用于合并策略选择
    • 优化:可以进一步筛选(如按大小、时间等)
  4. 监控时CreateSlice(ST_DUMPING) 获取转储中的 Segment
    • 目的:监控转储进度,统计转储任务
    • 用途:性能监控、资源管理

设计优势

  • 封装性:隐藏内部实现,外部代码不需要知道 Segment 的存储方式
  • 性能:Slice 是轻量级视图,不复制数据,只是提供迭代器
  • 灵活性:支持按状态、类型、时间等多种条件筛选
  • 线程安全:Slice 的创建和遍历是线程安全的

5. MemSegment 的实现细节

5.1 NormalMemSegment 的构建流程

通过阅读 table/normal_table/NormalMemSegment.h,我们可以看到 NormalMemSegment 的实现:

// table/normal_table/NormalMemSegment.h
class NormalMemSegment : public plain::PlainMemSegment
{
public:
    NormalMemSegment(const config::TabletOptions* options, 
                    const std::shared_ptr<config::ITabletSchema>& schema,
                    const framework::SegmentMeta& segmentMeta);
    
protected:
    // 创建转储参数
    std::pair<Status, std::shared_ptr<framework::DumpParams>> CreateDumpParams() override;
    
    // 计算转储内存成本
    void CalcMemCostInCreateDumpParams() override;
};

MemSegment 的构建流程

MemSegment 的构建是索引写入的核心流程。让我们通过序列图来理解完整的构建过程:

sequenceDiagram
    participant Writer as TabletWriter
    participant MemSeg as MemSegment
    participant Indexer1 as InvertedIndexer
    participant Indexer2 as AttributeIndexer
    participant MemCtrl as MemoryQuotaController
    
    Writer->>MemSeg: Open(SegmentMeta, BuildResource)
    MemSeg->>Indexer1: CreateIndexer(indexConfig)
    MemSeg->>Indexer2: CreateIndexer(indexConfig)
    MemSeg-->>Writer: Success
    
    Writer->>MemSeg: Build(documentBatch)
    MemSeg->>MemSeg: DispatchDocIds(batch)
    MemSeg->>Indexer1: BuildDocument(doc, docId)
    MemSeg->>Indexer2: BuildDocument(doc, docId)
    Indexer1-->>MemSeg: Success
    Indexer2-->>MemSeg: Success
    MemSeg->>MemSeg: UpdateSegmentInfo()
    MemSeg-->>Writer: Success
    
    Writer->>MemSeg: NeedDump()?
    MemSeg->>MemCtrl: GetUsedQuota()
    MemCtrl-->>MemSeg: usedQuota
    MemSeg->>MemSeg: CheckThreshold(usedQuota)
    MemSeg-->>Writer: true/false
    
    alt NeedDump == true
        Writer->>MemSeg: CreateDumpParams()
        MemSeg->>MemSeg: CalcMemCost()
        MemSeg->>MemSeg: PrepareDumpItems()
        MemSeg-->>Writer: DumpParams
    end

构建流程详解

  1. Open:初始化构建资源,创建 Indexer
    • 资源初始化:创建内存池、缓存等资源
    • Indexer 创建:根据 Schema 创建倒排索引、正排索引等 Indexer
    • 状态设置:设置 Segment 状态为 ST_BUILDING
  2. Build:接收文档批次,写入各个 Indexer
    • DocId 分配:为文档分配局部 DocId(从 0 开始递增)
    • 文档写入:将文档写入各个 Indexer(倒排索引、正排索引等)
    • 元数据更新:更新 SegmentInfo(docCount、Locator 等)
  3. NeedDump:检查是否达到转储条件
    • 内存检查:检查内存使用是否达到阈值
    • 文档数检查:检查文档数是否达到阈值
    • 时间检查:检查是否达到转储时间间隔
  4. CreateDumpParams:创建转储参数,计算内存成本
    • 内存估算:估算转储所需的内存
    • 转储项准备:准备转储项列表(索引文件、元数据文件等)
    • 资源预留:预留转储所需的内存和 IO 资源

5.2 MemSegment 的内存管理

MemSegment 在内存中构建索引,需要严格控制内存使用。关键代码(table/plain/PlainMemSegment.h):

class PlainMemSegment : public MemSegment {
public:
    // 估算内存使用
    std::pair<Status, size_t> EstimateMemUsed(
        const std::shared_ptr<config::ITabletSchema>& schema) override;
    
    // 评估当前内存使用
    size_t EvaluateCurrentMemUsed() override;
};

内存管理机制

MemSegment 的内存管理是保证系统稳定性的关键。让我们通过流程图来理解内存管理的完整机制:

flowchart TD
    Start[开始构建] --> Estimate[EstimateMemUsed<br/>估算内存需求]
    
    Estimate --> QuotaCheck{内存配额检查<br/>检查可用配额}
    
    QuotaCheck -->|配额不足| WaitOrReject[等待或拒绝<br/>等待配额释放或拒绝构建]
    QuotaCheck -->|配额充足| Allocate[分配内存<br/>从MemoryQuotaController分配]
    
    Allocate --> BuildLoop[构建循环]
    
    subgraph BuildLoop["构建循环"]
        direction TB
        Build[Build文档<br/>写入MemSegment]
        Evaluate[EvaluateCurrentMemUsed<br/>评估当前内存使用]
        MemCheck{内存使用检查<br/>是否超过阈值?}
        
        Build --> Evaluate
        Evaluate --> MemCheck
        MemCheck -->|未超阈值<br/>继续构建| Build
        MemCheck -->|超过阈值<br/>触发转储| Dump[触发转储<br/>NeedDump返回true]
    end
    
    Dump --> DumpProcess[转储处理]
    
    subgraph DumpProcess["转储处理"]
        direction TB
        CreateDump[CreateSegmentDumpItems<br/>创建转储项]
        AsyncDump[异步转储到磁盘<br/>不阻塞写入]
        ReleaseMem[释放内存<br/>释放MemSegment内存]
        CreateNew[创建新MemSegment<br/>继续构建]
        
        CreateDump --> AsyncDump
        AsyncDump --> ReleaseMem
        ReleaseMem --> CreateNew
    end
    
    CreateNew --> BuildLoop
    
    WaitOrReject --> End[结束]
    BuildLoop -.->|构建完成| End
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Estimate fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style QuotaCheck fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style WaitOrReject fill:#ffebee,stroke:#c62828,stroke-width:2px
    style Allocate fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style BuildLoop fill:#f5f5f5,stroke:#757575,stroke-width:2px
    style Build fill:#fff3e0,stroke:#f57c00,stroke-width:1px
    style Evaluate fill:#fff3e0,stroke:#f57c00,stroke-width:1px
    style MemCheck fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Dump fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style DumpProcess fill:#f5f5f5,stroke:#757575,stroke-width:2px
    style CreateDump fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1px
    style AsyncDump fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1px
    style ReleaseMem fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px
    style CreateNew fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px
    style End fill:#e3f2fd,stroke:#1976d2,stroke-width:2px

内存管理策略

  • 估算EstimateMemUsed() 估算构建所需内存
    • 目的:在构建前预估内存需求,避免内存不足
    • 方法:根据 Schema、文档数、索引类型等估算
    • 精度:估算值通常略大于实际值,保证安全
  • 评估EvaluateCurrentMemUsed() 评估当前实际内存使用
    • 目的:实时监控内存使用,及时触发转储
    • 方法:统计所有 Indexer 的内存使用
    • 频率:每次 Build 后评估,或定期评估
  • 控制:通过 MemoryQuotaController 控制内存上限
    • 配额管理:为每个 Tablet 分配内存配额
    • 动态调整:根据系统负载动态调整配额
    • 超限处理:内存超限时触发转储或拒绝写入
  • 转储:达到阈值时触发转储,释放内存
    • 触发条件:内存使用超过阈值、文档数超过阈值、时间间隔达到
    • 转储策略:异步转储,不阻塞写入
    • 内存释放:转储完成后释放 MemSegment 的内存

性能优化

  • 内存池:使用内存池减少内存分配开销
  • 预分配:预分配常用大小的内存块,减少系统调用
  • 内存复用:转储后复用内存,减少内存分配

6. DiskSegment 的实现细节

6.1 NormalDiskSegment 的加载流程

通过阅读 table/normal_table/NormalDiskSegment.h,我们可以看到 NormalDiskSegment 的实现:

// table/normal_table/NormalDiskSegment.h
class NormalDiskSegment : public plain::PlainDiskSegment
{
public:
    NormalDiskSegment(const std::shared_ptr<config::ITabletSchema>& schema,
                     const framework::SegmentMeta& segmentMeta, 
                     const framework::BuildResource& buildResource);
    
    // 估算内存使用
    std::pair<Status, size_t> EstimateMemUsed(
        const std::shared_ptr<config::ITabletSchema>& schema) override;

private:
    // 打开 Indexer
    std::pair<Status, std::vector<plain::DiskIndexerItem>>
    OpenIndexer(const std::shared_ptr<config::IIndexConfig>& indexConfig) override;
};

DiskSegment 的加载流程

flowchart TD
    Start[DiskSegment.Open<br/>打开磁盘段] --> ReadInfo[读取SegmentInfo<br/>从磁盘加载元数据]
    ReadInfo --> ModeSelect{OpenMode选择}
    
    subgraph Normal["NORMAL 模式:立即加载"]
        direction TB
        N1[遍历所有IndexConfig] --> N2[打开所有Indexer<br/>并行加载]
        N2 --> N3[InvertedIndexer<br/>倒排索引]
        N2 --> N4[AttributeIndexer<br/>正排索引]
        N2 --> N5[PrimaryKeyIndexer<br/>主键索引]
        N2 --> N6[SummaryIndexer<br/>摘要索引]
        N3 --> N7[所有Indexer在内存<br/>查询延迟低]
        N4 --> N7
        N5 --> N7
        N6 --> N7
    end
    
    subgraph Lazy["LAZY 模式:按需加载"]
        direction TB
        L1[只读取SegmentInfo<br/>不加载Indexer] --> L2[等待查询请求]
        L2 --> L3[GetIndexer调用<br/>type, indexName]
        L3 --> L4{Indexer已加载?}
        L4 -->|否| L5[按需打开Indexer<br/>OpenIndexer]
        L4 -->|是| L8[返回缓存的Indexer]
        L5 --> L6[加载索引数据到内存]
        L6 --> L7[缓存Indexer]
        L7 --> L8
    end
    
    subgraph Reopen["Reopen操作:Schema变更"]
        direction TB
        R1[Schema变更检测] --> R2[调用Reopen<br/>重新打开Segment]
        R2 --> R3[使用新Schema<br/>重新加载Indexer]
        R3 -.->|重新打开| Start
    end
    
    subgraph Memory["内存管理"]
        direction TB
        M1[MemoryQuotaController<br/>内存配额控制]
        M2[估算内存使用<br/>EstimateMemUsed]
        M3[检查内存配额]
        M4[分配内存]
        
        M1 --> M2
        M2 --> M3
        M3 --> M4
    end
    
    ModeSelect -->|NORMAL| Normal
    ModeSelect -->|LAZY| Lazy
    
    N2 -.->|内存分配| Memory
    L5 -.->|内存分配| Memory
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style ReadInfo fill:#e3f2fd,stroke:#1976d2,stroke-width:1px
    style ModeSelect fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Normal fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Lazy fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Reopen fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style Memory fill:#f5f5f5,stroke:#757575,stroke-width:2px
    style N7 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style L8 fill:#fff9c4,stroke:#f57f17,stroke-width:2px
  1. Open:打开 Segment 目录,读取 SegmentInfo
  2. OpenIndexer:按需打开各个 Indexer(NORMAL 模式立即打开,LAZY 模式按需打开)
  3. GetIndexer:查询时获取 Indexer,LAZY 模式下此时才加载
  4. Reopen:Schema 变更时重新打开

6.2 DiskSegment 的按需加载

DiskSegment 支持按需加载,通过 GetIndexer() 方法实现:

// framework/Segment.h
class Segment {
public:
    // 获取 Indexer(LAZY 模式下按需加载)
    virtual std::pair<Status, std::shared_ptr<indexlibv2::index::IIndexer>> 
        GetIndexer(const std::string& type, const std::string& indexName) {
        return std::make_pair(Status::NotFound(), nullptr);
    }
};

按需加载的优势

flowchart TD
    A[DiskSegment<br/>LAZY模式] --> B[Open调用<br/>只读取SegmentInfo]
    B --> C[不加载任何Indexer<br/>快速启动]
    
    subgraph Query["查询时按需加载"]
        Q1[查询请求到达]
        Q2[GetIndexer调用<br/>指定type和indexName]
        Q3{Indexer缓存中?}
        Q4[从缓存返回]
        Q5[按需打开Indexer<br/>OpenIndexer]
        Q6[读取索引文件<br/>从磁盘加载]
        Q7[解析索引数据]
        Q8[缓存Indexer<br/>避免重复加载]
        Q9[返回Indexer]
        
        C --> Q1
        Q1 --> Q2
        Q2 --> Q3
        Q3 -->|是| Q4
        Q3 -->|否| Q5
        Q5 --> Q6
        Q6 --> Q7
        Q7 --> Q8
        Q8 --> Q9
        Q4 --> Q9
    end
    
    subgraph Advantages["LAZY模式优势"]
        A1[减少内存占用<br/>只加载查询需要的索引]
        A2[提高启动速度<br/>不需要等待所有索引加载]
        A3[灵活查询<br/>支持部分索引查询场景]
        A4[节省资源<br/>适合离线场景]
        A5[动态加载<br/>根据查询模式优化]
        
        C -.-> A1
        C -.-> A2
        Q9 -.-> A3
        Q9 -.-> A4
        Q9 -.-> A5
    end
    
    subgraph Comparison["对比NORMAL模式"]
        C1[NORMAL模式<br/>启动时加载所有索引]
        C2[内存占用大<br/>但查询延迟低]
        C3[适合在线查询场景]
        
        A1 -.-> C1
        A2 -.-> C2
        A3 -.-> C3
    end
    
    style Query fill:#e3f2fd
    style Advantages fill:#fff3e0
    style Comparison fill:#f5f5f5
  • 减少内存占用:只加载查询需要的索引
  • 提高启动速度:不需要等待所有索引加载完成
  • 灵活查询:支持部分索引查询场景

7. TabletWriter 与 Segment 的交互

7.1 TabletWriter 的构建流程

通过阅读 table/normal_table/NormalTabletWriter.h,我们可以看到 TabletWriter 的实现:

// table/normal_table/NormalTabletWriter.h
class NormalTabletWriter : public table::CommonTabletWriter
{
public:
    // 打开:初始化 TabletData 和构建资源
    Status Open(const std::shared_ptr<framework::TabletData>& tabletData, 
                const framework::BuildResource& buildResource,
                const framework::OpenOptions& openOptions) override;
    
    // 构建:接收文档批次并写入
    Status Build(const std::shared_ptr<document::IDocumentBatch>& batch) override;
    
    // 创建 SegmentDumper:准备转储
    std::unique_ptr<framework::SegmentDumper> CreateSegmentDumper() override;

private:
    std::shared_ptr<NormalMemSegment> _normalBuildingSegment;  // 当前构建中的 Segment
    docid_t _buildingSegmentBaseDocId;                         // 构建 Segment 的基础 DocId
};

TabletWriter 与 Segment 的交互流程

flowchart TD
    subgraph Open["Open阶段"]
        O1[TabletWriter.Open<br/>初始化]
        O2[保存TabletData引用]
        O3[保存BuildResource<br/>内存配额/IO配额]
        O4{当前有MemSegment?}
        O5[获取现有MemSegment]
        O6[创建新MemSegment<br/>CreateMemSegment]
        O7[初始化MemSegment<br/>设置状态ST_BUILDING]
        
        O1 --> O2
        O2 --> O3
        O3 --> O4
        O4 -->|是| O5
        O4 -->|否| O6
        O6 --> O7
        O5 --> B1
        O7 --> B1
    end
    
    subgraph Build["Build阶段"]
        B1[接收文档批次<br/>IDocumentBatch]
        B2[文档验证<br/>格式/Schema验证]
        B3[分配DocId<br/>DispatchDocIds]
        B4[写入MemSegment<br/>Build方法]
        B5[写入倒排索引<br/>InvertedIndexer]
        B6[写入正排索引<br/>AttributeIndexer]
        B7[更新SegmentInfo<br/>docCount/Locator]
        B8[评估内存使用<br/>EvaluateCurrentMemUsed]
        B9{NeedDump检查<br/>转储条件}
        
        B1 --> B2
        B2 --> B3
        B3 --> B4
        B4 --> B5
        B4 --> B6
        B5 --> B7
        B6 --> B7
        B7 --> B8
        B8 --> B9
        B9 -->|否| B1
    end
    
    subgraph Dump["Dump阶段"]
        D1[创建SegmentDumper<br/>CreateSegmentDumper]
        D2[设置状态<br/>ST_BUILDING → ST_DUMPING]
        D3[创建转储项<br/>CreateSegmentDumpItems]
        D4[索引文件转储]
        D5[元数据文件转储]
        D6[异步转储到磁盘<br/>Dump方法]
        D7[创建DiskSegment<br/>从转储文件]
        D8[初始化DiskSegment<br/>Open方法]
        
        B9 -->|是| D1
        D1 --> D2
        D2 --> D3
        D3 --> D4
        D3 --> D5
        D4 --> D6
        D5 --> D6
        D6 --> D7
        D7 --> D8
    end
    
    subgraph Update["更新阶段"]
        U1[Reopen TabletData<br/>更新版本]
        U2[添加DiskSegment<br/>AddSegment]
        U3[移除MemSegment<br/>RemoveSegment]
        U4[更新Version<br/>新增Segment]
        U5[释放MemSegment内存]
        
        D8 --> U1
        U1 --> U2
        U2 --> U3
        U3 --> U4
        U4 --> U5
    end
    
    subgraph Conditions["转储条件"]
        C1[内存使用 > 阈值<br/>默认80%]
        C2[文档数 > 阈值<br/>默认100万]
        C3[时间间隔 > 阈值<br/>默认5分钟]
        B9 -.-> C1
        B9 -.-> C2
        B9 -.-> C3
    end
    
    style Open fill:#e3f2fd
    style Build fill:#fff3e0
    style Dump fill:#f3e5f5
    style Update fill:#e8f5e9
    style Conditions fill:#f5f5f5
  1. Open:初始化 TabletData,创建或获取 MemSegment
  2. Build:将文档写入 _normalBuildingSegment
  3. NeedDump:检查 MemSegment 是否需要转储
  4. CreateSegmentDumper:创建转储器,准备转储
  5. Dump:将 MemSegment 转储为 DiskSegment
  6. Reopen:更新 TabletData,添加新的 DiskSegment

7.2 文档的 DocId 分配

TabletWriter 在构建时需要为文档分配 DocId。关键代码:

// table/normal_table/NormalTabletWriter.h
class NormalTabletWriter {
private:
    // 分发 DocId:为文档分配 DocId
    void DispatchDocIds(document::IDocumentBatch* batch);
    
    docid_t _buildingSegmentBaseDocId;  // 当前构建 Segment 的基础 DocId
};

DocId 分配机制

flowchart TD
    Start[文档写入] --> GetBase[获取BaseDocId<br/>_buildingSegmentBaseDocId]
    GetBase --> AllocLocal[分配LocalDocId<br/>从0开始递增]
    AllocLocal --> Increment[LocalDocId递增<br/>localDocId++]
    Increment --> CalcGlobal[计算GlobalDocId<br/>GlobalDocId = BaseDocId + LocalDocId]
    CalcGlobal --> Assign[为文档分配DocId<br/>设置到Document对象]
    
    subgraph Concepts["概念说明"]
        direction TB
        BaseConcept[BaseDocId<br/>基础文档ID] --> BaseDesc[前面所有Segment的<br/>docCount之和]
        LocalConcept[LocalDocId<br/>局部文档ID] --> LocalDesc[在Segment内<br/>从0开始递增]
        GlobalConcept[GlobalDocId<br/>全局文档ID] --> GlobalDesc[全局唯一<br/>BaseDocId + LocalDocId]
    end
    
    subgraph Example["示例计算"]
        direction TB
        E1[Segment1: docCount=100<br/>BaseDocId=0]
        E2[Segment2: docCount=200<br/>BaseDocId=100]
        E3[Segment3: 第1个文档<br/>BaseDocId=300, LocalDocId=0<br/>GlobalDocId=300]
        E4[Segment3: 第2个文档<br/>BaseDocId=300, LocalDocId=1<br/>GlobalDocId=301]
        
        E1 --> E2
        E2 --> E3
        E3 --> E4
    end
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style GetBase fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style AllocLocal fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style Increment fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1px
    style CalcGlobal fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Assign fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Concepts fill:#f5f5f5,stroke:#757575,stroke-width:1px
    style BaseConcept fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px
    style LocalConcept fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1px
    style GlobalConcept fill:#fce4ec,stroke:#c2185b,stroke-width:1px
    style Example fill:#fff9c4,stroke:#f57f17,stroke-width:1px
  • BaseDocId:当前 MemSegment 的全局 DocId 起始值
  • LocalDocId:在 MemSegment 内的局部 DocId(从 0 开始递增)
  • GlobalDocIdbaseDocId + localDocId

8. Segment 的转储机制

8.1 SegmentDumper:转储器

SegmentDumper 负责将 MemSegment 转储到磁盘,定义在 framework/SegmentDumper.h 中:

// framework/SegmentDumper.h
class SegmentDumper : public SegmentDumpable
{
public:
    SegmentDumper(const std::string& tabletName, 
                  const std::shared_ptr<MemSegment>& segment,
                  int64_t dumpExpandMemSize,
                  std::shared_ptr<kmonitor::MetricsReporter> metricsReporter)
        : _tabletName(tabletName)
        , _dumpingSegment(segment)
        , _dumpExpandMemSize(dumpExpandMemSize)
    {
        // 设置 Segment 状态为 DUMPING
        _dumpingSegment->SetSegmentStatus(Segment::SegmentStatus::ST_DUMPING);
    }
    
    // 执行转储
    virtual Status Dump() = 0;
    
    // 获取转储的 SegmentMeta
    virtual std::pair<Status, SegmentMeta> GetDumpedSegmentMeta() = 0;
};

转储流程

转储是将 MemSegment 持久化为 DiskSegment 的关键步骤。让我们通过序列图来理解完整的转储流程:

sequenceDiagram
    participant Writer as TabletWriter
    participant MemSeg as MemSegment
    participant Dumper as SegmentDumper
    participant DiskSeg as DiskSegment
    participant TabletData as TabletData
    participant FileSys as FileSystem
    
    Writer->>MemSeg: NeedDump()?
    MemSeg-->>Writer: true
    
    Writer->>Writer: CreateSegmentDumper()
    Writer->>Dumper: SegmentDumper(MemSeg)
    Dumper->>MemSeg: SetStatus(ST_DUMPING)
    Dumper-->>Writer: Dumper
    
    Writer->>Dumper: Dump()
    Dumper->>MemSeg: CreateDumpItems()
    MemSeg-->>Dumper: DumpItems
    
    loop 遍历每个DumpItem
        Dumper->>FileSys: WriteFile(dumpItem)
        FileSys-->>Dumper: Success
    end
    
    Dumper->>DiskSeg: CreateDiskSegment(SegmentMeta)
    DiskSeg->>DiskSeg: Open(OpenMode)
    DiskSeg-->>Dumper: Success
    Dumper-->>Writer: Success
    
    Writer->>TabletData: AddSegment(DiskSeg)
    Writer->>TabletData: RemoveSegment(MemSeg)
    TabletData-->>Writer: Success

转储流程详解

  1. 创建 DumperCreateSegmentDumper() 创建转储器
    • 参数准备:准备转储参数(内存配额、IO 配额等)
    • 资源预留:预留转储所需的内存和 IO 资源
    • 转储项创建:创建转储项列表(索引文件、元数据文件等)
  2. 设置状态:将 MemSegment 状态设置为 ST_DUMPING
    • 状态转换:从 ST_BUILDING 转换为 ST_DUMPING
    • 写入保护:设置状态后,MemSegment 不再接收新文档
    • 并发控制:通过状态标记避免并发转储
  3. 执行转储:调用 Dump() 将内存数据写入磁盘
    • 索引转储:将各个 Indexer 的数据写入磁盘文件
    • 元数据转储:将 SegmentInfo、SegmentMetrics 等写入磁盘
    • 文件组织:按照索引格式组织文件(Package、Archive 等)
  4. 创建 DiskSegment:转储完成后创建 DiskSegment
    • SegmentMeta 创建:创建 DiskSegment 的 SegmentMeta
    • DiskSegment 初始化:调用 Open() 初始化 DiskSegment
    • 索引加载:根据 OpenMode 决定是否立即加载索引
  5. 更新状态:MemSegment 状态变为 ST_BUILT(实际已被 DiskSegment 替代)
    • TabletData 更新:将 DiskSegment 添加到 TabletData
    • MemSegment 移除:从 TabletData 移除 MemSegment
    • 资源释放:释放 MemSegment 的内存资源

8.2 转储的异步机制

转储是异步的,不会阻塞新的写入。关键设计:

// framework/SegmentDumper.h
class DumpControl {
public:
    // 控制转储任务的执行
    std::tuple<uint32_t, uint32_t> StartTask();
    std::tuple<uint32_t, uint32_t> Iterate(Status& taskStatus);
    uint32_t ExitTask(const bool isCoordinator);

private:
    std::atomic<uint32_t> _finishCount = 0;  // 完成的任务数
    uint32_t _totalCount;                     // 总任务数
    std::mutex _dumpMutex;                    // 转储互斥锁
    std::condition_variable _dumpCv;          // 转储条件变量
};

异步转储的优势

异步转储是 IndexLib 高性能写入的关键设计。让我们通过流程图来理解异步转储的机制:

graph TD
    A[MemSegment达到转储条件] --> B[创建转储任务]
    B --> C[提交到转储队列]
    C --> D[创建新MemSegment]
    D --> E[继续接收写入]
    
    C --> F[转储线程池]
    F --> G[执行转储任务]
    G --> H[写入磁盘]
    H --> I[创建DiskSegment]
    I --> J[更新TabletData]
    
    K[转储控制] --> F
    K --> L{检查并发度}
    L -->|未超限| G
    L -->|超限| M[等待]
    M --> L
    
    style A fill:#e3f2fd
    style D fill:#fff3e0
    style G fill:#f3e5f5
    style J fill:#e8f5e9

异步转储的优势

  • 不阻塞写入:转储过程中可以创建新的 MemSegment 继续接收写入
    • 写入连续性:写入操作不会被转储阻塞,保证低延迟
    • 吞吐量提升:写入和转储并行,提高系统吞吐量
    • 用户体验:用户写入请求可以立即返回,不需要等待转储完成
  • 提高吞吐量:写入和转储可以并行进行
    • CPU 利用:充分利用多核 CPU,写入和转储可以并行执行
    • IO 优化:转储 IO 和写入 IO 可以并行,提高 IO 利用率
    • 资源平衡:通过资源控制平衡写入和转储的资源使用
  • 资源控制:通过 DumpControl 控制转储任务的并发度
    • 并发限制:限制同时进行的转储任务数量,避免资源竞争
    • 优先级调度:支持转储任务的优先级调度,重要任务优先执行
    • 资源监控:监控转储任务的资源使用,及时调整策略

性能优化

  • 写入延迟:异步转储有效降低写入延迟
  • 吞吐量:并行写入和转储显著提高吞吐量
  • 资源利用:CPU 和 IO 利用率显著提升

9. Segment 的查询机制

9.1 多 Segment 并行查询

查询时需要遍历多个 Segment,可以并行查询以提高性能:

flowchart TD
    A[查询请求<br/>Query对象] --> B[TabletData.CreateSlice<br/>ST_BUILT]
    B --> C[获取Segment列表<br/>已构建的Segment]
    
    subgraph Segments["Segment列表"]
        S1[Segment1<br/>docCount=1000<br/>BaseDocId=0]
        S2[Segment2<br/>docCount=2000<br/>BaseDocId=1000]
        S3[Segment3<br/>docCount=1500<br/>BaseDocId=3000]
        C --> S1
        C --> S2
        C --> S3
    end
    
    subgraph Parallel["并行查询执行"]
        P1[Segment1查询<br/>IndexReader.Search]
        P2[Segment2查询<br/>IndexReader.Search]
        P3[Segment3查询<br/>IndexReader.Search]
        P4[线程池执行<br/>并发查询]
        P5[收集查询结果<br/>Result1, Result2, Result3]
        
        S1 --> P1
        S2 --> P2
        S3 --> P3
        P1 --> P4
        P2 --> P4
        P3 --> P4
        P4 --> P5
    end
    
    subgraph Merge["结果合并"]
        M1[DocId去重<br/>避免重复文档]
        M2[按相关性分数排序<br/>或按指定字段排序]
        M3[分页处理<br/>offset/limit]
        M4[聚合统计<br/>总数/平均值等]
        
        P5 --> M1
        M1 --> M2
        M2 --> M3
        M3 --> M4
    end
    
    subgraph Performance["性能优化"]
        PF1[并行度控制<br/>线程池大小]
        PF2[结果流式合并<br/>边查询边合并]
        PF3[索引剪枝<br/>跳过不相关Segment]
        
        P4 -.-> PF1
        M1 -.-> PF2
        C -.-> PF3
    end
    
    M4 --> R[返回结果<br/>QueryResult]
    
    style Segments fill:#e3f2fd
    style Parallel fill:#fff3e0
    style Merge fill:#f3e5f5
    style Performance fill:#f5f5f5
    style R fill:#e8f5e9

查询流程

  1. 获取 Segment 列表TabletData->CreateSlice(ST_BUILT) 获取所有已构建的 Segment
  2. 并行查询:对每个 Segment 的 Indexer 进行查询(如果支持并行)
  3. 合并结果:将各 Segment 的查询结果合并(去重、排序等)

9.2 DocId 转换

查询时需要将全局 DocId 转换为局部 DocId:

// 伪代码:全局 DocId 转局部 DocId
for (auto& seg : segments) {
    docid64_t baseDocId = GetBaseDocId(seg);
    if (globalDocId >= baseDocId && globalDocId < baseDocId + seg->GetDocCount()) {
        docid_t localDocId = globalDocId - baseDocId;
        // 在 Segment 内查询
        return seg->GetIndexer()->Get(localDocId);
    }
}

DocId 转换流程

flowchart TB
    Start([查询请求<br/>Query Request<br/>GlobalDocId]) --> LocateLayer[定位Segment阶段<br/>Locate Segment Phase]
    
    subgraph LocateGroup["1. 定位Segment Locate Segment"]
        direction TB
        L1[遍历Segment列表<br/>Traverse Segment List]
        L2[计算BaseDocId<br/>Calculate BaseDocId<br/>累加前面Segment的docCount]
        L3{GlobalDocId在范围内?<br/>In Range?<br/>BaseDocId <= GlobalDocId<br/>< BaseDocId + docCount}
        L4[找到对应Segment<br/>Found Target Segment]
        L1 --> L2
        L2 --> L3
        L3 -->|否| L1
        L3 -->|是| L4
    end
    
    LocateLayer --> ConvertLayer[DocId转换阶段<br/>DocId Conversion Phase]
    
    subgraph ConvertGroup["2. DocId转换 DocId Conversion"]
        direction TB
        C1[获取BaseDocId<br/>Get BaseDocId]
        C2[计算LocalDocId<br/>Calculate LocalDocId<br/>LocalDocId = GlobalDocId - BaseDocId]
        C3[验证有效性<br/>Validate<br/>0 <= LocalDocId < docCount]
        C1 --> C2
        C2 --> C3
    end
    
    ConvertLayer --> QueryLayer[Segment内查询阶段<br/>Segment Query Phase]
    
    subgraph QueryGroup["3. Segment内查询 Segment Query"]
        direction TB
        Q1[使用LocalDocId查询<br/>Query with LocalDocId<br/>IndexReader.Get]
        Q2[InvertedIndexer<br/>倒排索引<br/>Inverted Index]
        Q3[AttributeIndexer<br/>正排索引<br/>Attribute Index]
        Q4[返回文档数据<br/>Return Document Data]
        Q1 --> Q2
        Q1 --> Q3
        Q2 --> Q4
        Q3 --> Q4
    end
    
    QueryLayer --> ExampleLayer[转换示例<br/>Conversion Example]
    
    subgraph ExampleGroup["转换示例 Conversion Example"]
        direction TB
        E1[GlobalDocId = 1500]
        E2[Segment1: BaseDocId=0, docCount=1000<br/>范围: 0-999 不在范围内]
        E3[Segment2: BaseDocId=1000, docCount=2000<br/>范围: 1000-2999 在范围内]
        E4[LocalDocId = 1500 - 1000 = 500]
        E5[在Segment2内查询<br/>Query in Segment2<br/>LocalDocId=500]
        E1 --> E2
        E2 --> E3
        E3 --> E4
        E4 --> E5
    end
    
    ExampleLayer --> End([返回查询结果<br/>Return Query Result])
    
    LocateLayer -.->|包含| LocateGroup
    ConvertLayer -.->|包含| ConvertGroup
    QueryLayer -.->|包含| QueryGroup
    ExampleLayer -.->|包含| ExampleGroup
    
    L4 --> C1
    C3 --> Q1
    Q4 --> E1
    
    style Start fill:#c8e6c9,stroke:#388e3c,stroke-width:3px
    style End fill:#c8e6c9,stroke:#388e3c,stroke-width:3px
    style LocateLayer fill:#e3f2fd,stroke:#1976d2,stroke-width:3px
    style ConvertLayer fill:#fff3e0,stroke:#f57c00,stroke-width:3px
    style QueryLayer fill:#f3e5f5,stroke:#7b1fa2,stroke-width:3px
    style ExampleLayer fill:#fff9c4,stroke:#f57f17,stroke-width:3px
    style LocateGroup fill:#e3f2fd,stroke:#1976d2,stroke-width:3px
    style L1 fill:#90caf9,stroke:#1976d2,stroke-width:2px
    style L2 fill:#90caf9,stroke:#1976d2,stroke-width:2px
    style L3 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style L4 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style ConvertGroup fill:#fff3e0,stroke:#f57c00,stroke-width:3px
    style C1 fill:#ffcc80,stroke:#f57c00,stroke-width:2px
    style C2 fill:#ffcc80,stroke:#f57c00,stroke-width:2px
    style C3 fill:#ffcc80,stroke:#f57c00,stroke-width:2px
    style QueryGroup fill:#f3e5f5,stroke:#7b1fa2,stroke-width:3px
    style Q1 fill:#ce93d8,stroke:#7b1fa2,stroke-width:2px
    style Q2 fill:#ce93d8,stroke:#7b1fa2,stroke-width:2px
    style Q3 fill:#ce93d8,stroke:#7b1fa2,stroke-width:2px
    style Q4 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style ExampleGroup fill:#fff9c4,stroke:#f57f17,stroke-width:3px
    style E1 fill:#ffe082,stroke:#f57f17,stroke-width:2px
    style E2 fill:#ffe082,stroke:#f57f17,stroke-width:2px
    style E3 fill:#ffe082,stroke:#f57f17,stroke-width:2px
    style E4 fill:#ffe082,stroke:#f57f17,stroke-width:2px
    style E5 fill:#ffe082,stroke:#f57f17,stroke-width:2px
  1. 定位 Segment:根据全局 DocId 找到对应的 Segment
  2. 计算 BaseDocId:计算该 Segment 的基础 DocId
  3. 转换为局部 DocIdlocalDocId = globalDocId - baseDocId
  4. Segment 内查询:使用局部 DocId 在 Segment 内查询

10. Segment 的生命周期管理

10.1 Segment 的创建

Segment 的创建通过 ITabletFactory 实现:

// framework/ITabletFactory.h
class ITabletFactory {
public:
    // 创建 MemSegment
    virtual std::unique_ptr<MemSegment> CreateMemSegment(
        const SegmentMeta& segmentMeta) = 0;
    
    // 创建 DiskSegment
    virtual std::unique_ptr<DiskSegment> CreateDiskSegment(
        const SegmentMeta& segmentMeta,
        const framework::BuildResource& buildResource) = 0;
};

Segment 创建流程

flowchart TD
    Start[开始创建Segment] --> CreateMeta[创建SegmentMeta<br/>设置元数据]
    
    CreateMeta --> SetMeta[设置SegmentMeta属性<br/>SegmentId/Directory/Schema<br/>SegmentStatus等]
    
    SetMeta --> CallFactory[调用ITabletFactory<br/>根据类型创建Segment]
    
    CallFactory --> TypeSelect{Segment类型选择}
    
    TypeSelect -->|MemSegment| CreateMem[CreateMemSegment<br/>创建内存段<br/>传入SegmentMeta]
    TypeSelect -->|DiskSegment| CreateDisk[CreateDiskSegment<br/>创建磁盘段<br/>传入SegmentMeta和BuildResource]
    
    CreateMem --> Init[调用Open初始化<br/>加载Schema和配置<br/>初始化Indexer]
    CreateDisk --> Init
    
    Init --> AddToTablet[添加到TabletData<br/>TabletData.AddSegment]
    
    AddToTablet --> UpdateList[Segment列表更新<br/>_segments列表添加新Segment<br/>保持SegmentId有序]
    
    UpdateList --> End[Segment创建完成]
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style CreateMeta fill:#e3f2fd,stroke:#1976d2,stroke-width:1px
    style SetMeta fill:#e3f2fd,stroke:#1976d2,stroke-width:1px
    style CallFactory fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style TypeSelect fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style CreateMem fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style CreateDisk fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style Init fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style AddToTablet fill:#fce4ec,stroke:#c2185b,stroke-width:2px
    style UpdateList fill:#fce4ec,stroke:#c2185b,stroke-width:1px
    style End fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
  1. 创建 SegmentMeta:设置 SegmentId、Directory、Schema 等
  2. 调用 Factory:通过 ITabletFactory 创建 Segment
  3. 初始化 Segment:调用 Open() 初始化
  4. 添加到 TabletData:将 Segment 添加到 TabletData 的 Segment 列表

10.2 Segment 的销毁

Segment 的销毁通过智能指针自动管理:

// Segment 使用 shared_ptr 管理
using SegmentPtr = std::shared_ptr<Segment>;

// 当 Segment 不再被引用时,自动析构
// 析构时会:
// 1. 释放内存资源(MemSegment)
// 2. 关闭文件句柄(DiskSegment)
// 3. 清理 Indexer

Segment 销毁时机

graph LR
    A[Segment销毁触发] --> B{触发条件}
    B -->|合并后| C[旧Segment不再被引用]
    B -->|版本清理| D[清理旧版本]
    B -->|资源回收| E[ReclaimSegmentResource]
    C --> F[自动析构]
    D --> F
    E --> F
    F --> G[释放内存资源]
    F --> H[关闭文件句柄]
    F --> I[清理Indexer]
    
    style A fill:#e3f2fd
    style B fill:#fff3e0
    style F fill:#e8f5e9
    style G fill:#f3e5f5
  • 合并后:合并后的旧 Segment 不再被引用,自动销毁
  • 版本清理:清理旧版本时,旧 Segment 被销毁
  • 资源回收:通过 ReclaimSegmentResource() 主动回收资源

11. 实际应用场景

11.1 实时写入场景

在实时写入场景中,Tablet 和 Segment 的组织方式:

graph LR
    A[文档持续写入] --> B[MemSegment]
    B --> C{达到阈值?}
    C -->|是| D[转储为DiskSegment]
    C -->|否| A
    D --> E[创建新MemSegment]
    E --> A
    D --> F[定期Commit]
    F --> G[更新Version]
    
    H[Tablet] --> B
    H --> I[DiskSegment1]
    H --> J[DiskSegment2]
    H --> K[DiskSegment3]
    
    style A fill:#e3f2fd
    style B fill:#fff3e0
    style D fill:#e8f5e9
    style F fill:#f3e5f5
    style H fill:#fce4ec
  1. 持续写入:文档持续写入 MemSegment
  2. 定期转储:MemSegment 达到阈值后转储为 DiskSegment
  3. 新 Segment:创建新的 MemSegment 继续接收写入
  4. 版本提交:定期 Commit,更新 Version

11.2 查询场景

在查询场景中,需要遍历多个 Segment:

flowchart TD
    Start[查询请求] --> GetTabletData[TabletData.GetSegment<br/>获取Segment列表]
    
    GetTabletData --> GetList[获取所有已构建的Segment<br/>ST_BUILT状态的Segment]
    
    GetList --> ParallelQueryStart[并行查询各Segment]
    
    subgraph ParallelQueryGroup["并行查询阶段"]
        direction LR
        Q1[Segment1查询<br/>使用LocalDocId]
        Q2[Segment2查询<br/>使用LocalDocId]
        Q3[Segment3查询<br/>使用LocalDocId]
        Q4[更多Segment...]
    end
    
    ParallelQueryStart --> Q1
    ParallelQueryStart --> Q2
    ParallelQueryStart --> Q3
    ParallelQueryStart --> Q4
    
    Q1 --> MergeStart[结果合并]
    Q2 --> MergeStart
    Q3 --> MergeStart
    Q4 --> MergeStart
    
    subgraph MergeGroup["结果合并阶段"]
        direction TB
        M1[收集各Segment结果<br/>Result1, Result2, Result3...]
        M2[DocId去重<br/>避免重复文档]
        M3[按相关性分数排序<br/>或按指定字段排序]
        M4[分页处理<br/>offset/limit]
        
        M1 --> M2
        M2 --> M3
        M3 --> M4
    end
    
    MergeStart --> M1
    M4 --> DocIdConvertStart[DocId转换]
    
    subgraph DocIdConvertGroup["DocId转换阶段"]
        direction TB
        D1[获取每个Segment的BaseDocId<br/>前面所有Segment的docCount之和]
        D2[转换LocalDocId为GlobalDocId<br/>GlobalDocId = BaseDocId + LocalDocId]
        D3[验证GlobalDocId有效性]
        
        D1 --> D2
        D2 --> D3
    end
    
    DocIdConvertStart --> D1
    D3 --> Return[返回查询结果<br/>包含GlobalDocId和文档数据]
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style GetTabletData fill:#e3f2fd,stroke:#1976d2,stroke-width:1px
    style GetList fill:#e3f2fd,stroke:#1976d2,stroke-width:1px
    style ParallelQueryStart fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style ParallelQueryGroup fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Q1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
    style Q2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
    style Q3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
    style Q4 fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
    style MergeStart fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style MergeGroup fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    style M1 fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style M2 fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style M3 fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style M4 fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
    style DocIdConvertStart fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style DocIdConvertGroup fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style D1 fill:#ffe0b2,stroke:#f57c00,stroke-width:1px
    style D2 fill:#ffe0b2,stroke:#f57c00,stroke-width:1px
    style D3 fill:#ffe0b2,stroke:#f57c00,stroke-width:1px
    style Return fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
  1. 获取 Segment 列表:从 TabletData 获取所有已构建的 Segment
  2. 并行查询:对多个 Segment 进行并行查询
  3. 结果合并:合并各 Segment 的查询结果
  4. DocId 转换:将局部 DocId 转换为全局 DocId

12. 性能优化与最佳实践

12.1 Segment 大小优化

Segment 大小的影响

  • 小 Segment
    • 优势:转储快,内存占用小,查询延迟低
    • 劣势:Segment 数量多,查询时需要遍历更多 Segment,合并频繁
  • 大 Segment
    • 优势:Segment 数量少,查询效率高,合并频率低
    • 劣势:转储慢,内存占用大,查询延迟可能增加

最佳实践

  • 实时写入:使用较小的 Segment(如 100MB),保证低延迟
  • 批量构建:使用较大的 Segment(如 1GB),提高构建效率
  • 动态调整:根据查询负载动态调整 Segment 大小

12.2 DocId 映射优化

优化策略

  1. BaseDocId 缓存
    • 缓存每个 Segment 的 BaseDocId,避免重复计算
    • 使用有序数组或跳表快速定位 Segment
  2. 二分查找
    • 使用二分查找定位 Segment,时间复杂度 O(log n)
    • 对于大量 Segment 的场景,性能提升明显
  3. 预计算
    • 在 Segment 添加时预计算 BaseDocId
    • 避免查询时的实时计算

12.3 内存管理优化

优化策略

  1. 内存池
    • 使用内存池减少内存分配开销
    • 预分配常用大小的内存块
  2. 内存回收
    • 及时释放不再使用的内存
    • 使用 LRU 等策略回收不常用的索引数据
  3. 内存监控
    • 实时监控内存使用,及时触发转储
    • 设置告警阈值,防止内存溢出

13. 小结

Tablet 和 Segment 的组织方式是 IndexLib 索引机制的核心。通过本文的深入解析,我们了解到:

核心概念

  • Tablet 管理多个 Segment:通过 TabletData 管理有序的 Segment 列表,保证 DocId 映射的正确性
  • Segment ID 分配:通过位掩码区分不同类型的 Segment(实时、合并等),支持快速类型判断
  • DocId 映射:使用两级 DocId 机制(全局 DocId = baseDocId + localDocId),支持高效的文档定位
  • SegmentMeta 和 SegmentInfo:记录 Segment 的元数据和详细信息,支持 Schema 演进和生命周期管理
  • MemSegment 和 DiskSegment:内存段用于实时写入,磁盘段用于持久化存储,采用策略模式实现
  • 转储机制:MemSegment 转储为 DiskSegment 是异步的,不阻塞写入,提高系统吞吐量
  • 查询机制:查询时遍历多个 Segment,可以并行查询提高性能,通过 DocId 映射实现全局查询
  • 生命周期管理:通过智能指针自动管理 Segment 的生命周期,保证资源正确释放

设计亮点

  1. 两级 DocId 机制:通过 BaseDocId 和 LocalDocId 实现高效的文档定位和查询
  2. Slice 机制:提供灵活的 Segment 筛选,隐藏内部实现,提高代码可维护性
  3. 异步转储:转储不阻塞写入,写入和转储并行,提高系统吞吐量
  4. 按需加载:DiskSegment 支持按需加载,减少内存占用,提高启动速度
  5. 资源管理:通过 ResourceMap 共享资源,减少资源开销,提高系统效率

性能优化

  • Segment 大小优化:根据场景选择合适的 Segment 大小,平衡写入和查询性能
  • DocId 映射优化:通过缓存、二分查找等优化 DocId 定位性能
  • 内存管理优化:使用内存池、及时回收、实时监控等优化内存使用

理解 Tablet 和 Segment 的组织方式,是掌握 IndexLib 索引构建和查询机制的基础。在下一篇文章中,我们将深入介绍索引构建的完整流程,包括 Build、Flush、Seal、Commit 等各个阶段的实现细节和性能优化策略。