本文主要介绍如何通过Flink JDBC Connector将数据写入ClickHouse以及直接使用Flink JDBC Connector操作ClickHouse存在什么样的问题。
private JDBCUpsertTableSink(
TableSchema schema,
JDBCOptions options,
int flushMaxSize,
long flushIntervalMills,
int maxRetryTime)
TableSchema schema = TableSchema
.builder()
.fields(
fieldName,
TypeConversions.fromLegacyInfoToDataType(fieldType)
).build();
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的,原因如下:
首先看一下源码中删除流的接口是怎么定义的:
/**
* 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源码并理清其实现思路以及每部分代码的具体作用。所以才写此篇文章以做记录。