背景
pyflink目前遇到性能瓶颈, 根因是因为在现有框架中flink流模式跑历史数据做验证
目前从MQ计算的特征,首先会在DAG图中,将每个测点到算法检测节点边拼接成一个邻接矩阵. 在获取到数据源后, 通过json配置中的监测算子名称订阅到侧流进行输出. 在存放每个tag点位的过程当中,下游开窗算子处理, 并且对每条测流进行水位线的分配, 在结果合并与分发节点中存在一个叫做watermark的字段,会将水印以及间隔时间添加到每条侧流的配置当中,最后在合并阶段,聚合所有侧流.
但总是会存在例外情况 比如如下这条flow
在数据流上表示为:
1688980174619 -> 1688980196059 - 1688980273474 (识别算法)
|
_ 1688980524194
在1688980273474这个算子当中 会调用测点的识别算法,该算法并不是一个简单的udf函数,包含着一个测点多设备的特征阈值分统计, 以及后续设备的异常时间序列监测
目前以在算法的特征计算逻辑中分两种:
- 评估算法
- 基于设备的转速,振动量规划的不同健康度的阈值区间
- 识别算法
-
- 基于固定阈值的多特征值检测,并计算融合分.
- 基于时间序列的连续计数统计阈值, 并根据中间的状态1变化, 输出从t0-tn时刻的状态值,缓存到状态列表
- 事件算法
- 特征算法(跟工况有关,特征值阈值区间的组合)
- 阻塞算法 (有状态的算法 输出0-n条结果 )
如果是多特征值连续匹配的话可以用udaf 在内存计数, 但对于保留状态的序列需要额外维护状态字段, 而对于时间序列的不同频段监测可以用开窗+UDF解决,因为知道设备的采样周期
名词解释
如果现在用户在设备上添加一个测点,比如xxx厂线下有多个设备,在程序中会根据算法的字典编码映射成多个设备点位, 一个设备id对应一个算法id. 多个设备会映射成虚拟点位.
特征工程中的json, 数据的时间戳是毫秒级,但是每秒会上报一次特征值
设计方案以及折中
目前的特征计算逻辑
- json当中的nodes代表算法节点 在一开始处理的时候需要编排, edges中的每个点代表数据处理节点
- endpoints中的orientation代表算子节点的算法权重, 便于在调用具体算法进行检测的时候,比如多
特征值的分布检测,根据权重给定不同的状态等级.
由于在流作业跑完后,算法侧需要在本地做历史数据验证
在source_utils类当中先加载ck配置 , 分为两个流 工作流和算法规则
工作流的输入在StreamJobGraph ,schema是确定的
t_env.execute_sql(""" CREATE TABLE clickhouse_sample_verfired (
node_id BIGINT,
uuid STRING,
batch_id STRING,
device_time TIMESTAMP(3),
device_timestamp BIGINT,
key_id STRING,
str_v STRING,
is_delete INTEGER,
create_time TIMESTAMP(3),
PRIMARY KEY (uuid) NOT ENFORCED
) WITH (
'connector' = 'clickhouse',
'url' = 'clickhouse://xxxxxxx?compress_algorithm=gzip',
'username' = 'xxxx',
'password' = 'xxxx',
'database-name' = 'ddps',
'table-name' = 'ts_kv_iot_main',
'scan.partition.column' = '{}',
'scan.partition.num' = '{}',
'sink.batch-size' = '500',
'sink.flush-interval' = '1000',
'sink.max-retries' = '3' ); """.format("device_time", parallelism))
# 对传入的时间区间做合并 收集需要过滤的nodeid列表
node_list = []
time_list = []
for i in range(0, len(node_sharding)):
tmp_node = node_sharding[i]
if len(tmp_node):
node_list.append(tmp_node['node_id'])
time_list.append(
[tmp_node['clickhouse_start_time'], tmp_node['clickhouse_end_time'], tmp_node['node_id']])
node_list_len = len(node_list)
node_list = str(node_list).strip("[]")
combine_time_list = merge_interval(time_list)
time_list = transfer_format(combine_time_list)
time_combine_str = ""
for time in time_list:
time_condition = "device_time >= " + "'" + time[0] + "'" + " AND " + "device_time <= " + "'" + time[
1] + "'" + " AND "
time_combine_str = time_combine_str + time_condition
# 对传入的时间区间做合并, 收集nodeid list
t_env.create_temporary_system_function("format_time", stream_format_time_refactor)
t_env.create_temporary_system_function("pivot_multi_column", group_device_data_agg)
t_env.execute_sql("""
create table print_table (
nodeId BIGINT,
uuid STRING,
pageBatchID STRING,
device_time TIMESTAMP(3),
device_timestamp BIGINT,
uniq_array STRING
) WITH (
'connector' = 'print'
)""")
# 做设备的行转列逻辑
# table = t_env.from_path("clickhouse_sample_verfired").group_by(col('node_id'), col('uuid'), col('batch_id'),
# col('device_time'), col('device_timestamp')) \
# .flat_aggregate(call("pivot", col('key_id'), col('str_v')).alias("uniq_array")) \
# .select(col('node_id'), col('uuid'), col('batch_id'))
sql = """ SELECT node_id AS nodeId,
uuid,
batch_id AS pageBatchID,
device_time,
device_timestamp,
format_time(device_timestamp, device_time) as new_time,
pivot_multi_column(key_id, str_v)
over (PARTITION BY
node_id, uuid, batch_id, device_time, device_timestamp
ORDER BY device_time
) AS uniq_array
FROM clickhouse_sample_verfired
WHERE {}
node_id in ( {} ) """.format(time_combine_str, node_list)
table = t_env.sql_query(sql)
stream = t_env.to_data_stream(table=table)
stream = stream.flat_map(FeatureTuple())
# stream.execute_and_collect("source_test")
return stream
对于输出而言,代码位置在scdap/frame/works/base.py, 因为会定义多条侧流 所以对每条输入流,会在开窗之前设置水位线, 在不同点位合流的时候进行水位线的间隔
在transform过程中分为两个阶段 合流和分流
可以用 BEGIN STATEMENT SET + 支流的翻滚窗口开窗
转换过程
在一个完整的算法DAG链路当中,在合流之前需要为不同点位的数据发送设置不同的水位线,在目前的逻辑当中,如果一个作业需要采集多个点位的数据,需要保证一定时间范围内的数据不丢,并且保持秒级发送. 但在生产环境当中,会出现因为每个设备关机所引起的设备监控数据断流,所以需要伪造心跳事件推高水位线,保证在窗口聚合的时候, 因为某个点位的数据迟迟不上报,造成改作业所有点位的数据都阻塞在那里。而对业务而言,补数据分为两种方式:
- 源端使用faker数据源的方式,在clickhouse-source后面单独接一个flatmap算子,在算子当中根据每秒产生事件的总量均匀的发送到各个subtask
- 使用pandas udf + clickhouse source以faker为主流,device_id为唯一特征值关联源端的source, 开一个滑动窗口进行补数据。
在补充完伪造事件之后, 在进入下游之前,与源端产生的事件进行合流,剔除之前产生的事件.
在分流的过程中 算法工程会根据json配置中所有的nodeid,反射拿到 所有的检测,识别,以及结果合并以及分发
BEGIN STATEMENT SET;
INSERT INTO realtime_aggregations
SELECT
browser,
status_code,
TUMBLE_ROWTIME(log_time, INTERVAL '5' MINUTE) AS end_time,
COUNT(*) requests
FROM browsers
GROUP BY
browser,
status_code,
TUMBLE(log_time, INTERVAL '5' MINUTE);
INSERT INTO offline_datawarehouse
SELECT
browser,
status_code,
DATE_FORMAT(TUMBLE_ROWTIME(log_time, INTERVAL '1' HOUR), 'yyyy-MM-dd') AS `dt`,
DATE_FORMAT(TUMBLE_ROWTIME(log_time, INTERVAL '1' HOUR), 'HH') AS `hour`,
COUNT(*) requests
FROM browsers
GROUP BY
browser,
status_code,
TUMBLE(log_time, INTERVAL '1' HOUR);
END;
合流: stackoverflow.com/questions/7…
基于视图的水位线延迟关联,确保到达时间存在一定的延迟
在flink的 水位线管理中 内连接使用hash port join, 两个流会缓存joinkey checkCoordnitor中的
全局时间大于窗口的结束时间的时候, 根据水位线两两之间延迟的时间间隔进行发送,所以不会有手动设置水位线的必要
ps: 但在工业检测的场景中, 存在一条流长时间没有数据的情况,如果手动发送心跳事件虽然可以推进水位线
但会造成算法检测的时候缺失值存在, 所以折中的方案可以考虑用pandas udf中在拼接样本的时候有一个延迟关联时间
如果超过延迟关联时间,则从配置的watermark的duration进行补全,具体要和算法商量
CREATE VIEW self_join AS (
SELECT l.ts, l.id, r.id
FROM stack as l INNER JOIN stack as r
ON l.id=r.id
WHERE ls.ts BETWEEN r.ts - INTERVAL '0' MINUTE AND r.ts
);
当绑定位点的时候 加载python对应的函数列表
work_flow/workflow/functions/operator_functions
根据节点id绑定函数位置 并添加侧输出流订阅tag作为绑定点位的输出条件.
在绑定算子的时候 会指定ScAlgorithmResultProcessFunction作为输入函数 将算法的feature数据源引入侧输出流
绑定之前,会调用脚本启动algorithm_flow_job#build函数 传入算法的全局参数
因为算法会绑定某一个点位 所以算法参数从启动时的点位json中来, 下面是一个健康度检查的配置
"evaluation": [
{
"global_parameter": {
"analysis_second": 600,
"health_define": [
"rt_stability",
"secular_stability",
"rt_trend",
"secular_trend",
"rt_volatility",
"secular_volatility",
"comprehensive"
],
"default_score": [
...........
]
}
]
该配置首先后调用initial_work做算法参数的初始化.
- 将feature的特征值维度先做初始化. 比如单个点位,每秒,算法上下游之间传输的数据结构
- 对于温度,转速,电压等特征值会对数据结构做编码和合并
work_flow建DAG图的逻辑
递归节点之间的先后层级关系
-
解决方案1:
通过计算被指向的次数,来计算节点的执行顺序
问题:被指向的次数多,不一定优先级低
-
解决方案2:
设置被指向节点优先级=所有父节点的和,保证子节点一定比父节点大
问题:如果子节点只有一个父节点,子节点的优先级就会跟父节点相同
- 解决方案3:
0.设置被指向节点优先级=所有父节点的和+被指向次数
1.遍历边表,先生成一个父节点和子节点的映射map
2.再次遍历边表,如果是头节点,默认值设0,如果是尾节点默认设0,已存在=节点原有值+1
3.递归给尾节点的所有下游节点+1
如此则避免了方案1、2的缺陷,保证子节点的优先级一定低于双亲节点
存在的问题
- 当前特征计算逻辑中,从kafka的分发端到写入clickhouse这条链路在生产环境稳定,但从clickhouse读取数据做二次特征历史检验时,存在基于flink流模式的性能问题.
- 由于历史数据验证流程中,原先的旧数据源无法很好的提高性能,是否要将特征计算的逻辑udf化
- 5.x 一个作业会存在多个点位从一个数据源发送数据,在分流的时候,有些点位迟迟无法上报数据,会造成一直等待这些点位, 需要手动补数据.
- 由于算法作业历史数据分析的过程中,本地存在数据量大的情况,原本的source改造前采用比较原始的SourceFunction
- 历史数据算法最终的输出结果到csv当中
一 特征计算的性能问题
目前定位到的问题存在两方面,一部分是因为python作业需要借助flink的资源运行,所以PVM需要与JVM交互,其中会产生网络问题
在之前pyflink的测试用例当中, 测试的只是从时序4 - 时序10, 也就是从PVM通过grpc通信,到在python本地计算这一个流程. 这里但从耗时的角度来说分为两部分。网络耗时和由于内存堆积造成刷新到下游缓冲区的耗时.
由于原来算法侧历史数据验证的时候,是自定义的一个clickhouse source源。并没有暴露isBound属性,所以造成没有办法启用1.16当中内置的弹性调度器.
弹性调度: nightlies.apache.org/flink/flink…
在流模式的调度策略当中, 存在两个开放参数
jobmanager.adaptive-scheduler.resource-stabilization-timeout
配置键默认为0:只要有足够的资源可用,Flink 就会开始运行作业。
在 TaskManager 不是同时连接,而是慢慢地一个接一个地连接的情况下,
这种行为会导致每当 TaskManager 连接时作业重新启动。
如果您想等待资源稳定后再安排作业,请增加此配置值
jobmanager.adaptive-scheduler.min-parallelism-increase:
此配置选项指定触发扩大规模之前额外的聚合并行度增加的最小量。
例如,如果您有一个带有源(并行度=2)和接收器(并行度=2)的作业,则聚合并行度为 4
由于目前clickhouse的源为批读模式, 如果要改造成流读模式,需要在source端重写InputSplit分片策略, 需要将数据写入缓存 然后在内存绑定clickhouse每个分片的数据。但是在1.16支持的Source API中无法获取当前迭代数据的上下文, 如需改造工作量比较大, 且需要大量时间测试验证。 所以重构成批数据源进行调优
目前批模式需要切换到流作业的执行模式
flink_config.set_string("execution.batch-shuffle-mode", 'ALL_EXCHANGES_PIPELINED')
可以稳定运行
1) 作业全局序列化问题
在目前的Pyflink当中,支持以下主要的数据类型和序列化;
-
- 数据类型
-
- 基本类型: String, Boolean, Numeric等
- 数据类型: ArrayType
- 行类型: RowType
- 符合类型 TupleType,MapType
- 序列化: pyflink目前支持对数据进行序列化和反序列化,在算子间通信也适应
-
- pickle序列化: 默认方式,可序列化大部分Python对象
- JSON序列化: 通过指定json库进行编码
- Avro序列化: 需要注册Avro序列化schema (table api的话不需要自定义schema)
- Arrow序列化: 支持跟pandas交换Arrow格式数据
其中Pickle序列化对Python对象支持广,但不够高效. avro和Arrow支持的类型受限制但是性能更好. 所以在序列化方面,在源端指定avro这种二进制格式作为算子间通信的主要方式, 对于复杂的算法逻辑. 尽量简化成pandas udf来减少格外的性能开销
此外兼容flink1.16的新api,提供了指定format的能力,保证算子传输的时候,如果使用json,avro等格式,可以不需要手动绑定schema, 具体使用方式如下:
from pyflink.dataset import ExecutionEnvironment
from pyflink.table import TableConfig, DataTypes, BatchTableEnvironment, EnvironmentSettings
env_settings = EnvironmentSettings.new_instance().in_batch_mode().use_blink_planner().build()
table_env = BatchTableEnvironment.create(environment_settings=env_settings)
table_env\
.get_config()\
.get_configuration()\
.set_string(
"pipeline.jars",
rf"file:///{os.getcwd()}/lib/flink-sql-avro-1.12.2.jar;file:///{os.getcwd()}/lib/flink-avro-1.12.2.jar;file:///{os.getcwd()}/lib/avro-1.10.2.jar"
)
table = table_env.from_elements(
a,
schema=DataTypes.ROW([
DataTypes.FIELD('text', DataTypes.STRING()),
DataTypes.FIELD('text1', DataTypes.STRING())
])
)
sink_ddl = f"""
create table Results(
a STRING,
b STRING
) with (
'connector' = 'clickhouse',
'format' = 'avro',
.........
)
在启动python interpretor的时候,由于下游要启动多个sdk worker进程(如果使用udf的话则需要额外的python udf worker),根据pyflink的源码分析, python env默认情况下会依赖flink的托管内存(manage memory), 以4g 单TM内存为例子 ,管理内存分为1.6g, 网络内存分配1g. 指定pyflink的batch shuffle模式为流编排运行后, 在通过集群提交命令后稳定运行.
内存设置思路: www.cnblogs.com/lighten/p/1…
flink_config.set_string("taskmanager.memory.process.size", '4gb')
flink_config.set_string("taskmanager.memory.managed.fraction", '0.4')
flink_config.set_string("taskmanager.memory.jvm-overhead.fraction", '0.1')
flink_config.set_string("taskmanager.memory.network.fraction", '0.1')
# # flink_config.set_string("taskmanager.memory.framework.heap.size", '0mb')
flink_config.set_string("taskmanager.memory.framework.off-heap.size", "512mb")
flink_config.set_string("taskmanager.memory.managed.consumer-weights", "OPERATOR:60,STATE_BACKEND:60,PYTHON:40")
flink_config.set_string("jobmanager.archive.fs.dir", "hdfs://hadoop-master:9000/completed-jobs/")
flink_config.set_boolean("python.fn-execution.memory.managed", False)
# # batch sort-shuffle reszie
# flink_config.set_string("taskmanager.network.blocking-shuffle.type", "mmap")
flink_config.set_string("execution.batch-shuffle-mode", 'ALL_EXCHANGES_PIPELINED')
# flink_config.set_string("jobmanager.scheduler", 'AdaptiveBatch')
# flink_config.set_string("jobmanager.adaptive-batch-scheduler.default-source-parallelism", '10')
# flink_config.set_string("python.fn-execution.arrow.batch.size", '1000')
flink_config.set_string('python.fn-execution.bundle.size', '10000')
2) udf工程化引申出的问题
-
- 为什么要引入udf工程化?
在flink batch的文档中pandas udf运行在python环境会提供arrow这种基于内存格式的序列化方式节省在python env计算的性能开销, 见邮件链接
-
- 如何改造现有框架
目前在工作流编排的逻辑当中,会根据配置的算子id,导入python动态库,经过讨论目前是打算尽量不要侵入到算法框架层面,而是把原来算法作为一个大udf注册到框架当中, 如下列代码所示
class UdfCommonProcess(ScalarFunction):
def __init__(self):
pass
def eval(self, node: Node): -> Any
plugin_param = node.plugin_param
function_name = plugin_param["class_name"]
function = importlib.import_class(function_name, "function")
# 注册函数
func = function()
# params 是算法输入的特征参数(feature.mean, meanLow)
func.initial_work(node,params)
algorithm(self) -> ?
create_result -> pd.DataFrame adapter抽象
- 算法框架需要做哪些兼容
-
- 联动点位配置: 涉及到linkage_config部分 会存在初始化的时候计算完一批数据后,将数据存入缓存,在框架当中迭代不断更新缓存值, 需要做单独udf的算子兼容
在算法实现当中,分为识别和评估两种算法
- 其一,对于评估算法而言,以编辑距离的算法举例. 计算健康度需要从迭代流中拿到即时的数据,将特征样本通过numpy拼接, 如果改造为pandas性能会有所提升, 但是create_result()需要前置抽象到base.py的算法通用基类
代码样例: /Users/windwheel/Documents/gitrepo/algorithm-dependency-library/algorithm/evaluation/distance_to_health36/adapter.py
def algorithm(self):
features, status, time = self.read_data()
# print('time', time)
res = self.dth.algorithm(features, status, time)
result = self.create_result()
result.set_health(**res)
dth的计算健康度距离算法实现
代码样例: /Users/windwheel/Documents/gitrepo/algorithm-dependency-library/algorithm/evaluation/distance_to_health36/distance_to_health36.py
def algorithm(self, features, status, time):
# print('time', time)
data = self.eva.pre_comp(time, features, status)
if data != {}:
change_index = step_jump_detect(
self.eva.feature_temp, self.eva.window, self.eva.compare_factor, self.eva.step, self.eva.time_limit,
self.eva.jump_feature)
if len(change_index) == 0:
score = self.eva.compute(self.eva.time_temp, self.eva.feature_temp)
# res = dict(zip(self.eva.health_define, [score]))
self.prev_score = score
else:
score_temp = []
for i in range(len(change_index)):
if i == 0:
score = self.eva.compute([[self.eva.time_temp[0][0][0:change_index[i]]]],
[[[self.eva.feature_temp[0][0][0][0:change_index[i]]]]])
# res = dict(zip(self.eva.health_define, [score]))
else:
score = self.eva.compute([[self.eva.time_temp[0][0][change_index[i - 1]:change_index[i]]]],
[[[self.eva.feature_temp[0][0][0][
change_index[i - 1]:change_index[i]]]]])
# res = dict(zip(self.eva.health_define, [score]))
score_temp.append(score)
self.prev_score = np.min(score_temp)
self.eva.reset()
return dict(zip(self.eva.health_define, [int(min(100, self.prev_score))]))
-
- 其二,识别算法以motor97为例.需要在输出状态之后记录前一个状态值
def algorithm(self) -> None:
# ---------------------------读取所需特征值---------------------------
time = self.features.time
features = np.hstack([[self.features.mean_hf, self.features.mean_lf, self.features.mean, self.features.std],
self.features.band_spectrum])[self.select_features]
# ---------------------------每秒状态值计算---------------------------
.......
# 输出状态
result = self.create_result()
result.set_status(self.prev_status)
也就是说create_result的返回结构有些其实是不固定的,所以希望算法侧将algorithm()方法的入出参
以及create_result的入出参 都限定为time.series格式
方便我们对算法依赖框架改造时,能够以侵入性最小的方式改造
二 一个作业多点位手动补数据的问题
1) 滑动窗口
每次滑动1s + pandas udf时间序列插值补数据
select udf(device_id,tp_watermark_time ) from
( select device_id, tp_watermark_time from iot_kv_main
group by device_id, slide_window(interval '1' s) ) time_series_tmp
udf的具体实现
@udf(result_type=DataTypes.TIMESTAMP(3), func_type="pandas")
def stream_format_time_refactor(deviceTimeStamp: pd.Series, deviceTime: pd.Series) -> pd.Series:
time_len = len(deviceTime)
result_series_index = data_range(start='2024-06-25 11:12:02', periods=7, freq='S')
//todo 去除空的值
date_to_remove = pd.to_datetime(..........)
result_series_index = date_index[~date_index.isin(date_to_remove)]
data_fill_value = {'deviceTimeStamp': ['2024-06-25 11:13:01', '2024-06-26 11:13:02',.......]]}
result_series_data = pd.DataFrame(data_fill_value, index=result_series_index)
//todo dataframe转series
.......
result = pd.Series(data=result_series_data, index=result_series_index)
return result
为了保证算法最后的样本分布不会出现右偏分布, 应该新增一个udf剔除手动插值的样本,返回是否插值的标记.
总结: 基于时间序列插值补数据,可以手动选择一个窗口中的空值数据进行补全, 但无法对迟到的数据在下游通过flink的changelog做upsert或者retract保证一致性
2) 自定义补数连接器
通过faker-connector在flink framework层(尚未进入flink算子前的时候,从SourceContext手动补数据), 从使用上来说相当于一个单独的数据源,会在框架层面缓存计算状态(双指针+滑动窗口),判断上一条数据是否已经到达且指定主键。如果到期主键一致,比上一条先到达的事件时间戳更新,则发往下游。基于flink本身的changelog做一致性保证(下面会展开)
public class FlinkSupplementaryDataGenerator extends RichFlatMapFunction<RowData, Row> {
private Faker faker;
private SerializationSchema<Row> serializationSchema;
private TypeInformation<RowData> outputType;
private DataType physicalRowDataType;
private String timeFakerFieldName;
/** 监控每个subtask攒批过程,等待多久时间 */
private long soFarThisSecond = 1;
/** 每秒发送多少事件 */
private long rowsPerSecond;
/** 读取下一条事件的时间戳 */
private long nextReadTime;
/** 当前事件的指针描述符 */
private ValueStateDescriptor<ClickhouseCollect> currentStateDescriptor;
/** 当前事件 */
private ValueState<ClickhouseCollect> currentState;
/** 下一条事件的指针描述符 */
private ValueStateDescriptor<ClickhouseCollect> collectStateDescriptor;
/** 下条事件 */
private ValueState<ClickhouseCollect> nextValueState;
DynamicTableSink.DataStructureConverter converter;
public FlinkSupplementaryDataGenerator(
String timeFakerFieldName,
long rowsPerSecond,
TypeInformation<RowData> outputType,
DataType physicalRowDataType,
DynamicTableSink.DataStructureConverter converter) {
this.outputType = outputType;
this.physicalRowDataType = physicalRowDataType;
this.timeFakerFieldName = timeFakerFieldName;
this.converter = converter;
this.rowsPerSecond = rowsPerSecond;
collectStateDescriptor =
new ValueStateDescriptor<ClickhouseCollect>("nextRecordEvent", ClickhouseCollect.class);
nextValueState = getRuntimeContext().getState(collectStateDescriptor);
currentStateDescriptor =
new ValueStateDescriptor<ClickhouseCollect>("currentRecordEvent", ClickhouseCollect.class);
currentState = getRuntimeContext().getState(currentStateDescriptor);
}
public FlinkSupplementaryDataGenerator(
TypeInformation<RowData> outputType,
DataType physicalRowDataType,
DynamicTableSink.DataStructureConverter converter) {
this.converter = converter;
this.outputType = outputType;
this.physicalRowDataType = physicalRowDataType;
}
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
}
@Override
public void flatMap(RowData value, Collector<Row> collector) throws Exception {
ResolvedSchema schema = DataTypeUtils.expandCompositeTypeToSchema(physicalRowDataType);
Object object = converter.toExternal(value);
String sourceJson = JacksonUtils.serialize(object);
ClickhouseCollect clickhouseCollect =
JacksonUtils.deserialize(sourceJson, ClickhouseCollect.class);
// 如果当前数据已经发送过, 依旧需要发送到下游做upsert
// 如果下一秒的数据突然在伪造完成后补上来了
int result =
nextValueState.value().getDeviceTime().compareTo(clickhouseCollect.getDeviceTime());
if (result == -1) {
collector.collect((Row) clickhouseCollect);
}
currentState.update(clickhouseCollect);
LocalDateTime deviceTime = clickhouseCollect.getDeviceTime();
ClickhouseCollect newHeartBeatEvent = new ClickhouseCollect();
// 伪造心跳事件
LocalDateTime nextRecordDeviceTime = deviceTime.plusSeconds(1);
newHeartBeatEvent.setHeartBeatEvent(true);
newHeartBeatEvent.setDeviceTime(nextRecordDeviceTime);
if (currentState.value() == null) {
// 根据前一个算子的事件时间戳生成伪造事件
collector.collect(generateNextRow(schema, newHeartBeatEvent));
}
// 生成完一条事件后,推进水位线
recordAndMaybeRest();
}
private void recordAndMaybeRest() throws InterruptedException {
soFarThisSecond++;
if (soFarThisSecond >= getRowsPerSecondForSubTask()) {
rest();
}
}
private void rest() throws InterruptedException {
nextReadTime += 1000;
long toWaitMs = Math.max(0, nextReadTime - System.currentTimeMillis());
Thread.sleep(toWaitMs);
soFarThisSecond = 0;
}
private long getRowsPerSecondForSubTask() {
int numSubtasks = getRuntimeContext().getNumberOfParallelSubtasks();
int indexOfThisSubtask = getRuntimeContext().getIndexOfThisSubtask();
long baseRowsPerSecondPerSubtask = rowsPerSecond / numSubtasks;
// Always emit at least one record per second per subtask so that each subtasks makes some
// progress.
// This ensure that the overall number of rows is correct and checkpointing works reliably.
return Math.max(
1,
rowsPerSecond % numSubtasks > indexOfThisSubtask
? baseRowsPerSecondPerSubtask + 1
: baseRowsPerSecondPerSubtask);
}
@VisibleForTesting
Row generateNextRow(ResolvedSchema schema, ClickhouseCollect object) {
List<Column> columnList = schema.getColumns();
// todo 根据每个特征阈值线的值来造
GenericRowData row = GenericRowData.of();
return null;
}
}
- 如何保证下游upsert
在sink层需要有一个存储介质来保证投递到下游的时候,能够根据主键来追加更新,比如目前flink-connectors当中的upsert-kafka。 但这需要将kafka的信息在建表的时候暴露出来
@Override
public SinkRuntimeProvider getSinkRuntimeProvider(Context context) {
DataStructureConverter converter = context.createDataStructureConverter(physicalRowDataType);
return new DataStreamSinkProvider() {
@Override
public DataStreamSink<Row> consumeDataStream(
ProviderContext providerContext, DataStream<RowData> dataStream) {
// 补数据
TypeInformation<RowData> outputType = dataStream.getType();
FlinkSupplementaryDataGenerator flapFunction =
new FlinkSupplementaryDataGenerator(outputType, physicalRowDataType, converter);
// todo 构造schema 从上层传下来
SinkFunction<Row> kafkaSink = produceStream("", null);
// 依赖kafka存储中间状态 指定并行度为1
return dataStream.flatMap(flapFunction).addSink(kafkaSink).setParallelism(1);
}
};
}