Tối ưu Import và Export CSV lớn trong Laravel

· 5 min read

Làm việc với file CSV là yêu cầu phổ biến, nhưng khi số lượng bản ghi lên đến hàng trăm nghìn hoặc hàng triệu, các cách tiếp cận thông thường sẽ nhanh chóng gặp lỗi Memory Limit Exceeded hoặc Execution Time Exceeded.

Bài viết này sẽ hướng dẫn bạn cách xử lý file CSV lớn hiệu quả sử dụng StreamedResponse, LazyCollectionQueues.

1. Export CSV (Dữ liệu lớn)

Vấn đề chính với export là chúng ta thường cố gắng load tất cả dữ liệu vào một biến trước khi ghi vào file. Với 1 triệu dòng, RAM của server sẽ tràn ngay lập tức.

Giải pháp: StreamedResponse

Laravel cung cấp streamDownload cho phép gửi dữ liệu trực tiếp đến trình duyệt của người dùng ngay khi nó được đọc từ database, thay vì đợi load tất cả.

Ngoài ra, khi truy vấn database, đừng bao giờ sử dụng get() hoặc all(). Sử dụng cursor() để chỉ giữ một bản ghi trong RAM tại một thời điểm.

use App\Models\User;
use Illuminate\Support\Facades\Response;

public function export()
{
    $headers = [
        'Content-Type' => 'text/csv',
        'Content-Disposition' => 'attachment; filename="users-export.csv"',
    ];

    $callback = function () {
        $file = fopen('php://output', 'w');
        
        // Ghi các cột header
        fputcsv($file, ['ID', 'Name', 'Email', 'Joined Date']);

        // Truy vấn sử dụng cursor để tiết kiệm bộ nhớ
        // cursor() tốt hơn chunk() cho streaming vì nó sử dụng Generator
        foreach (User::query()->cursor() as $user) {
            fputcsv($file, [
                $user->id,
                $user->name,
                $user->email,
                $user->created_at->format('Y-m-d'),
            ]);
        }

        fclose($file);
    };

    return Response::stream($callback, 200, $headers);
}

Với phương pháp này, bạn có thể export file 1GB với chỉ vài MB RAM.

2. Import CSV (Dữ liệu lớn)

Import phức tạp hơn Export vì chúng ta cần validate dữ liệu và ghi vào database. Nếu file quá lớn, request sẽ timeout trước khi xử lý xong.

Giải pháp 1: Đọc file từng dòng (Stream Read)

Để đọc file CSV mà không load tất cả vào RAM, chúng ta sử dụng LazyCollection hoặc fopen truyền thống.

Ví dụ sử dụng LazyCollection:

use Illuminate\Support\LazyCollection;

LazyCollection::make(function () {
    $handle = fopen(storage_path('app/large-file.csv'), 'r');
    
    while (($line = fgetcsv($handle)) !== false) {
        yield $line;
    }
    
    fclose($handle);
})
->skip(1) // Bỏ qua header
->chunk(1000) // Xử lý theo từng chunk 1000 dòng
->each(function ($lines) {
    // Insert 1000 dòng vào DB cùng lúc
    DB::table('users')->insert($lines->toArray());
});

Giải pháp 2: Sử dụng Queue (Khuyến nghị)

Với file lớn, bạn không nên bắt người dùng đợi trình duyệt load. Upload file, trả về thông báo "Đang xử lý", và đẩy task import vào Background Job.

Bước 1: Controller nhận file

public function import(Request $request)
{
    $request->validate(['file' => 'required|file|mimes:csv,txt']);

    // Lưu file vào storage (không xử lý ngay)
    $path = $request->file('file')->store('imports');

    // Dispatch Job
    ImportCsvJob::dispatch($path);

    return back()->with('message', 'File đang được xử lý trong background.');
}

Bước 2: Tạo Job để xử lý

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use App\Models\User;

class ImportCsvJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $timeout = 1200; // Cho phép job chạy lâu hơn (20 phút)

    protected $filePath;

    public function __construct($filePath)
    {
        $this->filePath = $filePath;
    }

    public function handle()
    {
        $path = Storage::path($this->filePath);
        
        if (($handle = fopen($path, 'r')) !== false) {
            // Bỏ qua header nếu cần
            fgetcsv($handle); 

            $batch = [];

            while (($data = fgetcsv($handle)) !== false) {
                // Map dữ liệu CSV vào mảng insert
                $batch[] = [
                    'name' => $data[0],
                    'email' => $data[1],
                    'password' => bcrypt('password'), // Ví dụ
                    'created_at' => now(),
                    'updated_at' => now(),
                ];

                // Bulk Insert mỗi 1000 dòng
                if (count($batch) >= 1000) {
                    DB::table('users')->insert($batch);
                    $batch = [];
                }
            }

            // Insert các dòng còn lại
            if (count($batch) > 0) {
                DB::table('users')->insert($batch);
            }

            fclose($handle);
            
            // Xóa file sau khi import xong
            Storage::delete($this->filePath);
        }
    }
}

3. Thư viện

Nếu bạn không muốn code thủ công, spatie/simple-excel là một lựa chọn tuyệt vời. Nó nhẹ hơn nhiều so với Laravel Excel (phpspreadsheet) và tập trung vào hiệu năng.

composer require spatie/simple-excel

Cách sử dụng:

use Spatie\SimpleExcel\SimpleExcelReader;

SimpleExcelReader::create($pathToFile)
    ->getRows()
    ->each(function(array $row) {
        // Xử lý từng dòng, thư viện tự động quản lý bộ nhớ
        User::create([
            'email' => $row['email'],
            // ...
        ]);
    });

Bình luận