Tối ưu Laravel — tốc độ và memory
Tài liệu này nói về tối ưu hiệu năng (performance — app chạy nhanh hơn) và tối ưu bộ nhớ (memory footprint — app ăn ít RAM hơn) trong ứng dụng Laravel thật, đang chạy production. Không phải danh sách mẹo vặt: mỗi kỹ thuật đều nói rõ cơ chế bên dưới, được gì, mất gì, và khi nào KHÔNG nên dùng.
Quy tắc số 1: không tối ưu khi chưa đo. 90% thời gian tối ưu sai chỗ đến từ việc đoán. Đo → tìm chỗ nghẽn thật → sửa 1 thứ → đo lại. Phần 0 nói cách đo.
Bổ sung cho migration-postgresql-zero-downtime (phần index và schema) và canary-laravel-livewire (phần deploy Livewire).
Từ điển nhanh
| Thuật ngữ | Nghĩa ngắn gọn |
|---|---|
| N+1 query | 1 query lấy danh sách, rồi mỗi dòng lại bắn thêm 1 query nữa để lấy quan hệ |
| hydration | Biến 1 dòng dữ liệu thô từ DB thành object Eloquent Model trong PHP |
| buffered query | Driver DB tải toàn bộ kết quả về RAM của PHP trước khi bạn đọc dòng đầu tiên |
| keyset pagination | Phân trang theo con trỏ WHERE id > ?, không dùng OFFSET |
| cache stampede | Cache hết hạn đúng lúc đông người, cả nghìn request cùng lao vào tính lại |
| opcache | Bộ nhớ đệm của PHP giữ bytecode đã biên dịch, khỏi parse lại file .php mỗi request |
| memory leak | RAM tăng dần và không trả lại, do có thứ giữ tham chiếu mãi |
| eventual consistency | Dữ liệu đúng sau một lúc, không đúng ngay lập tức |
Phần 0. Đo trước — biết thời gian và RAM đi đâu
0.1. Một request Laravel tiêu thời gian ở đâu
flowchart LR A["Nginx / FPM
nhận request"] --> B["Bootstrap Laravel
autoload + service provider
~10-40ms nếu không cache"] B --> C["Middleware
session, auth, CSRF"] C --> D["Controller / Livewire
business logic"] D --> E["Database
thường 60-90% thời gian"] D --> F["Cache / Redis"] D --> G["HTTP call ra ngoài
API bên thứ ba"] E --> H["Blade render"] F --> H G --> H H --> I["Response"]
Kinh nghiệm chung: trong app CRUD bình thường, database chiếm phần lớn thời gian, kế đó là các HTTP call đồng bộ ra ngoài, rồi mới tới PHP thuần. Nếu chưa đo mà đã đi tối ưu vòng lặp PHP thì gần như chắc chắn là tối ưu sai chỗ.
0.2. Bộ đồ nghề
| Công cụ | Dùng ở đâu | Cho biết gì |
|---|---|---|
| Laravel Debugbar | local | Số query, query trùng, thời gian mỗi query, RAM đỉnh |
| Laravel Telescope | local / staging | Như trên + queue job, cache hit/miss, request chậm. Không bật full ở production |
| Clockwork | local | Nhẹ hơn Debugbar, xem được cả request AJAX/Livewire |
| Blackfire / XHProf / SPX | staging / production (sampling) | Profiler thật: hàm nào tốn CPU, tốn RAM bao nhiêu byte |
| slow query log của MySQL/Postgres | production | Query chậm thật trên dữ liệu thật |
pg_stat_statements | production (Postgres) | Query nào tốn tổng thời gian nhiều nhất — quan trọng hơn query chậm nhất |
| APM (New Relic, Datadog, Sentry Performance) | production | p95/p99 theo route, trace xuyên suốt |
Đừng nhìn trung bình, nhìn p95/p99. Trung bình 200ms nghe đẹp nhưng có thể che việc 5% người dùng đợi 8 giây. Người khó chịu và bỏ đi luôn nằm ở đuôi phân phối.
0.3. Đo tay khi không có công cụ
// Đếm query + in ra query trùng lặp — bỏ vào AppServiceProvider::boot() khi debug
DB::listen(function ($q) {
logger()->debug($q->sql, ['time' => $q->time, 'bindings' => $q->bindings]);
});
// Đo RAM đỉnh của 1 đoạn code. true = RAM thật hệ điều hành cấp cho process
$before = memory_get_peak_usage(true);
$data = $this->heavyWork();
logger()->info('peak MB', ['mb' => (memory_get_peak_usage(true) - $before) / 1024 / 1024]);
Trong test tự động, chặn N+1 ngay từ đầu bằng cách bật preventLazyLoading:
// AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Model;
Model::preventLazyLoading(! $this->app->isProduction());
Khi bật, mọi lần truy cập quan hệ chưa eager load sẽ ném exception ở local và CI. Đây là kỹ thuật hiệu quả nhất trong cả tài liệu này: nó biến lỗi hiệu năng thành lỗi biên dịch, phát hiện lúc code chứ không phải lúc bị khách phàn nàn.
Phần 1. Database và Eloquent — chỗ đáng sửa đầu tiên
1.1. N+1 query — lỗi phổ biến nhất
sequenceDiagram
participant PHP as PHP
participant DB as Database
Note over PHP,DB: KHÔNG eager load — 1 + 50 query
PHP->>DB: SELECT * FROM posts LIMIT 50
DB-->>PHP: 50 dòng
loop 50 lần, mỗi post 1 lần
PHP->>DB: SELECT * FROM users WHERE id = ?
end
Note over PHP,DB: CÓ eager load — 2 query
PHP->>DB: SELECT * FROM posts LIMIT 50
PHP->>DB: SELECT * FROM users WHERE id IN (1,2,...,50)
Mỗi query dù chỉ 2ms, nhân 50 lần thành 100ms — cộng thêm chi phí đi lại mạng tới DB server. Trên trang có 200 dòng thì thành 400ms chỉ để lấy dữ liệu lẽ ra 1 query.
// Sai
$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
echo $post->author->name; // mỗi vòng lặp = 1 query
}
// Đúng
$posts = Post::with('author')->latest()->take(50)->get();
// Đúng hơn: chỉ lấy cột cần. Nhớ luôn kèm khoá ngoại, nếu không quan hệ sẽ null
$posts = Post::with('author:id,name')->latest()->take(50)->get();
// Đã có collection rồi mới cần quan hệ → loadMissing, không load lại cái đã có
$posts->loadMissing('comments.author');
Ba biến thể N+1 hay bị bỏ sót
a) Đếm quan hệ trong vòng lặp
$post->comments->count(); // tải hết comment về RAM rồi mới đếm
$post->comments()->count(); // 1 query COUNT mỗi post → vẫn N+1
Post::withCount('comments')->get(); // đúng: gộp vào subquery của query chính
b) Quay ngược lên model cha từ con — dù đã eager load, truy cập
$comment->post vẫn bắn query mới. Laravel có chaperone() để gắn sẵn model cha
vào từng con:
class Post extends Model
{
public function comments(): HasMany
{
return $this->hasMany(Comment::class)->chaperone();
}
}
// hoặc bật tại chỗ khi eager load
$posts = Post::with(['comments' => fn ($q) => $q->chaperone()])->get();
c) Accessor gọi DB. Một accessor như getFullAddressAttribute() mà bên
trong gọi $this->city->name sẽ biến mọi vòng lặp hiển thị thành N+1 mà nhìn
code view không thấy gì bất thường. Đây là loại khó tìm nhất.
1.2. Chỉ lấy cột thật sự cần
SELECT * có ba cái giá, cộng dồn:
- DB đọc và gửi qua mạng cả cột
text/jsonlớn mà bạn không dùng. - PHP phải cấp phát RAM cho từng cột đó, cho từng dòng.
- Query mất luôn cơ hội dùng covering index (index chứa đủ cột cần, DB không phải mở bảng ra đọc).
// Trang danh sách chỉ hiện tiêu đề và ngày, nhưng bảng có cột content dài 50KB
Post::select('id', 'title', 'created_at')->paginate(20);
1.3. Bỏ hydration khi chỉ cần đọc
Hydration là bước Laravel biến 1 dòng DB thành object Model. Mỗi model giữ ít
nhất: mảng attributes, mảng original (bản sao để biết cột nào đã đổi), mảng
relations, cùng cờ casts và trạng thái. Nói cách khác mỗi dòng dữ liệu bị lưu
hai lần trong RAM, kèm chi phí object.
flowchart TB A["1 dòng từ DB"] --> B["Eloquent Model
attributes + original + relations
+ casts + object overhead"] A --> C["stdClass qua DB::table
chỉ các thuộc tính"] A --> D["array qua toBase rồi toArray
nhẹ nhất"] B --> E["Nặng nhất
nhưng có accessor, cast, quan hệ, save()"] C --> F["Nhẹ hơn nhiều lần
không có gì ngoài dữ liệu thô"] D --> F
// Cần model đầy đủ (sẽ gọi save, dùng accessor, event) → Eloquent
$users = User::where('active', true)->get();
// Chỉ đọc để đổ ra JSON/CSV/report → bỏ hydration
$users = User::where('active', true)->toBase()->get(); // trả stdClass, vẫn giữ scope của model
$users = DB::table('users')->where('active', true)->get(); // bỏ hẳn Eloquent
// Chỉ cần 1 cột → pluck, không tạo model nào cả
$ids = User::where('active', true)->pluck('id');
Con số minh hoạ (ví dụ để thấy độ lớn tương đối, không phải benchmark chuẩn): 10.000 dòng dạng Eloquent model có thể ăn vài chục MB, cùng dữ liệu đó dạng
stdClassthường chỉ còn tầm một phần ba tới một phần năm. Hãy tự đo bằngmemory_get_peak_usage(true)trên dữ liệu của chính bạn.
1.4. Duyệt dữ liệu lớn: chunk, cursor, lazy, lazyById
Đây là phần quan trọng nhất về memory. Bốn cách nhìn giống nhau nhưng cơ chế khác hẳn.
flowchart LR
subgraph GET["get() - nạp hết"]
G1["1 query"] --> G2["Toàn bộ dòng nằm trong RAM PHP"]
end
subgraph CHUNK["chunk / lazy - nhiều query nhỏ"]
C1["query 1: LIMIT 500 OFFSET 0"] --> C2["xử lý 500 dòng rồi giải phóng"]
C2 --> C3["query 2: LIMIT 500 OFFSET 500"] --> C4["..."]
end
subgraph CURSOR["cursor - 1 query, đọc dần"]
R1["1 query duy nhất"] --> R2["đọc từng dòng từ kết quả
PHP chỉ giữ 1 model mỗi lúc"]
end
| Cách | Số query | RAM phía PHP | RAM phía DB driver | An toàn khi vừa duyệt vừa sửa? |
|---|---|---|---|---|
get() | 1 | Toàn bộ | Toàn bộ | — |
chunk(500, fn) | N/500 | 500 model | 500 dòng | Không — dùng OFFSET, sửa dữ liệu làm lệch trang |
chunkById(500, fn) | N/500 | 500 model | 500 dòng | Có — lọc theo id > last |
lazy(500) | N/500 | 500 model | 500 dòng | Không (giống chunk) |
lazyById(500) | N/500 | 500 model | 500 dòng | Có |
cursor() | 1 | 1 model | Tuỳ driver — xem cảnh báo | Có, nhưng giữ kết nối lâu |
// chunkById: an toàn nhất khi vừa duyệt vừa update
Flight::where('departed', true)
->chunkById(500, function ($flights) {
foreach ($flights as $flight) { /* ... */ }
});
// lazy/lazyById: cùng cơ chế nhưng viết như một luồng liền mạch
foreach (Flight::lazyById(500) as $flight) {
// ...
}
Flight::where('departed', true)
->lazyById(200, column: 'id')
->each->update(['departed' => false]);
// cursor: 1 query, PHP giữ 1 model tại một thời điểm
foreach (User::where('active', true)->cursor() as $user) {
// ...
}
Cái bẫy lớn của
cursor(): nó chỉ tiết kiệm RAM ở tầng PHP. Với MySQL và PDO ở chế độ mặc định (buffered query), driver vẫn kéo toàn bộ kết quả về bộ nhớ của tiến trình PHP trước đã — RAM vẫn nổ như thường, chỉ là nổ ở chỗ bạn không nhìn thấy trong profiler PHP. Muốncursor()thật sự stream thì phải tắt buffer (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false), và khi đó kết nối bị giữ cho tới khi duyệt xong, không chạy query khác trên cùng kết nối được. PostgreSQL dễ chịu hơn nhưng vẫn nên cẩn thận.Mặc định nên chọn
lazyById()/chunkById(). Chỉ dùngcursor()khi đã kiểm chứng driver và đã đo RAM thật.
1.5. LazyCollection — stream cả những thứ không phải DB
Collection giữ toàn bộ mảng trong RAM. LazyCollection dựa trên generator của
PHP: nó chỉ tạo phần tử khi bạn thực sự đọc tới.
// Đọc file log 5GB — không bao giờ nạp quá 1 dòng vào RAM
LazyCollection::make(function () {
$handle = fopen('storage/logs/access.log', 'r');
while (($line = fgets($handle)) !== false) {
yield $line;
}
})
->filter(fn ($line) => str_contains($line, ' 500 '))
->take(100) // dừng đọc file ngay khi đủ 100 dòng
->each(fn ($line) => report($line));
Điểm mạnh thật sự nằm ở take(): chuỗi lazy ngừng làm việc ngay khi đủ,
trong khi collection thường phải xử lý xong hết rồi mới cắt.
Đổi lại, LazyCollection chỉ đi được một lượt: không có count() rẻ, không
quay lại đầu, không truy cập theo chỉ số. Cần sắp xếp toàn bộ hay đếm tổng thì
vẫn phải nạp hết.
1.6. Ghi hàng loạt
// Sai: 10.000 model + 10.000 INSERT + 10.000 lượt bắn event
foreach ($rows as $row) {
Product::create($row);
}
// Đúng: chèn theo lô. Chú ý: KHÔNG bắn model event, KHÔNG tự điền timestamps
foreach (array_chunk($rows, 1000) as $batch) {
Product::insert($batch); // nhớ tự thêm created_at/updated_at nếu cần
}
// Chèn mới hoặc cập nhật nếu đã tồn tại, trong 1 lệnh
Product::upsert($rows, uniqueBy: ['sku'], update: ['price', 'stock']);
Bọc cả vòng lặp ghi trong một transaction cũng giúp nhiều: mỗi
INSERT lẻ là một lần commit và một lần fsync xuống đĩa.
insert()/upsert()đi thẳng xuống DB, nên bỏ qua accessor, cast, observer, vàupdated_at. Nếu logic nghiệp vụ của bạn nằm trong model event thì đây là đánh đổi phải nói rõ ra, không phải tối ưu miễn phí.
1.7. Phân trang: paginate vs simplePaginate vs cursorPaginate
flowchart TB
A["Cần phân trang"] --> B{"Người dùng có cần
số trang và tổng số dòng?"}
B -- "Có" --> C["paginate()
+1 query COUNT mỗi lần
chậm dần khi bảng lớn"]
B -- "Không" --> D{"Dữ liệu có bị chèn thêm
liên tục lúc đang xem?"}
D -- "Có" --> E["cursorPaginate()
keyset, không nhảy dòng
không nhảy trang tuỳ ý"]
D -- "Không" --> F["simplePaginate()
chỉ có Trước / Sau
bỏ được query COUNT"]
Vấn đề của OFFSET lớn: LIMIT 20 OFFSET 100000 buộc DB phải đọc và bỏ đi
100.000 dòng trước khi trả về 20 dòng. Trang 1 nhanh, trang 5000 treo máy.
cursorPaginate() dùng keyset pagination (phân trang theo con trỏ,
WHERE id < ?) nên trang nào cũng nhanh như nhau — giá phải trả là không cho
nhảy thẳng tới trang bất kỳ và bắt buộc phải sắp xếp theo cột có thứ tự duy nhất.
Phần 2. Memory — hiểu PHP đang giữ cái gì
2.1. Mô hình bộ nhớ cần nhớ
PHP-FPM: mỗi request là một tiến trình riêng, chạy xong trả lại toàn bộ RAM.
Nên "memory leak" trong web request thường không phải leak thật, mà là một request
ăn quá nhiều. Ngược lại, tiến trình sống lâu — queue:work, schedule:run,
Octane — thì leak thật sự tồn tại và tích luỹ.
flowchart LR
subgraph FPM["PHP-FPM"]
A["Request đến"] --> B["Cấp RAM"] --> C["Trả response"] --> D["Xoá sạch RAM"]
end
subgraph WORKER["Queue worker / Octane"]
E["Job 1"] --> F["Job 2"] --> G["Job 3"] --> H["Job N"]
H --> I["RAM tích luỹ nếu có gì
giữ tham chiếu lại"]
end
memory_limit là giới hạn cho mỗi tiến trình. Nâng nó lên không phải là sửa
lỗi — nó chỉ đẩy điểm sập ra xa hơn, và nhân với số worker thì rất dễ làm cả máy
bị OOM killer bắn chết.
2.2. Những nguồn ăn RAM hay gặp
| Nguồn | Vì sao | Cách xử lý |
|---|---|---|
Model::all() trên bảng lớn | Nạp hết + hydrate hết | lazyById(), hoặc thêm where |
| Query log | Laravel giữ mọi query đã chạy trong mảng | DB::disableQueryLog() ở đầu job dài |
| Nối chuỗi khổng lồ (CSV, XML) | Cả file nằm trong RAM | Ghi ra stream từng dòng |
Thu kết quả vào mảng ngoài chunk | chunk giải phóng lô cũ, nhưng mảng của bạn thì không | Ghi thẳng ra file/DB trong mỗi lô |
Thuộc tính static tích dữ liệu | Sống suốt đời tiến trình | Bỏ static, hoặc reset ở cuối job |
| Event listener đóng biến lớn | Closure giữ tham chiếu tới object nặng | Chỉ truyền id vào closure |
| Ảnh xử lý bằng GD/Imagick | Ảnh giải nén trong RAM: 6000×4000 ≈ 96MB | Xử lý trong queue, giới hạn kích thước, giải phóng ngay |
2.3. Xuất file lớn — mẫu chuẩn
Đây là đầu bài làm sập app nhiều nhất: "xuất Excel toàn bộ đơn hàng".
// Sai: cả 500.000 dòng nằm trong RAM, và người dùng ngồi đợi 3 phút
return Excel::download(new OrdersExport(Order::all()), 'orders.xlsx');
// Đúng cho file vừa: stream từng dòng, RAM gần như phẳng
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'khach_hang', 'tong_tien', 'ngay']);
Order::query()
->select('id', 'customer_name', 'total', 'created_at')
->toBase()
->lazyById(1000)
->each(function ($o) use ($out) {
fputcsv($out, [$o->id, $o->customer_name, $o->total, $o->created_at]);
});
fclose($out);
}, 'orders.csv');
// Đúng cho file rất lớn: đẩy hẳn sang queue, sinh file lên S3, gửi link qua email
ExportOrders::dispatch($filters, auth()->id());
Ba điểm cộng dồn ở bản đúng: select bớt cột, toBase() bỏ hydration,
lazyById() giữ RAM phẳng, và streamDownload đẩy byte ra trình duyệt ngay
thay vì dựng cả file trong RAM.
Ngưỡng nên nhớ: quá 30 giây hoặc quá vài chục nghìn dòng thì đừng làm trong request nữa. Nginx/FPM sẽ timeout, và người dùng sẽ bấm F5 — tạo thêm một tiến trình nặng y hệt bên cạnh cái đang chạy.
2.4. Cấu hình queue worker để leak không giết máy
# Worker tự thoát khi vượt 256MB hoặc sau 1000 job; supervisor khởi động lại giúp
php artisan queue:work --memory=256 --max-jobs=1000 --max-time=3600 --tries=3
Đây không phải sửa leak, mà là hàng rào chặn thiệt hại. Vẫn phải đi tìm nguyên nhân, nhưng hàng rào này giúp bạn không bị gọi dậy lúc 3 giờ sáng.
Phần 3. Cache — nhanh nhất là việc không phải làm lại
3.1. Các tầng cache, từ rẻ tới đắt
flowchart TB A["Request"] --> B["CDN / HTTP cache
không chạm tới server"] B --> C["opcache + preload
bỏ bước biên dịch PHP"] C --> D["config / route / view cache
bỏ bước bootstrap"] D --> E["Application cache - Redis
bỏ query và tính toán"] E --> F["Query cache trong 1 request
bỏ query lặp lại"] F --> G["Database"]
Nguyên tắc: tầng nào chặn sớm hơn thì rẻ hơn. Một trang tin công khai được CDN phục vụ không tốn của bạn một mili-giây PHP nào.
3.2. Cache ở tầng ứng dụng
// Mẫu cơ bản
$stats = Cache::remember("dashboard:stats:{$shopId}", now()->addMinutes(10), function () use ($shopId) {
return $this->computeExpensiveStats($shopId);
});
// Xoá theo nhóm — cần store hỗ trợ tag như Redis
Cache::tags(['shop:'.$shopId, 'orders'])->remember(...);
Cache::tags(['orders'])->flush();
// Nhớ mãi, chỉ xoá khi dữ liệu đổi (event-based invalidation)
Cache::rememberForever("product:{$id}:card", fn () => $this->buildCard($id));
// rồi trong ProductObserver::saved(): Cache::forget("product:{$id}:card");
Cache có lãi khi thoả cả ba: đọc nhiều hơn ghi, tính toán đắt, và dữ liệu cũ vài phút vẫn chấp nhận được. Thiếu điều kiện thứ ba thì cache không phải tối ưu, mà là một quyết định về đúng/sai dữ liệu.
3.3. Cache stampede — cái bẫy kinh điển
sequenceDiagram participant U as 2000 người dùng participant C as Redis participant DB as Database Note over C: Key hết hạn đúng 12:00:00 U->>C: 2000 lần GET cùng lúc C-->>U: miss hết U->>DB: 2000 query nặng cùng lúc Note over DB: CPU 100%, kết nối cạn, sập
Ba cách chống, dùng kết hợp được:
// 1. Khoá: chỉ 1 tiến trình được tính lại, số còn lại đợi
$value = Cache::lock("lock:stats:{$id}", 10)->block(5, function () use ($id) {
return Cache::remember("stats:{$id}", 600, fn () => $this->compute($id));
});
// 2. TTL có nhiễu ngẫu nhiên: các key không cùng hết hạn một lúc
Cache::put($key, $value, now()->addSeconds(600 + random_int(0, 120)));
// 3. Trả bản cũ trong lúc tính bản mới (stale-while-revalidate)
// Laravel 11+ có sẵn: tươi trong 5 phút, sau đó vẫn trả bản cũ
// và làm mới ngầm cho tới phút thứ 10
$value = Cache::flexible($key, [300, 600], fn () => $this->compute($id));
3.4. Điều đáng lo nhất về cache
Cache biến một hệ thống thành hai nguồn sự thật. Từ giờ mỗi lần dữ liệu sai, câu hỏi đầu tiên luôn là "cache cũ hay DB sai?". Chi phí thật của cache không nằm ở lúc viết, mà ở lúc gỡ lỗi.
Đừng cache để che một query dở. Query 8 giây được cache lại vẫn là query 8 giây — chỉ là bây giờ nó rơi vào đầu một người dùng xui xẻo, và rơi lại mỗi lần cache miss hoặc mỗi lần deploy làm sạch cache. Sửa query trước, cache sau.
Phần 4. Queue — đẩy việc chậm ra khỏi request
Việc nào không cần trả kết quả ngay cho người dùng thì không nên nằm trong request: gửi mail, dựng PDF, resize ảnh, gọi webhook, đồng bộ sang hệ thống khác, ghi log phân tích.
sequenceDiagram participant U as Người dùng participant W as Web participant Q as Redis queue participant K as Worker U->>W: POST /orders W->>W: lưu đơn - 40ms W->>Q: đẩy job SendInvoice, ProcessImages W-->>U: 201 Created - 50ms Q->>K: worker lấy job K->>K: dựng PDF, gửi mail - 6 giây
4.1. Vài luật khi viết job
class ProcessOrder implements ShouldQueue
{
use Queueable;
// Truyền ID, đừng truyền cả model nặng. SerializesModels chỉ lưu khoá chính,
// nhưng nếu bạn nhét collection hay mảng lớn vào constructor thì nó bị
// serialize nguyên xi vào payload của queue.
public function __construct(public int $orderId) {}
public $tries = 3;
public $backoff = [10, 60, 300]; // giãn dần khi thử lại
public $timeout = 120;
public function handle(): void
{
$order = Order::findOrFail($this->orderId);
// ...
}
}
Thêm hai điều hay quên:
- Job phải idempotent (chạy lại nhiều lần vẫn ra cùng kết quả) — vì worker
có thể chết giữa chừng và job sẽ được thử lại. Dùng
ShouldBeUniquehoặc tự kiểm tra trạng thái ở đầuhandle(). - Đẩy job sau khi commit. Đặt
after_commit => truetrong config queue, nếu không worker có thể chạy trước khi transaction commit xong và không tìm thấy bản ghi.
4.2. Chia lô công việc lớn
// Thay vì 1 job chạy 4 tiếng, chia thành nhiều job nhỏ chạy song song
$jobs = Order::pending()->pluck('id')->chunk(500)
->map(fn ($ids) => new SyncOrders($ids->all()));
Bus::batch($jobs)
->allowFailures()
->then(fn () => Notification::send($admin, new SyncDone()))
->dispatch();
Job nhỏ dễ thử lại, dễ chạy song song, và không giữ RAM lâu. Một job chạy 4 tiếng mà chết ở phút 230 thì bạn mất trắng cả 230 phút.
Phần 5. Tầng PHP và deploy
5.1. Cache bootstrap khi deploy
composer install --no-dev --optimize-autoloader --classmap-authoritative
php artisan optimize # gộp config:cache + route:cache + view:cache + event:cache
# php artisan optimize:clear # gỡ tất cả khi cần
| Lệnh | Tiết kiệm gì | Cạm bẫy |
|---|---|---|
config:cache | Gộp mọi file config thành 1 file PHP, khỏi đọc và merge | env() ngoài thư mục config/ sẽ trả về null |
route:cache | Biên dịch sẵn bảng route | Không dùng được với route dạng closure |
view:cache | Biên dịch sẵn Blade, khỏi compile lúc có request | Nhớ chạy lại mỗi lần deploy |
event:cache | Bỏ bước quét file để tự tìm listener | Listener mới không nhận nếu quên chạy lại |
--optimize-autoloader | Bản đồ class sẵn, khỏi dò thư mục | --classmap-authoritative làm hỏng class sinh động lúc chạy |
Lỗi kinh điển: gọi
env('SOME_KEY')trong controller hay service. Ở local không cache config nên chạy tốt; deploy lên production chạyconfig:cachelà nó thànhnullngay. Luật:env()chỉ xuất hiện trongconfig/*.php, chỗ khác dùngconfig('...').
5.2. opcache
; php.ini cho production
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; không kiểm tra file đổi → PHẢI reload FPM khi deploy
opcache.interned_strings_buffer=16
validate_timestamps=0 là thay đổi có lãi nhất trong bảng này — nhưng nó cũng có
nghĩa là deploy xong mà không reload php-fpm thì code mới không có hiệu lực.
Rất nhiều sự cố "deploy rồi mà vẫn chạy bản cũ" là từ đây.
5.3. Octane — bỏ hẳn bước bootstrap
flowchart LR
subgraph FPM["PHP-FPM: mỗi request bootstrap lại"]
A1["Request"] --> A2["Nạp autoload + provider"] --> A3["Xử lý"] --> A4["Huỷ tất cả"]
end
subgraph OCT["Octane: bootstrap 1 lần rồi giữ trong RAM"]
B1["Khởi động: nạp framework"] --> B2["Request 1"] --> B3["Request 2"] --> B4["Request N"]
end
Bỏ được 10–40ms bootstrap mỗi request là thật, nhưng cái giá là app không còn sạch sẽ giữa các request:
// Rò rỉ bộ nhớ điển hình dưới Octane: static tích dữ liệu mãi mãi
public function index(Request $request): array
{
Service::$data[] = Str::random(10); // không bao giờ được dọn
return [];
}
Ba nguồn lỗi khi lên Octane:
- Thuộc tính static tích dữ liệu qua từng request.
- Singleton đăng ký lúc khởi động mà lại giữ
RequesthayAuthcủa request đầu tiên → người dùng B nhìn thấy dữ liệu của người dùng A. Đây là lỗi bảo mật, không chỉ là lỗi hiệu năng. - Package cũ viết theo giả định "mỗi request một tiến trình".
Octane có Cache::store('octane') — cache nằm ngay trong RAM tiến trình, đọc ghi
cực nhanh, nhưng chỉ dùng cho dữ liệu chấp nhận được là không chia sẻ giữa các
worker:
Cache::store('octane')->put('framework', 'Laravel', 30);
Lời khuyên thẳng: Octane là thứ nên làm cuối cùng. Nếu app đang tốn 900ms vì N+1 thì bỏ 30ms bootstrap chẳng cứu được gì, mà bạn lại nhận thêm một loại lỗi mới rất khó tái hiện. Sửa DB trước.
5.4. Tính số tiến trình FPM
# pm.max_children ≈ (RAM dành cho PHP) / (RAM trung bình mỗi tiến trình)
# Ví dụ: máy 8GB, chừa 2GB cho hệ thống + Redis, mỗi tiến trình ~80MB
# → (6144 MB) / 80 MB ≈ 76 tiến trình
Đặt quá cao thì máy swap rồi chậm toàn tập; đặt quá thấp thì request xếp hàng.
Cần đo pm.status thật chứ đừng copy con số từ blog.
Phần 6. Livewire và Blade
Mỗi tương tác Livewire là một round-trip HTTP mang theo snapshot (ảnh chụp trạng thái component, gửi đi gửi lại giữa trình duyệt và server). Snapshot to thì mọi cú click đều chậm và tốn băng thông.
| Vấn đề | Cách xử lý |
|---|---|
| Nhét cả Eloquent collection vào public property | Chỉ giữ id/filter; lấy dữ liệu trong #[Computed] hoặc trong render() |
| Gõ một chữ là bắn một request | wire:model.live.debounce.400ms, hoặc wire:model.blur, hoặc mặc định deferred |
| Danh sách vẽ lại toàn bộ khi đổi 1 dòng | wire:key ổn định trên mỗi phần tử vòng lặp |
| Property nhạy cảm bị sửa từ phía client | #[Locked] — đây là vấn đề bảo mật, không phải hiệu năng |
| Cùng một query bị gọi lại nhiều lần trong 1 lần render | #[Computed] nhớ kết quả trong phạm vi request đó |
| Danh sách dài | WithPagination, hoặc tải thêm dần |
use Livewire\Attributes\{Computed, Locked};
class ProductList extends Component
{
#[Locked]
public int $shopId; // client không sửa được
public string $search = '';
#[Computed] // chỉ chạy 1 lần cho mỗi lần render
public function products()
{
return Product::where('shop_id', $this->shopId)
->when($this->search, fn ($q) => $q->where('name', 'like', "%{$this->search}%"))
->select('id', 'name', 'price')
->paginate(20);
}
}
Bên Blade: tuyệt đối không gọi query trong view (view chạy sau khi controller đã
xong, profiler dễ bỏ sót), dùng @once cho script/style lặp, và nhớ
view:cache khi deploy.
Phần 7. Lợi ích — được gì và trong hoàn cảnh nào
| Kỹ thuật | Lợi ích thật | Chỉ đáng làm khi |
|---|---|---|
| Sửa N+1 | Thường cắt 50–90% thời gian trang danh sách | Gần như luôn luôn — đây là món lãi cao nhất, rủi ro thấp nhất |
| Thêm index | Query từ giây xuống mili-giây | Có query chậm thật; index thừa làm chậm mọi lệnh ghi |
select cột cần | Ít băng thông DB, ít RAM | Bảng có cột lớn, hoặc trả nhiều dòng |
lazyById / chunkById | RAM phẳng bất kể dữ liệu bao nhiêu | Job/command duyệt bảng lớn |
toBase() | Giảm RAM và CPU hydration đáng kể | Chỉ đọc, không cần accessor/quan hệ |
| Cache | Bỏ hẳn công việc lặp lại | Đọc nhiều, tính đắt, chịu được dữ liệu cũ |
| Queue | Request trả về ngay, chịu tải đột biến tốt | Việc không cần kết quả tức thì |
artisan optimize | Bớt vài chục ms bootstrap, gần như miễn phí | Mọi production |
| opcache | Bỏ toàn bộ chi phí biên dịch PHP | Mọi production |
| Octane | Bỏ nốt chi phí bootstrap | Đã tối ưu DB xong, cần thêm throughput |
Phần 8. Tác hại và hạn chế
- Cache tạo ra nguồn sự thật thứ hai. Mỗi báo cáo "số liệu sai" từ nay tốn thêm một bước điều tra. Cache tag/flush sai còn có thể lộ dữ liệu của shop này sang shop khác nếu key thiếu định danh.
- Queue biến lỗi đồng bộ thành lỗi im lặng. Job fail lúc 2 giờ sáng thì không ai
biết cho tới khi khách hỏi hoá đơn. Bắt buộc phải có cảnh báo trên
failed_jobs. insert()/upsert()bỏ qua model event. Observer đang lo việc tính lại tồn kho hay ghi audit log sẽ không chạy — và không có lỗi nào báo cho bạn biết.cursor()giữ kết nối DB. Duyệt 20 phút là chiếm một kết nối 20 phút. Với pool nhỏ, vài job như vậy đủ làm cạn kết nối của cả web.- Octane phá vỡ giả định "mỗi request một tiến trình". Rò rỉ dữ liệu giữa các người dùng là rủi ro bảo mật thật, và nó không xuất hiện ở local.
config:cache+env()sai chỗ gây lỗi chỉ có ở production.- Index thừa làm chậm INSERT/UPDATE và ăn dung lượng đĩa.
- Code tối ưu thường khó đọc hơn. Một chuỗi
toBase()->lazyById()->each()khó hiểu hơnModel::all(). Người sửa nó sáu tháng sau có thể vô tình bỏ đi.
Phần 9. Trade-off — chọn cái nào, theo tiêu chí gì
flowchart TB A["Trang hoặc job chậm"] --> B["Đo trước:
bao nhiêu query? bao lâu? bao nhiêu RAM?"] B --> C{"Nghẽn ở đâu?"} C -- "Nhiều query nhỏ" --> D["N+1 → eager load, withCount
Rẻ nhất, ít rủi ro nhất"] C -- "Ít query nhưng chậm" --> E["EXPLAIN → thiếu index
hoặc query viết dở"] C -- "RAM cao" --> F["lazyById + select + toBase
+ stream output"] C -- "PHP tốn CPU" --> G["Profiler chỉ đúng hàm
rồi mới sửa thuật toán"] C -- "Gọi API bên ngoài" --> H["Đẩy sang queue
hoặc gọi song song bằng Http::pool"] D --> I{"Còn chậm không?"} E --> I F --> I G --> I H --> I I -- "Còn" --> J["Cache — chấp nhận dữ liệu cũ
đổi lấy tốc độ"] J --> K{"Còn chậm không?"} K -- "Còn" --> L["Đổi hạ tầng: read replica,
Octane, scale ngang"] K -- "Hết" --> M["Dừng. Đừng tối ưu thêm."] I -- "Hết" --> M
| Lựa chọn | Chọn A khi | Chọn B khi |
|---|---|---|
| Eloquent vs Query Builder | Cần accessor, cast, quan hệ, event, hoặc sẽ save() | Chỉ đọc để hiển thị/xuất file, số dòng lớn |
chunkById vs cursor | Vừa duyệt vừa sửa, hoặc dùng MySQL buffered | Chỉ đọc, đã kiểm chứng driver stream thật |
paginate vs cursorPaginate | Người dùng cần thấy tổng số trang | Bảng lớn, cuộn vô hạn, cần trang nào cũng nhanh |
| Cache vs sửa query | Query đã tối ưu hết mức mà vẫn đắt | Query còn sửa được — luôn sửa trước |
| Đồng bộ vs queue | Người dùng cần kết quả ngay để đi bước tiếp | Chậm hơn 1 giây và không cần kết quả ngay |
| Scale dọc vs sửa code | Cần cầm máu gấp trong đêm sự cố | Mọi lúc khác — máy to hơn chỉ mua thêm thời gian |
| Octane vs FPM | Đã hết chỗ tối ưu ở DB, cần thêm throughput | Còn N+1, còn thiếu index, hoặc team chưa quen |
Thứ tự ưu tiên gần như luôn đúng: sửa N+1 → thêm index → bớt cột và bớt hydration → stream thay vì nạp hết → đẩy sang queue → cache → đổi hạ tầng. Đi từ trên xuống, dừng ngay khi đủ nhanh.
Phần 10. Khó khăn khi vận hành thực tế
- Local không giống production. 500 dòng dữ liệu mẫu thì query nào cũng nhanh. Vấn đề chỉ lộ ra ở 5 triệu dòng. Cần môi trường staging có dữ liệu gần thật (đã che dữ liệu cá nhân) thì mới đo có ý nghĩa.
- Profiler ở production tốn tiền và tốn hiệu năng. Blackfire/New Relic là chi phí hàng tháng; Telescope bật full sẽ tự nó làm chậm app và phình DB.
- Tối ưu không có test là canh bạc. Đổi
with()hoặc bỏ hydration rất dễ làm sai dữ liệu một cách âm thầm. Cần test đảm bảo kết quả không đổi trước khi sửa. - Không đặt được ngân sách hiệu năng thì tối ưu vô tận. Nên chốt rõ, ví dụ: "trang danh sách p95 dưới 300ms, tối đa 15 query" và đưa vào CI.
- Debug lúc 3 giờ sáng: khi app chậm mà không có APM, thứ tự nên xem là
SHOW PROCESSLISThoặcpg_stat_activity(query đang treo) → độ dài hàng đợi queue → RAM và swap của máy → tỉ lệ hit của Redis. Không có mấy chỉ số này thì chỉ còn nước đoán mò. - Yếu tố con người. Một dev mới thêm một dòng
$order->itemstrong Blade là N+1 quay lại. Chỉ cópreventLazyLoadingở CI và code review mới giữ được lâu dài.
Phần 11. Case thực tế và cách xử lý
Case 1 — Trang danh sách sản phẩm mất 4 giây
Triệu chứng: trang admin hiện 50 sản phẩm, mất 4s. Debugbar báo 213 query.
Xử lý theo bước:
- Nhìn danh sách query trong Debugbar: thấy
select * from categories where id = ?lặp 50 lần, vàselect count(*) from reviews where product_id = ?lặp 50 lần. - Sửa:
Product::with('category:id,name')->withCount('reviews')→ còn 3 query. - Vẫn 900ms.
EXPLAINquery chính: thiếu index trênproducts(shop_id, created_at)đang dùng chowhere + order by. Thêm index (dùngCONCURRENTLYtrên Postgres, xem tài liệu migration) → còn 180ms. - Bỏ
select *vì bảng có cộtdescriptiondài → còn 120ms. - Bật
Model::preventLazyLoading()ở CI để lỗi này không quay lại.
Kết quả: 4s → 120ms, 213 query → 3 query. Không đụng tới cache, không đụng tới hạ tầng.
Case 2 — Job xuất báo cáo bị OOM
Triệu chứng: ExportOrders chết với Allowed memory size of 512M exhausted.
Ai đó đã nâng memory_limit lên 1G, được hai tuần rồi chết lại.
Chẩn đoán: code là Order::with('items', 'customer')->get() trên 480.000 đơn,
rồi build một chuỗi CSV khổng lồ trong RAM.
Xử lý:
- Hạ
memory_limitvề mức cũ — nâng nó chỉ che triệu chứng. - Thay bằng
lazyById(1000): RAM đi từ tăng tuyến tính sang phẳng. - Ghi thẳng ra file qua
fputcsvtheo từng dòng, không dựng chuỗi trong RAM. - Bỏ
with('items'): báo cáo chỉ cần tổng tiền, đã có sẵn trênorders.total. - Thêm
select()vàtoBase(). - Thêm
DB::disableQueryLog()ở đầuhandle(). - Đặt
--memory=256cho worker làm hàng rào cuối.
Kết quả: RAM đỉnh còn khoảng vài chục MB và không phụ thuộc số đơn hàng — đây mới là thứ quan trọng, vì năm sau có 5 triệu đơn nó vẫn chạy.
Case 3 — Deploy xong site trắng, log báo "could not find driver"
Triệu chứng: pipeline vừa thêm bước php artisan optimize. Deploy lên là hỏng,
rollback thì hết.
Nguyên nhân: có chỗ gọi env('DB_CONNECTION') trong một service class. Trước đây
không cache config nên env() vẫn đọc được file .env; sau khi config:cache
thì Laravel không nạp .env nữa, env() trả null.
Xử lý:
- Cầm máu:
php artisan config:clearđể site sống lại. grep -rn "env(" app/ routes/ resources/tìm hết chỗ dùng sai.- Chuyển sang
config('database.default'), khai báo trongconfig/*.php. - Thêm một bước CI chặn: pipeline fail nếu
env(xuất hiện ngoài thư mụcconfig/. - Bật lại
optimize.
Case 4 — Redis đầy RAM lúc 12 giờ trưa mỗi ngày
Triệu chứng: đúng 12:00 hàng ngày, CPU của DB vọt lên 100% khoảng 40 giây, một số request timeout.
Nguyên nhân: job chạy 00:00 làm ấm cache cho toàn bộ trang danh mục với TTL 12 tiếng. Tất cả key hết hạn cùng một khoảnh khắc → cache stampede.
Xử lý:
- Thêm nhiễu ngẫu nhiên vào TTL:
addHours(12)->addMinutes(random_int(0, 90))→ các key rải ra, không cùng chết một lúc. - Bọc phần tính lại bằng
Cache::lock()để chỉ một tiến trình tính, số còn lại đợi. - Với các key nóng nhất, chuyển sang
Cache::flexible()để trả bản cũ trong lúc làm mới ngầm — người dùng không phải đợi lần nào. - Thêm cảnh báo trên tỉ lệ cache hit; sụt đột ngột là dấu hiệu sớm.
Case 5 — Sau khi lên Octane, người dùng nhìn thấy dữ liệu của người khác
Triệu chứng: lác đác báo cáo "tôi thấy tên shop khác trên thanh menu". Không tái hiện được ở local.
Nguyên nhân: một service được đăng ký singleton và nhận Request vào
constructor. Dưới FPM, mỗi request tạo container mới nên không sao. Dưới Octane,
container sống qua nhiều request, singleton giữ mãi Request đầu tiên.
Xử lý:
- Rollback Octane ngay — đây là rò rỉ dữ liệu giữa người dùng, không phải lỗi hiệu năng để từ từ sửa.
- Rà mọi
singletonnhậnRequest,Auth, hay dữ liệu theo người dùng. Đổi sangbind()(tạo mới mỗi lần) hoặc lấy request tại chỗ khi cần. - Rà thuộc tính
staticcó tích dữ liệu. - Chạy staging bằng Octane với 1 worker và test nhiều tài khoản đan xen nhau.
- Bật lại từ từ và theo dõi RAM của worker theo thời gian.
Phần 12. Câu hỏi phụ interviewer hay hỏi tiếp
1. Không có Debugbar, làm sao biết trang bị N+1?
Bật DB::listen ghi log rồi đếm query trùng dạng; hoặc bật
Model::preventLazyLoading() ở môi trường không phải production để nó ném exception;
hoặc nhìn slow query log thấy cùng một câu lặp lại hàng nghìn lần với binding khác nhau.
2. chunk và cursor khác nhau ở đâu, khi nào chọn cái nào?
chunk chạy nhiều query nhỏ, mỗi lần giữ một lô trong RAM. cursor chạy một
query, đọc dần từng dòng. cursor ít query hơn nhưng giữ kết nối lâu, và với MySQL
buffered thì driver vẫn kéo hết dữ liệu về RAM nên không tiết kiệm như tưởng.
Mặc định tôi chọn lazyById: RAM ổn định và an toàn khi vừa duyệt vừa sửa.
3. Vì sao chunk không an toàn khi update trong lúc duyệt?
Vì nó dùng OFFSET. Nếu điều kiện lọc là where departed = true và bạn sửa cột
đó thành false, các dòng chưa duyệt sẽ dịch lên, và OFFSET của lô sau nhảy
qua chúng. chunkById lọc theo id > last_id nên không bị.
4. Cache và sửa query, chọn cái nào trước?
Sửa query trước, luôn luôn. Cache chỉ giấu query dở đi; chi phí vẫn còn nguyên và
sẽ rơi vào người dùng ở mỗi lần cache miss hoặc mỗi lần deploy.
5. Sao không nâng memory_limit cho xong?
Vì nó là giới hạn mỗi tiến trình, nhân với số worker. Nâng lên chỉ đẩy điểm sập
ra xa và làm cả máy dễ bị OOM killer bắn chết. Và code đó vẫn tăng RAM tuyến tính
theo dữ liệu — nghĩa là chắc chắn sẽ chết lại, chỉ là muộn hơn.
6. Octane nhanh hơn FPM, sao không dùng luôn?
Vì nó chỉ bỏ được phần bootstrap, thường 10–40ms. Nếu app chậm 900ms vì N+1 thì
không đáng. Đổi lại phải xử lý state rò rỉ giữa các request — có trường hợp là lỗ
hổng bảo mật. Tôi để Octane sau cùng, khi DB đã tối ưu xong.
7. Làm sao ngăn N+1 quay lại sau khi đã sửa?
Model::preventLazyLoading() bật ở local và CI, cộng thêm một assertion đếm số
query trong test cho các route quan trọng. Không có hàng rào tự động thì nó chắc
chắn quay lại trong vài sprint.
8. Đo hiệu năng thì nhìn chỉ số nào?
p95 và p99 theo từng route, số query mỗi request, RAM đỉnh mỗi job, độ dài hàng đợi
queue, tỉ lệ cache hit. Trung bình gần như vô dụng vì nó che mất cái đuôi — mà cái
đuôi mới là chỗ người dùng bỏ đi.
9. Có bao giờ tối ưu là sai lầm không?
Có. Tối ưu khi chưa đo, tối ưu chỗ chỉ chiếm 2% thời gian, hoặc đánh đổi tính đúng
đắn lấy tốc độ. Ví dụ dùng insert() hàng loạt trên bảng mà observer đang lo việc
tính tồn kho — nhanh hơn thật, và sai dữ liệu một cách âm thầm.
10. Trang chậm mà DB thì nhàn, PHP cũng nhàn — nghi gì?
Gọi HTTP đồng bộ ra ngoài. Kiểm tra các API bên thứ ba trong luồng request; xử lý
bằng cách đặt timeout ngắn, gọi song song bằng Http::pool(), hoặc đẩy hẳn sang
queue. Sau đó tới lượt nghi mạng giữa app và DB, rồi tới việc chờ ghi session.