ClickHouse 寫入怎麼批次:queue 加 Go worker、async insert 與重試去重
往 ClickHouse 寫資料,我目前還是偏好前面放 queue,後面用自己寫的 Go worker 累積 batch 再送出,大小與送出時機都由自己決定,最好掌控。寫入端太多、每筆太小,client 沒辦法自己累積時,可以考慮 async insert。
重試不要指望 exactly-once,要靠 ClickHouse 的 insert 去重,而且去重紀錄有保留範圍,也有其他前提。Replicated 表更新資料則要避開 now() 這類 non-deterministic 函式,開發環境只用一般 MergeTree,測不出這項限制。
parts、背景 merge、稀疏索引和 S3 冷熱分層在〈ClickHouse 入門:MergeTree 與用 S3 做冷熱儲存〉講過,這篇只談寫入和更新。下面的 SQL 範例在 ClickHouse 26.9.2.1 上跑過,用的是 chDB 4.4.0 內嵌的引擎。這次沒有 server,async insert 與副本相關的行為依據官方文件和原始碼,沒有實跑;Go 範例則用 Go 1.26.9 與 clickhouse-go v2.49.0 編譯並跑過單元測試。
一、逐筆寫入會留下一堆 part
同步寫入時,MergeTree 每一次 INSERT 至少產生一個 part,逐筆同步寫入就是一筆一個 part(async insert 會先在 server 端合併請求,第四節再談)。建兩張一樣的表,先停掉背景 merge,方便看原始的 part 數,一張逐筆送 200 次,另一張一次送 200 筆:
CREATE TABLE bets (
bet_id UInt64, player_id UInt32, amount Decimal(18,2), created_at DateTime
) ENGINE = MergeTree ORDER BY (player_id, created_at)
SETTINGS old_parts_lifetime = 0;
CREATE TABLE bets_batch AS bets;
SYSTEM STOP MERGES bets;
-- 用迴圈送 200 次,每次一筆
INSERT INTO bets VALUES (1, 1, 10.00, now());
-- 一次送 200 筆
INSERT INTO bets_batch
SELECT number + 1, (number + 1) % 7, 10.00, now() FROM numbers(200);
SELECT table, count() AS active_parts, sum(rows) AS rows
FROM system.parts
WHERE database = currentDatabase() AND active AND table IN ('bets', 'bets_batch')
GROUP BY table ORDER BY table;
┌─table──────┬─active_parts─┬─rows─┐
1. │ bets │ 200 │ 200 │
2. │ bets_batch │ 1 │ 200 │
└────────────┴──────────────┴──────┘
同樣 200 筆,一邊 200 個 part,一邊 1 個。正式環境的 merge 不會停,但它要讀寫磁碟,寫入速度超過 merge 速度,part 就會越積越多。同一個 partition 裡的 active part 達到 parts_to_delay_insert(預設 1000)時,ClickHouse 會刻意放慢 INSERT;達到 parts_to_throw_insert(預設 3000)就直接失敗,錯誤訊息是 Too many parts (N). Merges are processing significantly slower than inserts,也就是常說的 TOO_MANY_PARTS。這兩個門檻只在該 partition 的平均 part 大小不超過 max_avg_part_size_for_too_many_parts(預設 1 GiB)時生效。
partition key 也會影響 part 數。一個 batch 跨了好幾個 partition,每個 partition 就各產生一個 part,partition key 切得太細,batch 再大也還是會分開產生 part。
之前做過的注單 pipeline 就是這樣設計:明細先進 queue,由 Go worker 取出來累積 batch,再 bulk insert 進 ClickHouse。資料依時間分成熱、暖、冷三層,冷層放 S3。
二、queue 加 Go worker:自己控制 batch 大小與送出時機
batch 大小、送出時機都在 worker 裡控制,寫入成功後才 commit offset。要查 batch 或重試出了什麼問題,直接看 worker 的程式與紀錄就好。
worker 湊滿筆數或等到時間就送出,失敗時用同一批資料、同一個 token 重送,成功後才 commit。下面節錄寫入與重試的部分,完整程式含讀 queue 的迴圈,以及從 stdin 讀 JSON lines 的示範 queue,已通過 go vet 與編譯。這個範例寫入的 bets 表有 bet_id、player_id、amount_cents、created_at 欄位,金額以分為單位存成整數,和第一節示範 part 數的表不同:
// flush retries the same rows with the same token, then commits the queue offset.
func (w *Worker) flush(ctx context.Context, batch []Message) error {
if len(batch) == 0 {
return nil
}
if w.MaxRetries < 1 {
return fmt.Errorf("MaxRetries must be at least 1, got %d", w.MaxRetries)
}
first, last := batch[0].Offset, batch[len(batch)-1].Offset
token := fmt.Sprintf("bets-%d-%d", first, last)
rows := make([]Bet, len(batch))
for i, m := range batch {
rows[i] = m.Bet
}
var err error
for attempt := 1; attempt <= w.MaxRetries; attempt++ {
if err = w.Insert(ctx, token, rows); err == nil {
break
}
log.Printf("insert %s attempt %d: %v", token, attempt, err)
if attempt == w.MaxRetries {
break
}
select {
case <-time.After(w.Backoff * time.Duration(attempt)):
case <-ctx.Done():
return ctx.Err()
}
}
if err != nil {
return fmt.Errorf("insert %s gave up: %w", token, err)
}
if err := w.Queue.Commit(ctx, last); err != nil {
return fmt.Errorf("commit offset %d: %w", last, err)
}
return nil
}
// ClickHouseInsert writes one batch through the native protocol.
func ClickHouseInsert(conn driver.Conn) InsertFunc {
return func(ctx context.Context, token string, rows []Bet) error {
ctx = clickhouse.Context(ctx, clickhouse.WithSettings(clickhouse.Settings{
"async_insert": 0, // the worker already batches
"insert_deduplication_token": token,
}))
batch, err := conn.PrepareBatch(ctx, "INSERT INTO bets (bet_id, player_id, amount_cents, created_at)")
if err != nil {
return fmt.Errorf("prepare batch: %w", err)
}
defer batch.Close()
for _, r := range rows {
if err := batch.Append(r.BetID, r.PlayerID, r.AmountCents, r.CreatedAt); err != nil {
return fmt.Errorf("append bet %d: %w", r.BetID, err)
}
}
if err := batch.Send(); err != nil {
return fmt.Errorf("send batch: %w", err)
}
return nil
}
}
這次沒有 ClickHouse server 可以連,寫入的部分抽成 InsertFunc,用假的 insert 跑單元測試。第一個測試是 5 筆資料、每批 3 筆,前兩次寫入失敗;第二個測試把 MaxRetries 設成 0,確認不會略過寫入就直接 commit(log 時間是這次執行的紀錄)。
=== RUN TestRetryReusesTokenAndCommitsAfterSuccess
2026/10/15 22:12:17 insert bets-1-3 attempt 1: connection reset
2026/10/15 22:12:17 insert bets-1-3 attempt 2: connection reset
worker_test.go:52: insert calls: [bets-1-3 bets-1-3 bets-1-3 bets-4-5]
worker_test.go:53: committed offsets: [3 5]
--- PASS: TestRetryReusesTokenAndCommitsAfterSuccess (0.00s)
=== RUN TestZeroRetriesNeverCommits
worker_test.go:77: Run: EOF
MaxRetries must be at least 1, got 0
worker_test.go:78: inserts: 0, committed offsets: []
--- PASS: TestZeroRetriesNeverCommits (0.00s)
三次寫入用的都是 bets-1-3,offset 3 等到寫入成功之後才 commit;MaxRetries 是 0 時沒有寫入也沒有 commit。少了這個檢查,重試迴圈一次都不會執行,err 維持 nil,程式會直接 commit。另外把 server 位址指到沒有服務的 port 實際跑一次,五次都是 connection refused,最後 worker 停下來,沒有 commit 任何 offset。
async_insert: 0 用來關閉 server 端的 buffer。ClickHouse 從 26.2 起 async_insert 預設開啟,但這個範例的 worker 已經累積好 batch。這樣一個 Send() 就是一次 INSERT,也是一個去重單位。clickhouse-go 的 Batch.Flush() 在 native protocol 會先送出目前的資料,一批因此拆成多個 block。重試時每個 block 的切法都得和上次相同,去重才會成立,所以這裡只用 Send()。
三、clickhouse-bulk 這類 proxy 和它的 dumps 目錄
更早的一個專案沒有 queue,就在 ClickHouse 前面放 bulk 寫入 proxy。clickhouse-bulk 就是這類服務,收下很多小 INSERT,湊到 flush_count(預設 10000,TabSeparated 按行計算,其他格式每個請求算一筆)或 flush_interval(預設 1000 ms)就合成一次大的 INSERT。ClickHouse 回錯時,未送出的資料會寫到 dump_dir(預設 dumps),每 dump_check_interval(預設 300 秒)再試著重送。
當時我在意的是 dumps 目錄放在哪裡。和 ClickHouse 共用同一顆磁碟,ClickHouse 因為磁碟滿了寫不進去,dumps 多半也寫不進去。proxy 跑在容器裡又沒替 dumps 掛 volume,檔案就只在容器的可寫層,刪除重建後,等著重送的資料也沒了,所以當時考慮把儲存獨立出來。
README 沒有交代 process 停掉時,記憶體裡尚未 flush 的資料會怎樣,這部分得當成可能遺失。
有 queue 就不需要這種目錄。worker 讀取之前,資料已經寫進 queue;ClickHouse 無法寫入時,worker 不 commit。只要 queue 有持久化資料、支援重讀,而且還沒超過保留期限,稍後就能重送。
四、async insert:給沒辦法自己累積 batch 的寫入端
async insert 由 server 累積 batch,client 照樣送很多小 INSERT,server 先放進記憶體 buffer,達到條件再一起寫成 part。26.9 的預設值如下:
SELECT name, value FROM system.settings
WHERE name IN ('async_insert', 'wait_for_async_insert', 'async_insert_max_data_size',
'async_insert_busy_timeout_max_ms', 'async_insert_max_query_number',
'async_insert_use_adaptive_busy_timeout', 'deduplicate_insert')
ORDER BY name;
┌─name───────────────────────────────────┬─value────┐
1. │ async_insert │ 1 │
2. │ async_insert_busy_timeout_max_ms │ 200 │
3. │ async_insert_max_data_size │ 10485760 │
4. │ async_insert_max_query_number │ 450 │
5. │ async_insert_use_adaptive_busy_timeout │ 1 │
6. │ deduplicate_insert │ enable │
7. │ wait_for_async_insert │ 1 │
└────────────────────────────────────────┴──────────┘
buffer 累積到 10 MiB、累積 450 個 query,或第一筆進來後過了 200 ms(自 24.2 起會依流量在 50 ms 和這個上限之間自動調整),三個條件中任一先到就 flush。ClickHouse Cloud 的預設不同,資料量上限是 100 MiB,timeout 是 1 秒。
buffer 依 INSERT 的欄位結構與 settings 分開放,叢集裡每個節點各自一份,flush 之前資料查不到。
wait_for_async_insert 決定什麼時候回應 client:
1(預設):資料 flush 進表之後才回應,flush 失敗會回傳錯誤,官方建議正式環境用這個。0:放進 buffer 就回應,資料不保證寫入,client 拿不到錯誤,也沒有 dead-letter queue。system.asynchronous_inserts只列出尚未 flush、還在佇列裡的請求;結果與錯誤訊息要查system.asynchronous_insert_log,而且 server 設定裡的asynchronous_insert_log區段要先啟用才會記錄。官方文件直接說這種模式「very risky」,它會繞過 backpressure,可能把 server 壓垮。
官方文件建議用在 client 沒辦法自己累積 batch 的情況,例如大量 agent 持續送少量資料的 observability 場景。自己的 worker 已經能控制 batch 大小,就沒有這個問題。幾百個無法改的服務各自送單筆時,用 async insert 就省得每個服務都加批次邏輯。它也比 Buffer table 可靠,Buffer table 當機會遺失 buffer 裡的資料。
async insert 不會讓 part 問題消失:每次 flush 仍然在每個 partition 各產生至少一個 part,partition key 太細照樣會遇到 TOO_MANY_PARTS。
五、重試要靠去重,去重有前提
逾時之後不知道 ClickHouse 到底寫了沒有,只能重送,安不安全就看去重。付款 API 也是這個處境,〈同一筆付款送了兩次〉談過為什麼逾時只能重送。ClickHouse 的 insert 去重以 block 為單位,記住最近寫入的 block hash,同一個 block 再來就跳過。
ReplicatedMergeTree 預設記最近 10000 個 block,而且只保留最新一筆紀錄往前 3600 秒內的紀錄(replicated_deduplication_window 與 replicated_deduplication_window_seconds);一般 MergeTree 的 non_replicated_deduplication_window 預設是 0,等於完全不去重。分別建一張保留範圍 0 和保留 1000 個 block 的表,同一批送兩次:
CREATE TABLE bets_nodedup (bet_id UInt64, player_id UInt32, amount Decimal(18,2))
ENGINE = MergeTree ORDER BY bet_id
SETTINGS non_replicated_deduplication_window = 0;
CREATE TABLE bets_dedup (bet_id UInt64, player_id UInt32, amount Decimal(18,2))
ENGINE = MergeTree ORDER BY bet_id
SETTINGS non_replicated_deduplication_window = 1000;
-- 兩張表各送兩次同一批
INSERT INTO bets_nodedup VALUES (1, 7, 10.00), (2, 7, 25.00), (3, 9, 5.00);
INSERT INTO bets_nodedup VALUES (1, 7, 10.00), (2, 7, 25.00), (3, 9, 5.00);
INSERT INTO bets_dedup VALUES (1, 7, 10.00), (2, 7, 25.00), (3, 9, 5.00);
INSERT INTO bets_dedup VALUES (1, 7, 10.00), (2, 7, 25.00), (3, 9, 5.00);
SELECT 'bets_nodedup' AS t, count() FROM bets_nodedup
UNION ALL SELECT 'bets_dedup', count() FROM bets_dedup;
┌─t────────────┬─count()─┐
1. │ bets_nodedup │ 6 │
2. │ bets_dedup │ 3 │
└──────────────┴─────────┘
保留範圍 0 的表多了一份。去重比對的是資料本身,官方文件寫的是「same rows in the same order」,重送時順序變了就認不出來。接著對 bets_dedup 再送一次同樣三筆,只是順序打亂;另外建一張同樣設定的 bets_token,兩次 INSERT 都帶同一個 token,第二次一樣打亂順序:
INSERT INTO bets_dedup VALUES (2, 7, 25.00), (1, 7, 10.00), (3, 9, 5.00);
CREATE TABLE bets_token AS bets_dedup;
INSERT INTO bets_token SETTINGS insert_deduplication_token = 'batch-000042'
VALUES (1, 7, 10.00), (2, 7, 25.00);
INSERT INTO bets_token SETTINGS insert_deduplication_token = 'batch-000042'
VALUES (2, 7, 25.00), (1, 7, 10.00);
SELECT 'bets_token' AS t, count() FROM bets_token
UNION ALL SELECT 'bets_dedup (reordered retry)', count() FROM bets_dedup;
┌─t────────────────────────────┬─count()─┐
1. │ bets_token │ 2 │
2. │ bets_dedup (reordered retry) │ 6 │
└──────────────────────────────┴─────────┘
順序一變,bets_dedup 就把同樣的三筆又收了一次;帶了 token,ClickHouse 不看資料的 hash,只認 token,順序不同也擋得住。worker 用 offset 範圍當 token,是因為從 queue 重讀時,資料的序列化方式不一定和上次一樣,offset 範圍則不會變。這要求同一批訊息來自同一個 queue partition,並依 offset 遞增排列,第一筆和最後一筆的 offset 才代表整批範圍。
範例只有一個來源。多個 queue partition 寫進同一張表時,token 得帶上 partition 名稱,不然不同 partition 的相同 offset 範圍會撞在一起,連不同資料都會被當成重複略過。
這仍然算不上 exactly-once,下面幾種情況就擋不住:
- worker 停了一個多小時才重試,這段時間其他寫入照常進來(3600 秒以最新一筆紀錄的時間為準),或寫入量大到 10000 個 block 很快就擠掉舊紀錄,重送時已超過保留範圍,沒有去重紀錄可查。
- worker 重啟後從上次 commit 的 offset 重讀,這次若用時間切 batch,範圍可能和上次不同,token 也跟著變了。batch 邊界必須能重現,例如固定以 offset 區間切 batch,token 才有用。
- 用了
Flush(),或改走 HTTP 送 CSV、JSONEachRow 這類格式,一批可能拆成多個 block。後者由 server 解析時依max_insert_block_size(26.9 預設 1048449 行)等設定切 block,重試時切法一變,去重就對不上。
表本身也要能處理重複資料。例如注單用 ReplacingMergeTree,以 bet_id 當 sorting key,就算多寫一份,merge 後或用 FINAL 查詢時仍只剩一筆。queue 多半只保證 at-least-once,insert 去重有保留範圍,表的去重又得等 merge 或 FINAL,這三層各有前提。還沒去除的重複資料,查詢端仍要處理。
26.2 之後,deduplicate_insert 預設改為 enable,async insert 和同步 INSERT 共用同一份去重紀錄;在 buffer 裡,async insert 仍以 client 的每個 query 為單位去重。materialized view 的目標表也會一起去重(deduplicate_blocks_in_dependent_materialized_views 預設開啟)。不過,async insert 經過的 view,每個輸入 block 只能產生一個輸出 block,第二個就會直接丟 NOT_IMPLEMENTED。26.2 之前的版本,async insert 的去重預設是關的。
六、Replicated 表上更新資料,避開 now()
在多節點的 ClickHouse 上用 ALTER TABLE ... UPDATE 更新資料時,如果 SET 裡用了 now(),開發環境可能一切正常,上到多副本的正式環境卻會失敗。
對 Replicated 表下 ALTER ... UPDATE,指令只會先寫進 ZooKeeper,各副本再改寫自己的 part。now() 在每個副本執行的時間不同,結果就不一致,所以 ClickHouse 在 MutationsInterpreter.cpp 直接擋掉這種寫法,只要表的引擎名稱以 Replicated 開頭就檢查,錯誤訊息是:
The source storage is replicated so ALTER UPDATE/ALTER DELETE statements must use only deterministic functions. Function '{}' is non-deterministic
這項檢查看表引擎和設定,與節點數無關,非 Replicated 的表直接放行。在 chDB 的一般 MergeTree 上(另建一張有 status、settled_at 欄位的 bets 表),ALTER TABLE bets UPDATE status = 'settled', settled_at = now() WHERE bet_id = 1 跑得過;依原始碼的條件,Replicated 表就算只有一個副本也會被擋。chDB 沒有 Keeper,建不了 Replicated 表,這部分沒有實跑。開發環境只用一般 MergeTree,就永遠測不到這個錯誤。
ClickHouse 提供兩個設定繞過限制。mutations_execute_nondeterministic_on_initiator(預設關)會在發起的節點先算出 now(),換成常數再發給各副本;allow_nondeterministic_mutations(預設關)則直接允許,文件說各節點資料一致與否由使用者自己負責。前者比較安全,但 ALTER ... UPDATE 終究是 mutation,受影響的 data part 都得改寫,成本較高,文件也說不適合頻繁使用。
需要寫入時間這類值時,可以改用 INSERT INTO ... SELECT ... 寫入新版本的資料,不去 UPDATE。INSERT 的資料在執行 query 的那台算好,再複製到其他副本,每台不會各算一次。搭配 ReplacingMergeTree 的版本欄位,寫法如下:
CREATE TABLE bet_status (
bet_id UInt64, status LowCardinality(String),
settled_at Nullable(DateTime64(3)), version UInt64
) ENGINE = ReplacingMergeTree(version) ORDER BY bet_id;
INSERT INTO bet_status VALUES (1, 'open', NULL, 1), (2, 'open', NULL, 1), (3, 'open', NULL, 1);
INSERT INTO bet_status
SELECT bet_id, 'settled', toDateTime64('2026-10-15 21:30:00.000', 3), version + 1
FROM bet_status FINAL
WHERE bet_id IN (1, 2) AND status = 'open';
SELECT * FROM bet_status FINAL ORDER BY bet_id;
SELECT count() AS physical_rows FROM bet_status;
┌─bet_id─┬─status──┬──────────────settled_at─┬─version─┐
1. │ 1 │ settled │ 2026-10-15 21:30:00.000 │ 2 │
2. │ 2 │ settled │ 2026-10-15 21:30:00.000 │ 2 │
3. │ 3 │ open │ ᴺᵁᴸᴸ │ 1 │
└────────┴─────────┴─────────────────────────┴─────────┘
┌─physical_rows─┐
1. │ 5 │
└───────────────┘
這次執行時實體上有 5 筆,這張表沒有停掉背景 merge,重跑時筆數可能已經變少。FINAL 查到每個 bet_id 最新的版本,舊版本由背景 merge 清掉,範例裡的時間則是應用程式算好傳入的常數,每個副本拿到的值相同。
這個寫法還沒處理並行更新。兩個流程同時讀到 version 1,各自寫回 version 2,版本號就撞了,所以要由單一寫入者產生版本號,或用應用程式產生的單調遞增值。
25.7 之後 ClickHouse 有了標準 SQL 的 UPDATE(lightweight update,用 patch part 記錄改動),文件目前標為 beta。表要先開 enable_block_number_column 與 enable_block_offset_column,在 26.9 沒開就會得到 Lightweight updates are not supported。它設計給改動量在全表 10% 以內的情況,頻繁的小更新一樣會造成 part 太多。遇到 non-deterministic 函式時怎麼處理,文件沒有寫,還需要驗證,這裡不下結論。
開發環境的表引擎與 mutation 設定應該跟正式環境一致。正式環境用 Replicated 表加 Keeper,開發環境哪怕只有一個 Keeper 加一個副本,也得用 Replicated 表,才有機會在上線前發現這類限制。
七、查詢端:大表不要直接給玩家查
注單 pipeline 若直接查詢明細大表,可能拖垮整個系統,所以當時規劃把聚合結果同步回 PostgreSQL。玩家查自己的紀錄走 PostgreSQL,讓查詢體驗穩定,ClickHouse 留給報表和分析。
報表要求當日數字即時,我原本想在 PostgreSQL 用 FDW 連 ClickHouse,可是那個 PostgreSQL 是雲端託管服務,裝不了 FDW 擴充,最後改成後台直接查 ClickHouse 的 view 拿當日資料。也評估過反方向,由 ClickHouse 用 PostgreSQL table engine 把結果寫進 PostgreSQL,這個 engine 支援對遠端表 SELECT 與 INSERT。
同一個報表的每日彙總用了 AggregatingMergeTree 加 materialized view,當時遇到新資料「沒有合併」,同一天同一個玩家查出好幾列,就用 OPTIMIZE TABLE 手動觸發合併。後來在 chDB 上重現才弄清楚,這是 AggregatingMergeTree 的正常行為:每次 INSERT 經過 view 都寫成新的 part,同一個排序鍵的資料要等背景合併才會併成一列,合併時間不保證。下面的範例先建一張欄位和第二節相同的 bets_raw 明細表,停掉合併後送兩次 INSERT,再用兩種方式查:
CREATE TABLE bets_raw (
bet_id UInt64, player_id UInt32, amount_cents Int64, created_at DateTime
) ENGINE = MergeTree ORDER BY (player_id, created_at);
CREATE TABLE daily_turnover (
day Date, player_id UInt32,
bets AggregateFunction(count), turnover AggregateFunction(sum, Int64)
) ENGINE = AggregatingMergeTree ORDER BY (day, player_id);
CREATE MATERIALIZED VIEW daily_turnover_mv TO daily_turnover AS
SELECT toDate(created_at) AS day, player_id,
countState() AS bets, sumState(amount_cents) AS turnover
FROM bets_raw GROUP BY day, player_id;
SYSTEM STOP MERGES daily_turnover;
INSERT INTO bets_raw VALUES (1, 7, 1000, '2026-10-15 10:00:00'), (2, 7, 2500, '2026-10-15 11:00:00');
INSERT INTO bets_raw VALUES (3, 7, 500, '2026-10-15 12:00:00');
SELECT day, player_id, finalizeAggregation(bets) AS bets, finalizeAggregation(turnover) AS turnover
FROM daily_turnover ORDER BY bets;
SELECT day, player_id, countMerge(bets) AS bets, sumMerge(turnover) AS turnover
FROM daily_turnover GROUP BY day, player_id;
┌────────day─┬─player_id─┬─bets─┬─turnover─┐
1. │ 2026-10-15 │ 7 │ 1 │ 500 │
2. │ 2026-10-15 │ 7 │ 2 │ 3500 │
└────────────┴───────────┴──────┴──────────┘
┌────────day─┬─player_id─┬─bets─┬─turnover─┐
1. │ 2026-10-15 │ 7 │ 3 │ 4000 │
└────────────┴───────────┴──────┴──────────┘
直接讀表會看到兩列部分聚合的結果;照官方文件的寫法,用和寫入時相同的聚合函式加上 -Merge 後綴(這裡是 countMerge、sumMerge),再搭配 GROUP BY,不管合併了沒都是正確的 3 筆、4000。OPTIMIZE TABLE ... FINAL 之後直接讀表也只剩一列,可是它會強制改寫 part,每次查詢前都跑一次並不實際,只適合當權宜之計,查詢端應該一律用 -Merge 加 GROUP BY。
當時研究的最佳化方向,除了批次寫入,還有 sorting key、partition key、sharding、讀寫分離和副本備份。sorting key 與 partition key 怎麼選,入門那篇已經寫過。
也不是每個專案都需要 ClickHouse,有些系統刻意不用它,資料放在 Aurora PostgreSQL 分層,冷資料匯出成 Parquet 放到 S3 就夠了。
補充筆記
- 同步寫入時每次 INSERT 至少產生一個 part,同一 partition 的 active part 達到 1000 會放慢 INSERT,達到 3000 會
TOO_MANY_PARTS;partition key 太細時,放大批次仍會在各 partition 產生 part。 - queue 加 Go worker 累積 batch,大小和等待時間由自己控制;寫入成功才 commit offset,重試用同一個 token,
MaxRetries要先檢查,否則可能沒寫入就 commit。改用 clickhouse-bulk 這類 proxy 時,存放失敗 INSERT 的 dumps 目錄要和 ClickHouse 分開,容器刪除重建後也要保留。 - 26.2 起
async_insert預設開啟,適合沒辦法自己累積 batch 的寫入端;wait_for_async_insert=0不保證寫入,官方稱為 very risky。 - 去重依 block 的資料與順序比對,一般 MergeTree 預設不去重,Replicated 記 10000 個 block、最新紀錄往前 3600 秒;
insert_deduplication_token能擋住順序不同的重送,但去重紀錄過期、batch 邊界改變時仍會重複,表本身要能容忍重複。 - AggregatingMergeTree 的資料要等背景合併才會併成一列,查詢用
-Merge組合子加GROUP BY,OPTIMIZE TABLE ... FINAL只是權宜。 - Replicated 表的
ALTER ... UPDATE預設不能用now()這類 non-deterministic 函式,與節點數無關,只用一般 MergeTree 的開發環境測不出來;改用INSERT INTO ... SELECT寫新版本,時間由應用程式傳入。
延伸閱讀
- ClickHouse 文件〈Asynchronous inserts〉:async insert 的 flush 條件、
wait_for_async_insert兩種模式的風險,以及什麼時候該用。 - ClickHouse 文件〈Deduplicating inserts on retries〉:Replicated 與一般 MergeTree 的去重差異、
insert_deduplication_token、26.2 起的預設變更與 materialized view 的限制。 - ClickHouse 文件〈async_insert settings〉與〈parts_to settings〉:
async_insert在 26.2 改為預設開啟的紀錄,以及parts_to_delay_insert、parts_to_throw_insert的原文說明。 - ClickHouse 文件〈AggregatingMergeTree〉與〈PostgreSQL table engine〉:查詢時用
-Merge後綴加GROUP BY的原文,以及對遠端 PostgreSQL 表SELECT、INSERT的支援。 - ClickHouse 文件〈Data Replication〉:INSERT 在一台寫入後複製到其他副本,以及 block 去重「same rows in the same order」的原文。
- ClickHouse 原始碼〈MutationsInterpreter.cpp〉:Replicated 表拒絕 non-deterministic 函式的檢查與錯誤訊息。
- ClickHouse 文件〈allow_nondeterministic_mutations〉與〈mutations_execute_nondeterministic_on_initiator〉:兩種繞過方式各自的語意與預設值。
- ClickHouse 文件〈The Lightweight UPDATE Statement〉與〈ClickHouse Release 25.7〉:patch part 的運作方式、beta 狀態與限制,以及推出的版本。
- clickhouse-go 與 pkg.go.dev 文件:
PrepareBatch、Flush與Send的差別,以及WithAsync()取代舊的AsyncInsert()。 - nikepan/clickhouse-bulk:
flush_count、flush_interval、dump_dir與dump_check_interval的設定說明。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 ClickHouse 26.9.2.1(chDB 4.4.0 內嵌引擎)、Go 1.26.9 與 clickhouse-go v2.49.0 實測。