玩转ClickHouse数据导入

1,584 阅读8分钟

本文正在参加「技术专题19期 漫谈数据库技术」活动

写本地表还是分布式表

在使用ClickHouse的过程中,我们都知道针对数据的写入需要写本地表,对数据的查询则是分布式表。这里简单介绍一下为什么对数据的写入选择本地表。

首先来看一下本地表和分布式表的创建:

本地表创建:
CREATE TABLE IF NOT EXISTS test on cluster default
(
    id Int64 ,
    user_id Int64 ,
    name String DEFAULT '',
    pt String
)ENGINE = ReplicatedMergeTree('/clickhouse/tables/db/test/{shard}', '{replica}')
    PARTITION BY (pt)
    ORDER BY id

分布式表
CREATE TABLE IF NOT EXISTS test_all on cluster default
AS db.test
    ENGINE = Distributed(default, db,test, rand())

分布式表是分片上本地表的逻辑视图,其本身不进行数据的存储,但是提供了查询和写入功能。通过建表可以看到分布式表指定了ENGINE = Distributed引擎,并且可以指定一个sharding参数:分片的key,可以设置字段名或者rand()。通过这个参数可以做到写入时控制数据均衡存储。这种方式虽然简单但是不推荐使用,因为基于分布式表的数据写入需要先在分布式表所在节点落盘,压力会集中在分布式表所在节点。如果分布式表节点宕机,数据可能会丢失,同时对ZK也会有一定的压力。

本地表代表了分布式表引擎指定的每个分片上的表名称。它可以将写入压力分散到每个本地表所在的分片节点,并且支持数据原子写入和去重,保证数据一致性。但是需要应用保证写入的负载均衡。

多种策略保证数据均衡地写入本地表

选择了基于本地表的写入,就可以在应用中定制多种数据写入的策略。

常见的负载均衡策略如轮训,随机,指定分片Key(比如根据用户ID,将相同用户的数据写入到一个分片中)。下面我们来看看具体如何在应用代码中实现

1-获取分片对应的节点信息

获取分片对应的节点信息用于JDBC连接.

我们可以通过查询ClickHouse的系统表来获取这些信息,可以参考系统表的介绍

select shard_num, host_name,host_address from system.clusters 
where cluster = 'cluster'

 replicated              │         1 │ chi-repl-05-replicated-0-0127.0.0.1    │
│ replicated             │         1 │ chi-repl-05-replicated-0-110.54.91.30

shard_num从1开始,因为可能有副本,副本的shard_num是一样的,可以修改SQL获取每个分片所有副本的host_address

select shard_num, groupArray(host_address) as hosts from system.clusters 
where cluster = 'cluster' group by shard_num

2-代码实现

//获取每个分片对应的集群和连接信息
Map<Integer, Shard> shardMap = new HashMap<>();
ResultSet rs = //TODO 省略读取数据库信息;
while (rs.next()) {
     int shardNum = rs.getInt(1);
     String[] hostAddress = (String[]) rs.getArray(2).getArray();
     shardMap.put(shardNum, new Shard("cluster",shardNum,hostAddress));
}

3-基于Spark写入

我们是使用Spark来进行的数据写入的,这里介绍一下如何基于Spark写入。一般来说基于Spark做数据写入的时候我们都会基于如下方式来按分区写入,每个分区获取一次连接,然后写入数据。

  data.foreachPartition(iter => {
        val partitionId = TaskContext.getPartitionId()//获取当前分区ID
        //获取数据库连接
        val conn = getConn();
        val statement=conn.prepareStatement(this.insertSql)
        //数据写入
        for (item <- iter) {
          statement.addBatch()
          if (length >= bulkSize) {
            statement.executeBatch()
          }
        }
        statement.executeBatch()
      })

如果想要实现随机或者轮训的策略,只需要在获取连接的时候从shardMap中轮训或者随机获取对应的shard信息。shardMap中的Key代表了分片,value则是当前分片的集群信息以及所有副本的连接信息。

1-随机

 int index = new Random().nextInt(shardMap.size())+1;
 Shard shard = shardMap.get(index);
 //TODO 获取连接

2-轮训

需要先获取到foreachPartition中的分区ID,基于当前分区ID来进行轮训

val partitionId = TaskContext.getPartitionId()//获取当前分区ID
partitionId % shardMap.size + 1
Shard shard = shardMap.get(index);
//TODO 获取连接

上面的方式是按分区来获取对应分片的连接信息,如果分区数据不均衡,就需要考虑使用repartition算子来重分区。

3-如何可以将相同key的数据写入到一个分区

spark中提供了相关算子,如repartition,repartitionByRange都可以指定列

      val repartitionNum = config.getInt("repartition")
      data.repartition(repartitionNum, col("shardkey")).rdd.foreachPartition(iter => {
        val partitionId = TaskContext.get.partitionId
        //TODO 获取当前分片对应的连接信息
        iter .foreach(info => {
          //处理数据
        })

      })

4-按key进行hash写入

这里我们脱离Spark来看看Seatunnel中v2插件是如何来实现的.

首先也是维护分片连接信息的map

this.statementMap = initStatementMap();

每条数据写入的方法

  public void write(SeaTunnelRow element) throws IOException {

        Object shardKey = null;
        //这里获取shardKey的值
        if (StringUtils.isNotEmpty(this.option.getShardMetadata().getShardKey())) {
            int i = this.option.getSeaTunnelRowType().indexOf(this.option.getShardMetadata().getShardKey());
            shardKey = element.getField(i);
        }
        //shardRouter.getShard 来路由当前数据属于哪个分片连接信息
        ClickhouseBatchStatement statement = statementMap.get(shardRouter.getShard(shardKey));
        PreparedStatement clickHouseStatement = statement.getPreparedStatement();
        IntHolder sizeHolder = statement.getIntHolder();
        // add into batch
        addIntoBatch(element, clickHouseStatement);
        sizeHolder.setValue(sizeHolder.getValue() + 1);
        // flush batch
        if (sizeHolder.getValue() >= option.getBulkSize()) {
            flush(clickHouseStatement);
            sizeHolder.setValue(0);
        }
    }

路由方法

  public Shard getShard(Object shardValue) {
        if (!splitMode) {
            return shards.firstEntry().getValue();
        }
        if (StringUtils.isEmpty(shardKey) || shardValue == null) {
            return shards.lowerEntry(threadLocalRandom.nextInt(shardWeightCount + 1)).getValue();
        }
        int offset = (int) (HASH_INSTANCE.hash(ByteBuffer.wrap(shardValue.toString().getBytes(StandardCharsets.UTF_8)),
                0) & Long.MAX_VALUE % shardWeightCount);
        return shards.lowerEntry(offset + 1).getValue();
    }

基于clickhouse-local导入数据到本地表

1-介绍

在阅读seatunnel对clickhouse写入的代码中发现,其提供了一个ClickHouseFile的插件,发现如果想进一步提升导入的性能,减少对服务器的压力我们可以尝试使用clickhouse-local来帮我们进行数据的导入。

对于clickhouse-local的介绍可以直接去官网进行学习。
clickhouse.com/docs/zh/ope…

2-基础命令使用

在clickhouse-local的帮助命令提示了使用方法:

ps aux | tail -n +2 | awk '{ printf("%s\t%s\n", $1, $4) }' | clickhouse-local -S "user String, mem Float64" -q "SELECT user, round(sum(mem), 2) as mem_total FROM table GROUP BY user ORDER BY mem_total DESC FORMAT PrettyCompact"
┌─user─────┬─mem_total─┐
 clickho+        5.3 
 root            0.6 

-S指定了结构,-N指定了表名
默认是\t分隔符:
echo -e "1\t小李\n2\t小明\n3\t小路"|clickhouse-local -S "id Int64,name String" -N "tmp_table" -q "SELECT * FROM tmp_table;"
1       小李
2       小明
3       小路

在上面都是基于echo命令来生成数据,在实际使用中可以替换成文件,clickhouse-local 可以指定--file指定文件。这样就我们只需要将数据写入到本地文件,就可以实现数据的导入了。

3-完整功能测试

表和基础数据准备

//测试表准备
CREATE TABLE test_local (  id Int64,  pt Date) ENGINE =  MergeTree()PARTITION BY toYYYYMM(pt)ORDER BY id

//数据模拟写入
INSERT INTO TABLE test_local 
SELECT number,'2022-11-04' FROM `system`.numbers LIMIT 10000

//查询
select count(1) from test_local

SELECT count(1)
FROM test_local

Query id: f94777ad-0c53-4bc4-ba39-0b48a61336cb

┌─count()─┐
│   10000 │
└─────────┘

//查询分区情况
SELECT
    partition,
    name,
    rows
FROM system.parts
WHERE table = 'test_local'

Query id: 064be019-e0fe-4a7f-a8c1-5b2cac993a60

┌─partition─┬─name─────────┬──rows─┐
│ 202211202211_1_1_0 │ 10000 │
└───────────┴──────────────┴───────┘

数据写入本地文件

首先需要准备一个文本文件路径,用于存储ClickHouse可以使用的本地数据

mkdir -p /clickhouse/local-data

验证一下数据写入的格式

echo -e "1\t2022-11-05\n2\t2022-11-05\n3\t2022-11-05"|clickhouse-local -S "id Int64,pt String" -N "tmp_table" -q "CREATE TABLE test_local (id Int64,pt Date) ENGINE = MergeTree() PARTITION BY toYYYYMM(pt) ORDER BY id;SELECT id,pt FROM tmp_table;"1       2022-11-052       2022-11-053       2022-11-05

数据写入到上面创建的本地路径,注意SQL修改为了insert,并且增加了-- --path参数

--path参数
echo -e "1\t2022-11-05\n2\t2022-11-05\n3\t2022-11-05"|clickhouse-local -S "id Int64,pt String" -N "tmp_table" -q "CREATE TABLE test_local (id Int64,pt Date) ENGINE = MergeTree() PARTITION BY toYYYYMM(pt) ORDER BY id;INSERT INTO TABLE test_local SELECT id,pt FROM tmp_table;" -- --path /clickhouse/local-data

查看数据的生成

 pwd
/clickhouse/local-data/data/_local/test_local
root@chi-repl-05-replicated-0-0-0:/clickhouse/local-data/data/_local/test_local# ls -lrt
total 12
drwxr-xr-x 2 root root 4096 Nov  4 09:27 202211_1_1_0
-rw-r--r-- 1 root root    1 Nov  4 09:27 format_version.txt
drwxr-xr-x 2 root root 4096 Nov  4 09:27 detached

如何将数据真正导入到ClickHouse中

找到ClickHouse真实的存储路径,我的表是default库下的test_local: /var/lib/clickhouse/data/default/test_local。

mv操作
mv /clickhouse/local-data/data/_local/test_local/202211_1_1_0/  /var/lib/clickhouse/data/default/test_local/detached
ALTER TABLE test_local ATTACH PART '202211_1_1_0'

ALTER TABLE test_local
    ATTACH PART '202211_1_1_0'

Query id: 34845ff3-0bcd-4a8a-a0a3-3f79e38be362

Ok.

验证数据

SELECT
    partition,
    name,
    rows
FROM system.parts
WHERE table = 'test_local'

Query id: 34b2a169-21ec-4084-a22f-26ef4ce4cf5d


┌─partition─┬─name─────────┬──rows─┐
│ 202211202211_1_1_0 │ 10000 │
│ 202211202211_3_3_0 │     3 │
└───────────┴──────────────┴───────┘

select count(1) from test_local

SELECT count(1)
FROM test_local

Query id: 73ae7617-b323-47bf-bb39-332db1b7e278

┌─count()─┐
│   10003 │
└─────────┘

最终可以发现数据已经成功导入到ClickHouse中了。整个过程可以发现都是对文件的操作,对ClickHouse服务本身压力都会很小。

4-代码实现参考

对于代码如何实现大家可以参考Seatunnel的源码。贴出部分代码(不同版本可能会不同)

 private def generateClickhouseFile(rows: Iterator[(Shard, Row)]): List[String] = {

    def getValue(kv: util.Map.Entry[String, String]): String = {
      if (this.fields.contains(kv.getKey)) {
        kv.getKey
      } else {
        val v = getDefaultValue(kv.getValue)
        if (v == null) {
          "NULL"
        } else if (v.isInstanceOf[Integer]) {
          "0"
        } else {
          s"'${v.toString}'"
        }
      }
    }

    val uuid = UUID.randomUUID().toString.substring(0, UUID_LENGTH).replaceAll("-", "_")
    val targetPath = java.lang.String.format("%s/%s", CLICKHOUSE_FILE_PREFIX, uuid)
    val target = new File(targetPath)
    target.mkdirs()
    val tmpDataPath = targetPath + "/local_data.log"

    mmapSaveDataSafely(tmpDataPath, rows.map(r => r._2))

    val exec = mutable.ListBuffer[String]()
    exec.appendAll(clickhouseLocalPath.trim.split(" "))
    exec.append("-S")
    exec.append(fields.map(f => s"$f ${this.table.tableSchema.get(f)}").mkString(","))
    exec.append("-N")
    exec.append("temp_table" + uuid)
    exec.append("-q")
    exec.append(java.lang.String.format("%s; INSERT INTO TABLE %s SELECT %s FROM temp_table%s;", this.table.getCreateDDLNoDatabase
      .replaceAll("`", ""), this.table.getLocalTableName,
      this.table.tableSchema.entrySet.map(getValue).mkString(","), uuid))
    exec.append("--path")
    exec.append(targetPath)
    val command = Process(Seq("less", tmpDataPath)) #| exec
    LOGGER.info(command.lineStream.mkString("\n"))

    new File(targetPath + "/data/_local/" + this.table.getLocalTableName).listFiles().filter(f => f.isDirectory).
      filterNot(f => f.getName.equals("detached")).map(f => f.getAbsolutePath).toList
  }

总结

本文主要总结了关于ClickHouse的数据导入方式,我们在使用过程中也借鉴了开源框架的实现,也是做了一些简单的总结分享给大家。

本文正在参加「技术专题19期 漫谈数据库技术」活动