Java项目中Clickhouse大数据量插入



文章目录

  • Java项目中Clickhouse大数据量插入
  • 背景
  • 代码实现
  • 导入依赖
  • 定义Clickhouse配置信息
  • 封装Clickhouse客户端
  • 实现Clickhouse批量插入
  • 总结



背景

最近,因为项目中使用到了Clickhouse数据库,本着MySQL数据库的思维,直接使用了Java + MP的实现方式,将大批量数据插入到Clickhouse中。由于MP的批量插入接口默认只支持1000的数据量,于是理所当然地将这批数据分页后再插入,便心安理得的交差了。

可是,后面项目测试后发现,只是单处理100w的数据就需要1h+的时间。一开始怀疑是程序内存不够导致频繁GC,所以就想着直接加大内存。后来内存加大后,性能并没有得到质的提升。

接着开始debug,这时才发现项目的瓶颈其实都在数据落库上。可是Clickhouse是专门应对大数据场景的数据库啊,不可能这么慢啊。于是开始查找Clickhouse相关大数据插入的资料,发现Clickhouse对大数据的导入性能支持得很好,于是便思考着能不能在项目上也应用上。最后终于在官方issue上找到最佳导入实践,通过测试后发现现在导入100w的数据直接缩短为了5m,这可是10倍性能的提升啊!!!话不多说,开始我们的实践吧。

代码实现

导入依赖

<dependency>
   <groupId>com.clickhouse</groupId>
    <artifactId>clickhouse-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.clickhouse</groupId>
    <artifactId>clickhouse-client</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
</dependency>
  • 由于需要先将数据生成附件,再通过附件导入Clickhouse。所以这里直接使用easyexcel将数据生成csv文件;
  • 其他两个依赖主要是针对Clickhouse提供的jdbc客户端。

定义Clickhouse配置信息

import lombok.Data;

import java.io.Serializable;

/**
 * <p>
 * ClickHouse 连接信息配置
 * </p>
 */
@Data
public class ClickHouseNodeProperties implements Serializable {

    /**
     * ClickHouse 连接信息 <br/>
     * eg: http://192.168.1.1:8123/test
     */
    private String uri;

    /**
     * ClickHouse 用户名
     */
    private String username = "";

    /**
     * ClickHouse 密码
     */
    private String password = "";

    /**
     * Maximum query execution time in seconds, 0 means no limit.
     */
    private Integer maxExecutionTime = 0;

    /**
     * Socket timeout in seconds.
     */
    private Integer socketTimeout = 3600;

    /**
     * Session timeout in seconds. 0 or negative number means same as server default.
     */
    private Integer sessionTimeout = 3600;

    /**
     * Connection timeout in seconds. It's also used for waiting a connection being closed.
     */
    private Integer connectionTimeout = 120;

}

封装Clickhouse客户端

import cn.hutool.core.util.StrUtil;
import com.clickhouse.client.ClickHouseCredentials;
import com.clickhouse.client.ClickHouseNode;
import com.clickhouse.client.config.ClickHouseClientOption;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;

/**
 * <p>
 * ClickHouse连接信息配置
 * </p>
 */
@Slf4j
@ConditionalOnProperty(prefix = "clickhouse", name = "uri")
public class ClickHouseNodeConfig {

    private static final String CLICK_HOUSE_NODE_PROPERTIES_BEAN = "clickHouseNodeProperties";

    @Bean(name = CLICK_HOUSE_NODE_PROPERTIES_BEAN)
    @ConfigurationProperties(prefix = "clickhouse")
    public ClickHouseNodeProperties clickHouseNodeProperties() {
        return new ClickHouseNodeProperties();
    }

    /**
     * ClickHouse 连接信息
     *
     * @return xxlJobExecutor
     */
    @Bean
    @ConditionalOnMissingBean
    public ClickHouseNode clickHouseNode(@Qualifier(CLICK_HOUSE_NODE_PROPERTIES_BEAN) ClickHouseNodeProperties clickHouseNodeProperties) {
        log.info(">>>>>>>>>>> clickHouseNode config init.");

        ClickHouseNode.Builder builder = ClickHouseNode.builder(ClickHouseNode.of(clickHouseNodeProperties.getUri()))
                .addOption(ClickHouseClientOption.MAX_EXECUTION_TIME.getKey(), String.valueOf(clickHouseNodeProperties.getMaxExecutionTime()))
                .addOption(ClickHouseClientOption.SOCKET_TIMEOUT.getKey(), String.valueOf(clickHouseNodeProperties.getSocketTimeout() * 1000))
                .addOption(ClickHouseClientOption.SESSION_TIMEOUT.getKey(), String.valueOf(clickHouseNodeProperties.getSessionTimeout()))
                .addOption(ClickHouseClientOption.CONNECTION_TIMEOUT.getKey(), String.valueOf(clickHouseNodeProperties.getConnectionTimeout()));

        // 认证信息处理
        if (StrUtil.isNotBlank(clickHouseNodeProperties.getUsername()) && StrUtil.isNotBlank(clickHouseNodeProperties.getPassword())) {
            ClickHouseCredentials credentials = ClickHouseCredentials.fromUserAndPassword(clickHouseNodeProperties.getUsername(), clickHouseNodeProperties.getPassword());
            builder.credentials(credentials);
        }

        return builder.build();
    }
}

实现Clickhouse批量插入

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ClassUtil;
import cn.hutool.core.util.RandomUtil;
import com.alibaba.excel.EasyExcel;
import com.clickhouse.client.ClickHouseClient;
import com.clickhouse.client.ClickHouseNode;
import com.clickhouse.data.ClickHouseCompression;
import com.clickhouse.data.ClickHouseFile;
import com.clickhouse.data.ClickHouseFormat;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

import javax.annotation.Resource;
import java.io.File;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
 * <p>
 * ClickHouse操作业务接口
 * </p>
 */
@Slf4j
@ConditionalOnProperty(prefix = "clickhouse", name = "uri")
public class ClickHouseService {

    @Resource
    private ClickHouseNode clickHouseNode;

    @Resource
    private CommonProperties commonProperties;

    /**
     * 将数据导入 ClickHouse(自定义字段插入顺序)
     *
     * @param table      数据导入目标表
     * @param dataList       待导入数据集
     * @param fieldOrderList 字段导入顺序
     */
    public void batchImport(String table, List dataList, List<String> fieldOrderList) throws Exception {
        Objects.requireNonNull(dataList, "dataList");
        // 排序字段为空时处理
        if (CollectionUtil.isEmpty(fieldOrderList)) {
            batchImport(table, dataList);
            return;
        }

        // 对列表字段进行排序
        List<List<Object>> orderDataList = new ArrayList<>(dataList.size());
        for (Object data : dataList) {
            List<Object> orderData = new ArrayList<>(fieldOrderList.size());
            orderDataList.add(orderData);

            // 对字段排序
            Class<?> dataClass = data.getClass();
            for (String fieldName : fieldOrderList) {
                Field field = ClassUtil.getDeclaredField(dataClass, fieldName);
                Object fieldValue = null;
                if (Objects.nonNull(field)) {
                    field.setAccessible(true);
                    fieldValue = field.get(data);
                }
                orderData.add(fieldValue);
            }
        }

        // 数据导入
        batchImport(table, orderDataList);
    }

    /**
     * 将数据导入 ClickHouse(默认按类中字段的定义顺序导入数据)
     *
     * @param table    数据导入目标表
     * @param dataList 待导入数据集
     */
    public void batchImport(String table, List dataList) throws Exception {
        Objects.requireNonNull(table, "table");
        Objects.requireNonNull(dataList, "dataList");

        // 1. 创建临时文件
        String fileName = table + "_" + System.currentTimeMillis() + "_" + RandomUtil.randomString(5);
        File file = File.createTempFile(fileName, ".csv");

        try {
            // 2. 使用EasyExcel将数据写入临时文件
            log.info("------------------>> 数据写入临时文件开始, filePath: {}", file.getAbsolutePath());
            EasyExcel.write(file)
                    .sheet()
                    .doWrite(dataList);
            log.info("------------------>> 数据写入临时文件成功, filePath: {}", file.getAbsolutePath());
            
            // 3. 将文件导入ClickHouse
            batchImport(table, file);
        } finally {
            try {
                // 4. 删除临时文件
                file.delete();
            } catch (Exception e) {
                log.error("删除临时文件失败 --> ", e);
            }
        }
    }

    /**
     * 将csv文件导入 ClickHouse
     *
     * @param table   数据导入目标表
     * @param csvFile 待导入数据集csv文件
     */
    public void batchImport(String table, File csvFile) throws Exception {
        batchImport(table, csvFile, ClickHouseFormat.CSV);
    }

    /**
     * 将指定文件导入 ClickHouse
     *
     * @param table  数据导入目标表
     * @param file   待导入数据文件
     * @param format 待导入数据文件格式
     */
    public void batchImport(String table, File file, ClickHouseFormat format) throws Exception {
        Objects.requireNonNull(table, "table");
        Objects.requireNonNull(file, "file");

        // 将文件导入ClickHouse
        ClickHouseClient.load(
                clickHouseNode,
                table,
                // will parse file name later so that you don't have to specify compression and format
                ClickHouseFile.of(file, ClickHouseCompression.NONE, 0, format))
                .get();
        log.info("------------------>> clickHouse import data success.");
    }
}

总结

以上就是Java项目中Clickhouse大数据插入的实践,本文仅仅简单介绍了Clickhouse的csv形式导入,具体可以根据实际项目需要,封装更多方式的数据导入方法。Clickhouse本身是一款优秀的列式存储数据库,数据批量插入的效率提升后,使用Clickhouse进行后续的数据分析都将不再是问题啦。。。。