![SeaTunnel调优]()
作者 | 肌肉娃子
起因:我以为只是"复制一份配置"这么简单
最开始的想法很朴素: amzn_order 的 Seatunnel CDC → Doris 同步已经跑得挺稳了,那我把这套配置直接"平移"到 amzn_api_logs 上,表名改一改,跑起来就完事。
结果就是: 线上机器内存一路飙到十几 G,Java 进程频繁 OOM,Doris / Trino 全在同一台机器上跟着抖。 更扎心一点:这事本质不是 SeaTunnel 的 bug,而是我自己对数据分片、流式写入和内存模型的理解太粗糙。 这篇就当是一次复盘:从"我以为是流式,不会堆内存"到慢慢意识到------你以为的"流",其实是很多层 buffer 和 batch 堆起来的。
事故现场:一台 60G 机器,快被我榨干了
当时的 top 大概是这样:
MiB Mem : 63005.9 total, 2010.6 free, 53676.2 used, 8097.3 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used
...
PID VIRT RES %MEM COMMAND
2366021 22.5g 16.9g 27% java ... seatunnel-2.3.11 ...
1873099 14.3g 7.1g 11% trino
1895794 49.5g 1.7g 2% doris_be
SeaTunnel 这个 Java 进程实打实吃了 16~17G 堆,全机 free 内存不到 2G,Swap 又是关的,随时有被 OOM Killer 一刀秒掉的风险。
当时我脑子里还有个迷思:"不是流式写吗?为啥会把内存吃满?"
表结构和配置:看起来正常,其实每一项都在助推 OOM
表结构:amzn_api_logs
CREATE TABLE `amzn_api_logs` (
`id` bigint NOT NULL,
`business_type` varchar(100) NOT NULL,
`req_params` json DEFAULT NULL,
`resp` json DEFAULT NULL,
`seller_id` varchar(32) NOT NULL,
`market_place_id` varchar(32) NOT NULL,
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(255) DEFAULT NULL,
`is_delete` bigint NOT NULL DEFAULT '0',
`version` bigint NOT NULL DEFAULT '0',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB;
两列 JSON:req_params / resp。
日志类 JSON,体积能有多大大家心里都有数。
初版 SeaTunnel 配置(核心部分)
job.name = "amzn_api_logs"
execution.parallelism = 10
job.mode = "STREAMING"
checkpoint.interval = 60000
}
source {
MySQL-CDC {
parallelism = 6
incremental.parallelism = 4
snapshot.parallelism = 4
table-names = ["amzn_data_prd.amzn_api_logs"]
snapshot.split.size = 50000
snapshot.fetch.size = 10000
chunk-key-column = "id"
exactly_once = true
startup.mode = "initial"
}
}
sink {
doris {
sink.model = "UNIQUE_KEYS"
sink.primary_key = "id"
sink.label-prefix = "amzn_api_logs_cdc_doris"
sink.enable-2pc = "true"
doris.batch.size = 50000
...
doris.config {
format = "json"
read_json_by_line = "true"
}
}
}
当时我的心理预期大概是:
"CDC + STREAMING + Doris,一条条流过去,内存顶多放点 buffer,不至于炸。"
事后看,这套组合几乎是为"大 JSON + 高并发 + initial 全量"量身定制的灾难套餐:
- JSON 字段巨大: MySQL 里是压得比较紧的二进制,进到 JVM 里变成一个个 String / Map 对象,膨胀系数轻松 3~5 倍。
- doris.batch.size = 50000: 一次攒 5 万行日志再发,5000 行都动辄上百 MB,5 万行是什么级别不用算。
- execution.parallelism = 10 + 多个 snapshot.*.parallelism: 实际上是多路并发各自攒批次,内存占用是成倍放大的。
- exactly_once = true + sink.enable-2pc = true: 为了精确一次,Checkpoint 期间的数据要"憋住不放",内存峰值进一步拉高。
Linux 的 available 不是你的安全感
中间有一段是我死磕 free 和 available:
"free 只有 2G,但 available 还有 9G,看起来还能撑一会儿吧?"
结果事实证明这是种幻觉。
available ≈ free + "可以回收的 cache"。 从内核视角:"真不行我就把磁盘 cache 挤掉让你用。"
但对一堆 Java 进程来说(Trino、SeaTunnel、Cloudera Agent...):
GC 时会申请额外内存做对象移动;
SeaTunnel 遇到大 JSON,会突然要一大块连续空间;
一旦申请失败,就是 Java heap space + 一串连锁异常。
所以那种 "free 2G + available 9G = 还早" 的想法,在没有 Swap、Java 堆又开得很大的情况下,基本不成立。
OOM 现场:Debezium + SnapshotSplit 全在叫
典型的报错长这样(截一段):
Caused by: java.lang.OutOfMemoryError: Java heap space
...
Caused by: org.apache.seatunnel.common.utils.SeaTunnelException:
Read split SnapshotSplit(tableId=amzn_data_prd.amzn_api_logs,
splitKeyType=ROW<id BIGINT>,
splitStart=[125020918847214509],
splitEnd=[125027189499467705]) error
due to java.lang.IllegalArgumentException: Self-suppression not permitted
再往上看堆栈,是 MySqlSnapshotSplitReadTask 在执行:
MySqlSnapshotSplitReadTask.doExecute(...)
MySqlSnapshotSplitReadTask.createDataEventsForTable(...)
...
OutOfMemoryError: Java heap space
简单翻译一下:
Debezium 正在跑 snapshot split,一次处理一个 id 范围的分片(splitStart / splitEnd)。
每个 split 里包含了 snapshot.split.size 条记录(我当时是 50,000)。
这些记录里面有大 JSON,进 JVM → 变对象,这一步就已经在吃堆了。
再加上 Sink 还没来得及消费完,整个 pipeline 中间的 buffer 也在堆积。
后面那些 Self-suppression not permitted 其实是 OOM 之后异常处理也开始乱套产生的副作用,本质问题就是内存耗尽。
原来"流式"是有很多水坝的
这次踩坑最大的收获之一,是重新理解了"流"的边界。 在我脑子里的一开始模型是:
MySQL → SeaTunnel → Doris
一边读一边写,应该就是"边走边丢",不会攒太多在内存。
实际上至少有三层"水坝":
- Source 侧 -- Debezium 快照分片 snapshot.split.size:一个 split 里要读多少行。 snapshot.fetch.size:一次从数据库拉多少行。 snapshot.parallelism:多少个 split 同时读。
- 中间队列 -- Source → Sink 之间的缓冲 execution.parallelism × 各种 channel 的 queue。
- Sink 侧 -- Doris Stream Load 批次 doris.batch.size(或者 ClickHouse 的 bulk_size); sink.buffer-size / sink.buffer-count;
以及开启 2PC 时,为了 Exactly-once,Checkpoint 周期内的数据需要被记住。
流式写入≠不占内存,只是"数据先在内存兜一圈,不落盘"而已。
你怎么配 batch / split,决定了这圈到底兜得多大。
调整思路:不是一味降并发,而是"高并发 + 小颗粒"
一开始的直觉调整是:把并发往下砍。比如把 execution.parallelism 从 10 改成 2、4,确实内存会好看很多,但直觉上总觉得有点浪费机器。
后来我对自己的目标想清楚了:
我想要的是:高并发没问题,但每一份并行处理的数据块要足够小。
于是思路从"把线程数砍掉"变成了"线程保留,大块切碎"。对应到配置上大概是这样:
- Source 端:把 snapshot.split.size 砍碎
从最开始的:
snapshot.split.size = 50000
snapshot.fetch.size = 10000
snapshot.parallelism = 4
调整为更细颗粒的思路(示意):
snapshot.split.size = 5000 # 分片变小
snapshot.fetch.size = 1000 # 每次 fetch 更少
snapshot.parallelism = 8 # 保留/提升并行度
目的很简单:
单个 split 里的大 JSON 数量受控;
每个 Debezium 线程手里拿的是"小包裹",OOM 风险降低;
并发数可以依然保持比较高。
- Sink 端:batch 是硬上限,别迷信 5 万行
doris.batch.size 从 50000 调到 5000 之后,观感上有两个变化:
- Doris 日志里 Stream Load 的节奏变得更密了,每 5k 一批,很快就一条条 Success 打出来;
- SeaTunnel 进程的堆占用不再一路往上堆,而是在一个区间内波动。
日志里像这样的一段很有参考价值,来自doris的 http接口的批量上传的响应:
"NumberTotalRows": 5000,
"LoadBytes": 134564375,
"LoadTimeMs": 1727
5000 行就已经是 134MB 的原始数据,用 JSON 传,再加上 JVM 内部对象,单批次占几百 MB 堆一点不夸张。所以 batch 开到 50000,纯粹是给自己找 OOM。
enable-2pc = true 的好处是 Exactly-once,但对我这个场景有几个现实情况:
- 我跑的是 50G initial 全量;
- 目标表是 UNIQUE KEY(id),天然幂等;
真要挂一次,大不了重跑一遍,Doris 会按主键覆盖。
所以 2PC 带来的更多是:
1. Checkpoint 周期内的数据需要被"憋住";
2. 一旦周期内数据体量太大,内存会瞬间顶满。
最后我直接把: sink.enable-2pc = "false" exactly_once = false # 或者改成至少不是严格精确一次。
关掉之后,最直观的变化是:
- 写入变得"细水长流",不再一分钟憋一大口;
- 内存峰值低了一截,GC 也没那么狂暴了。
但是后续要改回来进行增量同步
监控:不要只看"跑没跑",要看"怎么跑的"
中途有几个监控方式对我判断很有帮助:
Doris Stream Load 日志
- 看每批的 NumberTotalRows / LoadBytes / LoadTimeMs;
- 能直观感受到"单批是不是过大""Doris 是不是已经扛不动了"。
top + RES / wa
- RES 稳定在某个区间而不是一直涨,是一个健康信号;
- wa 高说明 IO 被打满,继续加并发也没用。
SeaTunnel 自己的 HealthMonitor 日志
- heap.memory.used/max 能看出堆有没有接近极限;
- minor.gc.count / major.gc.count 大概能猜到 GC 压力。
一些教训/小结
这次折腾下来,反思了几件事:
"配置复用"这件事很危险
amzn_order 和 amzn_api_logs 唯一的区别是多了两个 JSON 字段,量级却完全不是一个量级。我直接把订单表的 CDC 配置套过来,是典型的只看行数,不看字节数。
流式也需要认真设计"水坝"
- Source:snapshot.split.size / fetch.size / 各种 parallelism;
- Sink:batch.size / buffer / 2PC;
中间:Checkpoint 周期、exactly_once 策略。 任何一层配大了,在大 JSON 场景下都会直接把 JVM 送走。
并发不是越大越好,颗粒度才是关键
真正要调的是:
- 并发 × 每份任务的大小;
- 而不是仅仅盯着 parallelism 数字。