欢迎光临
我们一直在努力

SeaTunnel 架构没那么复杂:从 JDBC 读写说起

image.png

很多人第一次看 SeaTunnel 架构,可能会觉得有点复杂。

Source API、Sink API、Transform API、Connector、Zeta、Flink、Spark、Checkpoint、Two Phase Commit……

概念一下子很多。

但如果先把这些架构名词放一边,只看一个最简单的数据同步任务,其实它的本质非常朴素:

从一个地方读数据,再写到另一个地方。

比如最常见的 MySQL 同步到 MySQL,本质上就是:

JDBC Source 读取源表
↓
Transform 可选处理
↓
JDBC Sink 写入目标表

再直接一点,如果不用任何框架,自己用 Java 写一个最小版本,大概就是下面这样。

一个最小 MVP:JDBC 读,JDBC 写

下面这段代码不考虑分页、不考虑并发、不考虑容错,也不考虑性能。

它只表达一个最核心的事情:

用 PreparedStatement 从源库查询,再用 PreparedStatement 写入目标库。

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class JdbcSyncMvp {

public static void main(String[] args) throws Exception {
String sourceUrl = "jdbc:mysql://127.0.0.1:3306/source_db";
String targetUrl = "jdbc:mysql://127.0.0.1:3306/target_db";

String user = "root";
String password = "123456";

String querySql = "select id, name, age from user_source";
String insertSql = "insert into user_target(id, name, age) values (?, ?, ?)";

try (
Connection sourceConn = DriverManager.getConnection(sourceUrl, user, password);
Connection targetConn = DriverManager.getConnection(targetUrl, user, password);
PreparedStatement queryStatement = sourceConn.prepareStatement(querySql);
PreparedStatement insertStatement = targetConn.prepareStatement(insertSql);
ResultSet resultSet = queryStatement.executeQuery()
) {
targetConn.setAutoCommit(false);

int count = 0;

while (resultSet.next()) {
long id = resultSet.getLong("id");
String name = resultSet.getString("name");
int age = resultSet.getInt("age");

insertStatement.setLong(1, id);
insertStatement.setString(2, name);
insertStatement.setInt(3, age);
insertStatement.addBatch();

count++;

if (count % 1000 == 0) {
insertStatement.executeBatch();
targetConn.commit();
}
}

insertStatement.executeBatch();
targetConn.commit();

System.out.println("sync finished, total rows: " + count);
}
}
}

这段代码其实就已经是一个最小版的数据同步工具了。

它做了三件事:

1. 连接源数据库
2. 查询源表数据
3. 写入目标数据库

如果用 SeaTunnel 的语言来描述:

queryStatement.executeQuery() ≈ Source 读数据
while (resultSet.next()) ≈ 数据一行一行流动
insertStatement.addBatch() ≈ Sink 写数据

所以从最小 MVP 的角度看,数据同步并不神秘。

最开始就是:

读出来,写进去。

SeaTunnel 做的事情,是把这个 MVP 扩展成一个通用系统

当然,上面这段代码只是一个玩具版本。

真实的数据同步系统,会遇到很多问题:

数据量很大怎么办?
一张表太慢怎么办?
怎么并行读取?
中途失败了怎么办?
写入一半失败了怎么办?
如何保证不丢、不重?
源端和目标端字段不一样怎么办?
MySQL 换成 Oracle 怎么办?
JDBC 换成 Kafka 怎么办?
目标端换成 Doris、StarRocks、Iceberg 怎么办?

如果每一种场景都手写一套代码,系统很快就会变得非常复杂。

SeaTunnel 的做法是把这件事拆开。

它不是只写一条固定链路,而是把数据同步拆成几个核心角色:

Source:负责读数据
Transform:负责处理中间数据
Sink:负责写数据
MetaData:负责描述数据长什么样
Engine:负责把任务跑起来

这样一来,刚才那个最小 JDBC 程序,就可以被抽象成:

JdbcSource
↓
Transform
↓
JdbcSink

如果用 SeaTunnel 配置来看,大概就是:

source {
Jdbc {
url = "jdbc:mysql://127.0.0.1:3306/source_db"
driver = "com.mysql.cj.jdbc.Driver"
user = "root"
password = "123456"
query = "select id, name, age from user_source"
}
}

sink {
Jdbc {
url = "jdbc:mysql://127.0.0.1:3306/target_db"
driver = "com.mysql.cj.jdbc.Driver"
user = "root"
password = "123456"
table = "user_target"
}
}

你会发现,它和刚才的 Java MVP 思路是一样的。

只是 SeaTunnel 把连接、读取、写入、批量提交、任务调度、失败恢复这些通用能力封装起来了。

元数据从哪里来?其实很多时候就是来自 Connection

前面说 JDBC MVP 的时候,我们只写了最简单的读写逻辑:

PreparedStatement 查询数据
↓
ResultSet 读取数据
↓
PreparedStatement 写入数据

但真实的数据同步工具,不能只知道怎么读写数据。

它还需要知道一些更基础的信息,比如:

源表有哪些字段?
字段名是什么?
字段类型是什么?
字段是否可以为空?
主键是什么?
目标表是否存在?
目标表字段和源表是否匹配?
能不能自动建表?

这些信息就是元数据。

如果还是从最简单的 JDBC 角度来看,元数据其实也不神秘。

很多时候,它就是从 Connection 里拿出来的。

比如 JDBC 里可以这样拿数据库元数据:

Connection conn = DriverManager.getConnection(url, user, password);

DatabaseMetaData metaData = conn.getMetaData();

ResultSet tables = metaData.getTables(
null,
null,
"user_source",
new String[]{"TABLE"}
);

while (tables.next()) {
String tableName = tables.getString("TABLE_NAME");
System.out.println("tableName = " + tableName);
}

也可以通过 DatabaseMetaData 获取字段信息:

ResultSet columns = metaData.getColumns(
null,
null,
"user_source",
null
);

while (columns.next()) {
String columnName = columns.getString("COLUMN_NAME");
String typeName = columns.getString("TYPE_NAME");
int columnSize = columns.getInt("COLUMN_SIZE");
int nullable = columns.getInt("NULLABLE");

System.out.println(columnName + " " + typeName + "(" + columnSize + "), nullable = " + nullable);
}

还可以获取主键信息:

ResultSet primaryKeys = metaData.getPrimaryKeys(
null,
null,
"user_source"
);

while (primaryKeys.next()) {
String columnName = primaryKeys.getString("COLUMN_NAME");
String pkName = primaryKeys.getString("PK_NAME");

System.out.println("primary key = " + columnName + ", pkName = " + pkName);
}

甚至,如果我们已经执行了一条查询 SQL,也可以直接从 ResultSet 里拿字段元数据:

PreparedStatement statement = conn.prepareStatement(
"select id, name, age from user_source"
);

ResultSet resultSet = statement.executeQuery();
ResultSetMetaData rsMetaData = resultSet.getMetaData();

int columnCount = rsMetaData.getColumnCount();

for (int i = 1; i <= columnCount; i++) {
String columnName = rsMetaData.getColumnName(i);
String columnTypeName = rsMetaData.getColumnTypeName(i);
int columnType = rsMetaData.getColumnType(i);

System.out.println(columnName + " => " + columnTypeName + ", jdbcType = " + columnType);
}

所以你会发现,所谓元数据,在 JDBC 场景下最开始就是这些东西:

Connection
└── DatabaseMetaData
├── tables
├── columns
├── primary keys
└── indexes

ResultSet
└── ResultSetMetaData
├── column name
├── column type
└── column count

SeaTunnel 当然不会只停留在 JDBC 这一层。

它会把不同 Connector 拿到的元数据信息,进一步抽象成统一的表结构模型。

可以简单理解为:

JDBC Connection MetaData
↓
Connector 解析字段、类型、主键等信息
↓
CatalogTable / TableSchema
↓
Source / Transform / Sink 使用统一的表结构

这就很重要了。

因为不同数据源获取元数据的方式不一样。

JDBC 可以通过 Connection#getMetaData()。

Kafka 可能来自消息格式、Schema Registry,或者用户配置。

CDC 场景可能来自数据库表结构和 binlog 里的 DDL 变更。

数据湖场景可能来自 Catalog。

但是 SeaTunnel 不能让上层逻辑到处感知这些差异。

所以它需要把不同来源的元数据,最终收敛成统一的 Table API 模型。

这样后面才能做很多事情:

字段解析
字段映射
自动建表
类型转换
Schema Evolution
多表同步
DDL 变更传递

从 SeaTunnel Web 的角度看,这个点会更直观。

当我们在页面上选择一个数据源,然后点击“字段解析”时,背后其实就是:

根据数据源连接信息创建 Connection
↓
通过 Connector 获取表结构或 SQL 查询结果结构
↓
解析字段名、字段类型、主键等元数据
↓
展示到页面上
↓
用户做字段映射、改字段名、生成 Sink 表结构

所以 SeaTunnel 的元数据也可以用一句简单的话理解:

元数据很多时候来自连接器背后的连接能力,再由 Connector 转成 SeaTunnel 统一认识的表结构。

还是回到最开始的 JDBC 例子。

数据同步的第一步是:

我能不能连上数据库?

第二步就是:

我能不能知道这张表长什么样?

知道表长什么样之后,才有后面的字段映射、自动建表、类型转换和同步执行。

所以 Source / Sink 解决的是数据怎么流动。

MetaData 解决的是:

这批数据长什么样。

把 JDBC 换掉,架构就扩展起来了

理解了 JDBC Source 到 JDBC Sink,再看 SeaTunnel 的其它能力,就会清晰很多。

如果源端不是普通 JDBC,而是 MySQL CDC,那么只是把 Source 换掉:

MySQL CDC Source
↓
Transform
↓
Jdbc Sink / Doris Sink / StarRocks Sink

如果源端是 Kafka,那么就是:

Kafka Source
↓
Transform
↓
Jdbc Sink / Elasticsearch Sink

如果目标端是数据湖,那么就是:

Jdbc Source / CDC Source
↓
Transform
↓
Iceberg Sink / Hudi Sink / DeltaLake Sink

所以 SeaTunnel 的架构核心不是写死某一种同步链路。

它真正做的是:

把读和写抽象出来,然后用不同 Connector 进行组合。

今天是 MySQL 到 MySQL。

明天可以是 Oracle 到 Doris。

后天可以是 Kafka 到 Elasticsearch。

再后来可以是 MySQL CDC 到 Iceberg。

变化的是 Connector。

不变的是这条主线:

Source → Transform → Sink

再加上元数据,就是:

MetaData 描述数据长什么样
Source 负责读数据
Transform 负责处理数据
Sink 负责写数据

Connector 关注读写,Engine 关注执行

很多人容易把 Connector 和 Engine 混在一起。

其实可以简单理解:

Connector:负责数据从哪里来,到哪里去,以及这批数据长什么样
Engine:负责这个任务怎么运行

比如一个任务是 MySQL CDC 同步到 Doris。

那么:

Source 是 MySQL CDC
Sink 是 Doris
MetaData 来自源端表结构、DDL 事件或者 Connector 解析结果
Engine 可以是 Zeta,也可以是 Flink

Source 只关心怎么读取 MySQL binlog。

Sink 只关心怎么写入 Doris。

MetaData 负责告诉系统,这张表有哪些字段、字段类型是什么、主键是什么、结构有没有变化。

至于任务如何提交、如何分配资源、如何并行执行、如何做 Checkpoint、失败后如何恢复,这是 Engine 要解决的问题。

这就是为什么 SeaTunnel 架构里会有 Zeta、Flink、Spark 这些执行引擎。

从用户视角看,我们写的是:

Source → Transform → Sink

从系统视角看,执行引擎会把这条链路真正跑起来。

再回到 SeaTunnel 架构图

所以再看 SeaTunnel 的整体架构,就没有那么难了。

可以从上往下这样理解:

用户配置层:
HOCON / SQL / Web UI

API 层:
Source API / Sink API / Transform API / Table API

Connector 层:
Jdbc / Kafka / MySQL CDC / Elasticsearch / Iceberg …

Translation 层:
适配 Flink / Spark 等执行引擎

Engine 层:
Zeta / Flink / Spark 真正执行任务

看起来层次很多,但主线其实很简单:

用户描述任务,Connector 负责读写和元数据,Engine 负责执行。

其中 API 层很关键。

因为它定义了统一的 Source、Sink、Transform、Table 模型。

Connector 只要按照这套模型实现,就可以被 SeaTunnel 统一调度和组合。

这也是为什么 SeaTunnel 可以支持很多不同类型的数据源和目标端。

它不是为每一种链路都写一套固定代码,而是提供一套统一抽象,让不同 Connector 按照同一种方式接入进来。

Source 可以理解成什么?

Source 负责读数据。

如果回到 JDBC MVP 里,它很像这一段:

PreparedStatement queryStatement = sourceConn.prepareStatement(querySql);
ResultSet resultSet = queryStatement.executeQuery();

while (resultSet.next()) {
long id = resultSet.getLong("id");
String name = resultSet.getString("name");
int age = resultSet.getInt("age");
}

但是在真实系统里,Source 不能只是简单执行一条 SQL。

它还要考虑:

怎么切分数据?
怎么并行读取?
每个 Reader 读哪一部分?
失败后从哪里恢复?
读取进度怎么保存?

所以 SeaTunnel 里会有类似 Enumerator、Reader、Split 这样的设计。

可以简单理解为:

SourceSplitEnumerator:负责切分任务、分配 Split
SourceReader:负责真正读取数据
SourceSplit:表示一份可以被独立处理的数据分片

这比一个简单的 while (resultSet.next()) 要复杂很多。

但它解决的是同一个问题:

怎么把数据从源端读出来。

Sink 可以理解成什么?

Sink 负责写数据。

如果回到 JDBC MVP 里,它很像这一段:

insertStatement.setLong(1, id);
insertStatement.setString(2, name);
insertStatement.setInt(3, age);
insertStatement.addBatch();

insertStatement.executeBatch();
targetConn.commit();

但是在真实系统里,Sink 也不能只是简单 insert。

它还要考虑:

批量写入
事务提交
失败回滚
重复提交
幂等写入
Exactly-Once

所以 SeaTunnel 的 Sink 不只是 Writer。

它还会有 Committer 这样的角色。

可以简单理解为:

SinkWriter:负责写入或准备写入
SinkCommitter:负责最终提交

这其实就和数据同步里的可靠性有关了。

如果任务写到一半失败了,不能简单地说重新跑就行。

因为重新跑可能会重复写。

所以很多场景下,需要结合 Checkpoint 和两阶段提交,尽量保证数据不丢、不重。

Transform 可以理解成什么?

Transform 负责处理中间数据。

如果只是简单同步,可能不需要 Transform。

但实际场景里,经常会遇到:

字段改名
字段裁剪
字段类型转换
SQL 处理
数据过滤
一行转多行

比如源表是:

id, name, age, create_time

目标表想变成:

id, username, age, create_time

那中间就需要做一次字段映射。

所以 Transform 可以理解成:

Source 读出来的数据
↓
Transform 做处理
↓
Sink 写入目标端

在 SeaTunnel Web 里,字段映射、SQL 节点、Copy 节点,本质上都是在帮用户更直观地配置这个过程。

Engine 可以理解成什么?

Engine 负责把任务跑起来。

如果只是 JDBC MVP,一个 main 方法就能跑。

但是到了生产环境,就不是这样了。

一个数据同步任务可能需要:

提交任务
解析配置
生成执行计划
申请资源
分发 Task
执行任务
收集状态
处理失败
做 Checkpoint
停止任务
查看日志和指标

这些事情不是 Source 和 Sink 该管的。

它们属于执行引擎的职责。

所以 SeaTunnel 里会有 Zeta Engine。

也可以适配 Flink、Spark。

简单理解就是:

Connector 负责能力
Engine 负责执行

或者再口语一点:

Connector 负责“会不会读写”
Engine 负责“怎么把它跑起来”

为什么这个设计适合数据同步?

因为数据同步天然就是排列组合。

源端很多:

MySQL
Oracle
PostgreSQL
SQL Server
Kafka
Hive
HDFS

目标端也很多:

MySQL
Doris
StarRocks
Elasticsearch
Iceberg
Hudi
ClickHouse

如果每一种组合都单独开发一条链路,复杂度会非常高。

比如:

MySQL → MySQL
MySQL → Doris
MySQL → StarRocks
Oracle → Doris
Kafka → Elasticsearch
CDC → Iceberg

如果每条链路都单独写,那后面会越来越难维护。

SeaTunnel 更像是搭积木:

选一个 Source
选一个 Transform
选一个 Sink
选一个 Engine

然后组合成一个任务。

这也是为什么 SeaTunnel 可以支持那么多 Connector。

它不是靠一条条硬编码链路堆出来的,而是靠统一抽象和插件化架构组合出来的。

从 SeaTunnel Web 的角度看

如果从 SeaTunnel Web 的角度来看,用户看到的是页面操作:

创建数据源
测试连接
选择源表
字段解析
配置字段映射
选择目标表
自动建表
预览 HOCON
发布任务
运行任务
查看日志和指标

但这些操作背后,其实都能对应到 SeaTunnel 的架构角色。

创建数据源和测试连接,本质上是在验证 Connector 能不能连接到外部系统。

字段解析,本质上是在获取 MetaData。

字段映射,本质上是在配置 Transform。

选择源表,本质上是在配置 Source。

选择目标表,本质上是在配置 Sink。

发布和运行任务,本质上是交给 Engine 执行。

日志和指标,本质上是观察任务运行状态。

所以 Web UI 并没有改变 SeaTunnel 的核心模型。

它只是把原来需要手写配置的事情,变成了更容易理解和操作的可视化流程。

用一句话总结

如果让我用一句话解释 SeaTunnel 架构,我会这么说:

SeaTunnel 的核心就是 Source 读数据,Transform 处理数据,Sink 写数据,MetaData 描述数据长什么样,Engine 负责把任务跑起来。

所以刚开始学习 SeaTunnel,不要一上来就陷入很多架构名词。

先理解最简单的 JDBC 读写。

再理解 JDBC Source、JDBC Sink 和 JDBC MetaData。

然后把 JDBC 换成 MySQL CDC、Kafka、Doris、StarRocks、Iceberg。

你会发现,SeaTunnel 的整体架构其实就是围绕几个问题展开的:

从哪里读?
数据长什么样?
中间要不要处理?
写到哪里去?
用哪个引擎跑?

理解了这几个问题,SeaTunnel 的架构就清晰很多了。

很多复杂系统,刚开始看都像是一堆概念。

但拆开以后,它往往都有一个很简单的起点。

对 SeaTunnel 来说,这个起点就是:

读出来,写进去。

写在最后

SeaTunnel Web 是我正在持续完善的一个 SeaTunnel 可视化项目,希望把数据源管理、Zeta Engine 连接、任务配置、运行日志和指标查看这些常用能力,做得更直观、更容易上手。

项目地址:

https://github.com/weifuwan/seatunnel-web

里面有体验地址、部署文档、社区交流群

如果你也在学习 SeaTunnel,或者正在做数据同步、数据集成相关的事情,也欢迎一起交流。

这里有一群上进的小伙伴,也有一群爱分享的大佬。大家可以一起讨论问题、分享实践、完善文档,也一起把 SeaTunnel 这件事讲得更清楚、做得更好用。

赞(0)
未经允许不得转载:171主机测评 » SeaTunnel 架构没那么复杂:从 JDBC 读写说起
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址