Flink特征计算 - 基于机器学习工作流的改造

331 阅读18分钟

背景

pyflink目前遇到性能瓶颈, 根因是因为在现有框架中flink流模式跑历史数据做验证

目前从MQ计算的特征,首先会在DAG图中,将每个测点到算法检测节点边拼接成一个邻接矩阵. 在获取到数据源后, 通过json配置中的监测算子名称订阅到侧流进行输出. 在存放每个tag点位的过程当中,下游开窗算子处理, 并且对每条测流进行水位线的分配, 在结果合并与分发节点中存在一个叫做watermark的字段,会将水印以及间隔时间添加到每条侧流的配置当中,最后在合并阶段,聚合所有侧流.

 

但总是会存在例外情况 比如如下这条flow

  image.png

在数据流上表示为:

 

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, 开一个滑动窗口进行补数据。

在补充完伪造事件之后, 在进入下游之前,与源端产生的事件进行合流,剔除之前产生的事件.

   分流: github.com/ververica/f…

    在分流的过程中 算法工程会根据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的缺陷,保证子节点的优先级一定低于双亲节点

 

存在的问题

  1. 当前特征计算逻辑中,从kafka的分发端到写入clickhouse这条链路在生产环境稳定,但从clickhouse读取数据做二次特征历史检验时,存在基于flink流模式的性能问题.
  2. 由于历史数据验证流程中,原先的旧数据源无法很好的提高性能,是否要将特征计算的逻辑udf化
  3. 5.x 一个作业会存在多个点位从一个数据源发送数据,在分流的时候,有些点位迟迟无法上报数据,会造成一直等待这些点位, 需要手动补数据.
  4. 由于算法作业历史数据分析的过程中,本地存在数据量大的情况,原本的source改造前采用比较原始的SourceFunction
  5. 历史数据算法最终的输出结果到csv当中

 

 

一 特征计算的性能问题

目前定位到的问题存在两方面,一部分是因为python作业需要借助flink的资源运行,所以PVM需要与JVM交互,其中会产生网络问题

 

image.png

 

在之前pyflink的测试用例当中, 测试的只是从时序4 - 时序10, 也就是从PVM通过grpc通信,到在python本地计算这一个流程. 这里但从耗时的角度来说分为两部分。网络耗时和由于内存堆积造成刷新到下游缓冲区的耗时.

 

由于原来算法侧历史数据验证的时候,是自定义的一个clickhouse source源。并没有暴露isBound属性,所以造成没有办法启用1.16当中内置的弹性调度器.

弹性调度: nightlies.apache.org/flink/flink…

image.png

 

在流模式的调度策略当中, 存在两个开放参数

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);
    }
  };
}