0


Flink 实时数仓(二)【DIM 层搭建】

1、DIM 层搭建

1.1、设计要点

DIM层设计要点:

  • DIM层存的是维度表(环境信息,比如人、场、货等)
  • DIM层的数据存储在 HBase 表中
  • DIM层表名的命名规范为dim_表名

DIM 层表是用于维度关联的,要通过主键(维度外键)去获取相关维度信息,这种场景下 K-V 类型数据库的效率较高。常见的 K-V 类型数据库有 Redis、HBase,而 Redis 的数据常驻内存,会给内存造成较大压力,所以这里选用 HBase 存储维度数据。

1.2、设计分析

在 ODS 层,我们需要首先把所有维表全量同步一次,之后当事实数据来了的时候就可以直接关联;现在 DIM 层,我们需要考虑的问题就是,如何把维表信息保存到 HBase:

  • 读取数据 - Kafka 的 topic_db 主题中(包含所有46张业务表)- 可以使用 Flink 的 Kafka 连接器读取
  • 过滤数据 - 从 46 张业务表中过滤出所有维表数据 - 在代码中写死十几张维度表的表名- 问题:如果增加维表就需要修改代码重新编译项目,重启任务- 思考:如何不修改代码且不重启任务? - 1)定时任务:每隔一段时间加载一次配置信息(实时性不好,不可取)- 2)监控配置信息: - MySQL binlog:配置信息写到 MySQL,然后使用 FlinkCDC 监控直接创建流(数据流 connect 配置流,将配置流中的信息写入到状态中,然后数据流把状态中存在的表过滤出来)- 文件:Flume tailDir Source -> Kafka -> Flink 的 kafka source 消费创建流(太复杂了,不可取)

注意:多个并行度下,配置流中的数据(需要过滤的表名)会被分配给多个相同的算子处理,会导致并行算子之间的状态不一致,可能导致数据丢失的问题;这就需要把配置流做成一个广播流来和数据流进行 connect,这样写入并行算子的状态就是一致的了;

    广播流的缺点就是存在冗余,而且并行度越大冗余也越大;配置信息小点还好,如果配置信息很大,那么将会占用的资源页越大;这种情况下我们的解决办法就是分流:

    对数据流和配置流都按照表名进行 keyby,相同的 key 再去做 connect,但是这种方案会产生数据倾斜;
  • 写出数据 - 使用 Phoenix 写出到 HBase(使用 JdbcSink,如果不行就自定义 Sink)

1.3、DIM 层实现

DIM 层的主要任务:读取 Kafka 的数据 -> 简单ETL -> 保存到HBase

    Maxwell 同步过来的数据是到 Kafka 的,我们通过 Flink 自带的 Kafka 连接器进行连接读取,然后对数据先进行简单的 ETL,比如
  • 删除掉非 JSON 格式的数据(这里因为是业务数据所以一般不会有非JSON的情况出现,但是日志数据可能会存在这种情况)
  • 删除掉 type 为 bootstrap-start 和 bootstrap-complete 的数据;
  • 删除掉 type 为 delete 的数据;

最后,通过 Phoenix API 将数据插入到 HBase;

1.3.1、读取 Kafka 中的数据

    读取 Kafka 中的数据为的是创建主流,这里设计到的一个重点就是:Flink 作为消费者在从 Kafka 消费的时候需要对数据进行反序列化,而在反序列时如果使用 Flink 默认的 Kafka 反序列化器(FlinkKafkaConsumer)进行消费的话,可能会出现空指针异常:

    可以看到,反序列化方法中是直接把 kafka message 创建为一个 String 对象,但是 String 的构造器源码中明确声明构造参数不可为 null,而我们的 message 又不可避免存在一些空值,所以这里我们需要重写 FlinkKafkaConsumer 的反序列化方法:
public class MyKafkaUtil {

    private static final String KAFKA_SERVER = "hadoop102:9092";

    public static FlinkKafkaConsumer<String> getFlinkKafkaConsumer(String topic,String groupId){

        Properties properties = new Properties();
        properties.setProperty(ConsumerConfig.GROUP_ID_CONFIG,groupId);
        properties.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,KAFKA_SERVER);

        return new FlinkKafkaConsumer<String>(
                topic,
                // 反序列化格式
                new KafkaDeserializationSchema<String>() {
                    @Override
                    public boolean isEndOfStream(String nextElement) {
                        return false; // 无界流所以返回 false
                    }

                    @Override
                    public String deserialize(ConsumerRecord<byte[], byte[]> record) throws Exception {
                        if (record == null || record.value() == null){
                            return null;
                        }else {
                            return new String(record.value());
                        }
                    }

                    @Override
                    public TypeInformation<String> getProducedType() {
                        return BasicTypeInfo.STRING_TYPE_INFO;
                    }
                },
                properties
        );
    }
}

这样,我们就可以直接使用通过 Kafka 地址、**主题 **和 组id 使用 Flink 对 Kafka 的主题进行消费:

env.addSource(MyKafkaUtil.getFlinkKafkaConsumer(topic, groupId));

至此,我们的主流已经创建完毕;

1.3.2、简单 ETL

    这一步主要为的是将不必要的数据移除掉,比如非JSON数据(日志数据中才可能出现)、maxwell 的脏数据( bootstrap-start 、bootstrap-complete 和 delete)这种无意义的信息;
public class DimApp {

    public static void main(String[] args) {
        // TODO 1. 获取执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1); // 生产环境中设置为kafka主题的分区数

        // 1.1 开启checkpoint
        env.enableCheckpointing(5 * 60000L, CheckpointingMode.EXACTLY_ONCE);
        env.getCheckpointConfig().setCheckpointStorage("hdfs://hadoop102:8020/s/ck");
        env.getCheckpointConfig().setCheckpointTimeout(10 * 60000L);
        env.getCheckpointConfig().setMaxConcurrentCheckpoints(2); // 设置最大共存的checkpoint数量
        env.setRestartStrategy(RestartStrategies.fixedDelayRestart(3,5000L)); // 固定频率重启: 尝试3次重启,每5s重启一次

        // 1.2 设置状态后端
        env.setStateBackend(new HashMapStateBackend());

        // TODO 2. 读取Kafka的topic_db主题,创建主流
        String topic = "topic_db";
        String groupId = "test";
        DataStreamSource<String> kafkaDS = env.addSource(MyKafkaUtil.getFlinkKafkaConsumer(topic, groupId));

        // TODO 3. ETL
        // TODO 3.1 过滤掉非JSON数据以及Maxwell的脏数据
        SingleOutputStreamOperator<JSONObject> filterJsonDS = kafkaDS.flatMap(new FlatMapFunction<String, JSONObject>() {
            @Override
            public void flatMap(String value, Collector<JSONObject> out) throws Exception {
                try {
                    // 过滤非JSON数据
                    JSONObject jsonObject = JSON.parseObject(value);

                    String type = jsonObject.getString("type");
                    if ("bootstrap-insert".equals(type) || "insert".equals(type) || "update".equals(type)) {
                        out.collect(jsonObject);
                    }
                } catch (Exception e) {
                    System.out.println("发现脏数据" + value);
                }
            }
        });

        // ...
    }
}

关于 Flink 的并行度,生产中一般设置为 Kafka 的分区数量(使消费者数量 = 主题分区数量),而不是机器的 CPU核数(机器总不能只跑这一个任务)!

1.3.3、动态增删维表

    现在我们需要从 46 张业务表中过滤出需要的维表,而且这个维表并不是写死的,很可能会出现新增和删除,所以我们希望做到不修改代码且不重启服务的情况下实现,我们上面业说过了,最好使用监控配置信息的方式,一共有三种解决方案:
  • MySQL binlog - 也就是把配置信息做成表格,使用 FlinkCDC 实时监控,使用双流联结(主流和配置流),配置流把配置信息(需要同步的维度表信息,包括业务系统中的维表名、写入到phoenix的表名、字段、主键、额外信息等)写入到状态当中,然后主流再去状态中读取并处理;
  • 文件:使用 Flume 的 tailDir source 实时监听文件内容,写到 Kafka 当中,Flink 再从 Kafka 去读,这种方式太复杂了,一般不用
  • Zookeeper:通过 Zookeeper 的 watch 机制将配置信息写入到一个 znode 节点,同样比较复杂

综上,我们一般选择第一种方案:

1)创建配置表
CREATE TABLE `table_process` (
  `source_table` varchar(200) NOT NULL COMMENT '业务系统来源表', -- mysql业务系统中的表名
  `sink_table` varchar(200) DEFAULT NULL COMMENT 'phoenix输出表', -- phoenix的表名
  `sink_columns` varchar(2000) DEFAULT NULL COMMENT 'phoenix建表所需字段', -- 建表需要的字段,过滤主流数据字段
  `sink_pk` varchar(200) DEFAULT NULL COMMENT 'phoenix建表的主键字段', -- 建表使用的主键(表名做主键)
  `sink_extend` varchar(200) DEFAULT NULL COMMENT 'phoenix建表扩展', -- 比如预分区等信息
  PRIMARY KEY (`source_table`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

这里,我们将 source_table 作为配置表的主键,这样可以通过它获取到该表需要同步的字段、主键和建表扩展,从而得到完整的 Phoenix 建表语句。

2)创建配置表的实体类

配置流在创建的时候我们并不会直接把它转为 JSON 格式,毕竟我们还要对它进行一些处理,而操作java对象比json对象要更容易;

注意:下面的属性命名我们使用的驼峰命名规则,当 JSONObject 在解析该类型 JavaBean 的时候会自动转为 source_table 这样的格式!

@Data
public class TableProcess {
    //来源表
    String sourceTable;
    //输出表
    String sinkTable;
    //输出字段
    String sinkColumns;
    //主键字段
    String sinkPk;
    //建表扩展
    String sinkExtend;
}
3)使用 FlinkCDC 创建配置流

注意:FlinkCDC 把 binlog 读取过来会转为 json 格式

        // TODO 4. 使用FlinkCDC读取MySQL配置信息表创建配置流
        MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
                .hostname("hadoop102")
                .port(3306)
                .username("root")
                .password("123456")
                .databaseList("gmall")
                .tableList("gmall.table_config")
                .startupOptions(StartupOptions.initial()) // 全部读取
                .deserializer(new JsonDebeziumDeserializationSchema()) // flink读取binlog会把它转为json格式,所以这里需要一共json的反序列化方式
                .build();

        DataStreamSource<String> mysqlSourceDS = env.fromSource(
                mySqlSource,
                WatermarkStrategy.noWatermarks(),
                "MysqlSource"
        );
4)配置流形成广播流

配置流不能直接和主流联结,会造成数据丢失(多并行度下,配置信息会轮询发送到相同的算子上),所以我们需要把它转为广播流;

创建广播流需要传入一个 Map 类型的状态描述器:

  • K 必须是主流和配置流都有的信息,这样主流才能和广播流产生关联,所以这里我们使用表名做为 K;
  • V 是配置流中的数据,这里我们选择上面自定义 TableProcess 对象,这个对象包含了该表(K)的所有配置信息;
        // TODO 5. 将配置流处理为广播流
        // K的要求: 1.必须是主流和配置流都有的字段 2. 唯一
        // V: 这里的v应该是配置流中的数据,但是为了方便过滤字段(操作java对象比json对象要容易),所以这里转为Java对象
        MapStateDescriptor<String, TableProcess> mapStateDescriptor = new MapStateDescriptor<>("map-state", String.class, TableProcess.class);
        // 这里的泛型是广播流的类型
        BroadcastStream<String> configBroadcastStream = mysqlSourceDS.broadcast(mapStateDescriptor);
5)双流联结并处理

这里对主流和配置流联结后需要进行处理:

  • 把配置流中的配置信息写入到状态后端使其自动广播 - 从配置流(FlinkCDC 读取过来 json 信息)中提取出表格信息- 校验 phoenix 表格(不存在就创建)- 写入到状态中(因为现在是广播流所以会自动广播)
  • 把主流中的非维表去除掉,以及维表中不需要的字段 - 从状态中获得配置信息- 除去非维表并过滤字段,只留下配置流当中存在的字段- 给主流添加上配置信息中的 sinkTable 字段(因为主流不知道最终向哪个phoenix表写入)
        // TODO 6. 连接主流和广播流
        // 这里的泛型: 1. 非广播流的数据类型 2. 广播流的数据类型
        BroadcastConnectedStream<JSONObject, String> connectedStream = filterJsonDS.connect(configBroadcastStream);

        // TODO 7. 处理连接流,根据配置信息处理主流数据
        // 得到维表数据流(已经把配置流中不需要的维表字段过滤掉了,以及非维表也被过滤掉了)
        SingleOutputStreamOperator<JSONObject> dimDS = connectedStream.process(new TableProcessFunction(mapStateDescriptor));

这里的 TableProcessFunction 是我们自定义的广播流处理函数:

// 这里的泛型: 1. 非广播流的数据类型 2. 广播流的数据类型 3. 输出类型这里选择主流的数据类型,因为毕竟我们用配置流的目的就是为了得到过滤后的主流数据
public class TableProcessFunction extends BroadcastProcessFunction<JSONObject, String, JSONObject> {

    private Connection connection;
    private MapStateDescriptor<String, TableProcess> mapStateDescriptor;

    public TableProcessFunction(MapStateDescriptor<String, TableProcess> mapStateDescriptor){
        this.mapStateDescriptor = mapStateDescriptor;
    }

    // 保证每个并行度创建一个连接
    @Override
    public void open(Configuration parameters) throws Exception {
        connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
    }

    @Override
    public void close() throws Exception {
        connection.close();
    }

    /**
     * value 的值:
     * {
     *  "before":null,
     *  "after":{
     *      "source_table":"aa",
     *      "sink_table":"bb",
     *      "sink_columns":"cc",
     *      "sink_pk":"id",
     *      "sink_extend":"xxx"},
     *  "source":{
     *      "version":"1.5.4.Final",
     *      "connector":"mysql",
     *      "name":"mysql_binlog_source",
     *      "ts_ms":1652513039549,
     *      "snapshot":"false",
     *      "db":"gmall-211126-config",
     *      "sequence":null,
     *      "table":"table_process",
     *      "server_id":0,
     *      "gtid":null,
     *      "file":"",
     *      "pos":0,
     *      "row":0,
     *      "thread":null,
     *      "query":null},
     *   "op":"r",
     *   "ts_ms":1652513039551,
     *   "transaction":null}
     */
    @Override
    public void processBroadcastElement(String value, Context context, Collector<JSONObject> collector) throws Exception {
        // TODO 1. 获取并解析数据,方便主流操作(把 "after" 字段的内容解析为 TableProcess 对象)
        JSONObject jsonObject = JSONObject.parseObject(value);
        TableProcess tableProcess = JSONObject.parseObject(jsonObject.getString("after"), TableProcess.class);
        // TODO 2. 校验表在phoenix中是否存在
        checkTable(tableProcess.getSinkTable(),
                tableProcess.getSinkColumns(),
                tableProcess.getSinkPk(),
                tableProcess.getSinkExtend());

        // TODO 3. 写入状态,广播出去
        BroadcastState<String, TableProcess> broadcastState = context.getBroadcastState(mapStateDescriptor);
        broadcastState.put(tableProcess.getSourceTable(),tableProcess);
    }

    /**
     * 校验并创建phoenix表: create table if not exists db.tb(xx varchar primary key,xx varchar, ...) xxx
     * @param sinkTable 表名
     * @param sinkColumns 字段
     * @param sinkPk 主键
     * @param sinkExtend 扩展字段
     */
    private void checkTable(String sinkTable, String sinkColumns, String sinkPk, String sinkExtend) {

        PreparedStatement preparedStatement = null;

        try {
            // 处理特殊字段值(null)
            if (sinkPk == null || "".equals(sinkPk))
                sinkPk = "id";
            if (sinkExtend == null)
                sinkExtend = "";

            // 拼接 SQL
            StringBuilder sql = new StringBuilder("create table if not exists ")
                    .append(GmallConfig.HBASE_SCHEMA)
                    .append(".")
                    .append(sinkTable)
                    .append("(");
            String[] columns = sinkColumns.split(",");
            for (int i = 0; i < columns.length-1; i++) {
                sql.append(" ").append(columns[i]).append(" varchar");
                if (columns[i].equals(sinkPk))
                    sql.append(" primary key");
                sql.append(",");
            }
            sql.append(columns[columns.length-1])
                    .append(") ")
                    .append(sinkExtend);

            // 编译 SQL
            preparedStatement = connection.prepareStatement(sql.toString());

            // 执行 SQL
            preparedStatement.execute();
        }catch (SQLException e){
            // 要停止程序必须使用运行时异常而不是编译时异常
            throw new RuntimeException("建表失败 " + sinkTable);
        }finally {
            // 释放资源
            if (preparedStatement != null){
                try {
                    preparedStatement.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }

    }

    // 主流数据是一个json格式(maxwell数据)
    /**
     * {
     *  "database":"gmall-211126-flink",
     *  "table":"base_trademark",
     *  "type":"bootstrap-insert",
     *  "ts":1652499295,
     *  "data":{
     *      "id":1,
     *      "tm_name":"三星",
     *      "logo_url":"/static/default.jpg"
     *      }
     * }
     */
    @Override
    public void processElement(JSONObject jsonObject, ReadOnlyContext readOnlyContext, Collector<JSONObject> collector) throws Exception {
        // TODO 1. 获取广播的配置数据
        ReadOnlyBroadcastState<String, TableProcess> broadcastState = readOnlyContext.getBroadcastState(mapStateDescriptor);
        // 如果返回 null 说明不是维表
        TableProcess tableProcess = broadcastState.get(jsonObject.getString("table"));

        // TODO 2. 过滤字段,只留下配置流当中存在的字段
        if (tableProcess == null) return;
        filterColumns(jsonObject.getJSONObject("data"),tableProcess.getSinkColumns());

        // TODO 3. 补充 SinkTable 字段(因为主流中是不包含phoenix表名的)
        jsonObject.put("sinkTable",tableProcess.getSinkTable());
        collector.collect(jsonObject);
    }

    /**
     * 过滤字段: 将主流中配置流中有的字段留下,其它字段删除
     * @param data 可能是维表,也可能是事实表 {"database":"gmall-211126-flink","table":"base_trademark","type":"bootstrap-insert","ts":1652499295,"data":{"id":1,"tm_name":"三星","logo_url":"/static/default.jpg"}}
     * @param sinkColumns phoenix 列名
     */
    private void filterColumns(JSONObject data, String sinkColumns) {
        // 把 JSONObject 当做 Map 处理即可
        String[] split = sinkColumns.split(",");
        Set<String> phoenix_columns = new HashSet<>(Arrays.asList(split));
        for (String column : data.keySet()){
            if (!phoenix_columns.contains(column)){
                data.remove(column);
            }
        }
        // 简写: data.entrySet().removeIf(entry -> !phoenix_columns.contains(entry.getKey()));
    }

}

至此,我们就得到了最终等待写入到 HBase 的流 dimDS;

1.3.4、写入 Phoenix

    上面我们在连接 phoenix 校验表格的时候用的是 jdbc 来访问的,而 Flink 也提供了 JdbcSink 连接器,那这里我们能不能使用呢?

    其实这里使用 JdbcSink 是可以的,但是不推荐,因为 **JdbcSink 适合的是单表写入的场景**,而我们的 dimDS 数据流中存放的是多个维度表的数据,这就要求当数据来的时候,我们要根据不同的表生成不同的 SQL,而这里的 addSink 方法中的 sql 语句必须是先给定的,尽管不确定的表名、字段名等可以使用占位符,但是我们不能保证所有维表的字段数量都是一样的;所以,这种方式显然不可取,那我们就只能自定义一个 Sink 了:
1)创建 Druid 连接池

Phoenix 是支持 JDBC 协议,这里为了方便连接管理我们使用 Druid 来创建连接池;

public class DruidDSUtil {
    private static DruidDataSource druidDataSource = null;

    public static DruidDataSource createDataSource() {
        // 创建连接池
        druidDataSource = new DruidDataSource();
        // 设置驱动全类名
        druidDataSource.setDriverClassName(GmallConfig.PHOENIX_DRIVER);
        // 设置连接 url
        druidDataSource.setUrl(GmallConfig.PHOENIX_SERVER);
        // 设置初始化连接池时池中连接的数量
        druidDataSource.setInitialSize(5);
        // 设置同时活跃的最大连接数
        druidDataSource.setMaxActive(20);
        // 设置空闲时的最小连接数,必须介于 0 和最大连接数之间,默认为 0
        druidDataSource.setMinIdle(1);
        // 设置没有空余连接时的等待时间,超时抛出异常,-1 表示一直等待
        druidDataSource.setMaxWait(-1);
        // 验证连接是否可用使用的 SQL 语句
        druidDataSource.setValidationQuery("select 1");
        // 指明连接是否被空闲连接回收器(如果有)进行检验,如果检测失败,则连接将被从池中去除
        // 注意,默认值为 true,如果没有设置 validationQuery,则报错
        // testWhileIdle is true, validationQuery not set
        druidDataSource.setTestWhileIdle(true);
        // 借出连接时,是否测试,设置为 false,不测试,否则很影响性能
        druidDataSource.setTestOnBorrow(false);
        // 归还连接时,是否测试
        druidDataSource.setTestOnReturn(false);
        // 设置空闲连接回收器每隔 30s 运行一次
        druidDataSource.setTimeBetweenEvictionRunsMillis(30 * 1000L);
        // 设置池中连接空闲 30min 被回收,默认值即为 30 min
        druidDataSource.setMinEvictableIdleTimeMillis(30 * 60 * 1000L);

        return druidDataSource;
    }
}
2)Phoenix 工具类

为了方法的复用性,我们把写入 Phoenix 的方法抽出来:

public class PhoenixUtil {

    /**
     * 将主流数据写入 phoenix
     * @param connection phoenix连接
     * @param sinkTable 表名
     * @param data 数据
     * @throws SQLException 这里的异常直接抛出去.因为工具类中的方法是给大家公用的,而不同的业务捕获到异常的处理方案是不一样的
     * 所以这里把处理异常的权利交给每个调用该方法的人
     */
    public static void upsertValues(DruidPooledConnection connection, String sinkTable, JSONObject data) throws SQLException {
        // 1. 拼接 SQL: upsert into db.tb(id,name,sex) values ('1001','zhangsan','man')
        Set<String> columns = data.keySet();
        Collection<Object> values = data.values();
        String sql = "upsert into " + GmallConfig.HBASE_SCHEMA + "." + sinkTable + "("
                + StringUtils.join(columns,",") +") values ( '"
                + StringUtils.join(values,"','") + "')";

        // 2. 预编译 SQL
        PreparedStatement preparedStatement = connection.prepareStatement(sql);

        // 3. 执行
        preparedStatement.execute();
        connection.commit();

        // 4. 释放资源
        preparedStatement.close(); // connection 在 Sink 的 invoke 里面关
    }
}
3)自定义 Sink

自定义 Sink ,在 open 方法中获得连接,在 invoke 中执行插入数据到 Phoenix ,然后回收连接;

// 输入数据类型应该是 dimDS 的类型,也就是主流类型 JSONObject
public class DimSinkFunction extends RichSinkFunction<JSONObject> {

    private DruidDataSource druidDataSource = null;

    @Override
    public void open(Configuration parameters) throws Exception {
        druidDataSource = DruidDSUtil.createDataSource();
    }

    /**
     * value:
     * {
     *  "database":"gmall-211126-flink",
     *  "table":"base_trademark",
     *  "type":"bootstrap-insert",
     *  "ts":1652499295,
     *  "data":{
     *      "id":1,
     *      "tm_name":"三星"
     *      },
     *  "sinkTable": "dim_xxx"
     * }
     */
    @Override
    public void invoke(JSONObject value, Context context) throws Exception {
        // 获取连接
        DruidPooledConnection connection = druidDataSource.getConnection();
        // 写出数据(需要知道写出的表名、字段)
        String sinkTable = value.getString("sinkTable");
        JSONObject data = value.getJSONObject("data");
        // 如果插入数据失败 invoke 方法抛出的 Exception 会导致程序停止
        PhoenixUtil.upsertValues(connection,sinkTable,data);
        // 归还连接
        connection.close();
    }
}

总结

    至此,DIM 层搭建完毕,在离线数仓的 DIM 层中,它需要在 ODS 层的基础上抽取出主维表和相关维表,然后主维表通过 left join 相关维表得到最终的 dim 层的维表;而实时数仓中我们主要是通过 Flink 代码来对数据流进行实时处理,代码的编写确实比 SQL 更有意思;
标签: flink 大数据

本文转载自: https://blog.csdn.net/m0_64261982/article/details/140791867
版权归原作者 让线程再跑一会 所有, 如有侵权,请联系我们删除。

“Flink 实时数仓(二)【DIM 层搭建】”的评论:

还没有评论