Druid目前已经有很多公司用于实时计算和实时OLAP,而且效果很好。
缺点:配置和查询都比较复杂和繁琐,不支持SQL或类SQL接口。对SQL支持的不够完善(不支持Join)
1. 特性
Druid 官网:https://druid.apache.org,Github: apache/incubator-druid
根据官方文档,Druid 的核心特性主要包括:
- 列式存储。列式存储的优势在于查询的时候可以只返回指定的列的数据,其次同一列数据往往具有很多共性,这带来另一个好处就是存储的时候压缩效果比较好。
- 可扩展的分布式架构。
- 并行计算。
- 数据摄入支持实时和批量。这里的实时的意思是输入摄入即可查。如果大家看过我之前关于实时计算的文章,应该猜到了这就是典型的 lambda 架构,后面再细说。
- 运维友好。
- 云原生架构,高容错性。
- 支持索引,便于快速查询。
- 基于时间的分区
- 自动聚合。
当时我选型使用 Druid 的时候,其实最吸引我的主要是下面三条:
- 实时摄取可查询。换句话说就是数据查询无延迟,这个在一些对实时性要求比较高的场景下,比如监控告警,还是很重要的。
- 自动实时聚合。
- 高效的索引结构便于查询。
2. 架构
Druid 的架构在我看来还是比较复杂的,包含 6 个不同的组件。
- Coordinator:顾名思义,Coordinator 就是协调器,主要负责 segment 的分发等。比如我们只保存 30 天的数据,这个规则就是由 Coordinator 来定时执行的。
- Overlord:处理数据摄入的 task,将 task 提交到 MiddleManager。比如使用 Tranquility 做数据摄入的时候,每个 segment 都会生成一个对应的 task。
- Broker : 处理外部请求,并对结果进行汇总。
- Router : Router 相当于多个 Broker 前面的路由,不是必须的。
- Historical :Historical 可以理解为将 segment 存储到本地,相当于 cache。相比于 Deep Storage 的,Historical 将 segment 直接存储到本地磁盘,只有 segment 存储到本地才能被查询。其实这个地方是有点异于直观感受的。正常我们可能会认为查询先查本地,如果本地没有数据才去查 Deep Storage,但是实际上如果本地没有相应的 segment,则查询是无法查询的。
Historical 处理那些 segment 是由 Coordinator 指定的,但是 Historical 并不会和 Coordinator 直接交互,而是通过 Zookeeper 来解耦。 - MiddleManager : MiddleManager 可以认为是一个任务调度进程,主要用来处理 Overload 提交过来的 task。每个 task 会以一个 JVM 进程启动。
各个组件之间的交互如下:
根据线条,上图主要关注三个部分:
- Queries: Routers 将请求路由到 Broker,Broker 向 MiddleManager 和 Historical 进行数据查询。这里 MiddleManager 主要负责查询正在进行摄入的数据查询,比如现在正在摄入 12:00 ~ 13:00 的数据,那么我们要查询就去查询 MiddleManager,MiddleManager 再将请求分发到具体的 peon,也就是 task 的运行实体上。而历史数据的查询是通过 Historical 查询的,然后数据返回到 Broker 进行汇总。这里需要注意的时候数据查询并不会落到 Deep Storage 上去,也就是查询的数据一定是 cache 到本地磁盘的。很多人一个直观理解查询的过程应该是先查询 Historical,Historical 没有这部分数据则去查 Deep Storage。Druid 并不是这么设计的。
- Data/Segment: 这里包括两个部分,MiddleManager 的 task 在结束的时候会将数据写入到 Deep Storage,这个过程一般称作 Segment Handoff。然后 Historical 定期的去下载 Deep Storage 中的 segment 数据到本地。
- Metadata: Druid 的元数据主要存储到两个部分,一个是 Metadata Storage,这个一般是 MySQL 等关系型数据库;另一个是 Zookeeper。下图是 Druid 在 Zookeeper 中的 znode。zk 的作用主要是用来给各个组件进行解耦。
3. 数据存储
Druid 的数据存储单位是 segment,segment 按时间粒度(可以通过参数 segmentGranularity 指定)划分。每个 segment 会被存储到 Deep Storage 和 Historical 进程所在的节点上,当然 segment 可以是有多个备份的,这样查询的时候就可以实现并行查询,并不是为了高可用,高可用通过 Deep Storage 保证。
Druid 的数据格式如下:
数据格式
数据源:datasource,datasource的结构有:时间列(timestamp)、维度列(Dimension)和指标列(Metric)
时间列:将时间相近的一些数据聚合在一起,查询的时候指定时间范围
维度列:标识一些统计的维度,比如:名称、类别等
指标列:用于聚合和计算的列,比如:访问总数、合计金额等
timestamp | demensions | metric | ||||
date | userid | username | age | sex | visits | costs |
2020-01-01T00:00:00Z | 100001 | 张三 | 20 | 男 | 201 | 20.10 |
2020-01-01T00:00:00Z | 100002 | 李四 | 21 | 男 | 160 | 16.00 |
2020-01-01T00:00:00Z | 100003 | 王五 | 20 | 女 | 100 | 10.00 |
Druid 会自动对数据进行 Rollup,也就是聚合。如果时间粒度是一小时,那么在这一个小时内维度相同的数据会被合并为一条,Timestamp 都变成整点,metrics 会根据聚合函数进行聚合,比如 sum, max, min 等,注意是没有平均 avg 的。Timestamp 和 Metrics 直接压缩存储即可,比较简单。下面重点说一下维度的存储。
Druid 的一大亮点就是支持多维度实时聚合查询,简单来说就是 filter 和 group。而实现这个特性的关键技术主要两点:bitmap + 倒排。
首先,Druid 会将维度值编码映射成数字 ID,类似数据仓库中的维度表,主要是为了存储节省空间。比如上面图中的 Page 维度:Justin Bieber 被编码成 0,Ke$ha 被编码成 1。对于 Username 维度:Boxer -> 0,Reach -> 1,Helz -> 0,Xeno -> 1。
然后 Page 这列数据就会被存储为 [0,0,1,1]。
最后是位图,用来表示对于某个维度的某个值,有哪些列包含了这个值,比如:
- Justin Bieber: [1,1,0,0]
- Boxer: [1,0,0,0]
那么 filter 查询 Page='Justin Bieber' and Username='Boxer'
,直接将 1100 和 1000 做位运算 and 即可。group 也是类似。
上面的位图,其实也是一种倒排,常规的倒排后面的 list 中直接包含的是 Document ID,这里直接表示成位图,其实是异曲同工。
4. 数据摄入
Druid 的数据摄入支持实时流模式和批模式,也就是典型的 Lambda 架构。
通常通过像 Kafka 这样的消息总线(加载流式数据)或通过像 HDFS 这样的分布式文件系统(加载批量数据)来连接原始数据源。
Druid 通过 Indexing 处理将原始数据以 segment 的方式存储在数据节点,segment 是一种查询优化的数据结构。
数据存储
Druid 采用列式存储。根据不同列的数据类型(string,number 等),Druid 对其使用不同的压缩和编码方式。Druid 也会针对不同的列类型构建不同类型的索引。
类似于检索系统,Druid 为 string 列创建反向索引,以达到更快速的搜索和过滤。类似于时间序列数据库,Druid 基于时间对数据进行智能分区,以达到更快的基于时间的查询。
不像大多数传统系统,Druid 可以在数据摄入前对数据进行预聚合。这种预聚合操作被称之为 rollup,这样就可以显著的节省存储成本。
Druid的读写过程
- 实时摄入的过程:
- 实时数据会首先按行摄入Real-time Nodes,Real-time Nodes会先将每行的数据加入到1个map中。
- 等达到一定的行数或者大小限制时,Real-time Nodes 就会将内存中的map 持久化到磁盘中
- Real-time Nodes 会按照segmentGranularity将一定时间段内的小文件merge为一个大文件,生成segment
- 将segment上传到Deep Storage(HDFS,S3)中
- Coordinator知道有segment生成后,会通知相应的Historical Node下载对应的Segment,并负责该Segment的查询。
- 离线摄入的过程: 离线摄入的过程比较简单,就是直接通过MR job 生成Segment,剩下的逻辑和实时摄入相同
- 用户查询时,都会经过broker处理,通过Zookeeper找到查询目标所在的segment应该发往实时或离线的哪个节点。如果是实时请求,会发往Real-time Nodes;如果是离线请求,会发往Historical。
- broker节点拥有LRU的缓存机制,会将Historical发送回来的结果进行缓存,下次被查到的时候直接返回。但是实时数据被认为是不可信的,所以不会被broker缓存。
5. 查询
支持两种查询:JSON-HTTP,SQL两种方式
5.1 Natvie
Druid 最开始的时候是不支持 SQL 查询的,原生查询是通过查询 Broker 提供的 http server 来实现的,如下:
curl -X POST '<queryable_host>:<port>/druid/v2/?pretty' -H 'Content-Type:application/json' -H 'Accept:application/json' -d @<query_json_file>
下面是一个简单的 json 查询示例。
{
"queryType": "timeseries",
"dataSource": "sample_datasource",
"intervals": [ "2012-01-01T00:00:00.000/2012-01-03T00:00:00.000" ],
"granularity": "day",
"aggregations": [
{ "type": "longSum", "name": "sample_name1", "fieldName": "sample_fieldName1" },
{ "type": "doubleSum", "name": "sample_name2", "fieldName": "sample_fieldName2" }
],
"context": {
"grandTotal": true
}
}
查询类型
Timeseries:基于时间范围查询的类型
TopN:基于单维度的排名查询
GroupBy:基于多维度的分组查询
架构
6.1 明细查询
由于 Druid 会对存储的数据做 Rollup,正常情况下是不能存储明细的。但是如果是你一定需要明细的话,有个办法就是将所有所有的列,包括 metric,都设置成 dimension,同时将聚合粒度设置到可以接受的粒度,比如秒。
OLAP方案对比
Druid | Kylin | Elasticsearch | Spark SQL | |
数据规模 | 超大 | 超大 | 中等 | 超大 |
查询效率 | 高 | 高 | 中等 | 低 |
并发度 | 高 | 高 | 高 | 低 |
SQL支持 | 中 | 高 | 中 | 高 |
灵活度 | 中 | 低 | 高 | 高 |
Druid:是一个实时处理时序数据的OLAP数据库,因为它的索引首先按照时间分片,查询的时候也是按照时间线去路由索引。
Kylin:核心是Cube,Cube是一种预计算技术,基本思路是预先对数据作多维索引,查询时只扫描索引而不访问原始数据从而提速。
ES:最大的特点是使用了倒排索引解决索引问题。根据研究,ES在数据获取和聚集用的资源比在Druid高。
Spark SQL:基于Spark平台上的一个OLAP框架,基本思路是增加机器来并行计算,从而提高查询速度。
使用场景
- 广告数据分析
- 风控分析
- 服务器指标存储
- 应用性能指标
- 实时在线分析系统 OLAP
- 实时报表分析
- 离线+实时数据源
- 行为数据分析
使用建议
- 时序化数据:所有行记录中必须有日期指标
- OLAP并发有限,不适合OLTP查询,建议首次回源加Cache
- 目前不支持JOIN操作,不支持数据更新
- 离线数据替换前一天实时数据
- 分页支持的不够完善
另外、Druid在项目中已经投产多年,用OLAP方案解决业务上的问题,整理技术点为了方便相似业务同学参考和使用。
Druid基本使用
druid.properties
driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql:///db3
username=root
password=root
# 初始化连接数
initialSize=5
# 最大连接数
maxActive=10
# 超时时间
maxWait=3000
public class DruidDemo1 {
public static void main(String[] args) {
String sql = "select * from student where id = ?";
Scanner sc = new Scanner(System.in);
int id = sc.nextInt();
Connection conn = null;
PreparedStatement pstmt = null;
try {
//创建Properties对象,用于加载配置文件
Properties pro = new Properties();
//加载配置文件
pro.load(DruidDemo1.class.getClassLoader().getResourceAsStream("druid.properties"));
//获取数据库连接池对象
DataSource ds = DruidDataSourceFactory.createDataSource(pro);
//获取数据库连接对象
conn = ds.getConnection();
//获取statement,使用prepareStatement,防止sql注入
pstmt = conn.prepareStatement(sql);
//注入sql参数(sql中?将被替换)
pstmt.setInt(1,id);
//执行sql,返回数据集
ResultSet rs = pstmt.executeQuery();
//数据处理
while(rs.next()){
int id1 = rs.getInt("id");
String name = rs.getString("name");
System.out.println(id+" "+ name);
};
} catch (IOException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放stmt
if(pstmt != null){
try {
pstmt.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
//conn归还连接池
if(conn != null){
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
运行结果