Site logo
Tác giả
  • avatar Nguyễn Đức Xinh
    Name
    Nguyễn Đức Xinh
    Twitter
Ngày xuất bản
Ngày xuất bản

Upload File Lớn Trong PHP/Laravel: Fix Lỗi 413 Và Out Of Memory

Bài này kể lại một case thực tế: QC upload một file CSV 9.99MB vào màn hình import, lần đầu nhận lỗi 413 The POST data is too large., sửa xong thì chuyển sang lỗi 500 với response trống. Hai lỗi nhìn đơn giản, nhưng nguyên nhân nằm ở bốn tầng khác nhau: nginx, PHP-FPM, Laravel, và chính cách code đọc file.

Đi qua từng bước debug trong case này, bạn sẽ nắm được:

  • Một request upload đi qua những "cửa" giới hạn nào, và cửa nào trả lỗi gì.
  • Cách chỉnh upload_max_filesize, post_max_size, memory_limit đúng chỗ (và cái bẫy của .user.ini).
  • Vì sao tăng RAM không phải là cách sửa, và cách đo bộ nhớ thật của một đoạn code PHP.
  • Cách refactor code đọc CSV sang streaming bằng generator: RAM của reader giảm từ ~930MB xuống 16MB.

Tổng Quan: Request Upload Đi Qua Những Tầng Nào?

Trước khi debug, cần có bức tranh toàn cảnh. Với stack phổ biến nginx → PHP-FPM → Laravel, một request multipart/form-data phải đi qua lần lượt các giới hạn sau:

Tầng Cấu hình Giá trị mặc định Khi vượt giới hạn
nginx client_max_body_size 1m nginx trả 413 (HTML của nginx)
PHP-FPM post_max_size 8M PHP bỏ trống $_POST/$_FILES, Laravel trả 413 "The POST data is too large."
PHP-FPM upload_max_filesize 2M File bị đánh dấu lỗi UPLOAD_ERR_INI_SIZE, Laravel validation fail (422)
Laravel Rule max:10240 (đơn vị KB) Tùy code 422 kèm message validation
PHP runtime memory_limit 128M Fatal error, response thường là 500 trống
OS RAM + swap Tùy server OOM killer kill process, 502

Điểm quan trọng: mỗi tầng trả một mã lỗi khác nhau. Nhìn vào mã lỗi và nội dung response là đoán được request đã "chết" ở tầng nào:

  • 413 với body JSON The POST data is too large. → PHP (post_max_size), không phải nginx.
  • 413 với trang HTML 413 Request Entity Too Large → nginx.
  • 500 với body rỗng → PHP fatal error (thường là hết memory hoặc timeout), Laravel không kịp render JSON lỗi.

Bước 1: Tái Hiện Lỗi Bằng curl Thay Vì Click Trên UI

Đừng debug bằng cách nhờ QC upload lại. Hãy tạo một file cùng kích thước và bắn thẳng vào API:

# Tạo file ngẫu nhiên ~9.99MB
head -c 10475274 /dev/urandom > f999.csv

# POST vào một route không tồn tại để chỉ test tầng giới hạn body
curl -s -o /dev/null -w "HTTP %{http_code}\n" \
  -X POST -H 'Accept: application/json' \
  -F "file=@f999.csv" \
  https://api.example.com/api/v1/ping-upload-test

Giải thích:

  • head -c N /dev/urandom: tạo file đúng N byte, không cần nội dung hợp lệ vì ta chỉ test giới hạn kích thước.
  • Gửi vào route không tồn tại là một mẹo hay: nếu body qua được mọi giới hạn, Laravel sẽ trả 404. Nếu bị chặn ở PHP, ta nhận 413. Không cần auth, không ghi dữ liệu gì.
  • -H 'Accept: application/json' để Laravel trả JSON thay vì redirect.

Kết quả lần đầu: HTTP 413. Đã tái hiện được lỗi.

Bước 2: Kiểm Tra Giới Hạn Của nginx

grep -rn 'client_max_body_size' /etc/nginx
server {
    server_name api.example.com;
    client_max_body_size 50M;
    # ...
}

nginx cho phép tới 50MB, nên file 10MB qua được. nginx không phải thủ phạm, dù thông báo lỗi làm nhiều người nghĩ ngay tới nginx.

Bước 3: Kiểm Tra PHP-FPM, Không Phải PHP CLI

Đây là lỗi hay gặp nhất: chạy php -i | grep post_max_size trên terminal rồi kết luận. PHP CLI và PHP-FPM đọc hai file php.ini khác nhau.

# CLI: /etc/php/8.4/cli/php.ini
php -r 'echo ini_get("post_max_size"), PHP_EOL;'

# FPM: /etc/php/8.4/fpm/php.ini  ← cái web request thực sự dùng
grep -nE '^\s*(upload_max_filesize|post_max_size|memory_limit)' /etc/php/8.4/fpm/php.ini

# Hoặc xem giá trị FPM đang áp dụng
sudo php-fpm8.4 -i | grep -E '^(upload_max_filesize|post_max_size|memory_limit)'

Kết quả trên server:

Cấu hình FPM Giá trị File 9.99MB
post_max_size 8M ❌ bị chặn ở đây
upload_max_filesize 2M ❌ file > 2MB cũng sẽ lỗi
client_max_body_size (nginx) 50M ✅ qua
Laravel max:10240 10MB ✅ qua

Khi body vượt post_max_size, PHP không parse gì cả và $_POST, $_FILES đều rỗng. Middleware ValidatePostSize của Laravel so Content-Length với post_max_size và ném PostTooLargeException → HTTP 413 "The POST data is too large."

Log nginx cũng ghi rõ:

PHP Warning: PHP Request Startup: POST Content-Length of 10485944 bytes
exceeds the limit of 8388608 bytes in Unknown on line 0

Bước 4: Tăng Giới Hạn Upload Đúng Cách

Có hai cách, tùy bạn có quyền sudo hay không.

Cách A: .user.ini (không cần sudo)

PHP-FPM mặc định đọc file .user.ini trong document root (user_ini.filename = ".user.ini"):

; public/.user.ini
upload_max_filesize = 12M
post_max_size = 13M

Cái bẫy: user_ini.cache_ttl = 300. Mỗi FPM worker cache nội dung .user.ini riêng trong 300 giây. Khi đổi giá trị, trong 5 phút đó các worker giữ giá trị khác nhau, và kết quả test bị lẫn lộn:

f119:404 f140:413
f119:404 f140:413
f119:413 f140:404   ← worker khác, giá trị khác
f119:404 f140:413

Nếu gặp kết quả "lúc được lúc không" như vậy sau khi sửa .user.ini, đừng nghi ngờ bản thân: hãy đợi hết TTL hoặc reload FPM.

Ngoài ra .user.ini là file untracked trong thư mục app, nên sẽ mất khi clone lại repo. Chỉ nên dùng làm giải pháp tạm.

Cách B: conf.d ở tầng server (khuyến nghị)

Không sửa trực tiếp php.ini gốc (dễ bị ghi đè khi nâng cấp package). Thay vào đó, tạo một file override riêng trong conf.d:

printf '; upload limits (default: upload_max_filesize=2M, post_max_size=8M)\nupload_max_filesize = 12M\npost_max_size = 13M\n' \
  | sudo tee /etc/php/8.4/fpm/conf.d/99-uploads.ini

sudo php-fpm8.4 -t                 # test config trước khi reload
sudo systemctl reload php8.4-fpm   # reload, không phải restart
sudo php-fpm8.4 -i | grep -E '^(upload_max_filesize|post_max_size)'

Giải thích:

  • Tiền tố 99- đảm bảo file này load sau cùng, nên ghi đè được mọi giá trị trước đó.
  • php-fpm -t kiểm tra cú pháp, tránh reload một config lỗi làm sập cả site.
  • reload giúp worker cũ xử lý nốt request đang chạy rồi mới thay, không làm rớt request.
  • Nếu đã chuyển sang Cách B, xóa .user.ini tạm để chỉ còn một nơi cấu hình.

Vì sao post_max_size lớn hơn upload_max_filesize?

post_max_size giới hạn toàn bộ body, gồm cả phần bao multipart (boundary, header của từng part) và các field khác trong form. Nếu đặt cả hai đều là 12M, file sát 12MB sẽ vẫn bị 413. Nên để post_max_size dư ra khoảng 1MB.

Test lại sau khi reload, lần nào cũng cho cùng kết quả:

File Kết quả Ý nghĩa
9.99MB 404 Qua giới hạn PHP (404 chỉ vì route test không tồn tại)
11.8MB 404 Qua
14MB 413 Bị chặn đúng như mong đợi

Đồng bộ với Laravel validation

Đừng quên rule validation. max của rule file tính bằng kilobyte:

'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'], // 10MB

Nếu PHP cho phép 12MB mà Laravel vẫn để max:10240, file 10–12MB sẽ bị trả 422. Các giới hạn nên xếp theo thứ tự: nginx ≥ post_max_size > upload_max_filesize ≥ Laravel max, và giới hạn nghiệp vụ thật sự nên nằm ở Laravel, vì đó là tầng duy nhất trả được message dễ hiểu cho người dùng.

Bước 5: Lỗi Tiếp Theo, 500 Với Response Trống

Hết 413, QC upload lại và nhận 500, body rỗng. Response rỗng là dấu hiệu Laravel không kịp xử lý exception, tức là PHP chết ở mức fatal. Xem log nginx:

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
  in app/Support/CsvImportReader.php on line 79

134217728 byte = 128MB, đúng giá trị memory_limit mặc định. File chỉ 10MB mà dùng hết 128MB RAM? Mở file test ra xem:

wc -l ec-under-10mb.csv    # 1,497,965 dòng
head -3 ec-under-10mb.csv  # 10001 / 10001 / 10001

File có ~1.5 triệu dòng, mỗi dòng là một mã 5 chữ số. Đây là cách QC test trường hợp dữ liệu nhiều, và là một test case hoàn toàn hợp lệ.

Bước 6: Đo Bộ Nhớ Thật, Đừng Đoán

Viết một script nhỏ gọi đúng class đang lỗi và in memory_get_peak_usage():

<?php
// measure.php — chạy: php -d memory_limit=1G measure.php ec.csv
require __DIR__ . '/vendor/autoload.php';

$file = new Illuminate\Http\UploadedFile($argv[1], 'ec.csv', 'text/csv', null, true);

$base = memory_get_usage();
$t    = microtime(true);

$result = (new App\Support\CsvImportReader)->read($file);

printf(
    "rows=%d reader_mem=%.0fMB peak=%.0fMB time=%.1fs\n",
    count($result['rows']),
    (memory_get_usage() - $base) / 1048576,
    memory_get_peak_usage() / 1048576,
    microtime(true) - $t,
);

Để tiết kiệm thời gian, đo với file nhỏ trước rồi ngoại suy:

File Số dòng RAM của reader
1.0MB 142,858 89MB
1.9MB 285,715 178MB
10MB (ngoại suy) ~1.5 triệu ~930MB

RAM tăng tuyến tính theo số dòng, khoảng 620 byte/dòng, trong khi mỗi dòng trên đĩa chỉ có 7 byte (10001\r\n). Tức là gấp khoảng 90 lần.

Vì sao PHP array tốn RAM đến vậy?

Code cũ làm như sau:

public function read(UploadedFile $file): array
{
    $content = $this->readContent($file);          // cả file vào 1 string
    $rows    = [];

    foreach (preg_split('/\r\n|\n|\r/', $content) as $i => $line) {
        if (trim($line) === '') continue;
        $rows[] = [
            'line'    => $i + 1,
            'columns' => str_getcsv($line),
        ];
    }

    return ['status' => 'ok', 'rows' => $rows];
}

Mỗi dòng sinh ra: một string cho dòng (sau preg_split), một array ngoài {line, columns}, một array columns, và một string cho từng cột. Trong PHP, mỗi array là một HashTable (khoảng 56 byte header) cộng bucket đã cấp sẵn (tối thiểu 8 slot), cộng zend_string header cho từng chuỗi. Dữ liệu 7 byte bị "bọc" trong vài trăm byte metadata.

Code gọi reader còn tạo thêm các cấu trúc lớn khác:

  • mảng $candidates gồm 1.5 triệu phần tử;
  • mảng duplicate_rows gồm ~1.5 triệu phần tử (vì từ dòng 2 trở đi dòng nào cũng trùng);
  • JSON response chứa toàn bộ danh sách trùng, khoảng 49MB.

Tổng cộng: ~2.1GB RAM cho một file 10MB.

Bước 7: Tăng memory_limit Và Thêm Swap (Giải Pháp Tạm)

Server chỉ có 1.9GB RAM và không có swap. Khi đo với memory_limit=-1, process PHP bị Linux OOM killer kill ở khoảng 900MB:

sudo dmesg -T | grep -iE 'killed process|out of memory'
# Out of memory: Killed process 199090 (php) ... anon-rss:906772kB

Để QC test tiếp trong lúc sửa code, có thể thêm swap và nâng memory_limit tạm thời:

# Tạo swapfile 4G, bền qua reboot
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

# Chỉ dùng swap khi thật sự cần
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl -p /etc/sysctl.d/99-swappiness.conf

# Nâng memory_limit cho FPM
echo 'memory_limit = 1536M' | sudo tee -a /etc/php/8.4/fpm/conf.d/99-uploads.ini
sudo php-fpm8.4 -t && sudo systemctl reload php8.4-fpm

Tại sao đây KHÔNG phải cách sửa gốc?

Hãy làm phép tính với cấu hình pool FPM:

pm = dynamic
pm.max_children = 5

memory_limit áp dụng cho từng worker. Với memory_limit = 1.5G và max_children = 5, về lý thuyết FPM có thể dùng tới 7.5GB, gấp 4 lần RAM vật lý. Chỉ cần hai người import cùng lúc là server phải chạy trên swap, request chậm hẳn đi và kéo theo mọi site khác trên cùng máy.

Giải pháp Ưu điểm Nhược điểm
Tăng memory_limit Nhanh, không sửa code Không scale, nhân theo số worker, dễ gây OOM
Thêm swap Tránh bị OOM kill Chậm, chỉ là "đệm" chứ không phải RAM
Nâng cấp server Đơn giản Tốn tiền, vẫn không giới hạn được số dòng
Sửa code (streaming) RAM không tăng theo kích thước file Cần refactor và test

Nguyên tắc: memory_limit là lưới an toàn, không phải thông số để tune cho vừa với code tốn RAM.

Bước 8: Refactor Reader Sang Streaming Bằng Generator

Ý tưởng: thay vì dựng một mảng 1.5 triệu phần tử rồi trả về, reader yield từng dòng một. Caller vẫn foreach như cũ, nhưng tại mỗi thời điểm chỉ có một dòng nằm trong bộ nhớ.

use Generator;

/**
 * `rows` là generator, chỉ duyệt được MỘT lần.
 *
 * @return array{status: string, rows: iterable<int, array{line: int, columns: array<int, string>}>}
 */
public function read(UploadedFile $file): array
{
    $content = $this->readContent($file);

    if ($content === null) {
        return ['status' => 'unreadable', 'rows' => []];
    }

    return ['status' => 'ok', 'rows' => $this->rows($content)];
}

/**
 * @return Generator<int, array{line: int, columns: array<int, string>}>
 */
private function rows(string $content): Generator
{
    $length = strlen($content);
    $offset = 0;
    $line   = 0;

    while ($offset < $length) {
        $end = strpos($content, "\n", $offset);
        $end = $end === false ? $length : $end;

        $raw    = rtrim(substr($content, $offset, $end - $offset), "\r");
        $offset = $end + 1;
        $line++;

        if (trim($raw) === '') {
            continue; // bỏ dòng trống nhưng vẫn giữ số dòng vật lý
        }

        yield [
            'line'    => $line,
            'columns' => str_getcsv($raw),
        ];
    }
}

Giải thích:

  • read() vẫn giữ nguyên chữ ký trả về {status, rows}, nên caller gần như không phải sửa.
  • rows() dùng strpos + substr để cắt từng dòng thay vì preg_split (vốn tạo ra cả một mảng 1.5 triệu string).
  • $line là số dòng vật lý trong file, nên khi báo lỗi "dòng 1234" người dùng tìm đúng dòng đó trong Excel dù đã bỏ qua các dòng trống.
  • yield trả dòng về cho caller rồi "tạm dừng" hàm. Dòng trước bị giải phóng khi caller chuyển sang dòng tiếp theo.
  • Nếu caller dừng ở dòng lỗi đầu tiên (break), phần còn lại của file không bao giờ bị parse.

Caller chỉ cần đổi type hint từ array sang iterable:

/** @param iterable<int, array{line: int, columns: array<int, string>}> $rows */
private function validateRows(iterable $rows): array
{
    foreach ($rows as $row) {
        // ...
    }
}

Lưu ý khi dùng generator: chỉ duyệt được một lần. Nếu code cũ gọi count($rows) hay duyệt $rows hai lần, sẽ lỗi hoặc cho kết quả sai. Chạy lại toàn bộ test của hai import (68 test) để chắc chắn không có caller nào dựa vào array.

Kết quả đo

Phiên bản RAM đỉnh Thời gian
Reader cũ (array) ~930MB —
Reader mới (generator) 16MB 0.7s
Toàn bộ flow, code cũ ~2.1GB —
Toàn bộ flow, sau khi sửa reader ~1.24GB ~1s

RAM của reader giảm khoảng 58 lần. Nhưng toàn bộ flow vẫn tốn 1.24GB, vì phần xử lý phía sau vẫn gom mọi thứ vào mảng.

Tốt hơn nữa: đọc thẳng từ file handle

Bản trên vẫn giữ cả file trong một string (10MB), vì cần detect encoding (Shift_JIS/UTF-8) trên toàn bộ nội dung. Nếu không cần bước đó, hãy đọc thẳng từ file handle bằng SplFileObject. Khi đó RAM gần như cố định, dù file 10MB hay 1GB:

private function rows(string $path): Generator
{
    $file = new SplFileObject($path, 'r');
    $file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD);

    foreach ($file as $index => $columns) {
        if ($columns === [null]) continue;
        yield ['line' => $index + 1, 'columns' => $columns];
    }
}

Với file Shift_JIS, có thể gắn stream filter convert.iconv.SJIS-win/UTF-8 vào file handle để chuyển encoding từng khối thay vì chuyển cả file.

Bước 9: Xử Lý Phần Còn Lại Của Flow

Streaming ở reader mới chỉ là một nửa. Phần còn tốn RAM:

1. Loại trùng bằng hash map thay vì in_array

// ❌ O(n²) khi có nhiều giá trị khác nhau, và giữ mọi dòng
$styleNos = [];
foreach ($candidates as $c) {
    if (in_array($c['style_no'], $styleNos, true)) {
        $duplicateRows[] = $c;
        continue;
    }
    $styleNos[] = $c['style_no'];
}

// ✅ O(n), chỉ giữ mỗi giá trị một lần
$firstLine      = [];   // style_no => dòng đầu tiên
$duplicateCount = 0;
$duplicateHead  = [];   // chỉ giữ N dòng trùng đầu tiên để hiển thị

foreach ($rows as $row) {
    $styleNo = trim($row['columns'][0] ?? '');

    if (isset($firstLine[$styleNo])) {
        $duplicateCount++;
        if (count($duplicateHead) < 100) {
            $duplicateHead[] = ['line' => $row['line'], 'style_no' => $styleNo];
        }
        continue;
    }

    $firstLine[$styleNo] = $row['line'];
}

isset() trên key của array là tra cứu hash O(1), còn in_array() phải duyệt tuyến tính O(n). Với 100 nghìn giá trị khác nhau, bản in_array phải so sánh khoảng 5 tỷ lần.

2. Không trả hàng triệu dòng về frontend

Trả JSON 49MB chứa 1.5 triệu dòng trùng là bất lợi ở mọi tầng: backend tốn RAM để json_encode, network tốn băng thông, browser giật khi render modal. Hãy trả về số lượng + N dòng đầu:

{
  "duplicate_count": 1497964,
  "duplicate_rows": [ { "line": 2, "style_no": "10001" }, "... tối đa 100 dòng" ]
}

Đây là thay đổi hành vi, nên cần xác nhận lại với PM/khách hàng trước khi làm, nhất là khi spec đang ghi "hiển thị toàn bộ danh sách trùng".

3. Ghi DB theo batch

Khi ghi dữ liệu, đừng tạo 1.5 triệu model Eloquent. Hãy gom theo lô (chunk) và dùng upsert/whereIn:

foreach (array_chunk(array_keys($firstLine), 1000) as $chunk) {
    DB::table('style_colors')
        ->whereIn('style_no', $chunk)
        ->update(['ec_flag' => 1, 'updated_at' => now()]);
}

Xử Lý Lỗi Thường Gặp

Triệu chứng Nguyên nhân Cách xử lý
413 HTML của nginx client_max_body_size quá nhỏ Tăng trong server block, nginx -t && systemctl reload nginx
413 JSON "The POST data is too large." post_max_size của FPM Tăng trong fpm/conf.d/99-*.ini, reload FPM
422 "The file failed to upload." upload_max_filesize Tăng, và để nhỏ hơn post_max_size
422 "must not be greater than X kilobytes" Rule max: của Laravel Sửa rule (đơn vị KB)
Sửa .user.ini mà kết quả lúc được lúc không user_ini.cache_ttl 300s theo từng worker Đợi 5 phút hoặc reload FPM
Sửa php.ini mà không có tác dụng Sửa nhầm file CLI Kiểm tra bằng php-fpm8.4 -i, không phải php -i
500 body rỗng PHP fatal (memory/timeout) Xem log nginx/FPM, tìm Allowed memory size
502 Bad Gateway khi upload Worker bị OOM killer kill dmesg -T | grep -i killed, sửa code thay vì tăng RAM
504 Gateway Timeout Xử lý quá lâu fastcgi_read_timeout, hoặc chuyển sang queue job

Best Practices Cho Tính Năng Upload/Import File

  1. Đọc lỗi theo tầng. Mã lỗi + định dạng body (HTML/JSON/rỗng) cho biết request chết ở đâu. Đừng tăng mọi giới hạn cùng lúc "cho chắc".
  2. Xếp giới hạn có thứ tự: nginx ≥ post_max_size > upload_max_filesize ≥ validation. Giới hạn nghiệp vụ đặt ở tầng ứng dụng để trả message dễ hiểu.
  3. Giới hạn cả số dòng, không chỉ dung lượng. 10MB có thể là 5 nghìn dòng dài hoặc 1.5 triệu dòng ngắn. Cái làm tốn RAM là số dòng.
  4. Stream, đừng load cả file. Dùng generator, SplFileObject, fgetcsv, LazyCollection::make() của Laravel. RAM phải là hằng số, không tăng theo kích thước input.
  5. Dùng hash map cho tra cứu trùng/tồn tại (isset($map[$key])), không dùng in_array trong vòng lặp.
  6. Không trả toàn bộ dữ liệu lỗi về frontend. Trả số lượng + N mẫu đầu, hoặc cho tải file lỗi dạng CSV.
  7. File rất lớn thì xử lý bất đồng bộ. Upload lên storage (S3/GCS), đẩy job vào queue, frontend polling hoặc nhận notification khi xong.
  8. Đo bằng số liệu thật. memory_get_peak_usage(), microtime(true), test với file nhỏ rồi ngoại suy. Ghi con số vào commit message để người review hiểu vì sao phải sửa.
  9. Cấu hình server bằng file override (conf.d/99-*.ini), luôn chạy -t trước khi reload, và ghi lại những gì đã đổi.
  10. Test case "dữ liệu nhiều" là test case hợp lệ. Khi QC upload 1.5 triệu dòng giống nhau, họ đang tìm đúng loại bug này.

Tổng Kết

Case "upload file 10MB bị lỗi" thực ra là hai bug chồng lên nhau:

  1. Giới hạn cấu hình: post_max_size = 8M của PHP-FPM chặn request (413). Sửa bằng conf.d/99-uploads.ini và reload FPM, không phải sửa nginx.
  2. Code không scale: reader dựng một PHP array cho từng dòng, nên 1.5 triệu dòng tốn ~930MB và làm PHP chết với lỗi 500 trống. Tăng memory_limit và thêm swap chỉ giúp QC test tiếp được. Cách sửa gốc là streaming bằng generator: RAM của reader giảm từ ~930MB xuống 16MB.

Bài học lớn nhất: khi RAM tăng tuyến tính theo input, lỗi nằm ở code, không nằm ở server. Server luôn có giới hạn, còn input thì không. Hãy thiết kế tính năng import sao cho bộ nhớ gần như không đổi, dù người dùng upload 1 nghìn hay 1 triệu dòng.

Ở bài tiếp theo trong series Performance, chúng ta sẽ bàn về N+1 query: cách phát hiện, xử lý bằng eager loading, và khi nào nên load toàn bộ dữ liệu rồi xử lý trong bộ nhớ thay vì query trong vòng lặp.