前言
我从6月到现在8月底中间两个月时间呢,除了日常项目之外,大部分时间都在搞数据同步那个项目,作为团队内部的小数仓,我个人在从零开始的路上也是积累了很多经验。网上我有看过尚硅谷的数仓项目,咋说呢,都是用flink-cdc去做,对我来说没有太多参考价值。之前我也有提到过,MySQL数据源是cdc做的,这没毛病,但是Oracle是另一套做法,详情见如何巧妙解决Flink数据倾斜问题。虽然Oracle的cdc官方也有支持,但是这个OGG是外采的,领导说不支持,那就只能硬着头皮转换SQL来做,就很难。
两个月之前的时间解决了准确的问题,这两个月着重解决速度的问题。主要是对Flink的一些特性进行了深入学习和实践,在8月30日这天算是有了阶段性的结果,由此沉淀下这一两月的知识。虽说这两月我没有需求开发任务,但是运维还是不少,占比有四分之一的时间,没法全身心投入,主要是老被紧急问题打断,这就很烦。现在回头来看这两月的重要结果,代码整体优化依赖升级(flink升到1.17.1,fastjson2最新,日志优化,切除冗余依赖),Sql处理升级为批量,Yarn集群提交从session替换为per-job,此外还有不少BUG解决。接下来我想通过时间线来一步步的描述我走的弯路和尝试,It's Showtime!
分流-侧输出流与键控流
一开始考虑用侧输出流,是因为他的功能很符合我的场景,从主流分出去支流,各干各的互不干扰。可以从主流中分出去多个支流,并且支流的数据类型不受主流限制。
Flink侧输出流版本
部署环境
flink 1.17.1,配置3台节点集群,单节点TM(taskmanager)设置为4Slot,总内存16G
代码逻辑
分流版本代码代码重新开发,其核心思路是利用了侧输出流,在不阻塞主流的情况下提升QPS。设计思路是按照之前keyBy的路径,尽量平衡每个支流的数据输入输出,由于旁路输出与主流共享一个Slot,因此减少了TM的Slot数提升单个Slot的内存大小以期望获得更好的性能。
实现效果
从上图看,无论是从TIDB的QPS面板还是Flink下面接收数据的红框数据都可以得到一个数字,300+。从flink看板的算子图可以看出,出现了严重背压,与预期不同的是支流繁忙导致背压严重会阻塞主流,从而使整体速度下降。
在此过程中可以观察到各项指标正常。flink的JM和TM的内存使用情况正常,GC看起来也正常,这里发现一个有意思的事情,在1.17.1默认使用的是新的G1垃圾回收器,而1.15.2使用的是JD8默认的PS,如果可以的话推荐升一下JDK版本。
在观察TIDB看板后,延迟200ms正常,因为只涉及到一个ptm_erp数据库,所以压力大部分在一个TIDB和TIKV上,142服务器的CPU使用率和内存使用率正常上升,均处于正常水平。
因为142机器上同时部署了Flink和TIDB,所以特意查看了内存占用,还算正常
CPU部分也算正常
Flink的键控流(KeyBy)版本
部署环境
flink 1.17.1,配置3台节点集群,单节点TM设置为16Slot,共计16G
代码逻辑
该版本为基于线上2.0版本微调,仅对transform算子部分的并行度增大,并扩充了两张大表,Sink并行度划分一共32个Slot。
实现效果
从上图来看,和分流版本差不多,但是仍然出现了严重的背压,主要集中在Sink端算子,太慢
TIDB部分和分流版本几乎一致
多个TM的占用上升,并且可观测到GC略微增加,判断可能是单个Slot资源过少的情况
flink多任务KeyBy模式
部署环境
flink 1.17.1,配置3台节点集群,单节点TM设置为16Slot,共计16G,和普通版本无区别。
代码逻辑
基于keyBy版本,按照重写后的Key算法将数据切分为两块,划分为两个服务,分别提交为独立flink任务
实现效果
非常惊喜和意外,QPS攀升到了五百多,相比之前的三百多,大约有1.5倍的提升,并且该服务数据并未完全平分。
从下图看,仍然存在着严重背压,因为16个并行度中仍然存在阻塞,两个服务的数据也能看出未能均分,一个服务已运行4.7G,而另一个服务只运行了1.4G,倘若能均分,QPS应该还能提升
各个TM表现正常,TIDB由于QPS上升,CPU使用率和内存占用量少量提升,均属于正常范围
分流方案开发测试总结
分流方案我这一共实验了三种,分别是侧输出流、键控流和拆分任务的键控流,本质上都是通过并发处理提速,结论是拆分任务最有效。先叠个甲,博主也是在学习中,如有不对快点告诉我吧,真不想走弯路了。一个月后回头来分析,当时我对侧输出流的期望出现了问题,我完全没有想过支流会反过来阻塞主流的问题,我天真的以为支流流出去了就不用管了,后续数据往上堆就行了。但从flink的设计角度思考,很难允许这样不受控制的数据流存在啊。
键控流是我在处理1.0版本顺序问题砥砺前行-初学Flink的我如何快速定位并解决数据同步问题时用到的优化版本,代码保持不变。拆分任务这个是怎么想出来的呢,主要是破除键控流版本的困境,如果有一张大表执行太慢,那么就会阻塞整个流,其他分支都不动了,就这个大表对应Key的Slot在老牛拉破车,一核有难九核围观。拆分任务的原理就是,把大表拎出来,不能因为这一个慢就影响别的Slot运行。经历以上开发测试后,我就继续对原本的大任务就行拆分,并做了数据倾斜的处理。
期间的困境与弯路
在以上分流方案实施后,虽然稳定了,但是仍然没有完全解决速度上的问题,原因是,就算SQL执行再快就几毫秒或者几十毫秒,那人家一条SQL修改上千上万条SQL,通过OGG传过来就是上千上万条数据,转换成SQL也是上千上万条。换算下来,人家一条语句需要我这边通过更多的语句去执行,就像是MySQL的BinLog,我肯定拍马也追不上人家数据源的更新速度。
虽说一直解决的是不同的问题,但是困难的是,数据同步的一切问题呈现的表象都是一样的,无论是慢了还是执行错了,对用户来说就是数据有问题。很无语,这段时间我也一直在想法提升速度,中间走了很多弯路,同时也解决了很多问题。
部署模式的升级失败
原始版本也就是1.0版本用的Flink是1.15.2,包括线上和测试环境部署的都是这个版本。测试环境部署的是flink集群模式,正式环境部署的是Yarn集群的Session(会话)模式。
测试环境用的flink集群无所谓,我替换1.17.1后无缝升级,具体配置我就不详述了。正式环境我想部署成per-job(单任务)或者application(应用)模式,为啥呢?因为Session模式资源共享。我就遇到一个问题,有一个任务阻塞了,导致这个任务对应的taskManager心跳超时,进而TM丢失,有用到这个TM上面的Slot的任务就全都挂了。单任务或者应用模式资源隔离,那就不会有这个问题,至少不会影响别的任务,这很关键。当时Hadoop和Yarn的配置有问题,导致flink的任务提交出现了问题,但是那会儿我没有意识到,单纯是以为我提交的命令和flink配置的不对,经过接近一天的尝试后,无奈放弃。
8.28追加更新--解决了这个问题,Hadoop的classpath没有配置,还有各个服务器的Hadoop环境变量并没有都配上,重启后目前以yarn-per-job模式已正常提交任务。
arduino
./flink run -t yarn-per-job -d -Dyarn.application.name="erpPro6" -Djobmanager.memory.process.size=2g -Dtaskmanager.memory.process.size=8g -Dtaskmanager.numberOfTaskSlots=4 -Dyarn.containers.vcores=6 -c com.it.ruijie.basedata.app.CommonApp job/flink-ogg3.jar -config ogg-erp-pro-6
以上配置的的意思如下,-t yarn-per-job表示以单任务模式提交,-d分离模式就是后台运行,-Dyarn.application.name="erpPro6"提交到Yarn集群的名字是erpPro6,-Djobmanager.memory.process.size=2g意思是jobmanager总内存大小为2G,-Dtaskmanager.memory.process.size=8g就是单个taskmanager总内存8G,-Dtaskmanager.numberOfTaskSlots=4意思是单个TM划分为4个Slot,-Dyarn.containers.vcores=6使用到Yarn容器的CPU要六核(这个需要改Yarn的配置,目前未生效)。后面就是常规配置了,-c入口类,指定jar包位置,-config ogg-erp-pro-6这个是我自定义的参数,不用管。
数据库断连
这个Communications Link Failure错误真的是传世经典,背后的原因多种多样。在看到这个错误的时候,我以为是SQL处理超时导致数据库连接失效,但是想了想不对,因为通过TIDB后台可以看到没有运行时间超过我设置的超时时间的SQL出现。然后一个奇异的共性现状引起了我的注意,从我提交任务到出现断连异常的时间差不多是8小时,8小时?这不是MySQL侧的默认连接保活时间吗,再一看我的代码,当时瞎逼优化,为了节省创建数据库连接的开销,选择在Sink的open方法中固化连接。
这里解决也很简单,回退到之前的代码,选择固化连接池即可。这里不由得感概我也算见多识广,这8小时的特殊情况,我一眼顶针。
优化大乱炖
代码优化
- 代码因为我是切分成多任务,为了避免出现多份类似的代码,我做了一些配置化的工作,通过打包时添加参数,来加载不同的配置。
- 此外优化了依赖,缩小包体积以及提高启动速度。
- 弃用fastjson1,改用fastjson2,提升序列化速度,代码中序列化次数频繁,因此该项很有必要,特别是应对大规模流量冲击的时候
DataX使用指南
之前全量数据同步的时候,额,让大家见笑了,用的Navicat数据传输。从Oracle传到TiDB,自动建表加传输是真的香,但也埋下了隐患。Navicat在TiDB建表时为了保证Oracle的数据能顺利传输,字段类型只要有模糊的值,统统拉到最大,比如小数类型,整数位和小数位直接拉满。造成的影响就是数据膨胀,我在重新优化表结构后,数据大小少说也能小个四分之一,多的能少一半。
在重新建表的时候呢,为了能快速的传输全量数据,引入了DataX。
快速部署
bash
wget https://datax-opensource.oss-cn-hangzhou.aliyuncs.com/202303/datax.tar.gz (链接来自GITHUB,右键复制链接,别下载了还往服务器传,浪费时间)
tar -zxvf datax.tar.gz
检查JDK和Python是否安装
java -version python
下载后解压至本地某个目录,进入bin目录,即可运行同步作业:
cd {YOUR_DATAX_HOME}/bin
python datax.py {YOUR_JOB.json}
自检脚本: python {YOUR_DATAX_HOME}/bin/datax.py {YOUR_DATAX_HOME}/job/job.json
使用指南
DataX官方文档,基本看文档就好,我看有个第三方写的定时平台,内嵌XXL-JOB来执行脚本,但是好多年不更新了,不知道还能不能用了。官方有提供付费升级服务,看着是挺爽,可惜我肯定是没法用了,没经费。分享一个我常用的脚本模板,其实配置很简单,看了官方的文档,其实也没啥可配置的,大部分都是限制而不是强化,默认就很强了。
lua
{
"job": {
"setting": {
"speed": {
"channel": 16--通道数(并发数)。新版本DataX3.0提供了包括通道(并发)、记录流、字节流三种流控模式,一般推荐用通道控制。
}
},
"content": [
{
"reader": {
"name": "oraclereader",
"parameter": {
"username": "用户名",
"password": "密码",
"splitPk": "拆分主键",--推荐splitPk用户使用表主键,因为表主键通常情况下比较均匀,因此切分出来的分片也不容易出现数据热点
"column": ["字段"],--尽量不要用*,插入有字段顺序的,不能保证原表结构和现表结构完全一致的别用*
"connection": [
{
"table": [
"CUX.表名"
],
"jdbcUrl": [
"jdbc:oracle:thin:@xxx:xxx"
]
}
]
}
},
"writer": {
"name": "mysqlwriter",
"parameter": {
"batchSize": 3072, --批次和上面的channel联合测试,得到一个最佳效率点
"writeMode": "insert",--最好是insert,这个快
"username": "用户名",
"password": "密码",
"column": ["字段"],
"preSql": ["TRUNCATE 输出表"],
"connection": [
{
"jdbcUrl": "jdbc:mysql://ptm-rtc-tidb.ruijie.com.cn:4000/xxx?rewriteBatchedStatements=true&allowMultiQueries=true&useConfigs=maxPerformance&useServerPrepStmts=true&connectTimeout=300000&socketTimeout=600000&useSSL=false",
"table": [
"输出表"
]
}
]
}
}
}
]
}
}
操作要点
执行命令用下面这俩模板就好,一个是后台输出的,一个是指定了JVM堆内存大小。
ruby
nohup python datax.py mtl.json > /usr/tool/datax/bin/mtl.log 2>&1 &
python /data/datax/bin/datax.py --jvm="-Xms8G -Xmx8G" /data/datax/job/erp-test/TEST-MTL_GENERIC_DISPOSITIONS_DATAX.json
指定堆内存大小的原因是,前面通道数和批次大小影响一次性读取的总数据大小,内存小了装不下会报OOM。还要注意的是,DataX读取的是快照数据,也就是传输这个时间段的数据是丢失的,需要额外补一下。总的来说,作为一个ETL工具,DataX足够便捷、稳定和快速,很好用。对了,提一嘴,jdbcurl后面那一串优化参数不用加,DataX默认会给你加上,这个不用担心。
SQL转换为批量操作
从八月上旬开始,着手进行批量的改造,我之前预估到了这是一个巨大的变动,因为这是整个底层处理逻辑的革新。原本的逻辑是对ROW模式的OGG日志进行数据行一对一的转换,这样就会出现天生的性能缺陷,删改的批量语句通过行记录转换,会出现一条SQL语句影响多行的情况。换言之,2.0的代码逻辑进行处理时,批量SQL通过OGG再到Flink进行转换后,会变成多个单行处理的SQL,想也知道成千上万条SQL怎么优化也赶不上一条SQL的执行速度。那么,如何巧妙地转换为批量SQL就是当下最重要的痛点。
问题现状
认识到这个问题的最直接现状就是一张频繁变动的小表(3W数据量),在ERP里这个表采用了很多批量操作。我单独拿下数据,做了SQL的转换,通过对数据的观察发现,短时峰值能达到上万QPS,平均下来每秒约一千QPS。前面我通过在线上问题的摸爬滚打中突击TIDB,观察TiDB的SQL时效,优化后大部分SQL维持在毫秒级,算上IO开销大部分也就几百毫秒,但是盖不住量大,因此长时间运算后,数据同步严重后置,往往还在处理几天前抛过来的数据。一开始我是选择单任务处理该表,不与其他表争夺资源后,独立运行应该会快些。但是可预料的是,依旧不行,因此要考虑批量处理。为了保证SQL的执行顺序,批量也不能随意批量。
方案需要考虑的问题
综合下来要考虑的有如下几个问题:
- 在批量时如何保证SQL执行的顺序
- 如何来做批量才更有效
- 批量多久提交,不能因为攒批而放弃实时
保证顺序
数据源是顺序的,需要保证转化成批量SQL同样保持顺序执行。我的解决方案是相同类型(增删改)的SQL进行攒批,遇到不同类型立刻转换之前的批数据成为SQL,发送到TIDB执行,最后将新的数据保留重新开始攒批。
更有效的批量
提到批量SQL,最简单的就是攒一批SQL语句一次性发送到TIDB执行,这样就减少了多次IO带来的开销,这是最轻松的。但是既然要做,那就优化到底。更新的语句,这没办法,只能攒批直接推。
新增的,攒批后统一改成批量增语句,类似于INSERT INTO表名VALUES ([列值],[列值])),([列值],[列值])),([列值],[列值]));。在做增语句时,要注意序列化时不要忽略NULL值数据,避免字段丢失,fastjson2的写法是JSON.toJSONString(obj, JSONWriter.Feature.WriteMapNullValue)。
删除的语句也好处理,攒批后按主键写IN查询删除。
保证时效
批量和时效通常是冲突的,强如Kafka内部也有批量大小和发送时延的博弈选择,因此我也需要提供这样的手段来保证数据的时效性。根据我对Flink的认知,初步设计了三套方案逐个实验,分别是Sink端加入自定义定时器、Flink定时器、时间窗口。
我先选择的是使用自定义定时器,实现相对简单,最快出效果。但自定义定时器同样存在问题,来源于异步,基于flink对Sink的生命周期管理,开启异步的时候即使通过状态管理的全局变量进行控制,在极端情况下依旧存在数据丢失和数据覆盖的问题。
typescript
public class TidbPowerSink extends RichSinkFunction<DataDTO> {
private transient MapState<String, PendingData> pendingMap;
private transient ValueState<Boolean> onTime;
@Override
public void open(Configuration parameters) {
pendingMap = getRuntimeContext().getMapState(new MapStateDescriptor<>
("pendingMap", String.class, PendingData.class));
onTime = getRuntimeContext().getState(new ValueStateDescriptor<>("onTime", Boolean.class, false));
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
try {
invoke(new DataDTO<>(), new Context() {
@Override
public long currentProcessingTime() {
return 0;
}
@Override
public long currentWatermark() {
return 0;
}
@Override
public Long timestamp() {
return null;
}
});
} catch (Exception e) {
log.error("定时任务出现异常", e);
}
}, 5, 5, TimeUnit.SECONDS);
}
@Override
public void invoke(DataDTO dataRaw, Context context) {
while (true) {
Boolean value = false;
try {
value = onTime.value();
} catch (IOException e) {
log.error("忙循环重获取onTime失败", e);
}
if (!value) {
// log.info("从忙循环中脱离,继续流输出");
break;
}
try {
Thread.sleep(200); // 暂停200毫秒
} catch (InterruptedException e) {
log.error("线程终端执行异常", e);
}
}
......
这里用到了Flink的状态管理,我的原意是将onTime作为判断当前流运行时是否同时有定时任务执行,就像上面invoke判断里的,如果当前定时任务执行,就阻塞当前流。不幸的是,我这种写法属于违规操作,在open里面开定时器调用invoke方法,我是真魔怔了,而且状态管理的字段不能应用于open中。测试该代码的时候,invoke里面的判断是失效的,并且定时器内部会疯狂报错,状态管理使用上的报错。归根结底就是invoke的调用是受到Flink的生命周期管理,我的做法是打破了规则,并且漏洞百出。
接下来测试的是flink官方的定时器。Flink的定时器通过源码得知会禁止数据的读取,优先执行定时器,但是缺点是不能在Sink端注入,因此原来的代码需要重新设计和大幅修改。
java
SingleOutputStreamOperator<DataDTO> process = filterStream.keyBy(new ReBalanceGroupSelector<>(parallelismSink))
.process(new KeyedProcessFunction<Integer, DataDTO, DataDTO>() {
@Override
public void processElement(DataDTO dataDTO, KeyedProcessFunction<Integer, DataDTO, DataDTO>.Context context,
Collector<DataDTO> collector) {
TimerService timerService = context.timerService();
//5秒后定时器
timerService.registerProcessingTimeTimer(timerService.currentProcessingTime() + 5000L);
// collector.collect(dataDTO);
}
@Override
public void onTimer(long timestamp, KeyedProcessFunction<Integer, DataDTO, DataDTO>.OnTimerContext ctx, Collector<DataDTO> out) throws Exception {
super.onTimer(timestamp, ctx, out);
//定时出发全量计算
out.collect(new DataDTO(null, null, null, null, null, "all", null, ctx.getCurrentKey()));
TimerService timerService = ctx.timerService();
//5秒后定时器
timerService.registerProcessingTimeTimer(timerService.currentProcessingTime() + 50000L);
}
});
process.keyBy(new ReBalanceGroupSelector<>(parallelismSink))
// .addSink(new TidbPowerSink(parameterTool, tableMap))
.print()
我对定时器的设想是,每隔五秒钟,将批次的数据处理后统一提交到Sink。但是实际测试后发现,是流中每一个数据传过来都会触发一次定时器,这肯定不对。那么我最开始的想法就是适合批处理而不是流处理,因此该方法废弃。
那么,就只剩下最后一招了,开窗。开窗的话,我选择的是十秒一次的固定时间窗口,然后用了一个Map存储各表的数据。这个做法类似于传统的批处理,缺点是时间点相对固定,会增大Sink端的TiDB的瞬时压力。
swift
SingleOutputStreamOperator<List<DataDTO>> returns = filterStream
.keyBy(new ReBalanceGroupSelector<>(parallelismSink))
//使用系统处理时间,不用EventTime就无需设置水位线
.window(TumblingProcessingTimeWindows.of(Time.seconds(10)))
.process(new ProcessWindowFunction<DataDTO, List<DataDTO>, Integer, TimeWindow>() {
@Override
public void process(Integer integer, ProcessWindowFunction<DataDTO, List<DataDTO>, Integer, TimeWindow>.Context context,
Iterable<DataDTO> iterable, Collector<List<DataDTO>> collector) {
List<DataDTO> list = new ArrayList<>();
for (DataDTO dataDTO : iterable) {
list.add(dataDTO);
}
collector.collect(list);
list.clear();
}
});
returns.addSink(new TidbPowerSink(parameterTool, tableMap))
.setParallelism(parallelismSink).name(realJobName + "-sink");
目前基于时间窗口的方案,已测试通过,数据不会差太多,误差控制在3分钟内,相比之前大几天的延迟,和迟迟赶不上的尴尬情况已好了太多。3分钟的延迟更多来源于OGG本身采集和单通道的Kafka消费了几十张表数据过滤的时间。
批量的复盘
思路都比较直接,但代码其实改动非常大,一些小细节没法很好地展现出来。最后选择开窗,也是在时效和性能之间做出了妥协和平衡,我感觉这已经是批处理了,哈哈。总的开发时间其实有点赶,很多东西我都是半学半做,网上也缺乏完善的学习案例,算是磕磕绊绊地学习实践,不断碰壁的开拓之路。
当下遇到的问题
问题还是很多,简单罗列一下,避免自己忘记。
- Yarn集群的资源设置问题。我现在用yarn-per-job模式提交多个任务时,明明资源充足还是会提示我请求不到资源deployment took more than 60 seconds. Please check if the requested resources are available in the YARN cluster。
- yarn-per-job模式提交时,看不到flink控制台输出的日志。我用yarn-session模式就能看,本地也能看就很费解。
- checkPoints检查点机制没有打开,重试机制是缺失的,这块需要处理一下。
最主要的暂时就这俩问题,剩下的都是历史数据需要处理,重新建表,DataX推全量数据啥的。毕竟就我一个人搞,从设计到开发运维全包,太难了啊,时间不够。而且最困难的点在于学习资料很少,不像Java生态那么健全,很多都有解,flink相关的资料是真的少啊。
写在最后
今天周六加班,哎,真难啊,烦躁。借着周六加班的时间总结和沉淀一下,干了这么久也得好好回顾分析一下。想想都干了什么,有没有更进一步的地方,当时的考虑是不是周全,还有什么样的问题需要解决。哎,据说最近周末都要加班,心情很差,就这样吧。