0

Sếp yêu cầu Export 1 triệu dòng Excel: Cẩm nang sinh tồn từ Backend đến Frontend để Server không "đột tử"

Chào anh em cộng đồng Viblo!

Chắc hẳn anh em làm web ai cũng từng nhận một task thế này: "Em làm cho anh cái nút Xuất Excel danh sách đơn hàng tháng này nhé".

Anh em vui vẻ lên mạng tải một thư viện Export về, query Database, loop qua data, nặn ra file .xlsx rồi return response()->download(). Test ở local với 100 dòng? Tuyệt vời, mất 1 giây. Bạn đẩy lên Production. Cuối tháng, Kế toán cần xuất báo cáo doanh thu với 500,000 giao dịch. Chị kế toán bấm nút "Xuất file". Trang web quay đều, quay đều... 1 phút sau, màn hình hiện 504 Gateway Timeout, hoặc xui hơn là server chết đứng vì cạn kiệt RAM (OOM).

Hôm nay, chúng ta sẽ bóc trần những sai lầm kinh điển và thiết lập lại tiêu chuẩn "Hạng Nặng" khi làm chức năng Export từ Backend đến Frontend nhé. Lên xe!

Lưu ý 1: Tuyệt đối không dùng "Đồng bộ" (Synchronous) cho Big Data

Đây là sai lầm chết người nhất. Quy tắc vàng của Backend là: Bất kỳ HTTP Request nào có nguy cơ chạy quá 10 giây thì ĐỪNG bắt user phải chờ.

Bạn bắt Nginx, PHP-FPM và trình duyệt của User phải duy trì kết nối mạng mòn mỏi trong lúc DB đang hì hục query hàng triệu dòng.

Giải pháp (Bất đồng bộ - Asynchronous):

  1. User bấm "Export" ở Frontend.
  2. Backend lập tức trả về HTTP 202 Accepted kèm câu thông báo: "Hệ thống đang xử lý, vui lòng kiểm tra email sau ít phút".
  3. Backend ném một Job (ví dụ: ExportTransactionsJob) vào Message Queue (Redis/Kafka).
  4. Queue Worker chạy ngầm, từ từ query DB, tạo file, upload lên Cloud Storage (S3/MinIO).
  5. Xong việc, Worker gửi một Email (hoặc bắn WebSocket Notification) chứa Link tải (Presigned URL) về cho User.

Lưu ý 2: CSV luôn là "Chân ái", đừng cố đấm ăn xôi với XLSX

Sếp hay thích file .xlsx (Excel) vì nó đẹp, có format màu mè. Nhưng anh em phải biết: File XLSX thực chất là một cục nén ZIP chứa hàng tá file XML loằng ngoằng bên trong. Để tạo ra một file XLSX, thư viện (như maatwebsite/excel hay PhpSpreadsheet) phải dựng toàn bộ cấu trúc XML đó trên RAM của server trước khi ghi xuống ổ cứng. 1 triệu dòng XLSX có thể "nhai" của bạn 2-3GB RAM dễ như bỡn.

Giải pháp: Hãy đàm phán để dùng đuôi .csv. CSV chỉ là plain-text (văn bản thuần), cách nhau bằng dấu phẩy. Việc ghi file CSV tốn cực kỳ ít RAM vì bạn có thể ghi "cuốn chiếu" (stream) từng dòng xuống ổ cứng mà không cần nhớ dòng trước đó.

Lưu ý 3: Kỹ thuật Streamed Response & DB Cursor (Chống tràn RAM)

Nếu task yêu cầu user bấm nút là file phải tải về ngay (cỡ 50k - 100k dòng, chưa tới mức phải ném vào Queue), hãy vứt bỏ cách viết gom data vào Array rồi xuất.

Chúng ta sẽ kết hợp 2 vũ khí: DB::cursor() (như mình từng chia sẻ ở bài trước để lấy từng dòng từ DB) và StreamedResponse (Gửi data từng cục nhỏ về thẳng Browser của user).

Code "Thực chiến" với Laravel:

use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;

public function exportOrders()
{
    // Đừng quên tắt Query Log để tránh memory leak nhé!
    DB::connection()->disableQueryLog();

    $response = new StreamedResponse(function() {
        // Mở luồng output trực tiếp
        $handle = fopen('php://output', 'w');

        // Ghi dòng Header (Tiêu đề cột)
        fputcsv($handle, ['Mã Đơn', 'Khách Hàng', 'Tổng Tiền', 'Ngày Tạo']);

        // Dùng cursor() để load từng record một vào RAM thay vì get() toàn bộ
        $orders = DB::table('orders')->where('status', 'completed')->cursor();

        foreach ($orders as $order) {
            // Xử lý và ghi từng dòng trực tiếp xuống trình duyệt
            fputcsv($handle, [
                $order->order_code,
                $order->customer_name,
                $order->total_amount,
                $order->created_at
            ]);
        }

        fclose($handle);
    });

    // Ép trình duyệt hiểu đây là file tải về
    $response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
    $response->headers->set('Content-Disposition', 'attachment; filename="bao_cao_doanh_thu.csv"');

    return $response;
}

Bằng đoạn code thần thánh này, dù bạn xuất 100,000 dòng, RAM của PHP chỉ loanh quanh vài Megabytes. Server chạy êm ru!

Lưu ý 4: Giao tiếp với Frontend - Đừng để User bơ vơ

Ở phía Frontend (Vue/React/Angular), UX (Trải nghiệm người dùng) khi Export là cực kỳ quan trọng.

Những cái bẫy Frontend hay mắc phải:

  1. Bấm đúp (Spam click): Call API xuất file thường mất vài giây. User thấy chưa tải về liền bấm thêm 5 lần nữa. Server lãnh trọn 5 cái request nặng nề cùng lúc! 👉 Giải pháp: Ngay khi bấm nút, lập tức disable nút đó (Loading state) cho đến khi file bắt đầu được tải về.

  2. Xử lý File Blob mệt mỏi: Thay vì dùng Axios để call API rồi hì hục convert Blob thành file (rất tốn RAM của trình duyệt khách), nếu API dùng cách StreamedResponse hoặc GET link, hãy tạo một thẻ thẻ <a> ẩn, gán href và gọi hàm .click() bằng Javascript. Trình duyệt sẽ tự động bắt lấy file và hiện thanh download gốc của hệ điều hành.

Lời kết

Việc xây dựng chức năng Export không hề đơn giản như những bài tutorial "Hello World". Một Senior Backend luôn nhìn chức năng Export dưới góc độ của Memory Management (Quản lý RAM), Execution Time (Thời gian chạy) và Queue Architecture.

Lần tới, nếu sếp yêu cầu xuất báo cáo khổng lồ, hãy mạnh dạn đề xuất luồng Bất đồng bộ (Queue) + Link tải qua Email, kết hợp với việc xuất file CSV thông qua Cursor. Chắc chắn hệ thống của bạn sẽ miễn nhiễm với mọi lỗi OOM!

Anh em thường dùng thư viện nào để xử lý Export (như Laravel Excel, Spout, hay tự viết thuần) trong các dự án hiện tại? Cùng chia sẻ ở phần bình luận nhé!

Chúc anh em code chắc tay và server luôn xanh!


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.