IndexLib(2):Tablet 与 Segment:索引的组织方式
发布于:
在上一篇文章中,我们介绍了 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,用于私有数据
- Public Segment:第 29 位为 1(
设计优势:
- 快速判断:通过位运算快速判断 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
使用场景详解:
- 查询时:
CreateSlice(ST_BUILT)获取所有已构建的 Segment- 目的:只查询已持久化的 Segment,保证数据一致性
- 性能:跳过构建中的 Segment,减少不必要的查询
- 写入时:
CreateSlice(ST_BUILDING)获取构建中的 Segment- 目的:获取当前正在构建的 MemSegment,用于写入
- 场景:检查是否需要创建新的 MemSegment
- 合并时:
CreateSlice(ST_BUILT)获取需要合并的 Segment- 目的:获取所有已构建的 Segment,用于合并策略选择
- 优化:可以进一步筛选(如按大小、时间等)
- 监控时:
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
构建流程详解:
- Open:初始化构建资源,创建 Indexer
- 资源初始化:创建内存池、缓存等资源
- Indexer 创建:根据 Schema 创建倒排索引、正排索引等 Indexer
- 状态设置:设置 Segment 状态为
ST_BUILDING
- Build:接收文档批次,写入各个 Indexer
- DocId 分配:为文档分配局部 DocId(从 0 开始递增)
- 文档写入:将文档写入各个 Indexer(倒排索引、正排索引等)
- 元数据更新:更新 SegmentInfo(docCount、Locator 等)
- NeedDump:检查是否达到转储条件
- 内存检查:检查内存使用是否达到阈值
- 文档数检查:检查文档数是否达到阈值
- 时间检查:检查是否达到转储时间间隔
- 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
- Open:打开 Segment 目录,读取 SegmentInfo
- OpenIndexer:按需打开各个 Indexer(NORMAL 模式立即打开,LAZY 模式按需打开)
- GetIndexer:查询时获取 Indexer,LAZY 模式下此时才加载
- 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
- Open:初始化 TabletData,创建或获取 MemSegment
- Build:将文档写入
_normalBuildingSegment - NeedDump:检查 MemSegment 是否需要转储
- CreateSegmentDumper:创建转储器,准备转储
- Dump:将 MemSegment 转储为 DiskSegment
- 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 开始递增)
- GlobalDocId:
baseDocId + 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
转储流程详解:
- 创建 Dumper:
CreateSegmentDumper()创建转储器- 参数准备:准备转储参数(内存配额、IO 配额等)
- 资源预留:预留转储所需的内存和 IO 资源
- 转储项创建:创建转储项列表(索引文件、元数据文件等)
- 设置状态:将 MemSegment 状态设置为
ST_DUMPING- 状态转换:从
ST_BUILDING转换为ST_DUMPING - 写入保护:设置状态后,MemSegment 不再接收新文档
- 并发控制:通过状态标记避免并发转储
- 状态转换:从
- 执行转储:调用
Dump()将内存数据写入磁盘- 索引转储:将各个 Indexer 的数据写入磁盘文件
- 元数据转储:将 SegmentInfo、SegmentMetrics 等写入磁盘
- 文件组织:按照索引格式组织文件(Package、Archive 等)
- 创建 DiskSegment:转储完成后创建 DiskSegment
- SegmentMeta 创建:创建 DiskSegment 的 SegmentMeta
- DiskSegment 初始化:调用
Open()初始化 DiskSegment - 索引加载:根据 OpenMode 决定是否立即加载索引
- 更新状态: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
查询流程:
- 获取 Segment 列表:
TabletData->CreateSlice(ST_BUILT)获取所有已构建的 Segment - 并行查询:对每个 Segment 的 Indexer 进行查询(如果支持并行)
- 合并结果:将各 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
- 定位 Segment:根据全局 DocId 找到对应的 Segment
- 计算 BaseDocId:计算该 Segment 的基础 DocId
- 转换为局部 DocId:
localDocId = globalDocId - baseDocId - 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
- 创建 SegmentMeta:设置 SegmentId、Directory、Schema 等
- 调用 Factory:通过
ITabletFactory创建 Segment - 初始化 Segment:调用
Open()初始化 - 添加到 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
- 持续写入:文档持续写入 MemSegment
- 定期转储:MemSegment 达到阈值后转储为 DiskSegment
- 新 Segment:创建新的 MemSegment 继续接收写入
- 版本提交:定期 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
- 获取 Segment 列表:从 TabletData 获取所有已构建的 Segment
- 并行查询:对多个 Segment 进行并行查询
- 结果合并:合并各 Segment 的查询结果
- DocId 转换:将局部 DocId 转换为全局 DocId
12. 性能优化与最佳实践
12.1 Segment 大小优化
Segment 大小的影响:
- 小 Segment:
- 优势:转储快,内存占用小,查询延迟低
- 劣势:Segment 数量多,查询时需要遍历更多 Segment,合并频繁
- 大 Segment:
- 优势:Segment 数量少,查询效率高,合并频率低
- 劣势:转储慢,内存占用大,查询延迟可能增加
最佳实践:
- 实时写入:使用较小的 Segment(如 100MB),保证低延迟
- 批量构建:使用较大的 Segment(如 1GB),提高构建效率
- 动态调整:根据查询负载动态调整 Segment 大小
12.2 DocId 映射优化
优化策略:
- BaseDocId 缓存:
- 缓存每个 Segment 的 BaseDocId,避免重复计算
- 使用有序数组或跳表快速定位 Segment
- 二分查找:
- 使用二分查找定位 Segment,时间复杂度 O(log n)
- 对于大量 Segment 的场景,性能提升明显
- 预计算:
- 在 Segment 添加时预计算 BaseDocId
- 避免查询时的实时计算
12.3 内存管理优化
优化策略:
- 内存池:
- 使用内存池减少内存分配开销
- 预分配常用大小的内存块
- 内存回收:
- 及时释放不再使用的内存
- 使用 LRU 等策略回收不常用的索引数据
- 内存监控:
- 实时监控内存使用,及时触发转储
- 设置告警阈值,防止内存溢出
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 的生命周期,保证资源正确释放
设计亮点:
- 两级 DocId 机制:通过 BaseDocId 和 LocalDocId 实现高效的文档定位和查询
- Slice 机制:提供灵活的 Segment 筛选,隐藏内部实现,提高代码可维护性
- 异步转储:转储不阻塞写入,写入和转储并行,提高系统吞吐量
- 按需加载:DiskSegment 支持按需加载,减少内存占用,提高启动速度
- 资源管理:通过 ResourceMap 共享资源,减少资源开销,提高系统效率
性能优化:
- Segment 大小优化:根据场景选择合适的 Segment 大小,平衡写入和查询性能
- DocId 映射优化:通过缓存、二分查找等优化 DocId 定位性能
- 内存管理优化:使用内存池、及时回收、实时监控等优化内存使用
理解 Tablet 和 Segment 的组织方式,是掌握 IndexLib 索引构建和查询机制的基础。在下一篇文章中,我们将深入介绍索引构建的完整流程,包括 Build、Flush、Seal、Commit 等各个阶段的实现细节和性能优化策略。