Flink 1.13.0 sql-client 新特性及源码分析

简介: 在 Flink 1.13.0 版本中增加了很多新特征,具体可以参考前面一篇文章,其中很重要的一点是对 sql-client 功能做了加强,支持了初始化脚本和执行 SQL 文件,SQL 客户端是直接运行和部署 SQL 流和批处理作业的便捷方法,而无需从命令行或作为 CI 的一部分来编写任何代码,这个版本大大改进了 SQL 客户端的功能。现在,SQL 客户端和SQL 脚本都支持 Java 应用程序可用的几乎所有操作(通过编程方式从 TableEnvironment 启动查询时)。这意味着 SQL 用户在其 SQL 部署中需要粘贴的代码变的更少.由于篇幅的原因这篇文章只会介绍 SQL CLIENT

在 Flink 1.13.0 版本中增加了很多新特征,具体可以参考前面一篇文章,其中很重要的一点是对 sql-client 功能做了加强,支持了初始化脚本和执行 SQL 文件,SQL 客户端是直接运行和部署 SQL 流和批处理作业的便捷方法,而无需从命令行或作为 CI 的一部分来编写任何代码,这个版本大大改进了 SQL 客户端的功能。现在,SQL 客户端和SQL 脚本都支持 Java 应用程序可用的几乎所有操作(通过编程方式从 TableEnvironment 启动查询时)。这意味着 SQL 用户在其 SQL 部署中需要粘贴的代码变的更少.由于篇幅的原因这篇文章只会介绍 SQL CLIENT -i 和 -f 两个新特性的使用以及源码分析其实现原理.


SQL Client: Init scripts and Statement Sets

这个版本极大地改进了 SQL 客户端的功能。现在 SQL Client 和 SQL 脚本都支持 Java 应用程序可用的几乎所有操作(从 TableEnvironment 以编程方式启动查询)。这意味着 SQL 用户在 SQL 部署中需要的代码少了很多。其中最核心的功能就是支持了 -i 命令用来初始化脚本,-f 命令用来执行 SQL 语句,之前的 YAML 文件这个版本不再支持了,相反更多的是通过 SQL 脚本的方式来配置会话和提交任务.


类似于下面这种方式:


sql-client.sh -i init.sql -f test.sql


准备 init.sql 和 test.sql 脚本

init.sql

-- 设置 catalog (这个目前是不支持的)
USE CATALOG myhive;
SET execution.runtime-mode=streaming;
SET pipeline.name=my_flink_job;
SET parallism.default=4;


注意 init.sql 脚本里面是不支持加注释的,官方文档上的 demo 中有通过 -- 的方式加注释,其实这个是不支持的,如果加了的话解析 init.sql 脚本的时候会直接抛出异常.另外 init.sql 初始化脚本文件支持的功能还非常多,我这里就简单的设置了几个,更多的属性可以参考官网.


使用 -i <init.sql> 选项初始化 SQL Client 会话时,初始化 SQL 文件中允许以下语句:


DDL(CREATE/DROP/ALTER),


USE CATALOG/DATABASE,


LOAD/UNLOAD MODULE,


SET command,


RESET command.


test.sql

-- sql
DROP TABLE IF EXISTS PERSON;
CREATE TABLE PERSON (
    name VARCHAR COMMENT '姓名',
    age int COMMENT '年龄',
    city VARCHAR COMMENT '所在城市',
    address VARCHAR COMMENT '家庭住址',
    money DOUBLE COMMENT '金钱',
    ts BIGINT  COMMENT '时间戳',
    t as TO_TIMESTAMP(FROM_UNIXTIME(ts/1000,'yyyy-MM-dd HH:mm:ss')),
    proctime as PROCTIME(),
    WATERMARK FOR t AS t - INTERVAL '5' SECOND
)
WITH (
    'connector' = 'kafka', -- 使用 kafka connector
    'topic' = 'test',  -- kafka topic
    'scan.startup.mode' = 'latest-offset', -- 从起始 offset 开始读取
    'properties.bootstrap.servers' = 'master:9092,storm1:9092,storm2:9092',  -- broker连接信息
    'properties.group.id' = 'jason_flink_test',
    'format' = 'json',  -- 数据源格式为 json
    'json.fail-on-missing-field' = 'false', -- 字段丢失任务不失败
    'json.ignore-parse-errors' = 'true'  -- 解析失败跳过
);
DROP TABLE IF EXISTS print_table_1;
CREATE TABLE print_table_1
(
    name string,
    age INT,
    city string
) 
WITH ('connector' = 'print');
BEGIN STATEMENT SET;
insert into print_table_1 select name,age,city from PERSON;
insert into print_table_1 select name,age,city from PERSON;
END;


test.sql 脚本里面是可以通过 -- 加注释的,并且支持执行一组 SQL 语句,也就是可以同时执行多条 SQL 语句  SQL Client 将每个 INSERT INTO 语句作为单个 Flink 作业执行。但是,由于管道的某些部分可以重复使用,因此有时不是最佳选择。SQL Client 支持 STATEMENT SET 语法来执行一组 SQL 语句。这是 Table API 中StatementSet 的等效功能。STATEMENT SET 语法包含一个或多个 INSERT INTO 语句。全面优化了STATEMENT SET 块中的所有语句,并将其作为单个 Flink 作业执行。联合优化和执行允许重用常见的中间结果,因此可以显着提高执行多个查询的效率。


STATEMENT SET 的语法格式如下:


BEGIN STATEMENT SET;
  -- one or more INSERT INTO statements
  { INSERT INTO|OVERWRITE <select_statement>; }+
END;


上面的 test.sql 脚本里面就用到了这种写法,需要注意的是 STATEMENT SET 中包含的语句必须用分号(;)分隔.我这里为了省事,直接把同一条 SQL 复制了两遍.


提交任务

现在就可以直接提交任务了


sql-client.sh -i init.sql -f test.sql
sql-client.sh -f test.sql


-i 命令是可以选的,不用初始化也可以直接使用 -f 提交任务,在这种模式下,客户端不会打开交互式终端,提交任务完成后会自动退出终端.



可以看到任务提交成功了.接下来就来看一下底层源码是怎么实现的.


源码分析

然后来深入源码分析一下实现的过程.首先从 sql-client.sh 脚本里找到执行的入口类是 org.apache.flink.table.client.SqlClient ,然后来看下 SqlClient 对象属性源码如下:


public class SqlClient {
    private static final Logger LOG = LoggerFactory.getLogger(SqlClient.class);
    // 标记是否是 embedded 模式
    private final boolean isEmbedded;
    // 提交命令选项
    private final CliOptions options;
    // 用来返回结果的
    private final Supplier<Terminal> terminalFactory;
    public static final String MODE_EMBEDDED = "embedded";
    public static final String MODE_GATEWAY = "gateway";
    public SqlClient(boolean isEmbedded, CliOptions options, Supplier<Terminal> terminalFactory) {
        this.isEmbedded = isEmbedded;
        this.options = options;
        this.terminalFactory = terminalFactory;
    }
}


SQL 客户端,用于提交 SQL 语句。客户端可以以两种模式执行:gateway 和 embedded 目前只支持 embedded 模式,并且默认也是这种模式,SqlClient 对象的属性比较简单,代码里面也都添加了注释,就不在介绍了.


接着来看 SqlClient 的 main 方法,也就是程序的入口.main 方法里面调用的是 startClient 方法,所以直接来看 startClient 方法的源码:


@VisibleForTesting
protected static void startClient(String[] args, Supplier<Terminal> terminalFactory) {
    final String mode;
    final String[] modeArgs;
    // 设置启动模式默认是 embedded
    if (args.length < 1 || args[0].startsWith("-")) {
        // mode is not specified, use the default `embedded` mode
        mode = MODE_EMBEDDED;
        modeArgs = args;
    } else {
        // mode is specified, extract the mode value and reaming args
        mode = args[0];
        // remove mode
        modeArgs = Arrays.copyOfRange(args, 1, args.length);
    }
    switch (mode) {
        case MODE_EMBEDDED:
            // 解析提交命令里的参数
            final CliOptions options = CliOptionsParser.parseEmbeddedModeClient(modeArgs);
            // 打印参数说明
            if (options.isPrintHelp()) {
                CliOptionsParser.printHelpEmbeddedModeClient();
            } else {
                try {
                    // 构建 SqlClient 对象
                    final SqlClient client = new SqlClient(true, options, terminalFactory);
                    client.start();
                } catch (SqlClientException e) {
                    // make space in terminal
                    System.out.println();
                    System.out.println();
                    LOG.error("SQL Client must stop.", e);
                    throw e;
                } catch (Throwable t) {
                    // make space in terminal
                    System.out.println();
                    System.out.println();
                    LOG.error(
                            "SQL Client must stop. Unexpected exception. This is a bug. Please consider filing an issue.",
                            t);
                    throw new SqlClientException(
                            "Unexpected exception. This is a bug. Please consider filing an issue.",
                            t);
                }
            }
            break;
        case MODE_GATEWAY:
            throw new SqlClientException("Gateway mode is not supported yet.");
        default:
            CliOptionsParser.printHelpClient();
    }
}


首先是设置模式,不管你传不传 embedded 参数,都会设置成 embedded 模式,因为目前 Gateway 模式是不支持的.然后会调用 parseEmbeddedModeClient 方法解析提交命令里面的各种参数.包括我们上面用到的 -i 和 -f 都是在这一步解析并赋值的.


public static CliOptions parseEmbeddedModeClient(String[] args) {
    try {
        DefaultParser parser = new DefaultParser();
        CommandLine line = parser.parse(EMBEDDED_MODE_CLIENT_OPTIONS, args, true);
        return new CliOptions(
                line.hasOption(CliOptionsParser.OPTION_HELP.getOpt()),
                checkSessionId(line),
                checkUrl(line, CliOptionsParser.OPTION_ENVIRONMENT),
                checkUrl(line, CliOptionsParser.OPTION_DEFAULTS),
             // 这里就是 -i
                checkUrl(line, CliOptionsParser.OPTION_INIT_FILE),
             // 这里就是 -f
                checkUrl(line, CliOptionsParser.OPTION_FILE),
                checkUrls(line, CliOptionsParser.OPTION_JAR),
                checkUrls(line, CliOptionsParser.OPTION_LIBRARY),
                line.getOptionValue(CliOptionsParser.OPTION_UPDATE.getOpt()),
                line.getOptionValue(CliOptionsParser.OPTION_HISTORY.getOpt()),
                getPythonConfiguration(line));
    } catch (ParseException e) {
        throw new SqlClientException(e.getMessage());
    }
}
public static final Option OPTION_INIT_FILE =
            Option.builder("i")
                    .required(false)
                    .longOpt("init")
                    .numberOfArgs(1)
                    .argName("initialization file")
                    .desc(
                            "Script file that used to init the session context. "
                                    + "If get error in execution, the sql client will exit. Notice it's not allowed to add query or insert into the init file.")
                    .build();
public static final Option OPTION_FILE =
            Option.builder("f")
                    .required(false)
                    .longOpt("file")
                    .numberOfArgs(1)
                    .argName("script file")
                    .desc(
                            "Script file that should be executed. In this mode, "
                                    + "the client will not open an interactive terminal.")
                    .build();


解析参数完成后会构建 SqlClient 对象,然后就可以启动 SqlClient .


private void start() {
        if (isEmbedded) {
            // create local executor with default environment
            DefaultContext defaultContext = LocalContextUtils.buildDefaultContext(options);
            // 创建执行器
            final Executor executor = new LocalExecutor(defaultContext);
            // 启动
            executor.start();
            // Open an new session
            String sessionId = executor.openSession(options.getSessionId());
            try {
                // add shutdown hook
                Runtime.getRuntime()
                        .addShutdownHook(new EmbeddedShutdownThread(sessionId, executor));
                // do the actual work
                openCli(sessionId, executor);
            } finally {
                executor.closeSession(sessionId);
            }
        } else {
            throw new SqlClientException("Gateway mode is not supported yet.");
        }
    }

接着会创建一个 LocalExecutor 对象,用于本地执行程序,然后会启动起来,真正执行 SQL 的地方是 openCli 方法,源码如下:


private void openCli(String sessionId, Executor executor) {
    Path historyFilePath;
    if (options.getHistoryFilePath() != null) {
        historyFilePath = Paths.get(options.getHistoryFilePath());
    } else {
        historyFilePath =
                Paths.get(
                        System.getProperty("user.home"),
                        SystemUtils.IS_OS_WINDOWS ? "flink-sql-history" : ".flink-sql-history");
    }
    boolean hasSqlFile = options.getSqlFile() != null;
    boolean hasUpdateStatement = options.getUpdateStatement() != null;
    if (hasSqlFile && hasUpdateStatement) {
        throw new IllegalArgumentException(
                String.format(
                        "Please use either option %s or %s. The option %s is deprecated and it's suggested to use %s instead.",
                        CliOptionsParser.OPTION_FILE,
                        CliOptionsParser.OPTION_UPDATE,
                        CliOptionsParser.OPTION_UPDATE.getOpt(),
                        CliOptionsParser.OPTION_FILE.getOpt()));
    }
    try (CliClient cli = new CliClient(terminalFactory, sessionId, executor, historyFilePath)) {
        if (options.getInitFile() != null) {
            // 执行初始化 SQL -i 参数
            boolean success = cli.executeInitialization(readFromURL(options.getInitFile()));
            if (!success) {
                System.out.println(
                        String.format(
                                "Failed to initialize from sql script: %s. Please refer to the LOG for detailed error messages.",
                                options.getInitFile()));
                return;
            } else {
                System.out.println(
                        String.format(
                                "Successfully initialized from sql script: %s",
                                options.getInitFile()));
            }
        }
        if (!hasSqlFile && !hasUpdateStatement) {
            cli.executeInInteractiveMode();
        } else {
            // 执行真正的 SQL 文件 -f
            cli.executeInNonInteractiveMode(readExecutionContent());
        }
    }
}


这个里面会先获取 historyFilePath 的路径,然后判断是否存在 -i -f 这两个文件,如果有的话会先调用 executeInitialization 执行初始化的脚本.实际调用的是 executeInitialization#executeFile 方法来执行脚本,executeFile 的源码如下:


private boolean executeFile(String content, ExecutionMode mode) {
    terminal.writer().println(CliStrings.messageInfo(CliStrings.MESSAGE_EXECUTE_FILE).toAnsi());
    for (String statement : CliStatementSplitter.splitContent(content)) {
        terminal.writer()
                .println(
                        new AttributedString(String.format("%s%s", prompt, statement))
                                .toString());
        terminal.flush();
        if (!executeStatement(statement, mode)) {
            // cancel execution when meet error or ctrl + C;
            return false;
        }
    }
    return true;
}


其实不管是 -i 还是 -f 最终都会调用 executeFile 这个方法去解析脚本里的内容并且执行,这里方法里面先调用


splitContent 方法去做解析.


public static List<String> splitContent(String content) {
    List<String> statements = new ArrayList<>();
    List<String> buffer = new ArrayList<>();
    for (String line : content.split("\n")) {
        if (isEndOfStatement(line)) {
            buffer.add(line);
            statements.add(String.join("\n", buffer));
            buffer.clear();
        } else {
            buffer.add(line);
        }
    }
    if (!buffer.isEmpty()) {
        statements.add(String.join("\n", buffer));
    }
    return statements;
}
private static boolean isEndOfStatement(String line) {
    return line.replaceAll(MASK, "").trim().endsWith(";");
}


其实就是一行一行的读取初始化脚本和 SQL 脚本里面的内容,然后放到一个 List 里面.然后循环这个 List 调用 executeStatement 方法去执行 SQL 脚本.


private boolean executeStatement(String statement, ExecutionMode executionMode) {
    try {
        final Optional<Operation> operation = parseCommand(statement);
        operation.ifPresent(op -> callOperation(op, executionMode));
    } catch (SqlExecutionException e) {
        printExecutionException(e);
        return false;
    }
    return true;
}


执行之前会先对 SQL 做一个清洗,具体逻辑在 parseCommand 方法中.


private Optional<Operation> parseCommand(String stmt) {
    // normalize
    stmt = stmt.trim();
    // remove ';' at the end
    if (stmt.endsWith(";")) {
        stmt = stmt.substring(0, stmt.length() - 1).trim();
    }
    // meet bad case, e.g ";\n"
    if (stmt.trim().isEmpty()) {
        return Optional.empty();
    }
    Operation operation = executor.parseStatement(sessionId, stmt);
    return Optional.of(operation);
}


其实就是把 SQL 后面的 ; 去掉,并在遇到 bad case 的时候返回空.然后调用 parseStatement 方法将 SQL 语句解析成 Operation,后面的过程就跟 Flink SQL 翻译成代码的过程差不多.就不在往后面跟了.


-f 参数调用的是 executeInNonInteractiveMode 方法,实际也会调用 executeFile 方法,跟 -i 的执行逻辑是一样的.这里就不再分析了.


其实在新版本中除了 -i 和 -f 外还有很多其他的功能,感兴趣的同学可以到官网查看详细内容 https://ci.apache.org/projects/flink/flink-docs-release-1.13/docs/dev/table/sqlclient/#sql-client-configuration


另外当前的 SQL Client 仅支持嵌入式模式(也就是 embedded 模式)。将来,社区计划通过提供基于 REST 的SQL 客户端网关来扩展其功能,有关更多信息,请参见 FLIP-24 和 FLIP-91。


总结

这篇文章主要介绍了 Flink 1.13.0 版本中对于 SQL Client 在初始化会话和提交 SQL 脚本方面的增强.以及结合源码分析了其实现的原理.

相关实践学习
基于Hologres+Flink搭建GitHub实时数据大屏
通过使用Flink、Hologres构建实时数仓,并通过Hologres对接BI分析工具(以DataV为例),实现海量数据实时分析.
实时计算 Flink 实战课程
如何使用实时计算 Flink 搞定数据处理难题?实时计算 Flink 极客训练营产品、技术专家齐上阵,从开源 Flink功能介绍到实时计算 Flink 优势详解,现场实操,5天即可上手! 欢迎开通实时计算 Flink 版: https://cn.aliyun.com/product/bigdata/sc Flink Forward Asia 介绍: Flink Forward 是由 Apache 官方授权,Apache Flink Community China 支持的会议,通过参会不仅可以了解到 Flink 社区的最新动态和发展计划,还可以了解到国内外一线大厂围绕 Flink 生态的生产实践经验,是 Flink 开发者和使用者不可错过的盛会。 去年经过品牌升级后的 Flink Forward Asia 吸引了超过2000人线下参与,一举成为国内最大的 Apache 顶级项目会议。结合2020年的特殊情况,Flink Forward Asia 2020 将在12月26日以线上峰会的形式与大家见面。
相关文章
|
11月前
|
SQL 人工智能 JSON
Flink 2.1 SQL:解锁实时数据与AI集成,实现可扩展流处理
简介:本文整理自阿里云高级技术专家李麟在Flink Forward Asia 2025新加坡站的分享,介绍了Flink 2.1 SQL在实时数据处理与AI融合方面的关键进展,包括AI函数集成、Join优化及未来发展方向,助力构建高效实时AI管道。
1266 43
|
11月前
|
SQL 人工智能 JSON
Flink 2.1 SQL:解锁实时数据与AI集成,实现可扩展流处理
本文整理自阿里云的高级技术专家、Apache Flink PMC 成员李麟老师在 Flink Forward Asia 2025 新加坡[1]站 —— 实时 AI 专场中的分享。将带来关于 Flink 2.1 版本中 SQL 在实时数据处理和 AI 方面进展的话题。
635 0
Flink 2.1 SQL:解锁实时数据与AI集成,实现可扩展流处理
|
12月前
|
SQL 消息中间件 Kafka
Flink SQL 详解:流批一体处理的强大工具
Flink SQL 是 Apache Flink 提供的 SQL 引擎,支持流批一体处理,统一操作流数据与批数据,具备高性能、低延迟、丰富数据源支持及标准 SQL 兼容性,适用于实时与离线数据分析。
1327 1
|
SQL 大数据 API
大数据-118 - Flink DataSet 基本介绍 核心特性 创建、转换、输出等
大数据-118 - Flink DataSet 基本介绍 核心特性 创建、转换、输出等
384 0
|
SQL 大数据 数据处理
Flink SQL 详解:流批一体处理的强大工具
Flink SQL 是为应对传统数据处理框架中流批分离的问题而诞生的,它融合了SQL的简洁性和Flink的强大流批处理能力,降低了大数据处理门槛。其核心工作原理包括生成逻辑执行计划、查询优化和构建算子树,确保高效执行。Flink SQL 支持过滤、投影、聚合、连接和窗口等常用算子,实现了流批一体处理,极大提高了开发效率和代码复用性。通过统一的API和语法,Flink SQL 能够灵活应对实时和离线数据分析场景,为企业提供强大的数据处理能力。
2454 27
|
SQL 存储 缓存
Flink SQL Deduplication 去重以及如何获取最新状态操作
Flink SQL Deduplication 是一种高效的数据去重功能,支持多种数据类型和灵活的配置选项。它通过哈希表、时间窗口和状态管理等技术实现去重,适用于流处理和批处理场景。本文介绍了其特性、原理、实际案例及源码分析,帮助读者更好地理解和应用这一功能。
1487 14
|
SQL 存储 数据库
SQL学习一:ACID四个特性,CURD基本操作,常用关键字,常用聚合函数,五个约束,综合题
这篇文章是关于SQL基础知识的全面介绍,包括ACID特性、CURD操作、常用关键字、聚合函数、约束以及索引的创建和使用,并通过综合题目来巩固学习。
694 1
|
SQL 大数据 API
大数据-132 - Flink SQL 基本介绍 与 HelloWorld案例
大数据-132 - Flink SQL 基本介绍 与 HelloWorld案例
408 0
|
关系型数据库 MySQL 网络安全
5-10Can't connect to MySQL server on 'sh-cynosl-grp-fcs50xoa.sql.tencentcdb.com' (110)")
5-10Can't connect to MySQL server on 'sh-cynosl-grp-fcs50xoa.sql.tencentcdb.com' (110)")
|
SQL 存储 监控
SQL Server的并行实施如何优化?
【7月更文挑战第23天】SQL Server的并行实施如何优化?
1012 13