Bài trước cho thấy COPY nhanh nhất cho khối rất lớn, nhưng nhiều ứng dụng thực tế dùng cấp độ ở giữa: multi-row INSERT — gộp nhiều dòng vào một câu INSERT ... VALUES (...),(...),.... Nó tiện (không cần file), hợp với ORM/driver, và hỗ trợ ON CONFLICT. Câu hỏi thực dụng: gộp bao nhiêu dòng mỗi lô là tối ưu? Bài này đo thật đường cong và tìm điểm ngọt.

Multi-row INSERT giảm chi phí lặp lại

Thay vì N câu INSERT riêng lẻ, gộp nhiều dòng vào một câu:

INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c'), ...;

Một câu lệnh thay vì N câu nghĩa là: một lần phân tích cú pháp + lập kế hoạch (thay vì N lần), một round-trip client↔server (thay vì N round-trip), và một giao dịch nếu autocommit (một fsync thay vì N). Khi client và server cách nhau qua mạng, round-trip là chi phí thống trị — mỗi câu lệnh chờ một vòng đi-về. Gộp 500 dòng vào một câu giảm số round-trip đi 500 lần.

Ảnh chụp đoạn mã SQL nền tối minh hoạ multi-row INSERT và batch gộp nhiều dòng vào một câu lệnh, multi-row INSERT nhiều dòng trong một câu VALUES INSERT INTO t VALUES 1 a 2 b 3 c một câu lệnh thay vì N câu một lần parse plan một round-trip một giao dịch nếu autocommit bỏ chi phí lặp lại của INSERT từng dòng, vì sao batching thắng INSERT từng dòng qua mạng mỗi dòng một round-trip client server cộng nếu autocommit một fsync với độ trễ mạng đây là chi phí thống trị gộp 500 dòng mỗi câu giảm round-trip đi 500 lần nhanh gấp hàng chục lần, kích thước lô tối ưu lợi ích giảm dần từ 1 lên khoảng 50-100 dòng mỗi lô cải thiện khổng lồ bỏ round-trip mỗi dòng từ khoảng 500 trở lên lợi ích rất nhỏ mà câu lệnh dài hơn tốn RAM parse và nếu 1 dòng lỗi thì cả lô rollback điểm ngọt khoảng 500-1000 dòng mỗi lô, khi nào multi-row INSERT khi nào COPY multi-row INSERT tiện không cần file hỗ trợ ON CONFLICT upsert hợp với ORM driver đủ nhanh cho hầu hết tải nạp vừa COPY nhanh nhất cho khối rất lớn ETL khôi phục vẫn khoảng 10 lần nhanh hơn cả multi-row INSERT lớn nhưng không có logic per-row

Hình 1: Multi-row INSERT gộp nhiều dòng vào một câu — một lần parse/plan, một round-trip, một giao dịch. Batching bỏ round-trip mỗi dòng. Điểm ngọt ~500-1000 dòng/lô; COPY vẫn nhanh hơn cho khối rất lớn.

Đo thật: đường cong lợi ích giảm dần

Nạp 100.000 dòng bằng multi-row INSERT với các kích thước lô khác nhau:

Ảnh chụp bảng kết quả đo thật nền tối kích thước lô lợi ích lớn rồi giảm dần nạp 100.000 dòng bằng multi-row INSERT đổi số dòng mỗi câu PostgreSQL 16, thời gian nạp 100k dòng theo kích thước lô lô 1 từng dòng 8,49 s lô 5 1,90 s lô 50 0,32 s nhanh hơn lô 1 khoảng 26 lần lô 500 0,16 s lô 5000 0,14 s chỉ hơn lô 500 khoảng 10 phần trăm, bảng kích thước lô thời gian so lô 1 so lô trước 1 từng dòng 8,49 s 5 1,90 s 4,5x 4,5x 50 0,32 s 26x 5,9x 500 0,16 s 54x 2,0x 5000 0,14 s 59x 1,1x giảm dần, bài học cú nhảy lớn nhất là 1 sang 50 bỏ round-trip mỗi dòng khoảng 26 lần sau khoảng 500 dòng mỗi lô lợi ích rất nhỏ 0,16 sang 0,14s mà rủi ro tăng điểm ngọt khoảng 500-1000 dòng mỗi lô COPY vẫn nhanh hơn cho khối rất lớn

Hình 2: Nạp 100k dòng — lô 1 dòng (từng dòng) mất 8,49 s; lô 5 → 1,90 s; lô 50 → 0,32 s (nhanh ~26 lần); lô 500 → 0,16 s; lô 5000 → 0,14 s (chỉ hơn lô 500 ~10%). Lợi ích giảm dần rõ sau lô ~500.

  • Lô 1 (từng dòng): 8,49 s.
  • Lô 5: 1,90 s (nhanh 4,5 lần).
  • Lô 50: 0,32 s — nhanh hơn lô 1 tới ~26 lần.
  • Lô 500: 0,16 s (54 lần).
  • Lô 5000: 0,14 s (59 lần) — chỉ hơn lô 500 khoảng 10%.

Đường cong rõ ràng: cú nhảy lớn nhất là từ 1 lên ~50 (bỏ round-trip mỗi dòng cho ~26 lần), rồi lợi ích giảm dần nhanh sau lô ~500. Từ 500 lên 5000 chỉ nhanh thêm 10%, trong khi câu lệnh dài gấp 10 lần. Điểm ngọt thực tế là ~500-1000 dòng mỗi lô.

Đánh đổi cần cân nhắc

Lô quá lớn tăng rủi ro và chi phí bộ nhớ. Câu INSERT với 10.000 dòng VALUES là một chuỗi SQL rất dài — tốn bộ nhớ để phân tích, và nếu một dòng vi phạm ràng buộc thì cả lô rollback (mất công làm lại 10.000 dòng). Lô vừa phải (500-1000) cân bằng tốc độ với rủi ro và bộ nhớ. Ngoài ra có giới hạn cứng: một câu lệnh không quá 65535 tham số bound (với prepared statement) — lô quá lớn có thể vượt.

Đừng nhầm "batch" với gói-trong-giao-dịch. Multi-row INSERT gộp ở tầng câu lệnh. Một cải thiện khác là gói nhiều câu (kể cả nhiều multi-row INSERT) trong một BEGIN ... COMMIT để giảm số fsync. Hai kỹ thuật này bù nhau: dùng multi-row INSERT ~500 dòng/câu, và gói vài câu trong một giao dịch, cho tải nạp lớn qua ORM.

COPY vẫn nhanh hơn cho khối thật lớn. Như bài trước đo, COPY nạp 200k dòng trong ~29 ms — nhanh hơn cả multi-row INSERT lô lớn. Nếu bạn nạp hàng triệu dòng (ETL, khôi phục) và không cần logic per-row, dùng COPY. Multi-row INSERT là lựa chọn khi cần tiện lợi (không file), cần ON CONFLICT, hoặc tải vừa phải qua driver ứng dụng.

Ba ý mang về

  1. Multi-row INSERT gộp nhiều dòng vào một câu, giảm round-trip và chi phí lặp lại: đo thật, nạp 100k dòng với lô 50 nhanh hơn lô 1 tới ~26 lần — cú nhảy lớn nhất đến từ việc có batching (bỏ round-trip mỗi dòng), không phải từ lô cực lớn.
  2. Lợi ích giảm dần sau lô ~500: đo thật, lô 500 (0,16 s) so với lô 5000 (0,14 s) chỉ nhanh thêm ~10% dù câu lệnh dài gấp 10 — điểm ngọt thực tế là ~500-1000 dòng mỗi lô, cân bằng tốc độ với rủi ro rollback cả lô và bộ nhớ parse.
  3. Chọn công cụ theo quy mô và nhu cầu: multi-row INSERT ~500-1000 dòng/lô (tiện, hỗ trợ ON CONFLICT, hợp ORM) cho tải vừa; gói thêm trong giao dịch để giảm fsync; COPY cho khối rất lớn không cần logic per-row.

Phần sau ta xét một tình huống nạp đặc biệt phổ biến — chèn hoặc cập nhật tùy tồn tại: Phần sau mổ xẻ UPSERT với INSERT ... ON CONFLICT — cách nó xử lý xung đợt khóa hiệu quả, so với mẫu SELECT-rồi-INSERT dễ dính đua tranh.