Merge 1 triệu bản ghi vào bảng 1 tỷ rows
Chiến lược upsert quy mô lớn: staging table pattern, batch merge, index management, lock contention — production trade-offs khi merge file vào billion-row table.
Merge 1 triệu bản ghi vào bảng 1 tỷ rows
Câu hỏi
Bạn có 1 file CSV chứa 1 triệu bản ghi. Bảng đích có 1 tỷ bản ghi. Yêu cầu: bản ghi nào đã tồn tại thì UPDATE, bản ghi nào chưa có thì INSERT. Bạn sẽ làm như thế nào?
Dành cho level
Interviewer expect bạn biết không nên làm row-by-row và biết đến INSERT ... ON CONFLICT DO UPDATE hoặc tương đương.
Điểm cộng: đề xuất dùng staging table thay vì upsert trực tiếp, giải thích tại sao.
Cốt lõi cần nhớ
Staging table là chìa khóa — không upsert trực tiếp từ file vào bảng 1 tỷ rows. Load file vào staging table trước (COPY nhanh nhất, không trigger, không index check), sau đó merge từ staging vào main trong batch nhỏ. Tách biệt giai đoạn load và giai đoạn merge giúp isolate lỗi, retry an toàn, và không lock main table trong khi đọc file.
Batch là bắt buộc — một transaction merge 1 triệu rows sẽ giữ lock quá lâu. Chia merge thành batch 5.000–20.000 rows/transaction, commit sau mỗi batch. Giảm lock duration, tránh lock escalation, và cho phép production traffic tiếp tục giữa các batch.
Index là kẻ thù của tốc độ insert, nhưng là bạn của tốc độ lookup. Trong quá trình merge, index trên main table phải được update cho mỗi row → overhead lớn. Nếu merge trong maintenance window, có thể drop index → merge → rebuild; nếu online thì phải giữ index và chấp nhận tốc độ thấp hơn để không ảnh hưởng query đang chạy.
Câu trả lời mẫu
"Đây là bài toán upsert quy mô lớn — điều đầu tiên tôi tránh là loop từng row hoặc upsert trực tiếp từ app lên bảng 1 tỷ rows, vì cả hai đều cực kỳ chậm và block production traffic. Cách tôi tiếp cận là dùng staging table pattern: load toàn bộ 1 triệu bản ghi từ file vào một bảng tạm bằng
COPYcommand — không index, không trigger, không constraint, tốc độ tối đa. Sau đó tôi merge từ staging vào main table theo batch 10.000 rows một lần, dùngINSERT ... ON CONFLICT DO UPDATEtrên PostgreSQL. Mỗi batch là một transaction riêng, commit xong rồi mới batch tiếp — vừa giảm lock duration, vừa có checkpoint để resume nếu job fail. Tôi cũng thêm delay nhỏ giữa các batch và monitorpg_stat_activityđể không làm bão hòa I/O của production. Nếu đây là maintenance window thì tôi sẽ drop secondary indexes trước khi merge và rebuild sau — tốc độ tăng gấp 3–5 lần. Nếu cần online hoàn toàn thì giữ index nhưng throttle batch để response time không bị ảnh hưởng."
Phân tích chi tiết
Tổng quan pipeline
Bước 1: Load file vào Staging Table
Tại sao COPY thay vì INSERT từ app?
| Phương pháp | Tốc độ | Ghi chú |
|---|---|---|
| Row-by-row INSERT | ~500 rows/s | N round trips mạng |
| Batch INSERT (1000/batch) | ~50.000 rows/s | Ít round trip hơn |
COPY command | ~200.000–500.000 rows/s | Stream trực tiếp, bypass parser |
Nếu app Java phải đọc file trước, dùng CopyManager của JDBC:
Bước 2: Validate staging data (tùy yêu cầu)
Bước 3: Batch Merge từ Staging → Main Table
Sau khi staging table đã sẵn sàng, bước tiếp theo là merge vào main table. Nhưng merge 1 triệu rows trong 1 transaction sẽ giữ lock quá lâu — production queries bị block, và nếu fail thì rollback toàn bộ. Vì vậy phải chia thành batch nhỏ.
Tại sao batch 5.000–20.000 rows? Quá nhỏ (100 rows) → overhead commit/transaction quá nhiều, tốc độ chậm. Quá lớn (500K rows) → lock duration dài, ảnh hưởng production. 10K là sweet spot phổ biến — mỗi batch merge trong 1–3 giây, lock vừa đủ ngắn để production queries xen vào giữa các batch. Con số cụ thể tuỳ hardware — bắt đầu 10K rồi adjust dựa trên lock wait time thực tế.
SQL Upsert pattern (PostgreSQL)
Spring Boot batch merge job
Tại sao
ORDER BY order_idtrong batch? Nếu 2 batch chạy song song mà không có thứ tự nhất quán, chúng có thể lock cùng row theo thứ tự ngược nhau → deadlock. ORDER BY đảm bảo tất cả transaction lock rows theo cùng một thứ tự.
Bước 4: Index Strategy
Online merge (production traffic vẫn chạy)
Giữ nguyên tất cả index. Tốc độ chậm hơn nhưng không ảnh hưởng query.
Offline merge (maintenance window)
Giữ lại index nào? PRIMARY KEY (order_id) phải giữ vì ON CONFLICT cần nó. Secondary indexes có thể drop nếu không có read query nào dùng chúng trong lúc merge.
So sánh chiến lược theo scenario
| Scenario | Chiến lược | Trade-off |
|---|---|---|
| Maintenance window có | Drop indexes → merge → rebuild | Nhanh nhất, không ảnh hưởng production |
| Production online, ít traffic | Batch 10K + throttle 50ms | Chậm hơn, an toàn |
| Production online, nhiều traffic | Batch 5K + throttle 200ms + Read Replica check | Rất chậm nhưng zero impact |
| Bảng được partition | Partition swap (xem bên dưới) | Phức tạp nhưng atomic |
| Cần re-run idempotent | Staging với merge_status column | Có thể resume từ điểm fail |
Partition Swap — chiến lược advanced
Nếu bảng orders được partition theo thời gian (ví dụ orders_2024_01), và file chỉ update data của một partition:
Idempotency — job fail giữa chừng
Với pattern này, job có thể restart từ đầu mà không merge trùng.
Monitoring trong production
Bẫy thường gặp
❌ "Dùng INSERT ON CONFLICT trực tiếp từ app, 1 row 1 request"
→ Tại sao sai: Row-by-row là pattern chậm nhất: 1 triệu rows × 1 round-trip = hàng giờ, lock mỗi row riêng lẻ.
✅ Đúng hơn: Luôn batch. COPY vào staging trước, merge theo batch 5K–20K rows/transaction.
❌ "Một transaction cho toàn bộ 1 triệu rows" → Tại sao sai: Giữ lock trên hàng triệu rows trong hàng chục phút. Production queries bị block. Fail → rollback toàn bộ, mất công từ đầu. ✅ Đúng hơn: Batch nhỏ + commit thường xuyên. Mỗi batch là 1 transaction, fail chỉ mất 1 batch.
❌ "Chạy nhiều thread merge song song để nhanh hơn" → Tại sao sai: Hai thread lock cùng rows theo thứ tự khác nhau → deadlock. ✅ Đúng hơn: Nếu muốn song song, partition staging table theo key range không overlap — mỗi thread xử lý range riêng biệt.
❌ "Không cần staging table, đọc file rồi upsert luôn" → Tại sao sai: Mất khả năng validate trước, không có checkpoint để resume nếu fail, và đọc file chậm hơn COPY nhiều lần. ✅ Đúng hơn: Staging table là checkpoint thiết yếu — tách giai đoạn load và merge, retry an toàn.
❌ "Drop toàn bộ index để tăng tốc"
→ Tại sao sai: ON CONFLICT cần PRIMARY KEY để xác định conflict — drop PK thì merge không chạy được.
✅ Đúng hơn: Chỉ drop secondary indexes trong maintenance window. Giữ PK luôn.
Câu hỏi follow-up
1. Nếu file CSV có thể có duplicate order_id (cùng một order xuất hiện nhiều lần trong file)?
Deduplicate trong staging trước khi merge — bắt buộc, không bỏ qua. Có 2 cách: dùng DELETE ... USING để xoá duplicate giữ lại row mới nhất, hoặc tạo bảng dedup riêng bằng DISTINCT ON. Cách thứ 2 an toàn hơn vì không sửa staging table gốc — có thể audit lại nếu cần.
2. Làm sao tính được số row thực sự INSERT vs UPDATE?
ON CONFLICT DO UPDATE không phân biệt insert/update trong affected rows count. Trick: dùng xmax system column trong RETURNING — xmax = 0 nghĩa là row mới insert, xmax > 0 nghĩa là row đã update. Đây là PostgreSQL internal — hoạt động tốt nhưng không portable sang database khác.
3. Nếu bảng đích đang có write traffic cao trong lúc merge thì sao?
Tăng delay giữa batch (throttle 200ms+), giảm batch size xuống 1.000–2.000 rows. Monitor pg_stat_activity liên tục để xem lock wait time — nếu > 1 giây, giảm batch size tiếp. Nếu merge không khẩn cấp, schedule vào giờ thấp điểm. Với AWS RDS Aurora, route read-check query sang Read Replica để giảm tải primary.
4. Nếu cần làm điều này thường xuyên (daily batch)?
Cân nhắc chuyển sang CDC (Change Data Capture) với Kafka + Debezium: thay vì batch file hàng ngày, stream changes real-time → upsert liên tục với batch nhỏ. Giảm peak load, tăng data freshness, và không cần maintenance window. Trade-off: thêm complexity ops (Debezium connector, Kafka cluster), chỉ worth nếu batch frequency > 1 lần/ngày.