4. Segment详解

Segment 是 Druid 存储和查询的核心单元。说白了,Druid 能跑多快、能存多省,全靠 Segment 设计得好不好。我刚开始接触 Druid 时,觉得 Segment 不就是个文件嘛,后来踩了不少坑才明白——它的结构、生命周期、不可变特性,每一个细节都藏着性能优化的门道。

一句话总结:Segment 是 Druid 中不可变的、按时间组织的、列式存储的数据文件。它是查询加速和存储压缩的基础。

4.1 Segment 的内部结构

一个 Segment 对应一个时间区间(比如一小时、一天)和一个数据源。它内部长什么样?我拆开给你看。

Segment 内部结构示意图 Segment(时间区间 + 数据源) 元数据区 Segment ID | 时间区间 | 数据源名 | 列Schema | 版本号 | 大小 列式存储区(按列独立存储) 时间列 字典编码 Bitmap索引 min/max 压缩数据 维度列 字典编码 倒排索引 Bitmap索引 压缩数据 指标列 LZ4压缩 ZSTD压缩 浮点编码 压缩数据 索引区 时间索引 维度索引 空间索引 bloom filter 每个列独立存储,包含编码、索引、压缩数据三部分。查询时只读取需要的列。

每个 Segment 内部,数据是按列存储的。每一列又分成三个部分:

  • 编码区:字典编码、浮点编码等,把原始值变成整数 ID
  • 索引区:Bitmap 索引、倒排索引、min/max 索引,用于快速过滤
  • 数据区:压缩后的实际数据,LZ4 或 ZSTD 压缩

我的经验:时间列和维度列一定要建 Bitmap 索引。我在一个日志分析项目中,没给维度列建索引,查询慢得离谱。加上 Bitmap 索引后,过滤速度提升了 10 倍以上。

4.2 Segment 的生命周期

一个 Segment 从生到死,经历四个阶段。我习惯用「生、老、病、死」来记它。

阶段 状态 说明 存储位置
1. 生成 APPENDING 实时任务正在写入,数据还在内存中 实时节点内存
2. 发布 PUBLISHING Segment 落盘,正在上传到 Deep Storage 本地磁盘 + 上传中
3. 可用 USABLE 已上传完成,可以被查询 Deep Storage + 历史节点
4. 淘汰 UNUSABLE 被新版本覆盖,不再用于查询 Deep Storage(可清理)

为什么会这样设计?你想想看,实时数据不断进来,如果 Segment 一直在变,查询就没法做缓存和优化。所以 Druid 的做法是:实时数据先攒着,攒够了就生成一个不可变的 Segment,然后扔给历史节点去服务查询。

注意:Segment 在 APPENDING 状态时,查询性能很差。我曾经在生产环境遇到过实时查询超时,就是因为 Segment 还没落盘,查询走了实时节点的内存数据。后来我把实时窗口调小了,让 Segment 尽快落盘,问题就解决了。

4.3 Segment 的不可变性

Segment 一旦生成,就永远不能修改。这是 Druid 最核心的设计原则之一。

不可变性带来了三个好处:

  • 查询稳定:数据不变,缓存可以放心用,查询计划可以复用
  • 存储高效:列式压缩、编码、索引,都是基于不变数据做的优化
  • 容错简单:Segment 是只读的,不需要考虑并发写入的问题

但不可变性也有代价。比如你想修改一条历史数据,怎么办?Druid 的做法是:生成一个新的 Segment 版本,把旧版本标记为不可用。查询时,Druid 会自动选择最新版本。

核心机制:Segment 的版本号是一个时间戳。新版本覆盖旧版本,查询永远读最新版本。旧版本不会立即删除,而是保留一段时间,用于回滚或恢复。

我记得有一次,业务方要求修改三天前的一条数据。如果是传统数据库,一条 UPDATE 就搞定了。但在 Druid 里,我得重新生成那段时间的 Segment,然后发布新版本。虽然麻烦,但保证了查询的稳定性和一致性。

4.4 Segment 的合并与优化

Segment 太小太多,查询性能会下降。为什么?因为查询时要扫描的 Segment 数量太多,每个 Segment 都有元数据开销。我见过一个极端案例,一个数据源有 10 万个 Segment,查询光打开文件就花了 5 秒。

解决方案就是 Segment 合并(Compaction)。

4.4.1 什么时候需要合并?

  • 实时任务产生的 Segment 太小(比如几分钟一个)
  • Segment 数量太多,超过几千个
  • 数据有更新,产生了大量小 Segment 版本

4.4.2 合并怎么做?

Druid 提供了两种合并方式:

方式 触发方式 适用场景 注意事项
自动合并 Druid 自动调度 实时数据持续产生小 Segment 需要配置合并策略
手动合并 提交合并任务 批量数据导入后、数据更新后 需要指定时间区间和目标大小

合并的核心逻辑很简单:把多个小 Segment 读出来,重新排序、压缩、建索引,生成一个大的 Segment。合并后的 Segment 查询更快,存储也更省。

我建议:Segment 的大小控制在 300MB 到 700MB 之间。太小了查询慢,太大了加载慢。我在一个日活千万的系统中,把 Segment 大小设为 500MB,查询性能提升了 30%。

4.4.3 合并的避坑指南

我曾经踩过一个坑:合并任务跑得太频繁,把历史节点的 CPU 打满了,导致查询超时。后来我调整了合并策略,只在凌晨低峰期执行合并,问题就解决了。

这里有几个要点:

  • 合并不要并发太多:建议同时只跑 1-2 个合并任务
  • 合并时间窗口要合理:一次合并的时间区间不要太大,建议 1-7 天
  • 合并后要验证:检查 Segment 大小和数量是否符合预期

4.4.4 合并的配置示例

在 Druid 的 Coordinator 配置中,可以设置自动合并策略:

{
  "type": "compaction",
  "dataSource": "my_datasource",
  "interval": "2024-01-01/2024-12-31",
  "tuningConfig": {
    "maxRowsPerSegment": 5000000,
    "maxTotalRows": 20000000
  },
  "taskConfig": {
    "maxNumConcurrentSubTasks": 2,
    "maxRetry": 3
  }
}

这个配置的意思是:对 my_datasource 在 2024 年的数据做合并,每个 Segment 最多 500 万行,总共不超过 2000 万行,同时最多跑 2 个合并子任务。

注意:合并任务会消耗大量内存和 CPU。如果你的集群资源紧张,建议先做一次小规模测试,看看合并对查询的影响。

4.5 小结

Segment 是 Druid 的基石。理解它的结构、生命周期、不可变性和合并优化,你就能更好地调优 Druid 的性能。

我个人觉得,Segment 的不可变性是 Druid 最聪明的设计。虽然它带来了一些限制,但换来了查询的稳定和存储的高效。至于合并,记住一句话:小 Segment 是万恶之源,定期合并是解药。