Flink+Doris 实时数仓

Doris基本原理

Doris基本架构非常简单,只有FE(Frontend)、BE(Backend)两种角色,不依赖任何外部组件,对部署和运维非常友好。架构图如下

Flink+Doris 实时数仓_数据模型

可以 看到Doris 的数仓架构十分简洁,不依赖 Hadoop 生态组件,构建及运维成本较低。

FE(Frontend)以 Java 语言为主,主要功能职责:

  1. 接收用户连接请求(MySql 协议层)
  2. 元数据存储与管理
  3. 查询语句的解析与执行计划下发
  4. 集群管控

FE 主要有有两种角色,一个是 follower,还有一个 observer,leader是经过选举推选出的特殊follower。follower主要是用来达到元数据的高可用,保证单节点宕机的情况下,元数据能够实时地在线恢复,而不影响整个服务。

而 Observer 不会参与选举,因此 Observer 也不会成为 Master 。 一条元数据日志需要在多数 Follower 节点写入成功,才算成功。比如3个 FE ,2个写入成功才可以。这也是为什么 Follower 角色的个数需要是奇数的原因。 Observer 角色和这个单词的含义一样,仅仅作为观察者来同步已经成功写入的元数据日志,并且提供元数据读服务。

BE(Backend) 以 C++ 语言为主,主要功能职责:

  1. 数据存储与管理
  2. 查询计划的执行

数据模型

Doris内部表中,主要有3种数据模型,分别是 Aggregate ,Unique ,Duplicate

在介绍数据模型之前,先解释一下Column,在Doris中,Column 可以分为两大类:Key 和 Value。从业务角度看,Key 和 Value 分别对应维度列和指标列

Aggregate模型

简单来说,Aggregate模型就是预聚合模型,类似于Molap,通过提前定义key列及value列的聚合方式,在数据导入的时候已经将key列相同的数据按照value列的聚合方式聚合在一起,即最终表里key相同的数据只保留一条,value按照相应的规则计算。下面举例说明,表结构如下:

CREATE TABLE tmp_table_1
    (
        user_id varchar(64) COMMENT "用户id",
        channel varchar(64) COMMENT "用户来源渠道",
        city_code varchar(64) COMMENT "用户所在城市编码",
        last_visit_date DATETIME REPLACE DEFAULT "1970-01-01 00:00:00" COMMENT "用户最后一次访问时间",
        total_cost BIGINT SUM DEFAULT "0" COMMENT "用户总消费"
    )
ENGINE=OLAP
AGGREGATE KEY(user_id, channel, city_code)
DISTRIBUTED BY HASH(user_id) BUCKETS 6
    PROPERTIES("storage_type"="column","replication_num" = "1");

表结构中,key列分别是user_id, channel, city_code ,value列是last_visit_date,total_cost,他们的聚合方式分别为REPLACE,SUM。

现在,向该表中插入一批数据

insert into tmp_table_1 values('suh_001','JD','001','2022-01-01 00:00:01','57');
insert into tmp_table_1 values('suh_001','JD','001','2022-02-01 00:00:01','76');
insert into tmp_table_1 values('suh_001','JD','001','2022-03-01 00:00:01','107');

按照我们的理解,现在tmp_table_1中虽然我们插入了3条数据,但是这三条数据的key都是一致的,那么最终表中应该只有一条数据,并且last_visit_date的值应为"2022-03-01 00:00:01",total_cost的值应为 240。下面我们验证一下

Flink+Doris 实时数仓_大数据_02

可以看到,结果与我们预期⼀致。

Unique 模型

正如建设的实时数仓那样,我们更加关注的是如何保证主键的唯⼀性,即如何获得 Primary Key 唯⼀性约束,本质上是和数据库的主键约束一模一样的

Duplicate 模型

在某些多维分析场景下,数据既没有主键,也没有聚合需求。因此引⼊ Duplicate 数据模型来满⾜这类需求。举例说明。

CREATE TABLE tmp_table_2
    (
        user_id varchar(64) COMMENT "用户id",
        channel varchar(64) COMMENT "用户来源渠道",
        city_code varchar(64) COMMENT "用户所在城市编码",
        visit_date DATETIME COMMENT "用户登陆时间",
cost BIGINT COMMENT "用户消费金额"
    )
ENGINE=OLAP
DUPLICATE KEY(user_id, channel, city_code)
DISTRIBUTED BY HASH(user_id) BUCKETS 6
    PROPERTIES("storage_type"="column","replication_num" = "1");

插入数据

insert into tmp_table_2 values('suh_001','JD','001','2022-01-01 00:00:01','57');
insert into tmp_table_2 values('suh_001','JD','001','2022-02-01 00:00:01','76');
insert into tmp_table_2 values('suh_001','JD','001','2022-03-01 00:00:01','107');

因为此时数据是Duplicate 模型,因此不会进行任何处理,查询应该能查到3条数据

Flink+Doris 实时数仓_数据模型_03

数据模型的选择建议

因为数据模型在建表时就已经确定,且无法修改。所以,选择一个合适的数据模型非常重要。

Aggregate 模型可以通过预聚合,极大地降低聚合查询时所需扫描的数据量和查询的计算量,非常适合有固定模式的报表类查询场景。但是该模型对 count(*) 查询很不友好。同时因为固定了 Value 列上的聚合方式,在进行其他类型的聚合查询时,需要考虑语意正确性。

Unique 模型针对需要唯一主键约束的场景,可以保证主键唯一性约束。但是无法利用 ROLLUP 等预聚合带来的查询优势。

Duplicate 适合任意维度的 Ad-hoc 查询。虽然同样无法利用预聚合的特性,但是不受聚合模型的约束,可以发挥列存模型的优势。

技术架构

整体数据链路如下图

Flink+Doris 实时数仓_数据模型_04

  1. 通过FlinkCDC采集Mysql Binlog到Kafka中的Topic1
  2. 开发Flink任务消费上述Binlog生成相关主题的宽表,写入Topic2
  3. 配置Doris Routine Load 任务,将Topic2的数据导入Doris

实践

关于步骤1和步骤2的实践,在前文 “基于Flink-CDC数据同步⽅案” 的文章中已有说明,本文将对步骤3展开详细的说明。

建表

因业务数据经常伴随有UPDATE,DELETE 等操作,为了保持实时数仓的数据粒度与业务库一致,所以选择Doris Unique 模型(数据模型在下文有重点介绍)具体建表语句如下

CREATE TABLE IF NOT EXISTS table_1
(
key1 varchar(32),
key2 varchar(32),
key3 varchar(32),
value1 int,
value2 varchar(128),
value3 Decimal(20, 6),
data_deal_datetime DateTime COMMENT '数据处理时间',
data_status INT COMMENT '数据是否删除,1表示正常,-1表示数据已经删除'
) 
ENGINE=OLAP
UNIQUE KEY(`key1`,`key2`,`key3`)
COMMENT "xxx"
DISTRIBUTED BY HASH(`key2`) BUCKETS 32
PROPERTIES (
"storage_type"="column",
"replication_num" = "3",
"function_column.sequence_type" = 'DateTime'
);

可以看到,表结构中有两个字段分别是data_deal_datetime,data_status。

  1. data_deal_datetime主要是相同key情况下数据覆盖的判断依据
  2. data_status用来兼容业务库对数据的删除操作
数据导入任务

Doris提供了主动拉取Kafka数据的功能,配置如下

CREATE ROUTINE LOAD database.table1 ON table1
COLUMNS(key1,key2,key3,value1,value2,value3,data_deal_datetime,data_status),
ORDER BY data_deal_datetime
PROPERTIES
(
"desired_concurrent_number"="3",
"max_batch_interval" = "10",
"max_batch_rows" = "500000",
"max_batch_size" = "209715200",
"format" = "json",
"json_root" = "$.data",
"jsonpaths"="[\"$.key1\",\"$.key2\",\"$.key3\",\"$.value1\",\"$.value2\",
           \"$.value3\",\"$.data_deal_datetime\",\"$.data_status\"]"
)FROM KAFKA
(
"kafka_broker_list"="broker1_ip:port1,broker2_ip:port2,broker3_ip:port3",
"kafka_topic"="topic_name",
"property.group.id"="group_id",
"property.kafka_default_offsets"="OFFSET_BEGINNING"
);

导入语句中,

  1. ORDER BY data_deal_datetime 表示根据data_deal_datetime字段去覆盖key相同的数据
  2. desired_concurrent_number 表示期望的并发度。
  3. max_batch_interval/max_batch_rows/max_batch_size 这三个参数分别表示
  1. 每个子任务最大执行时间。
  2. 每个子任务最多读取的行数。
  3. 每个子任务最多读取的字节数。
任务监控与报警

Doris routine load 如果遇到脏数据会导致任务暂停,所以需要定时监控数据导入任务的状态并且自动恢复失败任务。并且将错误信息发至指定的lark群。具体脚本如下

import pymysql  #导入 pymysql
import requests,json


#打开数据库连接
db= pymysql.connect(host="host",user="user",
                    password="passwd",db="database",port=port)

# 使用cursor()方法获取操作游标
cur = db.cursor()

#1.查询操作
# 编写sql 查询语句 
sql = "show routine load"
cur.execute(sql)        #执行sql语句
results = cur.fetchall()        #获取查询的所有记录
for row in results :
    name = row[1]
    state = row[7]
    if state != 'RUNNING':
        err_log_urls = row[16]
        reason_state_changed = row[15]
        msg = "doris 数据导入任务异常:\n name=%s \n state=%s \n reason_state_changed=%s \n err_log_urls=%s \n即将自动恢复,请检查错误信息" % (name, state,
reason_state_changed, err_log_urls)
        payload_message = {
    "msg_type": "text",
    "content": {
        "text": msg
    }
}
        url = 'lark 报警url'
        s = json.dumps(payload_message)
        r = requests.post(url, data=s)
        cur.execute("resume routine load for " + name)

cur.close()
db.close()

线上配置的监控1分钟执行一次,如果遇到任务暂停,会自动恢复导入任务,但是导致任务失败的脏数据会跳过,此时需要人工排查失败原因,修复后重新触发该条数据的导入。