Vấn đề của việc tải lên tệp tin dung lượng lớn qua HTTP truyền thống

Trong các ứng dụng web hiện đại, việc cho phép người dùng tải lên các tệp tin có dung lượng lớn như video 4K, tệp tin nén ZIP hàng gigabyte hay các bản sao lưu cơ sở dữ liệu là nhu cầu rất phổ biến. Tuy nhiên, nếu sử dụng phương thức tải lên HTTP POST truyền thống, hệ thống sẽ nhanh chóng đối mặt với hàng loạt giới hạn kỹ thuật nghiêm trọng.

Đầu tiên là giới hạn cấu hình của máy chủ web và PHP. Các tham số như upload_max_filesize, post_max_size, và max_execution_time trong tệp cấu hình php.ini thường được đặt ở mức an toàn để tránh tấn công từ chối dịch vụ (DoS). Việc tăng các thông số này lên quá cao sẽ khiến máy chủ dễ bị cạn kiệt tài nguyên bộ nhớ (RAM) khi có nhiều người dùng tải tệp cùng lúc.

Thứ hai là tính kém ổn định của kết nối mạng. Nếu người dùng đang tải lên một tệp tin dung lượng 2GB và kết nối mạng bị ngắt quãng ở mức 99%, toàn bộ tiến trình sẽ thất bại và họ buộc phải bắt đầu lại từ đầu. Điều này gây ra trải nghiệm người dùng cực kỳ tồi tệ và lãng phí băng thông của cả client lẫn server.

Để giải quyết triệt để vấn đề này, kỹ thuật Chunked Upload (Tải lên phân đoạn) ra đời. Ý tưởng cốt lõi là chia nhỏ tệp tin lớn thành nhiều phần nhỏ (chunks) ở phía client, tải lên từng phần một cách độc lập, sau đó ghép nối lại ở phía server và đưa vào hàng đợi (Queue) để xử lý bất đồng bộ.

Kiến trúc giải pháp: Chunked Upload và State Machine

Hệ thống tải lên phân đoạn bền bỉ (resilient) cần đảm bảo các yếu tố sau:

  • Tính toàn vẹn dữ liệu: Mỗi phân đoạn phải được kiểm tra mã băm (MD5 hoặc SHA256) để đảm bảo không bị lỗi trong quá trình truyền tải.
  • Khả năng khôi phục (Resumable): Nếu tiến trình bị gián đoạn, client có thể truy vấn server để biết những phân đoạn nào đã được tải lên thành công và chỉ cần gửi các phân đoạn còn thiếu.
  • Xử lý bất đồng bộ (Asynchronous Processing): Việc ghép nối tệp tin và xử lý hậu kỳ (như nén, mã hóa, hoặc đẩy lên cloud storage) phải được thực hiện dưới nền thông qua Queue Worker để tránh nghẽn luồng xử lý HTTP.

Thiết kế cơ sở dữ liệu quản lý tiến trình tải lên

Chúng ta cần một bảng để lưu trữ trạng thái của tệp tin đang được tải lên. Dưới đây là cấu trúc migration trong Laravel:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('file_uploads', function (Blueprint $table) {
            $table->id();
            $table->string('uuid')->unique();
            $table->string('filename');
            $table->string('mime_type');
            $table->bigInteger('total_size');
            $table->integer('total_chunks');
            $table->string('status')->default('pending'); // pending, merging, completed, failed
            $table->string('storage_path')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('file_uploads');
    }
};

Triển khai Backend xử lý Chunked Upload trong Laravel

Chúng ta sẽ xây dựng một Controller chuyên biệt để xử lý hai tác vụ chính: Tiếp nhận phân đoạn tệp tin và kiểm tra trạng thái tải lên để hỗ trợ tính năng tải lên tiếp tục (resume).

1. Khởi tạo phiên tải lên (Initiate Upload)

Trước khi gửi các phân đoạn, client sẽ gửi một yêu cầu đăng ký phiên tải lên để nhận về một mã định danh duy nhất (UUID).

namespace App\Http\Controllers;

use App\Models\FileUpload;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class ChunkUploadController extends Controller
{
    public function initiate(Request $request)
    {
        $request->validate([
            'filename' => 'required|string',
            'total_size' => 'required|integer',
            'total_chunks' => 'required|integer',
            'mime_type' => 'required|string',
        ]);

        $uuid = (string) Str::uuid();

        $fileUpload = FileUpload::create([
            'uuid' => $uuid,
            'filename' => $request->input('filename'),
            'mime_type' => $request->input('mime_type'),
            'total_size' => $request->input('total_size'),
            'total_chunks' => $request->input('total_chunks'),
            'status' => 'pending',
        ]);

        return response()->json([
            'uuid' => $uuid,
            'uploaded_chunks' => []
        ]);
    }
}

2. Xử lý tải lên từng phân đoạn (Upload Chunk)

Mỗi phân đoạn gửi lên sẽ được lưu tạm thời vào một thư mục riêng biệt đặt tên theo UUID của phiên tải lên.

    public function uploadChunk(Request $request)
    {
        $request->validate([
            'uuid' => 'required|uuid|exists:file_uploads,uuid',
            'chunk_index' => 'required|integer',
            'file' => 'required|file',
        ]);

        $uuid = $request->input('uuid');
        $chunkIndex = $request->input('chunk_index');
        $file = $request->file('file');

        $tempDirectory = "chunks/{$uuid}";
        $chunkName = "chunk_{$chunkIndex}.part";

        // Lưu phân đoạn vào thư mục tạm thời
        $file->storeAs($tempDirectory, $chunkName, 'local');

        // Kiểm tra xem đã nhận đủ tất cả các phân đoạn chưa
        $fileUpload = FileUpload::where('uuid', $uuid)->firstOrFail();
        $uploadedChunks = count(Storage::disk('local')->files($tempDirectory));

        if ($uploadedChunks === $fileUpload->total_chunks) {
            $fileUpload->update(['status' => 'merging']);
            
            // Đẩy Job ghép nối tệp tin vào Queue
            dispatch(new \App\Jobs\MergeUploadedChunksJob($uuid));
        }

        return response()->json([
            'status' => 'chunk_uploaded',
            'chunk_index' => $chunkIndex
        ]);
    }

Xây dựng Job ghép nối phân đoạn bất đồng bộ

Việc ghép nối (merge) các phân đoạn tệp tin có thể tốn nhiều thời gian và tài nguyên CPU nếu tệp tin có dung lượng lớn. Do đó, chúng ta bắt buộc phải đưa tác vụ này vào hàng đợi xử lý bất đồng bộ bằng cách sử dụng Laravel Queue.

namespace App\Jobs;

use App\Models\FileUpload;
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 Exception;

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

    protected $uuid;

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

    public function handle(): void
    {
        $fileUpload = FileUpload::where('uuid', $this->uuid)->firstOrFail();
        $tempDirectory = "chunks/{$this->uuid}";
        $finalPath = "uploads/{$this->uuid}_{$fileUpload->filename}";

        $disk = Storage::disk('local');
        $absoluteTempPath = $disk->path($tempDirectory);
        $absoluteFinalPath = $disk->path($finalPath);

        // Tạo thư mục đích nếu chưa tồn tại
        if (!file_exists(dirname($absoluteFinalPath))) {
            mkdir(dirname($absoluteFinalPath), 0755, true);
        }

        // Mở luồng ghi tệp tin đích
        $out = fopen($absoluteFinalPath, 'wb');
        if (!$out) {
            throw new Exception("Không thể tạo tệp tin đích.");
        }

        // Ghép nối tuần tự các phân đoạn
        for ($i = 0; $i < $fileUpload->total_chunks; $i++) {
            $chunkFile = "{$absoluteTempPath}/chunk_{$i}.part";
            if (!file_exists($chunkFile)) {
                fclose($out);
                throw new Exception("Thiếu phân đoạn thứ {$i}.");
            }

            $in = fopen($chunkFile, 'rb');
            while ($buff = fread($in, 4096)) {
                fwrite($out, $buff);
            }
            fclose($in);
        }
        fclose($out);

        // Xóa thư mục tạm chứa các phân đoạn
        $disk->deleteDirectory($tempDirectory);

        // Cập nhật trạng thái hoàn thành
        $fileUpload->update([
            'status' => 'completed',
            'storage_path' => $finalPath
        ]);
    }

    public function failed(Exception $exception): void
    {
        FileUpload::where('uuid', $this->uuid)->update([
            'status' => 'failed'
        ]);
    }
}

Triển khai Frontend với JavaScript thuần

Phía giao diện người dùng cần cắt tệp tin thành các Blob nhỏ bằng phương thức File.prototype.slice() và gửi chúng tuần tự hoặc song song qua API.

const CHUNK_SIZE = 2 * 1024 * 1024; // Định nghĩa kích thước mỗi phân đoạn là 2MB

async function uploadLargeFile(file) {
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    
    // Bước 1: Khởi tạo phiên tải lên
    const initResponse = await fetch('/api/upload/initiate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            filename: file.name,
            total_size: file.size,
            total_chunks: totalChunks,
            mime_type: file.type
        })
    });
    
    const { uuid } = await initResponse.json();

    // Bước 2: Tải lên từng phân đoạn tuần tự
    for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
        const start = chunkIndex * CHUNK_SIZE;
        const end = Math.min(file.size, start + CHUNK_SIZE);
        const chunkBlob = file.slice(start, end);

        const formData = new FormData();
        formData.append('uuid', uuid);
        formData.append('chunk_index', chunkIndex);
        formData.append('file', chunkBlob, `chunk_${chunkIndex}.part`);

        await fetch('/api/upload/chunk', {
            method: 'POST',
            body: formData
        });

        const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
        console.log(`Tiến độ tải lên: ${progress}%`);
    }
    
    console.log('Tải lên hoàn tất, hệ thống đang xử lý ghép nối tệp tin...');
}

Các Best Practices tối ưu hóa hiệu năng và bảo mật

Khi triển khai hệ thống này trên môi trường production thực tế, bạn cần lưu ý các điểm mấu chốt sau:

1. Dọn dẹp các phân đoạn rác (Garbage Collection)

Trong thực tế, có nhiều trường hợp người dùng hủy tải lên giữa chừng hoặc mất kết nối vĩnh viễn. Thư mục tạm chunks/ sẽ tích tụ rất nhiều tệp tin rác gây lãng phí dung lượng đĩa cứng. Bạn nên thiết lập một Laravel Command chạy hàng ngày để xóa các thư mục tạm có thời gian khởi tạo quá 24 giờ.

$disk = Storage::disk('local');
$directories = $disk->directories('chunks');
foreach ($directories as $dir) {
    $lastModified = $disk->lastModified($dir);
    if (time() - $lastModified > 86400) { // Quá 24 giờ
        $disk->deleteDirectory($dir);
    }
}

2. Giới hạn tốc độ ghi đĩa và tối ưu hóa bộ nhớ đệm

Khi ghép nối tệp tin bằng PHP, việc sử dụng luồng (Streams) như fopen() và fread() với kích thước bộ đệm nhỏ (ví dụ 4096 bytes) giúp kiểm soát lượng RAM tiêu thụ cực kỳ thấp (chỉ vài megabytes) ngay cả khi ghép nối tệp tin hàng chục gigabytes. Tránh tuyệt đối việc sử dụng file_get_contents() vì nó sẽ tải toàn bộ nội dung tệp tin vào RAM, gây ra lỗi Fatal error: Allowed memory size exhausted.

Kết luận

Xây dựng hệ thống Chunked Upload là giải pháp tối ưu nhất để xử lý tệp tin dung lượng lớn một cách an toàn và chuyên nghiệp. Việc kết hợp quy trình chia nhỏ tệp ở client và xử lý ghép nối bất đồng bộ ở backend giúp tối ưu hóa tài nguyên máy chủ và nâng cao trải nghiệm người dùng.

Để làm chủ hoàn toàn các kỹ thuật lập trình hiện đại, tối ưu hóa hiệu năng ứng dụng và xây dựng các giao diện tương tác thời gian thực mượt mà mà không cần cấu hình phức tạp, bạn có thể nâng cao trình độ chuyên môn của mình một cách bài bản. Tham khảo khóa học "Khóa học Laravel Livewire từ cơ bản đến nâng cao" tại đây.