flink实战 -- 数据写入clickhouse(ClickHouseSink)

简介

本文主要介绍如何通过Flink JDBC Connector将数据写入ClickHouse以及直接使用Flink JDBC Connector操作ClickHouse存在什么样的问题。

Flink JDBC Connector的使用

  • 通过JDBCUpsertTableSink.Builder创建一个JDBCUpsertTableSink对象,JDBCUpsertTableSink需要的参数:
private JDBCUpsertTableSink(
		TableSchema schema,
		JDBCOptions options,
		int flushMaxSize,
		long flushIntervalMills,
		int maxRetryTime)
  • 创建TableSchema对象:
TableSchema schema = TableSchema
.builder()
.fields(
	fieldName,
	TypeConversions.fromLegacyInfoToDataType(fieldType)
).build();
  • 创建JDBCOptions对象
JDBCOptions.builder
      .setTableName(tablename)
      .setDBUrl(dbUrl)
      .setDriverName(driverName)
      .setDialect(clickHouseDialect)
      .setUsername(username)
      .setPassword(passdword)
      .build
// ----
private JDBCOptions(
	String dbURL, 
	String tableName, 
	String driverName, 
	String username,
	String password, 
	JDBCDialect dialect // 数据库的方言
)

进入到JDBCDialect这个类中发现源码中并不支持clickHouse Dialect

public final class JDBCDialects {
    
    

	private static final List<JDBCDialect> DIALECTS = Arrays.asList(
		new DerbyDialect(),
		new MySQLDialect(),
		new PostgresDialect()
	);
	...

现在根据JDBCDialect接口来新实现一个clickHouse Dialect

/**
 * Handle the SQL dialect of jdbc driver.
 */
public interface JDBCDialect extends Serializable {
    
    
	default Optional<String> getUpsertStatement(
			String tableName, String[] fieldNames, String[] uniqueKeyFields) {
    
    
		return Optional.empty();
	}
	default String getInsertIntoStatement(String tableName, String[] fieldNames) {
    
    
		String columns = Arrays.stream(fieldNames)
			.map(this::quoteIdentifier)
			.collect(Collectors.joining(", "));
		String placeholders = Arrays.stream(fieldNames)
			.map(f -> "?")
			.collect(Collectors.joining(", "));
		return "INSERT INTO " + quoteIdentifier(tableName) +
			"(" + columns + ")" + " VALUES (" + placeholders + ")";
	}
	default String getDeleteStatement(String tableName, String[] conditionFields) {
    
    
		String conditionClause = Arrays.stream(conditionFields)
			.map(f -> quoteIdentifier(f) + "=?")
			.collect(Collectors.joining(" AND "));
		return "DELETE FROM " + quoteIdentifier(tableName) + " WHERE " + conditionClause;
	}
}

看源码可以发现,在JDBCDialect中定义了增删改查的接口(在这里只列出了部分源码)。那么现在可以写一个ClickHouseDialect的类来重写这个接口。

/**
 * clickhouse方言
 */
public class ClickHouseJDBCDialect implements JDBCDialect {
    
    

    private static final long serialVersionUID = 1L;

    @Override
    public boolean canHandle(String url) {
    
    
        return url.startsWith("jdbc:clickhouse:");
    }

    @Override
    public Optional<String> defaultDriverName() {
    
    
        return Optional.of("ru.yandex.clickhouse.ClickHouseDriver");
    }

    @Override
    public String quoteIdentifier(String identifier) {
    
    
        return "`" + identifier + "`";
    }

    @Override
    public Optional<String> getUpsertStatement(String tableName, String[] fieldNames, String[] uniqueKeyFields) {
    
    
        return Optional.of(getInsertIntoStatement(tableName, fieldNames));
    }

    @Override
    public String getUpdateStatement(String tableName, String[] fieldNames, String[] conditionFields) {
    
    
        return null;
    }

}

至此实现一个ClickHouse的JDBC Sink就结束了。

存在的问题

上文中的实现是无法成功写入ClickHouse的,原因如下:

  • 前提:上游的操作逻辑是双流Join,并且有group by操作,对数据的操作包括增删改查,所以此处使用的是Upsert Sink流(关于Table Sink流的详解可以查看Flink 官方文档)
  • ClickHouse不支持修改和删除操作
  • 针对upsert操作这里直接调用insert操作来避免修改操作

解决删除流

首先看一下源码中删除流的接口是怎么定义的:

	/**
	 * Get delete one row statement by condition fields, default not use limit 1,
	 * because limit 1 is a sql dialect.
	 */
	default String getDeleteStatement(String tableName, String[] conditionFields) {
    
    
		String conditionClause = Arrays.stream(conditionFields)
			.map(f -> quoteIdentifier(f) + "=?")
			.collect(Collectors.joining(" AND "));
		return "DELETE FROM " + quoteIdentifier(tableName) + " WHERE " + conditionClause;
	}

可以看到JDBCDialect中删除流的接口接受的参数:表的名字和主键,然后形成一个delete SQL语句。 ClickHouse是不支持delete语句的,在这里,一开始笔者是将删除流接口转到insert接口:

default String getDeleteStatement(String tableName, String[] conditionFields) {
    
    
		return getInsertIntoStatement(tableName, conditionFields)
	}

这样就形成了一个关于只插入主键字段的insert流,为了不污染原始的表(既然选择ClickHouse应该考虑到ClickHouse的特性),新建一个Null表,将只包含主键的表插入到Null表。
至此,存在一个问题,每一个表的主键可能会不同,为了避免这种情况发生,对每一个表都创建一个存储主键的Null表。这样虽然问题解决的,但是每次建表都会创建一个Null表。所以比较冗余。

最终解决方案

修改UpsertWrite类源码,对删除流不做处理。

package org.apache.flink.api.java.io.jdbc.writer;

    @Override
    public void open(Connection connection) throws SQLException {
    
    
        this.keyToRows = new HashMap<>();
        // this.deleteStatement = connection.prepareStatement(deleteSQL);
    }
       @Override
    public void executeBatch() throws SQLException {
    
    
        if (keyToRows.size() > 0) {
    
    
            for (Map.Entry<Row, Tuple2<Boolean, Row>> entry : keyToRows.entrySet()) {
    
    
                Row pk = entry.getKey();
                Tuple2<Boolean, Row> tuple = entry.getValue();
                if (tuple.f0) {
    
    
                    processOneRowInBatch(pk, tuple.f1);
                }
                /*else {
                    setRecordToStatement(deleteStatement, pkTypes, pk);
                    deleteStatement.addBatch();
                }*/
            }
            internalExecuteBatch();
            // deleteStatement.executeBatch();
            keyToRows.clear();
        }
    }

对源码中注释掉的部分是当遇到删除流的时候做的处理,现在对它进行注释掉,也就是当有删除流来的时候是不做处理的。
至此,flink 通过JDBC Connector往ClickHouse输出数据的Sink解决了。

该解决办法虽然思路比较简单,但是需要阅读JDBC Connector源码并理清其实现思路以及每部分代码的具体作用。所以才写此篇文章以做记录。

猜你喜欢

转载自blog.csdn.net/txgANG/article/details/103737429