
作者 | 肌肉娃子
起因:我以為只是“複製一份配置”這麼簡單
最開始的想法很樸素:
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 之後,觀感上有兩個變化:
1. Doris 日誌裏 Stream Load 的節奏變得更密了,每 5k 一批,很快就一條條 Success 打出來;
2. SeaTunnel 進程的堆佔用不再一路往上堆,而是在一個區間內波動。
日誌裏像這樣的一段很有參考價值,來自doris的 http接口的批量上傳的響應:
"NumberTotalRows": 5000,
"LoadBytes": 134564375,
"LoadTimeMs": 1727
5000 行就已經是 134MB 的原始數據,用 JSON 傳,再加上 JVM 內部對象,單批次佔幾百 MB 堆一點不誇張。所以 batch 開到 50000,純粹是給自己找 OOM。
- 2PC:在全量同步場景下,可以先關掉
enable-2pc = true 的好處是 Exactly-once,但對我這個場景有幾個現實情況:
1. 我跑的是 50G initial 全量;
2. 目標表是 UNIQUE KEY(id),天然冪等;
真要掛一次,大不了重跑一遍,Doris 會按主鍵覆蓋。
所以 2PC 帶來的更多是:
1. Checkpoint 週期內的數據需要被“憋住”;
2. 一旦週期內數據體量太大,內存會瞬間頂滿。
最後我直接把:
sink.enable-2pc = "false"
exactly_once = false # 或者改成至少不是嚴格精確一次。
關掉之後,最直觀的變化是:
1. 寫入變得“細水長流”,不再一分鐘憋一大口;
2. 內存峯值低了一截,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 數字。