DorisDB测试

DorisDB在千亿级日增数据下的实践

一、使用背景

1.1 选用原因

        我司原有业务查询使用的数据库为greenplum,在数据源变更后,数据量从原来的日增千万级别(近百G)暴增至日增千亿(10T)级别,原有的12台GP集群在数据量增长后存在以下痛点:

  • 1、数据导入
            原有的数据导入借助于gpload的工具,在有索引的情况下,数据导入随着数据量的增加会变慢,在千亿级日增情况下,有索引的表根本无法导入。其次如果使用先导入,后建索引的方式,导入过程还是不理想,建索引的时间会由于数据量的增长而增长,整个数据导入时间无法满足业务需求。
  • 2、数据存储
            GP在数据存储这一块,如果使用heap表的方式创建表,数据来说是不做任何压缩进行存储,比较占用存储资源。如果采用列存表的方式,需要手动指定压缩等级和字段,在查询时,cpu会进行解压缩操作,增加了cpu的计算耗时。
  • 3、数据计算
            计算瓶颈其实是我们数据量增长之后主要的痛点。在原有的使用过程中,针对于业务A的整体运行时长,从客户触发到最终显示,需要大概100分钟左右,数据量增长后,业务A的基本跑不动。其次,在日常的etl过程中,一些定时表关联在原GP的处理过程中只能是对事实表按照时间粒度做切分,小部分小部分的进行关联,然后再进行合并处理,数据量增长之后的关联,在现有资源下也无法实现。

        在GP无法承受如此巨大的数据量,满足不了业务的需求时,我们将目光转向其他解决方案,在测试了DorisDB,clickhouse以及其他olap产品后,结合自身的业务特点和使用上的易用性,最终选用了DorisDB作为MPP的解决方案。此文档也是基于DorisDB进行详细的业务测试过程中整理的文档。

1.2 集群配置

此次测试使用的机器资源如下所示(只部署了DorisDB的环境):

机器数量:10台
机器系统:centos7.6
机器内存:256G
机器磁盘:7200转机械硬盘,每台机器为8T*4,做了raid0
网络带宽:内网万兆光迁
CPU:    2*12 core

此次部署的DorisDB的集群详情如下(未使用spark load,没有安装spark的客户端):

fe数量:3台(1 master + 2 follower)
be数量:10台
broker数量:10台

1.4 集群配置参数

针对自身业务特点,修改了以下参数:

fe:
broker load的参数
1.允许运行的最大的broker数量
max_broker_concurrency=10
2.每个be处理的数据量
max_bytes_per_broker_scanner=32212254720

        上述两个参数影响broker load导入时be处理数据的并发数量和单个be处理的数据量

be:
文件合并的参数
1.be节点base compaction线程数量
base_compaction_num_threads_per_disk=4
2.base compaction时写磁盘的限速,单位为M
base_compaction_write_mbytes_per_sec=20
3.be节点cumulative compaction的线程数量
cumulative_compaction_num_threads_per_disk=8
4.be节点cumulative compactiond写磁盘的限速,单位为M 
cumulative_compaction_write_mbytes_per_sec=300
5.be节点cumulative compactiond线程轮询的间隔
cumulative_compaction_check_interval_seconds=2

        上述五个参数主要控制DorisDB对于文件合并的效率,可以根据自身的硬件性能和实际业务情况调整该参数。大量数据导入到DorisDB中时,DorisDB需要根据排序key做排序,根据字段的值做压缩合并的操作,此时会占用磁盘性能,调整该参数(业务闲时)可以加速这一过程,使DorisDB专注于计算。

        以上参数仅提供参考,请根据自身资源和实际情况酌情调整


二、数据导入

        由于数据源的特殊性,数据存放在文件中,原始文件为压缩文件,因此在实际测试过程中,我们主要对以下几种导入进行了测试(spark load未测试成功),最终选取了broker load的方式作为最终的数据导入的方案。该方案能够实现单任务200W+/s的导入速度,并且支持并行的方式,进一步提高数据导入速度。

2.1 stream load

        刚开始使用DorisDB时,我们使用的导入方式即为stream load的方式测试小批量的数据,但是在数据量增大的情况下,大概数据单次导入到100G时,发现这种微批导入的方式有数据膨胀的情况,导入前的数据和入库后的数据量对比差异明显(导入前100G左右,导入后DorisDB在250G左右,并且磁盘IO占用高居不下),遂放弃。

2.2 datax

        datax主要是由于有丰富的使用经验,其次是datax在对于数据接入过程中很灵活,可以增加很多丰富的transformer插件来减轻后续的数据清洗的压力。datax使用时我们主要使用的是mysqlWriter和利用stream load实现的DorisWriter(此处艾特社区张怀北同学)。前者在我们测试时,DorisDB文档中还未增加doriswriter的内容,利用的是mysql的jdbc连接实现数据的导入。后者则是社区利用stream load 的api实现的数据导入。在使用过程中,发现导入速度并不理想(10台一起跑,导入速度在60W/s),满足不了我们每天的增量数据的导入的要求。也放弃datax的导入方案。(如果有对这种方式感兴趣的同学可以在社区留言)。文章末尾附件有该writer实现的核心代码。

2.3 broker load

        broker load 的导入方式使我们最终采用的方案。原本对spark load的方式抱有很大希望,因为我们业务中的数据另一个导入方向为hbase,使用的导入方式为bulkload的方式,利用spark 合成Hfile的方式写入hbase,该方式能够将待导入的数据进行排序后,形成hbase底层需要的hfile的格式写入到hdfs,hbase可以不用再将数据在内存中排序后再落盘,在进行合并形成hfile,能够借助于spark计算集群减轻hbase排序和文件合并的压力,使得hbase专注于业务。我们猜想DorisDB的spark load是否也采用了类似的思想,利用spark处理数据后直接生成DorisDB所需要的底层存储文件后写入DorisDB,但是在经过咨询后,现有的spark load不具备这种提前排序生成底层存储文件的导入功能,但是在未来会开发。后续开发完成后,对于DorisDB的导入应该提升很大(个人臆想_)。
        broker load的时候我们测试了分别从hdfs load csv文件和parquet文件,最终发现使用parquet导入比csv性能高出两倍到三倍的样子(相同数据条数,字段),也刚好是parquet文件和csv文件实际存储相差的样子。同时在导入时,可以先将待导入的表的副本设为1,可以减少导入过程中的数据clone,加快导入速度。最终测的的导入速度大概在(300W/s左右)

2.4 insert into

        insert into的方式主要应用于日常关联后的结果数据导入到新表的操作,为了测试insert into操作的速度以及影响,我们在一个时间段内,连续大批量的导入到另外一张表来发现问题。最终发现,insert into的速度大概在780W/s,但是连续大批量的insert之后,大概连续导入了四批次,每次一百二十亿的数据后(insert语句为:insert into tableA select * from tableB),cpu一段时间内占用会比较高,可能是内部数据的合并操作导致的cpu使用上升。


三、数据查询

        下面主要选取了业务测试中比较重要的场景A作为测试,该测试主要测试日常事实和维度之间的关联性能和面向业务的单表聚合查询的性能。

3.1 表模型选取

        此次测试结合实际业务,我们主要测试的是明细表模型。DUPLICATE KEY选用的也是业务上常用来作为过滤条件的字段,采用的是按照天创建动态分区的方式建表,分布键根据业务特点的关系,基本上所有的表的分布键都是用一个字段。

3.2 表创建方式

example:
create table ods.table1 (
col1 datetime not null comment "time1",
col2 varchar(128) not null comment "str1",
col3 varchar(64) not null comment "str2",
col4 TINYINT not null comment "0,1,2",
col5 varchar(128) not null comment "str3",
col6 datetime not null comment "time2",
col7 TINYINT not null comment "-1,0,1,2,3"
)
DUPLICATE KEY(col1,col2,col3)
PARTITION BY RANGE(col1) (
PARTITION p20210101 values less THAN ("2021-01-02 00:00:00"),
PARTITION p20210102 values less THAN ("2021-01-03 00:00:00"),
                            .
                            .
			    .
PARTITION p20210330 values less THAN ("2021-03-31 00:00:00"),
PARTITION p20210331 values less THAN ("2021-04-01 00:00:00")
)
DISTRIBUTED BY HASH(col3) BUCKETS 128
PROPERTIES(
"replication_num" = "1",
"dynamic_partition.enable"="true",
"dynamic_partition.time_unit"="DAY",
"dynamic_partition.start"="-110",
"dynamic_partition.end"="2",
"dynamic_partition.prefix"="p",
"dynamic_partition.buckets"="128"
);

        其余的表的创建方式类似,字段不同。

        在后续的join过程中,由于预先没有给表设置属性Colocation Group,因此我们使用的alter方式修改每个表的Colocation Group属性。如下:

ALTER TABLE table1 SET ("colocate_with" = "cg_col3");

3.3 查询测试

3.3.1 关联测试
1. hash join
  默认的join的方式为hash join,会使用JOIN (BROADCAST)的方式

2. shuffle join
  在join的后边显示的指定 [shuffle]的方式,会不采用广播,而是用shuffle的方式进行join。
  如果某些情况下使用默认的join时,右表数据量较大,广播到多个be节点时会造成不可忽略的性能开销,或者查询直接oom导致be挂掉,可以尝试使用此方式进行查询优化。

3. colocation join
  如果待关联的两张表的分布键和buckets数量一致,同时join的key是分布键,那么可以使用colocation join的方式进行本地join。
  由于数据会根据分布键进行hash分布,相同分布键的数据处于同一个机器上,在join的时候数据只会在本地进行join,避免跨网络IO。

4. 性能对比(全数据join之后的count)(大概的均值)
  DorisDB:
    左表数量:736亿 右表数量:15亿
     默认的join :oom
    shuffle join: 90S
    colocation join:60S
  GP(极限是不到十亿join不到一亿,耗时近1800s):
    跑不动!!!
3.3.2 单表查询测试
DorisDB:  
    group by 的字段为DUPLICATE KEY中的部分或者全部字段。单表数据量为736亿。
    逻辑为: 全数据量下的select count(a.col1) as num from (select col1 as col1,col2 as col2 from table1 group by col1,col2)a; 去重后的col1数据量为125亿
    耗时:600s

###3.4参数优化
以下参数在使用过程中需要根据实际情况进行具体调整:

1.exec_mem_limit 
该参数影响的地方很多,导入,查询,o查询om时可加大。建议可以设为机器内存资源的70%-80%(只有doris进程情况下)
2.is_report_success
该参数设为true后可以比较方便的查看物理执行计划
3.parallel_fragment_exec_instance_num
该参数影响查询时的并行度,建议为机器core数的一半,查询并发小的情况下可以酌情增加
4.query_timeout
查询或者insert的超时时间,数据量大的情况下可以增加该参数
5.disable_storage_page_cache
在内存资源充足的情况下,可以开启page cache,启用DorisDB自己维护的page cache,加速查询
6.storage_page_cache_limit
开启page cache占用的内存大小,酌情设置。

在经过测试后,DorisDB能够满足我司替换原有greenplum集群,解决原有业务。


此文档只是我司针对自己业务进行的部分测试的一个文档,欢迎大佬指出文档中不正确的地方,互相交流,共同进步

##附件

利用stream load实现的writer核心task代码

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package com.alibaba.datax.plugin.writer.doriswriter;

import com.alibaba.datax.common.element.Record;
import com.alibaba.datax.common.exception.DataXException;
import com.alibaba.datax.common.plugin.RecordReceiver;
import com.alibaba.datax.common.plugin.TaskPluginCollector;
import com.alibaba.datax.common.util.Configuration;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.codec.binary.Base64;
import org.apache.http.HttpHeaders;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.UUID;

public class DorisWriterTask
{
    private static final Logger LOG = LoggerFactory.getLogger(DorisWriterTask.class);
    private final Configuration configuration;
    private String username;
    private String password;
    private String loadUrl;
    private List<Object> heads;
    private List<String> column;
    private static final String SEPARATOR = "|";
    private int batchSize;
    private HttpClientBuilder httpClientBuilder;

    public DorisWriterTask(Configuration configuration) {this.configuration = configuration;}

    public void init()
    {
        List<Object> connList = configuration.getList(Key.CONNECTION);
        Configuration conn = Configuration.from(connList.get(0).toString());
        String endpoint = conn.getString(Key.ENDPOINT);
        String table = conn.getString(Key.TABLE);
        String database = conn.getString(Key.DATABASE);
        this.column = configuration.getList(Key.COLUMN, String.class);
        // 如果 column 填写的是 * ,直接设置为null,方便后续判断
        if (this.column!= null && this.column.size() == 1 && "*".equals(this.column.get(0))) {
            this.column = null;
        }
        this.batchSize = configuration.getInt(Key.BATCH_SIZE, 1024);
        this.heads = configuration.getList(Key.HEADS);
        this.username = configuration.getString(Key.USERNAME);
        this.password = configuration.getString(Key.PASSWORD, null);
        this.loadUrl = String.format("%s/api/%s/%s/_stream_load", endpoint, database, table);
        this.httpClientBuilder = HttpClients
                .custom()
                .setRedirectStrategy(new DefaultRedirectStrategy()
                {
                    @Override
                    protected boolean isRedirectable(String method)
                    {
                        return true;
                    }
                });
        LOG.info("connect DorisDB with {}", this.loadUrl);
    }

    private String basicAuthHeader(String username, String password)
    {
        String tokenEncode = username + ":" + password;
        byte[] encoded = Base64.encodeBase64(tokenEncode.getBytes(StandardCharsets.UTF_8));
        return "Basic " + new String(encoded);
    }

    public void startWrite(RecordReceiver recordReceiver, TaskPluginCollector taskPluginCollector)
    {
        Record record;
        int currSize = 0;
        StringBuilder stringBuilder = new StringBuilder();
        while ((record = recordReceiver.getFromReader()) != null) {
            int len = record.getColumnNumber();
            if (this.column != null && len != this.column.size()) {
                throw DataXException.asDataXException(
                        DorisWriterErrorCode.ILLEGAL_VALUE,
                        String.format("源字段数和目标字段数不匹配,源字段数为%s, 目标字段数为%s", len, this.column.size())
                );
            }
            StringBuilder oneRow = new StringBuilder();
            for (int i = 0; i < len; i++) {
                if (record.getColumn(i).getRawData() != null) {
                    oneRow.append(record.getColumn(i).asString());
                }
                if (i < len - 1) {
                    oneRow.append(SEPARATOR);
                }
            }
            oneRow.append("\n");
            stringBuilder.append(oneRow);
            currSize++;
            if (currSize >= this.batchSize) {
                stringBuilder.deleteCharAt(stringBuilder.length() - 1);
                sendData(stringBuilder.toString());
                currSize = 0;
                stringBuilder.setLength(0);
            }
        }
        if (stringBuilder.length() > 0) {
            sendData(stringBuilder.toString());
        }
    }

    private void sendData(String content)
    {
        try (CloseableHttpClient client = this.httpClientBuilder.build()) {
            HttpPut put = new HttpPut(this.loadUrl);
            StringEntity entity = new StringEntity(content, "UTF-8");
            put.setHeader(HttpHeaders.EXPECT, "100-continue");
            if (this.username != null && this.password != null) {
                put.setHeader(HttpHeaders.AUTHORIZATION, basicAuthHeader(this.username, this.password));
            }
            put.setHeader("column_separator", SEPARATOR);
            // the label header is optional, not necessary
            // use label header can ensure at most once semantics
            put.setHeader("label", UUID.randomUUID().toString());
            if (this.column != null ) {
                put.setHeader("columns", String.join(",", this.column));
            }
            if (this.heads != null && this.heads != null) {
                for (int i = 0; i < this.heads.size(); i++) {
                    System.out.println(this.heads.get(i));
                    JSONObject head = (JSONObject) this.heads.get(i);
                    for(Map.Entry<String,Object> entry : head.entrySet()){
                        String key = entry.getKey();
                        Object value = entry.getValue();
                        System.out.println("key:::"+key);
                        System.out.println("value:::"+value.toString());
                        put.setHeader(key, value.toString());
                    }
                }
            }
            put.setEntity(entity);

            try (CloseableHttpResponse response = client.execute(put)) {
                String loadResult = "";
                if (response.getEntity() != null) {
                    loadResult = EntityUtils.toString(response.getEntity());
                }
                int statusCode = response.getStatusLine().getStatusCode();
                // statusCode 200 just indicates that doris be service is ok, not stream load
                // you should see the output content to find whether stream load is success
                if (statusCode != 200) {
                    throw DataXException.asDataXException(
                            DorisWriterErrorCode.WRITER_ERROR,
                            String.format("Stream load failed, statusCode=%s load result=%s", statusCode, loadResult)
                    );
                }
            }
        }
        catch (IOException e) {
            throw DataXException.asDataXException(
                    DorisWriterErrorCode.CONNECT_ERROR,
                    String.format("Failed to connect Doris server with: %s, %s", this.loadUrl, e)
            );
        }
    }
}
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在使用 Spark 对 DorisDB 进行数据操作时,需要先将 DorisDB 的 JDBC 驱动程序添加到 Spark 的 classpath 中。可以通过以下代码实现: ```scala import java.sql.DriverManager import org.apache.spark.sql.{DataFrame, SparkSession} // 加载 DorisDB JDBC 驱动程序 Class.forName("com.mysql.jdbc.Driver") // 创建 SparkSession val spark = SparkSession.builder .appName("DorisDB Spark Demo") .master("local[*]") .getOrCreate() // 定义 DorisDB 连接信息 val dorisHost = "doris_host" val dorisPort = "9030" val dorisDb = "doris_database" val dorisUser = "doris_user" val dorisPassword = "doris_password" // 定义 DorisDB 表信息 val dorisTable = "doris_table" val dorisTableColumns = "col1, col2, col3" // 定义 Spark DataFrame val data: DataFrame = spark.read.format("csv") .option("header", "true") .option("inferSchema", "true") .load("data.csv") // 将数据写入 DorisDB data.write.format("jdbc") .option("url", s"jdbc:mysql://$dorisHost:$dorisPort/$dorisDb") .option("dbtable", dorisTable) .option("user", dorisUser) .option("password", dorisPassword) .option("batchsize", "10000") .option("isolationLevel", "NONE") .mode("append") .save() ``` 这个代码示例中,首先加载 DorisDB 的 JDBC 驱动程序,然后创建 SparkSession 对象。接下来定义 DorisDB 的连接信息和表信息,使用 Spark DataFrame 读取数据,最后将数据写入到 DorisDB 中。注意,这里的写入模式是 append,表示追加数据到 DorisDB 表中。如果需要覆盖原有数据,可以将 mode 参数设置为 overwrite。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值