Hàng triệu dòng CSV/Excel, xử lý theo lôMillions of CSV/Excel rows, batch processing
File được tải thẳng lên S3, API chỉ tạo job và trả 202. Reader đọc file theo stream, cắt thành lô 1.000 dòng và đẩy vào hàng đợi; worker pool kiểm tra, ghi lỗi và upsert vào Postgres, rồi gom kết quả khi lô cuối xong. Export đọc cursor trên replica và stream lên S3. Bật “2% dòng lỗi” hoặc “Export 500k dòng” để xem hai luồng còn lại.The file is uploaded straight to S3; the API only creates a job and answers 202. The reader streams the file, cuts it into 1,000-row chunks and queues them; a worker pool validates, records errors and upserts into Postgres, and the results are gathered once the last chunk is done. Exports read a cursor on a replica and stream to S3. Turn on “2% invalid rows” or “Export 500k rows” to see the other two flows.
1Admin tải customers.csv (184 MB) thẳng lên S3 bằng presigned URL.The admin uploads customers.csv (184 MB) straight to S3 with a presigned URL.
Apache POI XSSFWorkbook dựng cả file Excel trong RAM, 1 triệu dòng có thể cần vài GB. Dùng parser stream (univocity, encoding/csv, POI SAX/excel-streaming-reader, excelize Rows) và chỉ giữ một lô trong bộ nhớ.Apache POI's XSSFWorkbook builds the whole workbook in RAM; a million rows can need several GB. Use a streaming parser (univocity, encoding/csv, POI SAX/excel-streaming-reader, excelize Rows) and keep one chunk in memory.
1 triệu INSERT là 1 triệu round trip. Batch 1.000 dòng với reWriteBatchedInserts=true đạt vài chục nghìn dòng/giây; COPY vào bảng tạm rồi INSERT … SELECT ON CONFLICT còn nhanh hơn. Thêm worker pool 8 luồng, giới hạn theo pool connection.A million INSERTs are a million round trips. Batches of 1,000 with reWriteBatchedInserts=true reach tens of thousands of rows a second; COPY into a temp table followed by INSERT … SELECT ON CONFLICT is faster still. Add an 8-worker pool, capped by the connection pool.
Kiểm tra từng dòng trước khi ghi, gom lỗi theo dòng và cột vào import_error, dòng đúng vẫn ghi. Đặt ngưỡng: lỗi quá 10% thì dừng job để tránh nạp một file sai cột.Validate each row before writing, collect errors per line and column in import_error, and still write the good rows. Set a threshold: above 10% errors the job stops, which catches a file with shifted columns.
Kafka giao ít nhất một lần, admin cũng có thể import lại cùng file. Bảng import_chunk unique(job_id, chunk_no) chặn lô đã xử lý; upsert theo khoá tự nhiên external_id làm import lại cùng dữ liệu không đổi kết quả.Kafka delivers at least once and admins may re-import the same file. import_chunk with unique(job_id, chunk_no) blocks finished chunks; upserting on the natural key external_id makes a repeated import leave the result unchanged.
Không build file trong request HTTP. Chạy job nền, đọc server-side cursor trên replica (fetchSize 5.000, autocommit tắt với pgjdbc), stream qua gzip vào S3 multipart rồi gửi link. OFFSET phân trang sâu chậm dần, cursor hay keyset thì không.Never build the file inside the HTTP request. Run a background job that reads a server-side cursor on a replica (fetchSize 5,000, autocommit off with pgjdbc), streams through gzip into an S3 multipart upload and sends a link. Deep OFFSET paging slows down; a cursor or keyset does not.
Adapter đưa CSV, XLSX và JSON Lines về cùng một Iterator<Row>; phần còn lại của pipeline không biết file gốc là gì. Facade ImportService che các bước upload, tạo job, theo dõi.Adapters turn CSV, XLSX and JSON Lines into the same Iterator<Row>; the rest of the pipeline never knows the source format. An ImportService facade hides upload, job creation and tracking.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
import_job | id, tenant_id, file_key, file_sha256, status, total_rows, total_chunks, done_chunks, processed, failed, report_key, created_by, started_at, finished_at | unique(tenant_id, file_sha256); PENDING → RUNNING → COMPLETED · COMPLETED_WITH_ERRORS · FAILEDunique(tenant_id, file_sha256); PENDING → RUNNING → COMPLETED · COMPLETED_WITH_ERRORS · FAILED |
import_chunk | job_id, chunk_no, rows, errors, finished_at | PK(job_id, chunk_no): lô đã xong thì lần giao lại bị bỏ quaPK(job_id, chunk_no): a redelivered finished chunk is skipped |
import_error | job_id, line_no, column_name, code, message, raw_value | Nguồn của errors.csv; xoá theo job sau 30 ngàySource of errors.csv; purged per job after 30 days |
export_job | id, tenant_id, filter_json, status, processed, total, file_key, expires_at | file_key trên S3, lifecycle xoá exports/ sau 7 ngàyfile_key in S3; a lifecycle rule removes exports/ after 7 days |
Chunk worker: nhận một lô 1.000 dòng, bỏ qua nếu lô đã xử lý, kiểm tra từng dòng, ghi dòng đúng theo lô, lưu lỗi và cộng tiến độ trong cùng một transaction.The chunk worker takes one 1,000-row chunk, skips it if already done, validates every row, writes the good rows as a batch, stores the errors and bumps progress, all in one transaction.
@Component
@RequiredArgsConstructor
public class ImportChunkWorker {
private final JdbcTemplate jdbc;
private final RowValidator validator;
private final ImportErrorRepository errors;
private final ImportJobRepository jobs;
@KafkaListener(topics = "import.chunks", concurrency = "8")
@Transactional
public void handle(ChunkMessage m) {
int claimed = jdbc.update("""
INSERT INTO import_chunk(job_id, chunk_no) VALUES (?, ?)
ON CONFLICT DO NOTHING""", m.jobId(), m.chunkNo());
if (claimed == 0) return; // chunk already done
List<CustomerRow> valid = new ArrayList<>(m.rows().size());
List<ImportError> bad = new ArrayList<>();
for (CsvRow r : m.rows()) {
List<FieldError> problems = validator.check(r);
if (problems.isEmpty()) valid.add(CustomerRow.from(r));
else problems.forEach(p -> bad.add(ImportError.of(m.jobId(), r.lineNo(), p)));
}
jdbc.batchUpdate("""
INSERT INTO customer(external_id, email, name, updated_at)
VALUES (?, ?, ?, now())
ON CONFLICT (external_id) DO UPDATE
SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = now()""",
valid, 1000, (ps, c) -> {
ps.setString(1, c.externalId());
ps.setString(2, c.email());
ps.setString(3, c.name());
});
errors.saveAll(bad);
jobs.addProgress(m.jobId(), m.rows().size(), bad.size()); // done_chunks + 1
}
}func (w *ChunkWorker) Handle(ctx context.Context, m ChunkMessage) error {
return pgx.BeginFunc(ctx, w.db, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `INSERT INTO import_chunk(job_id, chunk_no)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, m.JobID, m.ChunkNo)
if err != nil || tag.RowsAffected() == 0 {
return err // nil when the chunk is already done
}
var valid, bad [][]any
for _, r := range m.Rows {
if probs := w.validate(r); len(probs) > 0 {
for _, p := range probs {
bad = append(bad, []any{m.JobID, r.Line, p.Column, p.Code})
}
continue
}
valid = append(valid, []any{r.ExternalID, r.Email, r.Name})
}
if _, err := tx.Exec(ctx, `CREATE TEMP TABLE stage
(external_id text, email text, name text) ON COMMIT DROP`); err != nil {
return err
}
cols := []string{"external_id", "email", "name"}
if _, err := tx.CopyFrom(ctx, pgx.Identifier{"stage"}, cols, pgx.CopyFromRows(valid)); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO customer(external_id, email, name)
SELECT external_id, email, name FROM stage
ON CONFLICT (external_id) DO UPDATE
SET email = EXCLUDED.email, name = EXCLUDED.name`); err != nil {
return err
}
errCols := []string{"job_id", "line_no", "column_name", "code"}
if _, err := tx.CopyFrom(ctx, pgx.Identifier{"import_error"}, errCols, pgx.CopyFromRows(bad)); err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE import_job SET processed = processed + $2,
failed = failed + $3, done_chunks = done_chunks + 1 WHERE id = $1`,
m.JobID, len(m.Rows), len(bad))
return err
})
}| COPYCOPY | Batch INSERT 1.000 dòngBatch INSERT of 1,000 | |
|---|---|---|
| Tốc độSpeed | Thường 100k–300k dòng/giâyTypically 100k–300k rows a second | Khoảng 20k–50k dòng/giây với reWriteBatchedInsertsAround 20k–50k rows a second with reWriteBatchedInserts |
| Xung đột khoáKey conflicts | Không có ON CONFLICT, cần bảng tạm rồi upsertNo ON CONFLICT; needs a temp table then an upsert | ON CONFLICT DO UPDATE trực tiếpON CONFLICT DO UPDATE directly |
| Một dòng hỏngOne broken row | Cả lệnh COPY thất bại, phải kiểm tra trướcThe whole COPY fails, so validate first | Cả lô thất bại, cũng phải kiểm tra trướcThe whole batch fails, so validate first too |
| Chọn khiPick it when | Hàng triệu dòng, Postgres, cần tốc độ tối đaMillions of rows on Postgres and speed matters most | Cần chạy trên nhiều DB qua JPA/JDBC, mã đơn giảnYou need portability across databases via JPA/JDBC and simple code |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Đổi XSSFWorkbook sang đọc stream (SAX hoặc excel-streaming-reader), xử lý theo lô 1.000 dòng và ghi xong mới đọc tiếp. Bộ nhớ khi đó tỉ lệ với kích thước lô, không phải kích thước file.Replace XSSFWorkbook with a streaming reader (SAX or excel-streaming-reader), process 1,000-row chunks and write each before reading on. Memory then scales with the chunk size, not the file size.
Ghi import_chunk, dữ liệu và tiến độ trong cùng transaction. Nếu chết trước commit thì không có gì được ghi và Kafka giao lại; nếu chết sau commit thì lần giao lại thấy import_chunk đã có và bỏ qua. Upsert theo external_id là lớp bảo vệ thứ hai.Write import_chunk, the data and the progress in one transaction. If it dies before commit nothing is written and Kafka redelivers; if it dies after commit the redelivery finds import_chunk and skips. Upserting on external_id is a second safety net.
Thường không: ghi dòng đúng, gom dòng sai vào báo cáo để người dùng sửa và import lại. Nhưng đặt ngưỡng, ví dụ lỗi quá 10% thì dừng, vì đó thường là file sai cột chứ không phải vài dòng lẻ.Usually not: write the good rows and put the bad ones in a report for the user to fix and re-import. But set a threshold, say 10%, above which the job stops, because that usually means a misaligned file rather than a few stray rows.
Mỗi trang sau phải quét lại các dòng phía trước nên càng về cuối càng chậm, và dữ liệu thay đổi giữa các trang gây trùng hoặc sót. Dùng server-side cursor trong một transaction, hoặc keyset theo id.Each later page rescans every row before it, so it gets slower towards the end, and changes between pages cause duplicates or gaps. Use a server-side cursor inside one transaction, or keyset paging on id.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.