背景
业务场景是需要根据用户配置规则对日志数据流动态地进行开窗计算,但是flink sql和datastream目前都不支持传入动态参数的开窗方法。
这里仅仅是测通了测试数据,先简单记录下的方法,应用到实际场景中可能还有较多需要测试、调整和优化的地方,比如缓存管理、自定义类缺乏通用性、类型安全以及资源消耗等等潜在问题。
解决思路
维表信息缓存
首先,数据流中的size字段可以根据规则配置表的更新频率选择flink table api中合适的join策略,或者自行设计维表状态的管理。本文是测试,主数据和维表数据量均不大,因此先采用guava cache缓存维表的方式,连接外部数据源(示例为mysql),自定义一个RichMapFunction类使数据通过一个map和filter获取到维表中记录的规则(所需窗口size)信息。自定义GetRuleMapFunction如下:
import java.io.*;
import com.alibaba.druid.pool.DruidDataSource;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.apache.flink.api.common.functions.RichMapFunction;
import org.apache.flink.api.java.tuple.*;
import org.apache.flink.configuration.Configuration;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
public class GetRuleMapFunction extends RichMapFunction<Tuple3<String, String, Integer>,Tuple4<String, String, String, Integer>> {
private LoadingCache<String, String> cache;
private DruidDataSource dbsource;
private Properties properties;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
//创建连接池
File file = new File("./src/main/resources/mysql_config.properties");
properties = new Properties();
properties.load(new FileInputStream(file));
dbsource=new DruidDataSource();
dbsource.configFromPropety(properties);
//创建缓存
cache = CacheBuilder.newBuilder()
.concurrencyLevel(3)
.initialCapacity(3)
.maximumSize(20)
.expireAfterWrite(1, TimeUnit.HOURS)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
//设置数据库查询,放入缓存中
String sql = "select rule from rule_table_for_flink_test where name='" + key + "'";
PreparedStatement statement = null;
ResultSet resultset = null;
Connection conn = null;
String res = "0";
try {
conn = dbsource.getConnection();
statement = conn.prepareStatement(sql);
resultset = statement.executeQuery();
while (resultset.next()) {
res = resultset.getString("rule");
}
} catch (SQLException e) {e.printStackTrace();}
finally { try
{ if (resultset != null) resultset.close();
if (statement != null) statement.close();
if (conn != null) conn.close();
} catch (SQLException e) {e.printStackTrace();}
return res;}
}
});
}
@Override
public Tuple4<String, String, String, Integer> map(Tuple3<String, String, Integer> event) throws Exception {
String eventkey= event.f0;
String res=cache.get(eventkey);
return Tuple4.of(event.f0,res,event.f1,event.f2);
}
public void close() throws Exception {
super.close();
dbsource.close();
cache.cleanUp();
}
}
根据记录中指定字段动态开窗
在解决维表信息的缓存(为每笔数据记录打上所适用的开窗size属性)之后,我们将问题简述为在同一个数据流中,针对数据的某固定字段判断该数据属于哪个对象、配置规则并以此为依据开窗。那么我们需要有一定理解基础的是:
1.如果要响应以上场景需求,不难想明白,我们必然是要根据“业务对象”、“规则配置”这两个属性(字段)来进行keyby的,因为配置规则实际上是在业务对象的维度上再开一个新维度;
2.需要明白datastream的窗口分配器(WindowAssigner)是如何运作的,给每笔数据记录分配窗口的,了解之后会发现关键步骤在assignWindows方法中。
在了解上述两点后,实际上改动最小的解决思路就比较清楚了,就是可以模仿flink原生的窗口分配器(比如SlidingEventTimeWindows,本文将以滑动窗口为例),稍作修,构造一个可以动态传参的自定义窗口分配器类,核心部分在于修改assignWindows,维护好该窗口的可用性即可。需要注意的是泛型问题,需要处理好Trigger方法的泛型,因此需要再造一个EventTimeTrigger(直接抄就可以),并维护好该类中的泛型。
细节记录
1.窗口起点公式:(last)Start=timestamp-(timestamp-offset+windowSize)%windowSize
上述公式中的windowSize,在滚动窗口中是size;在滑动窗口中可能分发给多个窗口,因此windowSize设为slide,计算得到的Start根据分发情况向前移动slide的距离,直到窗口不再包含当前记录的timestamp,因此滑动窗口的第一笔起始时间容易表现为Start再向前偏移一个slide,思考窗口逻辑或者观察测试结果其实是比较好理解其中原由的。
2.窗口是前闭后开的(End是Start+size-1毫秒)。
3.print后发现WindowedStream中(除了聚合值)记录的是每个窗口的第一个元素的值。
具体示例
数据准备
这里做个简单的实例,我们准备一个本地文本./src/main/resources/test_data0做数据输入流,数据内容大致下:
ailurus,2023-11-10 12:12:31 beaver,2023-11-10 12:12:31 civet,2023-11-10 12:12:31 ailurus,2023-11-10 12:12:32 beaver,2023-11-10 12:12:32 ... ailurus,2023-11-10 12:12:49 ailurus,2023-11-10 12:12:50 ailurus,2023-11-10 12:12:51 elephent,2023-11-10 12:12:55
size维表存于mysql,注意记得给关联键加索引,大概格式如下:
两个抄出来并自定义的类
DySlidingEventTimeWindows(大部分是抄源码,理解本文上面提到的点就很容易清楚哪些地方需要修改和如何修改了):
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.apache.flink.annotation.PublicEvolving;
import org.apache.flink.api.common.ExecutionConfig;
import org.apache.flink.api.common.typeutils.TypeSerializer;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.WindowAssigner;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.triggers.Trigger;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
@PublicEvolving
public class DySlidingEventTimeWindows extends WindowAssigner<Tuple, TimeWindow> {
private static final long serialVersionUID = 1L;
private final int index;
private long size;
private final long slide;
private final long offset;
protected DySlidingEventTimeWindows(int index, long slide, long offset) {
if (Math.abs(offset) < slide) {
this.index = index;
this.slide = slide;
this.offset = offset;
} else {
throw new IllegalArgumentException("SlidingEventTimeWindows parameters must satisfy abs(offset) < slide and size > 0");
}
}
//重点就在此方法上,其中element是记录,timestamp是数据时间(处理时间或事件时间),context为上下文
public Collection<TimeWindow> assignWindows(Tuple element, long timestamp, WindowAssignerContext context) {
this.size=Time.seconds(Long.parseLong(element.getField(this.index).toString())).toMilliseconds();
if (timestamp <= -9223372036854775808L) {
throw new RuntimeException("Record has Long.MIN_VALUE timestamp (= no timestamp marker). Is the time characteristic set to 'ProcessingTime', or did you forget to call 'DataStream.assignTimestampsAndWatermarks(...)'?");
} else {
List<TimeWindow> windows = new ArrayList((int)(this.size / this.slide));
long lastStart = TimeWindow.getWindowStartWithOffset(timestamp, this.offset, this.slide);
for(long start = lastStart; start > timestamp - this.size; start -= this.slide) {
windows.add(new TimeWindow(start, start + this.size));
}
return windows;
}
}
public long getSize() {
return this.size;
}
public long getSlide() {
return this.slide;
}
public Trigger<Tuple, TimeWindow> getDefaultTrigger(StreamExecutionEnvironment env) {
return RowEventTimeTrigger.create();
}
public String toString() {
return "SlidingEventTimeWindows(" + this.size + ", " + this.slide + ")";
}
public static DySlidingEventTimeWindows of(int index , Time slide) {
return new org.example.util.DySlidingEventTimeWindows(index , slide.toMilliseconds(), 0L);
}
public static DySlidingEventTimeWindows of(int index, Time slide, Time offset) {
return new org.example.util.DySlidingEventTimeWindows(index , slide.toMilliseconds(), offset.toMilliseconds());
}
public TypeSerializer<TimeWindow> getWindowSerializer(ExecutionConfig executionConfig) {
return new TimeWindow.Serializer();
}
public boolean isEventTime() {
return true;
}
}
RowEventTimeTrigger就不贴了,纯粹是抄了来改下泛型就用的。
Flink运行代码
运行作业。
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.api.java.functions.KeySelector;
import org.apache.flink.api.java.tuple.*;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.datastream.*;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.connector.jdbc.JdbcConnectionOptions;
import org.apache.flink.connector.jdbc.JdbcSink;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Duration;
import org.example.util.DySlidingEventTimeWindows;
import org.example.util.GetRuleMapFunction;
public class TestZyc {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
//创建数源(读取TextFile)
DataStreamSource<String> datasource = env.readTextFile("./src/main/resources/test_data0");
// 转换数据格式,这里转成常见结构化数据tuple类型
SingleOutputStreamOperator<Tuple3<String,String,Integer>> dataStream = datasource
.map((String line) -> {
String[] fields = line.split(",");
return Tuple3.of(fields[0],fields[1],1);
}).returns(Types.TUPLE(Types.STRING, Types.STRING,Types.INT))
//设置事件时间、水位线
//实际上本例中水位线并不会按预期工作,因为数源是静态文件,会被flink视为批处理
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Tuple3<String,String,Integer>>forBoundedOutOfOrderness(Duration.ofMillis(10)).withTimestampAssigner(
new SerializableTimestampAssigner<Tuple3<String,String,Integer>>() {
final SimpleDateFormat srtformat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Override
public long extractTimestamp(Tuple3<String,String,Integer>element, long recordTimestamp){
try {
return srtformat.parse(element.f1).getTime();
} catch (ParseException e) {
e.printStackTrace();
// System.out.println("日期无法解析,使用recordTimestamp");
} return recordTimestamp;
}
})
);
//map配置信息并过滤
DataStream<Tuple4<String, String, String, Integer>> mapedStream=dataStream
.map(new GetRuleMapFunction()).filter(event -> event.f1!="0");
//keyby操作
SingleOutputStreamOperator<Tuple4<String,String,String,Integer>> finalStream=mapedStream.keyBy(
new KeySelector<Tuple4<String,String,String,Integer>,Tuple2<String,String>>() {
@Override
public Tuple2<String, String> getKey(Tuple4<String, String, String, Integer> stringIntegerTuple4) throws Exception {
return Tuple2.of(stringIntegerTuple4.f0, stringIntegerTuple4.f1);
}
}
)
//动态size开窗,传参为size属性下标、slide大小[、offset大小]
.window(DySlidingEventTimeWindows.of(1,Time.seconds(2)))
//聚合计算,传参为需要计算属性的下标
.sum(3);
//可以打印
finalStream.print();
// //也可以添加SINK端,这里简单以myqsql为例
// String insert_sql = "INSERT INTO table_for_flink_sink (name,rule,eventtime,cnt) VALUE (?,?,?,?)";
// finalStream.addSink(JdbcSink.sink(
// insert_sql,
// ((statement, event) -> {
// statement.setString(1, event.f0);
// statement.setString(2, event.f1);
// statement.setString(3, event.f2);
// statement.setInt(4, event.f3);
// }),
// new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
// .withDriverName("com.mysql.cj.jdbc.Driver")
// .withUrl("jdbc:mysql://ServerIP:Port/Database")
// .withUsername("username")
// .withPassword("password")
// .build()
// ));
//执行
env.execute();
}
}
结尾
测试结果是满足需求的(这里就不做展示了),其中还是有些细节的,可以检索校相关文章配合理解(作者也是边写边看了很多)。
但是怎么看都感觉比较raw,这里建议可以结合Table API进行使用,更安全也更容易贴近实际的业务环境(尤其是join部分),虽然目前必须转datastream的地方可能还是得折腾下。另外动态开窗在维表数据量大且没有限制的情况下会引起可以想象到的灾难问题,所以建议在上游做好主数据和维表数据的控制和管理,事前避免潜在的异常情况。
PS.相反的,如果考虑更灵活的解决方式,可以根据需要求实现一个KeyedProcessFunction类(只有KeyedProcessFunction可以注册定时器),在其open中创建并管理一个状态缓存(准备用来缓存窗口对象),接着在processElement中根据每笔数据记录的ctx.timestamp注册定时器(同时更新状态缓存里命中窗口的状态),最后在onTimer中进行状态的计算并进行输出(输出缓存中符合本次触发条件的窗口状态,并清理已结束的窗口缓存)。当然这种方式更加底层,将面临很多需要维护的问题。
PSS.备忘下Table或SQL API中DataStream转Table的语法:
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, environmentSettings);
Table table = tableEnv.fromDataStream(dataStream,
Schema.newBuilder()
.column("f0", DataTypes.STRING().notNull())
.column("f1", DataTypes.STRING().notNull())
.column("f2", DataTypes.INT())
.primaryKey("f0","f1")
//指定处理时间
.columnByExpression("proctime","PROCTIME()")
// //指定事件时间
// .columnByExpression("rowtime","CAST(TO_TIMESTAMP(FROM_UNIXTIME(UNIX_TIMESTAMP(`f1`))) AS TIMESTAMP(3))")
// .watermark("rowtime","rowtime - interval '0' SECOND ")
.build()).as("id,time,cnt");
tableEnv.createTemporaryView("main_stream", table);