Phân tích
6 phút đọc26 tháng 5, 20262

Export 1 triệu dòng Excel mà server không chết: Bài học xương máu

Hướng dẫn thực chiến xử lý export dữ liệu lớn từ backend đến frontend, tránh OOM và timeout mà dev nào cũng nên biết.

N

Nguyễn Nhật Long

@nguyennhatlong1303

A step-by-step flowchart showing async export process: user clicks export button, backend returns 202 accepted, job enters Redis queue, worker processes data and creates file, uploads to S3 cloud storage, sends email notification with download link to user, clean flat design with blue and green accent colors on white background

Cuối tháng, chị kế toán bấm nút "Xuất Excel". Trang web quay tròn... quay tròn... rồi 504 Gateway Timeout. Slack của team dev bắt đầu nổ. Server monitoring đỏ lòm. Và bạn người viết cái nút export đó ngồi toát mồ hôi.

Nghe quen không? Mình đã từng ở đúng vị trí đó. Nhiều hơn một lần.

Tại sao export "đơn giản" lại giết server?

Câu chuyện thường bắt đầu giống nhau. Sếp bảo: "Em làm nút xuất Excel danh sách đơn hàng nhé". Bạn tải thư viện, query DB, loop data, trả về response()->download(). Test local 100 dòng mượt. Lên production với 500,000 dòng — boom.

Vấn đề cốt lõi nằm ở chỗ: bạn đang bắt một HTTP request đơn lẻ gánh toàn bộ công việc nặng nhọc query hàng triệu record, giữ tất cả trong RAM, build file, rồi mới trả về. Nginx timeout sau 60 giây, PHP-FPM hết memory limit, trình duyệt user thì disconnect.

Theo kinh nghiệm của mình, hầu hết dev mắc sai lầm không phải vì thiếu skill, mà vì chưa bao giờ test với data thật. 100 dòng và 1 triệu dòng là hai thế giới hoàn toàn khác nhau.

Synchronous vs Asynchronous: Ranh giới sống còn

Quy tắc vàng mình luôn áp dụng: bất kỳ request nào có nguy cơ chạy quá 10 giây, đừng bắt user chờ.

Với những export lớn, luồng bất đồng bộ hoạt động như sau:

Tiêu chíSynchronous (Đồng bộ)Asynchronous (Bất đồng bộ)
Cách hoạt độngUser bấm → chờ → nhận fileUser bấm → nhận thông báo → file gửi sau
Giới hạn data~50K dòng (tùy server)Hàng triệu dòng
Rủi ro timeoutRất caoKhông có
RAM usageCao (giữ connection + data)Thấp (worker xử lý riêng)
UXUser bị "treo" trình duyệtUser làm việc khác thoải mái
Độ phức tạp codeĐơn giảnCần Queue + Worker + Notification
  1. User bấm "Export" → Backend trả về HTTP 202 Accepted ngay lập tức
  2. Một Job (ví dụ ExportTransactionsJob) được đẩy vào Message Queue (Redis, Kafka, SQS...)
  3. Queue Worker chạy ngầm: query DB, tạo file, upload lên Cloud Storage (S3/MinIO)
  4. Worker gửi email hoặc WebSocket notification chứa Presigned URL để user tải file
A step-by-step flowchart showing async export process: user clicks export button, backend returns 202 accepted, job enters Redis queue, worker processes data and creates file, uploads to S3 cloud storage, sends email notification with download link to user, clean flat design with blue and green accent colors on white background

Điều mình thấy hay là luồng này không chỉ giải quyết vấn đề kỹ thuật, mà còn cải thiện UX đáng kể. User không bị "giam" ở một tab chờ đợi nữa.

CSV là "chân ái", đừng cố ép XLSX

Sếp thích file .xlsx vì đẹp, có format màu mè. Nhưng bạn cần hiểu bản chất:

CSV ghi được kiểu "cuốn chiếu" stream từng dòng xuống ổ cứng mà không cần nhớ dòng trước đó. Đây là lý do mình luôn đàm phán với sếp/BA để dùng CSV trước, chỉ dùng XLSX khi thực sự cần format phức tạp.

Tiêu chíCSVXLSX
Bản chấtPlain text, phân tách bằng dấu phẩyZIP chứa nhiều file XML phức tạp
RAM khi tạo fileCực thấp (ghi stream từng dòng)Rất cao (build toàn bộ XML trên RAM)
1 triệu dòng tốn RAM~vài MB2-3 GB dễ dàng
Tốc độ tạo fileRất nhanhChậm hơn nhiều lần
Mở bằng Excel✅ Được✅ Được
Format màu, font❌ Không✅ Có

Mẹo nhỏ: nếu sếp nhất quyết đòi XLSX, hãy dùng thư viện hỗ trợ streaming write như OpenSpout (trước đây là Spout) thay vì PhpSpreadsheet. OpenSpout ghi XLSX theo kiểu stream, RAM tiêu thụ thấp hơn rất nhiều.

StreamedResponse + DB Cursor: Combo chống tràn RAM

Với những export cỡ trung (50K-100K dòng), chưa cần queue nhưng vẫn phải cẩn thận RAM, mình dùng combo DB Cursor + StreamedResponse. Ý tưởng: lấy từng record từ DB, ghi thẳng xuống trình duyệt, không giữ gì trong RAM cả.

Code thực chiến với Laravel:

PHP
1use Illuminate\Support\Facades\DB;
2use Symfony\Component\HttpFoundation\StreamedResponse;
3
4public function exportOrders()
5{
6 DB::connection()->disableQueryLog();
7
8 $response = new StreamedResponse(function() {
9 $handle = fopen('php://output', 'w');
10
11 // BOM cho Excel đọc UTF-8 đúng tiếng Việt
12 fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
13
14 fputcsv($handle, ['Mã Đơn', 'Khách Hàng', 'Tổng Tiền', 'Ngày Tạo']);
15
16 $orders = DB::table('orders')
17 ->where('status', 'completed')
18 ->cursor();
19
20 foreach ($orders as $order) {
21 fputcsv($handle, [
22 $order->order_code,
23 $order->customer_name,
24 $order->total_amount,
25 $order->created_at
26 ]);
27 }
28
29 fclose($handle);
30 });
31
32 $response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
33 $response->headers->set('Content-Disposition', 'attachment; filename="bao_cao.csv"');
34
35 return $response;
36}

Một điểm nhiều người quên: DB::connection()->disableQueryLog(). Không tắt cái này, Laravel sẽ lưu mọi query vào memory export 1 triệu dòng thì query log cũng ngốn RAM kinh khủng.

Với 100,000 dòng, RAM của PHP chỉ loanh quanh vài MB. Server chạy êm ru.

Frontend: Đừng để user bơ vơ

Backend ngon rồi nhưng frontend mà xử lý dở thì vẫn toang. Mấy cái bẫy mình thấy team hay mắc:

Spam click: User bấm export, chưa thấy gì, bấm thêm 5 lần. Server lãnh 5 request nặng cùng lúc. Giải pháp đơn giản: disable nút ngay khi click, hiện loading state, chỉ enable lại khi file bắt đầu tải.

Dùng Axios download file: Nhiều bạn dùng Axios call API rồi convert response thành Blob rồi tạo URL.createObjectURL. Cách này tốn RAM trình duyệt vì toàn bộ file phải nằm trong memory của browser trước khi tải. Với file lớn, tab Chrome cũng crash luôn.

Cách tốt hơn: tạo thẻ <a> ẩn, gán href vào URL endpoint, rồi trigger .click() bằng JavaScript. Trình duyệt sẽ tự handle download bằng download manager native không tốn RAM.

JavaScript
1const downloadFile = (url) => {
2 const link = document.createElement('a');
3 link.href = url;
4 link.setAttribute('download', '');
5 document.body.appendChild(link);
6 link.click();
7 document.body.removeChild(link);
8};

Những điều mình luôn nhớ khi làm export

  • Dưới 10K dòng: Synchronous + StreamedResponse CSV là đủ
  • 10K - 100K dòng: StreamedResponse + DB Cursor, cân nhắc timeout
  • Trên 100K dòng: Bắt buộc dùng Queue + async + link tải qua email
  • Luôn test với data production-like trước khi deploy. Đừng bao giờ tin kết quả test với 100 dòng
  • Monitor RAM và execution time của worker/request. Một cái export tưởng vô hại có thể kéo sập cả hệ thống nếu nhiều user bấm cùng lúc

Theo kinh nghiệm của mình qua nhiều dự án, điều quan trọng nhất không phải là code mà là đặt đúng kỳ vọng với stakeholder. Giải thích cho sếp hiểu tại sao cần chờ email thay vì tải ngay, tại sao CSV thay vì XLSX. Phần lớn họ sẽ đồng ý khi hiểu trade-off.

Export data nghe thì nhỏ, nhưng đụng tới là đụng tới memory management, queue architecture, và cả UX design. Lần tới sếp giao task export, bạn đã biết phải hỏi câu đầu tiên: "Bao nhiêu dòng data anh/chị?" 🚀

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.

Thấy hay? Chia sẻ cho bạn bè!