目录
前言
最近在一边学习,一边将Flink流处理技术应用到公司的业务场景中。目前会通过采集方式将数据库的数据变更写入到Kafka,后面通过Flink处理后,落地到数仓中,因为目前数仓对外提供的是一些接口服务,所以目前还是以Oracle为主,其他的数据存放在Greenplum中。当前主要任务是将Kafka中的数据通过Flink落地到Oracle中。因为是一些交易数据,所以对数据的准确性非常敏感,Flink的强大之处就在于帮我们实现了方式来保证数据仅一次处理(Exactly-once)。这里我们抛开其他的点不说,只说TwoPhaseCommitSinkFunction这个类。这个类的实现思路就是两阶段提交。什么是两阶段提交?接下来我们就稍微展开一下。
解析
在分布式系统中有个CAP理论,其阐述了一个分布式系统中不可能同时满足一致性(Consistency)、可用性(Availability)及容错性(Partition Tolerance)。
一致性:在分布式系统中,数据往往存在多个副本,一致性描述的是这些副本中的数据在内容和组织上的一致;
可用性:描述系统对用户的服务能力,所谓可用是指在用户能够容忍的时间范围内返回用户期望的结果;
容错性:分布式系统通常由多个节点构成,由于网络是不可靠的,所以存在分布式集群中的节点因为网络通信故障导致被孤立成一个个小的集群的可能性,即网络分区,分区容错性要求在出现网络分区时系统仍然能够对外提供一致性的可用服务。
让分布式集群始终对外提供可用的一致性服务一直是富有挑战和趣味的任务。暂且抛开可用性,拿一致性来说,对关系型数据库我们通常利用事务来保证数据的强一致性,但是当我们的数据量越来越大,大到单库无法承担时,我们就不得不采取分库分表的策略对数据库实现水平拆分,或者引入NoSQL技术,构建分布式数据库集群以分摊读写压力,从而提升数据库的存储和响应能力,但是多个数据库实例也为我们使用数据库带来了许多的限制,比如主键的全局唯一、联表查询、数据聚合等等。另外一个相当棘手的问题就是数据库的事务由原来的单库事务变成了现在的分布式事务。
分布式事务的实现并不是误解的,比如我们最开始说的两阶段提交(2PC:Two-Phase Commit)和三阶段提交(3PC:Three-Phase Commit)都给我们提供了思路。但是在分布式环境下如何保证数据的强一致性,并对外提供高可用的服务还是相当棘手的,因此很多分布式系统对数据强一致性都敬而远之。
两阶段提交协议(2PC:Two-Phase Commit)
两阶段提交协议的目标自安于为分布式系统保证数据的一致性,许多分U币是系统采用该协议提供对分布式事务的支持。该协议将一个分布式的事务过程拆分成两个阶段:投票和事务提交。为了让整个数据库集群能够正常运行,该协议指定了一个协调者单点,用于协调整个数据库集群各节点的运行。为了简化描述,我们将数据库集群中的各个节点称为参与者,三阶段提交协议中同样包含协调者和参与者这两个角色的定义。
针对关系型数据库而言,以MySQL为例,两阶段提交主要解决binlog和InnoDB redo log的数据一致性的问题,流程如图。
提交原理描述:
阶段1:InnoDB redo log写盘,InnoDB 事务进入prepare状态
阶段2:如果前面prepare成功,binlog写盘,那么再继续将事务日志持久化到binlog,如果持久化成功,那么InnoDB事务则进入commit状态(实际是在redo log里面写上一个commit记录)
备注:每个事务binlog的末尾,会记录一个XID event,标志着事务是否提交成功,也就是说,recovery过程中,binlog最后一个XID event之后的内容都应该被purge。
说了这么多,我们回到主题。当我们通过Flink写数据到Oracle中,为了保证前一次checkpoint成功后到这次checkpoint成功之前这段时间内的数据不丢失,如果执行到一半过程失败了,从而导致前一次checkpoint成功后到任务失败前的数据已经存储到了Oracle,但是这部分数据并没有被写入到checkpoint。此时任务失败,重启后前一次checkpoint成功后到任务失败前的数据便会在此写入Oracle,从而导致数据重复的问题。当然对于关系型数据库,我们可以通过主键实现幂等,假设我们没有主键的情况下,就可以使用TwoPhaseCommitSinkFunction类来实现两阶段提交。
主流程
public class MysqlSinkOracle {
private static final Logger logger = Logger.getLogger(MysqlSinkOracle.class);
private static final String INSERT = "c";
private static final String UPDATE = "u";
private static final String DELETE = "d";
private static final String DDL = "ddl";
private static final int ARG_LENGTH = 4;
public static void main(String[] args) {
if (args.length < ARG_LENGTH) {
String msg =
"Usage:\n" +
" --ConsumerGroup <groupId: mysql_sink_oracle>\n" +
" --ConfigPath <config path>\n" +
" -- ConfigName <config name>\n" +
" --JobName <job name>";
System.out.println(msg);
System.exit(1);
}
String groupId = args[0];
String configPath = args[1];
String configName = args[2];
String jobName = args[3];
try {
// 创建Flink执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
FlinkKafkaConsumer011<ObjectNode> consumer = KafkaConsumer.getLaunchEnvironment(configPath, groupId, topics, env);
DataStream<ObjectNode> sourceStream = env.addSource(consumer);
SingleOutputStreamOperator<String> sinkStream = sourceStream.map((MapFunction<ObjectNode, String>) message -> {
String sql = "";
JsonNode value = message.get("value");
// 首先判断是不是DDL操作,DDL消息结构与DML有差异
JsonNode fields = value.get("schema").get("fields");
String isDdlOperationType = fields.get(2).get("field").asText();
// Kafka消息的key
JsonNode key = message.get("key");
sql = parseDmlSqlStatement(configPath, key, value, fields, tables);
return sql;
});
// TODO 在这里决定是否调用两阶段提交逻辑
sinkStream.addSink(new OracleSinkFunction());
// sinkStream.addSink(new OracleTwoPhaseCommitSink());
env.execute(jobName);
} catch (Exception e) {
logger.error(e.toString());;
}
}
这里是正常的逻辑,来一条直接处理一条,但是不是调用的两阶段提交逻辑。
非连接池、非两阶段提交实现
正常逻辑实现:
public class OracleSinkFunction extends RichSinkFunction<String> {
private static Connection connection;
private static final Logger logger = Logger.getLogger(OracleSinkFunction.class);
/**
* 建立数据库连接
* @param parameters Configuration
* @throws Exception Exception
*/
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
System.out.println("Configuration: " + parameters);
String url = "jdbc:oracle:thin:@localhost:1521:prod";
connection = DbConnectionUtils.getConnection(url, "ods", "ods");
}
/**
* 提交数据变更
* @param sql 操作语句
* @param context Context
*/
@Override
public void invoke(String sql, Context context) throws SQLException {
try {
if (!"".equals(sql)) {
PreparedStatement statement = connection.prepareStatement(sql);
statement.executeUpdate();
statement.close();
connection.commit();
}
} catch (Exception e) {
e.printStackTrace();
connection.rollback();
}
}
/**
* 关闭数据库连接
* @throws Exception Exception
*/
@Override
public void close() throws Exception {
super.close();
if (connection != null) {
connection.close();
}
}
}
这里没有使用连接池,因为兼容性问题,本人测试过C3P0、Druid等常用连接池,都会有各种各样的问题,导致数据不能正常落地。
OracleTwoPhaseCommitSink实现
使用两阶段提交实现数据落地逻辑如下:
public class OracleTwoPhaseCommitSink extends TwoPhaseCommitSinkFunction<String, Connection, Void> {
private static final Logger logger = Logger.getLogger(OracleTwoPhaseCommitSink.class);
public OracleTwoPhaseCommitSink() {
super(new KryoSerializer<>(Connection.class, new ExecutionConfig()), VoidSerializer.INSTANCE);
}
/**
* 获取连接,开启手动提交事物(getConnection方法中)
* @return 数据库连接
* @throws Exception exception
*/
@Override
protected Connection beginTransaction() throws Exception {
System.out.println("=====> beginTransaction... ");
Connection connection = DruidConnectionUtils.getConnection();
connection.setAutoCommit(false);
return connection;
}
/**
* 执行数据入库操作
* @param connection 连接
* @param sql 执行SQL
* @param context context
*/
@Override
protected void invoke(Connection connection, String sql, Context context) {
try {
if (!"".equals(sql)) {
PreparedStatement statement = connection.prepareStatement(sql);
statement.executeUpdate();
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 预提交,这里预提交的逻辑在invoke方法中
* @param connection ConnectionState
*/
@Override
protected void preCommit(Connection connection) {
}
/**
* 如果invoke执行正常则提交事物
* @param connection ConnectionState
*/
@Override
protected void commit(Connection connection) {
System.out.println("=====> commit... ");
try {
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("提交事物异常");
}
}
/**
* 如果invoke执行异常则回滚事物,下一次的checkpoint操作也不会执行
* @param connection Connection
*/
@Override
protected void abort(Connection connection) {
System.out.println("=====> abort... ");
try {
connection.rollback();
} catch (SQLException e) {
throw new RuntimeException("回滚事物异常");
}
}
}
DruidConnectionUtils实现如下
public class DruidConnectionUtils {
private transient static DataSource dataSource = null;
private final transient static Properties PROPERTIES = new Properties();
// 静态代码块
static {
PROPERTIES.put("driverClassName", "oracle.jdbc.OracleDriver");
PROPERTIES.put("url", "jdbc:oracle:thin:@localhost:1521:prod");
PROPERTIES.put("username", "ods");
PROPERTIES.put("password", "ods");
try {
dataSource = DruidDataSourceFactory.createDataSource(PROPERTIES);
} catch (Exception e) {
e.printStackTrace();
}
}
private DruidConnectionUtils() {
}
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}
总结:主逻辑+第一种方式是可以实现实时同步的,但是主逻辑+第二种(两阶段提交,非连接池方式)或者主逻辑+第二种+第三种方式都是无法在没有异常的情况下将数据实时落地到Oracle。参考了一些文章,都大同小异,没有发现我自己的跟别人的有什么特别大的区别,我的就是报错,不知道是只有我自己会遇到这样的问题。