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 全量”量身定製的災難套餐:

  1. JSON 字段巨大: MySQL 裏是壓得比較緊的二進制,進到 JVM 裏變成一個個 String / Map 對象,膨脹係數輕鬆 3~5 倍。
  2. doris.batch.size = 50000: 一次攢 5 萬行日誌再發,5000 行都動輒上百 MB,5 萬行是什麼級別不用算。
  3. execution.parallelism = 10 + 多個 snapshot.*.parallelism: 實際上是多路併發各自攢批次,內存佔用是成倍放大的。
  4. 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

一邊讀一邊寫,應該就是“邊走邊丟”,不會攢太多在內存。

實際上至少有三層“水壩”:

  1. Source 側 – Debezium 快照分片 snapshot.split.size:一個 split 裏要讀多少行。 snapshot.fetch.size:一次從數據庫拉多少行。 snapshot.parallelism:多少個 split 同時讀。
  2. 中間隊列 – Source → Sink 之間的緩衝 execution.parallelism × 各種 channel 的 queue。
  3. 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 日誌

  1. 看每批的 NumberTotalRows / LoadBytes / LoadTimeMs;
  2. 能直觀感受到“單批是不是過大”“Doris 是不是已經扛不動了”。

top + RES / wa

  1. RES 穩定在某個區間而不是一直漲,是一個健康信號;
  2. wa 高説明 IO 被打滿,繼續加併發也沒用。

SeaTunnel 自己的 HealthMonitor 日誌

  1. heap.memory.used/max 能看出堆有沒有接近極限;
  2. minor.gc.count / major.gc.count 大概能猜到 GC 壓力。

一些教訓/小結

這次折騰下來,反思了幾件事:

“配置複用”這件事很危險

amzn_order 和 amzn_api_logs 唯一的區別是多了兩個 JSON 字段,量級卻完全不是一個量級。我直接把訂單表的 CDC 配置套過來,是典型的只看行數,不看字節數。

流式也需要認真設計“水壩”

  1. Source:snapshot.split.size / fetch.size / 各種 parallelism;
  2. Sink:batch.size / buffer / 2PC;

中間:Checkpoint 週期、exactly_once 策略。 任何一層配大了,在大 JSON 場景下都會直接把 JVM 送走。

併發不是越大越好,顆粒度才是關鍵

真正要調的是:

  1. 併發 × 每份任務的大小;
  2. 而不是僅僅盯着 parallelism 數字。