TL;DR
- 场景: 实时/时序 OLAP,亿级明细,低延迟看板与多维分析
- 结论: 按时间 Chunk→Segment 列存 + Roll-up + Bitmap 索引 + mmap + 多级缓存
- 产出: 存储/查询机制要点、检查清单、常见坑位修复思路
数据存储
Druid 的数据存储架构
Druid 中的数据存储采用分层逻辑结构,主要包含以下几个层次:
1. DataSource(数据源)
- 概念类比: DataSource 类似于关系型数据库(RDBMS)中的 Table 或数据表
- 功能定位: 作为数据的顶层容器,一个 DataSource 包含特定业务领域的所有相关数据
- 示例: 电商网站可能创建”user_behavior”、“product_inventory”等 DataSource 来存储不同业务数据
2. Chunk(时间块)
- 时间分区: 每个 DataSource 的数据按照时间范围划分,形成 Chunk
- 分区粒度: 可根据业务需求配置不同的时间粒度
- 常见配置:天(1d)、小时(1h)、周(1w)等
- 示例:按天分区时,2023-01-01 就是一个独立的 Chunk
- 查询优势: 这种按时间划分的结构使时间范围查询非常高效
3. Segment(数据段)
- 物理存储: Segment 是数据的实际物理存储单元,每个 Segment 都是一个独立文件
- 数据规模: 一个 Segment 通常包含几百万行数据(约500万行)
- 文件特性: Segment 文件采用列式存储格式,具有压缩和索引特性
- 并行处理: Druid 可以并行加载和处理多个 Segment
数据分布机制
- 时间顺序: Segment 严格按照时间先后顺序组织在 Chunk 中
- 分布式存储: Segment 会被分布式存储在 Druid 集群的多个节点上
- 副本机制: 为确保高可用,每个 Segment 会有多个副本(通常2-3个)存储在不同节点
查询优化
- 时间过滤: 查询时系统首先确定涉及的时间范围(Chunk)
- Segment 筛选: 然后只加载相关 Chunk 中的 Segment 文件
- 性能优势: 这种机制大幅减少了需要扫描的数据量,特别适合时间序列数据分析场景
实际应用示例
- 监控系统: 每分钟生成一个 Segment,每小时形成一个 Chunk
- IoT 数据处理: 按设备ID+时间双重维度组织 Segment
- 广告分析: 每天创建一个 Chunk,按广告主ID进一步细分 Segment
数据分区
- Druid 处理的是事件数据,每条数据都会带有一个时间戳,可以使用时间进行分区
- 上图指定了分区粒度为天,那么每天的数据都会被单独存储和查询
Segment 内部存储
- Druid 采用列式存储,每列数据都是在独立的结构中存储
- Segment 中的数据类型主要分为三种:
- 类型1 时间戳: 每一行数据,都必须有一个 TimeStamp,Druid 一定会基于事件戳来分片
- 类型2 维度列: 用来过滤 Fliter 或者组合 GroupBY 的列,通过是 String、Float、Double、Int 类型
- 类型3 指标列: 用来进行聚合计算的列,指定的聚合函数 sum、average 等
MiddleManger 节点接受到 Ingestion 的任务之后,开始创建 Segment:
- 转换成列式存储格式
- 用 bitmap 来建立索引(对所有的 dimension 列建立索引)
- 使用各种压缩算法
- 算法1: 所有的使用 LZ4 压缩
- 算法2: 所有的字符串采用字典编码、标识以达到最小化存储
- 算法3: 对位图索引使用位图压缩
Segment 创建完成之后,Segment 文件就是不可更改的,被写入到深度存储(目的是为了防止 MiddleManager 节点宕机后,Segment 丢失)。然后 Segment 加载到 Historical 节点,Historical 节点可以直接加载到内存中。
同时,Metadata store 也会记录下这个新创建的 Segment 的信息,如结构、尺寸、深度存储的位置等等
Coordinator 节点需要这些元数据来协调数据的查找。
索引服务
索引服务是数据导入并创建 Segment 数据文件的服务
索引服务是一个高可用的分布式服务,采用主从结构作为架构模式,索引服务由三大组件构成:
- Overlord 作为主节点
- MiddleManager 作为从节点
- Peon 用于运行一个 Task
服务构成
Overlord 组件
负责创建 Task、分发 Task 到 MiddleManger 上运行,为 Task 创建锁以及跟踪 Task 运行状态并反馈给用户
MiddleManager 组件
作为从节点,负责接收主节点分配的任务,然后为每个 Task 启动一个独立的 JVM 进程来完成具体的任务
Peon(劳工)组件
由 MiddleManager 启动的一个进程用于一个 Task 任务的运行
对比 YARN
- Overlord 类似 ResourceManager 负责集群资源管理和任务分配
- MiddleManager 类似 NodeManager 负责接收任务和管理本节点的资源
- Peon 类似 Container 执行节点上具体的任务
Task 类型
- index hadoop task: Hadoop 索引任务,利用 Hadoop 集群执行 MapReduce 任务以完成 Segment 数据文件的创建,适合体量较大的 Segments 数据文件的创建任务
- index kafka task: 用于 Kafka 数据的实时摄入,通过 Kafka 索引任务可以在 Overlord 上配置一个 KafkaSupervisor,通过管理 Kafka 索引任务的创建和生命周期来完成 Kafka 数据的摄取
- merge task: 合并索引任务,将多个 Segment 数据文件按照指定的聚合方法合并为一个 segments 数据文件
- kill task: 销毁索引任务,将执行时间范围内的数据从 Druid 集群的深度存储中删除
Druid 高性能查询机制详解
Druid 之所以能够实现低延迟、高性能的查询,主要依赖于以下五个关键技术点:
1. 数据预聚合
Druid 在数据摄入阶段就进行预聚合处理,这显著减少了查询时需要处理的数据量。系统支持多种聚合方式:
- 计数(count)
- 求和(sum)
- 最大值(max)
- 最小值(min)
- 近似基数(hyperloglog)等
例如,针对网站访问日志数据,Druid 可以在数据摄入时就预先计算好每分钟的 PV、UV 等指标,避免查询时进行全量计算。
2. 列式存储与数据压缩
Druid 采用列式存储架构,配合多种压缩算法:
- 字符串类型: 字典编码(dictionary encoding)压缩
- 数值类型: 位压缩(bit compression)、LZ4 压缩、ZSTD 压缩
这种存储方式不仅减少了 I/O 操作,还能显著提高压缩率,例如时间戳列通常可以获得10倍以上的压缩比。
3. Bitmap 索引
Druid 为每个维度列都建立了 Bitmap 索引:
- 对每个维度值生成对应的 bitmap
- 支持快速的 AND/OR/NOT 等位运算
- 特别适合高基数维度的过滤查询
例如,对”浏览器类型”维度进行”Chrome OR Firefox”的查询,可以直接通过 bitmap 的 OR 运算快速定位到相关数据行。
4. 内存文件映射(mmap)
Druid 使用 mmap 技术来访问磁盘数据:
- 将索引文件和数据文件映射到内存地址空间
- 操作系统自动管理内存页的加载和回收
- 避免传统 I/O 的系统调用开销
- 支持热数据的自动缓存
这种机制使得查询可以像访问内存一样快速,同时由操作系统智能管理缓存。
5. 查询结果缓存
Druid 实现了多级缓存机制:
- 中间结果缓存: 存储部分查询结果
- 查询结果缓存: 完整查询结果的缓存
- 支持基于时间的缓存失效策略
- 对于相同查询模式的重复请求可立即返回
例如,仪表盘常见的”最近1小时数据”查询,在缓存有效期内可直接返回结果,无需重新计算。
数据预聚合
- Druid 通过 RollUp 的处理,将原始数据在注入的时候就进行了汇总处理
- RollUp 可以压缩我们需要保存的数据量
- Druid 会把选定的相同维度的数据进行聚合操作
- Druid 可以通过 queryGranularity 来控制注入数据的粒度,最小的 queryGranularity 是 millisecond(毫秒级别)
Roll-Up
聚合前: 原始数据包含多条相同维度组合的明细记录
聚合后: 按维度组合聚合后的汇总数据
位图索引
Druid 在摄入的数据示例:
- 第一列为时间,Appkey 和 Area 都是维度列,Value 为指标列
- Druid 会在导入阶段自动对数据进行 RollUp,将维度相同组合的数据进行聚合处理
- 数据聚合的粒度根据业务需要确定
按天聚合后的数据: Druid 通过建立位图索引,实现快速数据查找。
BitMap 索引主要为了加速查询时有条件过滤的场景,Druid 生成索引文件的时候,对每个列的每个取值生成对应的 BitMap 集合:
索引位图可以看作是:HashMap<String, BitMap>
- Key: 维度的值
- Value: 该表中对应的行是否有该维度的值
SQL 查询示例
SELECT sum(value) FROM tab1
WHERE time='2020-01-01'
AND appkey in ('appkey1', 'appkey2')
AND area='北京'
执行过程分析:
- 根据时间段定位到 Segment
- appkey in (‘appkey1’, ‘appkey2’) and area=‘北京’ 查到各自的 bitmap
- (appkey1 or appkey2) and 北京
- (110000 or 001100) and 101010 = 111100 and 101010 = 101000
- 符合条件的列为:第一行 & 第三行,这几行 sum(value) 的和为40
GroupBy 查询示例
SELECT area, sum(value)
FROM tab1
WHERE time='2020-01-01'
AND appkey in ('appkey1', 'appkey2')
GROUP BY area
该查询与上面的查询不同之处在与将符合条件的列:
- appkey1 or appkey2
- 110000 or 001100 = 111100
- 将第一行到第四行取出来
- 在内存中做分组聚合,结果为:北京40、深圳60
错误速查
| 症状 | 根因 | 定位 | 修复 |
|---|---|---|---|
| 任务成功但查询为空 | interval/时区不匹配,segmentGranularity 与 queryGranularity 混淆 | 查看 task 日志中的 intervals;用 SQL 检查 __time 过滤 | 统一时区;修正 interval;重建 Segment |
| Task failed to acquire lock | 多任务写同一 timeChunk 锁冲突 | Overlord UI/日志看锁与并发 | 拆分 interval/分区;序列化写入或升维分区键 |
| Rejected segment / overshadowed | 版本/规则覆盖导致拒绝 | Coordinator 规则与 segment 版本对比 | 调整 Load/Drop 规则或重置 version,重新发布 |
| Historical 不加载新段 | 深度存储路径/凭据错误或规则未命中 | Historical 日志 “Failed to download segment”;Rules 页 | 修正存储配置/权限;补充加载规则并回填 |
| Direct buffer memory OOM | MaxDirectMemorySize 偏小或并发过高 | 查看 hs_err / JVM 日志关键字 | 增大 Direct Memory;调小 processing.buffer/threads |
| GroupBy 内存不足/超时 | v2 spill 配置不足或数据倾斜 | Broker/Historical 日志与 query context | 开启/增大磁盘溢写;提升限额或改 Timeseries/TopN |
| 过滤命中差/慢 | 维度未建索引/高基数策略不当 | Segment metadata 查看列与索引 | 为常用维度建 bitmap / 使用 sketch;优化 schema |
| 无法解析时间 | 时间格式/字段映射错误 | timestampSpec 校验样例数据 | 修正 format/时区;设置 secondarySpec |
| Kafka 摄入落后 | 分区少/并行度低/变换耗时 | Supervisor 状态与 lag 指标 | 提升 task 并行与分区;下推过滤/精简 transform |
| Can not vectorize filter | 不可向量化函数/表达式 | Broker 日志报错点 | 替换函数或允许非向量化执行;评估代价 |