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 query1 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ệ
hydrationBiến 1 dòng dữ liệu thô từ DB thành object Eloquent Model trong PHP
buffered queryDriver 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 paginationPhân trang theo con trỏ WHERE id > ?, không dùng OFFSET
cache stampedeCache hết hạn đúng lúc đông người, cả nghìn request cùng lao vào tính lại
opcacheBộ nhớ đệm của PHP giữ bytecode đã biên dịch, khỏi parse lại file .php mỗi request
memory leakRAM tăng dần và không trả lại, do có thứ giữ tham chiếu mãi
eventual consistencyDữ 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 ở đâuCho biết gì
Laravel DebugbarlocalSố query, query trùng, thời gian mỗi query, RAM đỉnh
Laravel Telescopelocal / stagingNhư trên + queue job, cache hit/miss, request chậm. Không bật full ở production
ClockworklocalNhẹ hơn Debugbar, xem được cả request AJAX/Livewire
Blackfire / XHProf / SPXstaging / 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/PostgresproductionQuery chậm thật trên dữ liệu thật
pg_stat_statementsproduction (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)productionp95/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:

  1. DB đọc và gửi qua mạng cả cột text/json lớn mà bạn không dùng.
  2. PHP phải cấp phát RAM cho từng cột đó, cho từng dòng.
  3. 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 stdClass thường chỉ còn tầm một phần ba tới một phần năm. Hãy tự đo bằng memory_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áchSố queryRAM phía PHPRAM phía DB driverAn toàn khi vừa duyệt vừa sửa?
get()1Toàn bộToàn bộ
chunk(500, fn)N/500500 model500 dòngKhông — dùng OFFSET, sửa dữ liệu làm lệch trang
chunkById(500, fn)N/500500 model500 dòng — lọc theo id > last
lazy(500)N/500500 model500 dòngKhông (giống chunk)
lazyById(500)N/500500 model500 dòng
cursor()11 modelTuỳ driver — xem cảnh báoCó, 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ốn cursor() 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ùng cursor() 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ồnVì saoCách xử lý
Model::all() trên bảng lớnNạp hết + hydrate hếtlazyById(), hoặc thêm where
Query logLaravel giữ mọi query đã chạy trong mảngDB::disableQueryLog() ở đầu job dài
Nối chuỗi khổng lồ (CSV, XML)Cả file nằm trong RAMGhi ra stream từng dòng
Thu kết quả vào mảng ngoài chunkchunk giải phóng lô cũ, nhưng mảng của bạn thì khôngGhi thẳng ra file/DB trong mỗi lô
Thuộc tính static tích dữ liệuSống suốt đời tiến trìnhBỏ static, hoặc reset ở cuối job
Event listener đóng biến lớnClosure giữ tham chiếu tới object nặngChỉ truyền id vào closure
Ảnh xử lý bằng GD/ImagickẢnh giải nén trong RAM: 6000×4000 ≈ 96MBXử 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:

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ệnhTiết kiệm gìCạm bẫy
config:cacheGộp mọi file config thành 1 file PHP, khỏi đọc và mergeenv() ngoài thư mục config/ sẽ trả về null
route:cacheBiên dịch sẵn bảng routeKhông dùng được với route dạng closure
view:cacheBiên dịch sẵn Blade, khỏi compile lúc có requestNhớ chạy lại mỗi lần deploy
event:cacheBỏ bước quét file để tự tìm listenerListener mới không nhận nếu quên chạy lại
--optimize-autoloaderBả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ạy config:cache là nó thành null ngay. Luật: env() chỉ xuất hiện trong config/*.php, chỗ khác dùng config('...').

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:

  1. Thuộc tính static tích dữ liệu qua từng request.
  2. Singleton đăng ký lúc khởi động mà lại giữ Request hay Auth củ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.
  3. 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 propertyChỉ giữ id/filter; lấy dữ liệu trong #[Computed] hoặc trong render()
Gõ một chữ là bắn một requestwire: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òngwire: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àiWithPagination, 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ậtLợi ích thậtChỉ đáng làm khi
Sửa N+1Thường cắt 50–90% thời gian trang danh sáchGần như luôn luôn — đây là món lãi cao nhất, rủi ro thấp nhất
Thêm indexQuery từ giây xuống mili-giâyCó 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 RAMBảng có cột lớn, hoặc trả nhiều dòng
lazyById / chunkByIdRAM phẳng bất kể dữ liệu bao nhiêuJob/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ệ
CacheBỏ hẳn công việc lặp lạiĐọc nhiều, tính đắt, chịu được dữ liệu cũ
QueueRequest trả về ngay, chịu tải đột biến tốtViệc không cần kết quả tức thì
artisan optimizeBớt vài chục ms bootstrap, gần như miễn phíMọi production
opcacheBỏ toàn bộ chi phí biên dịch PHPMọi production
OctaneBỏ 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ế


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ọnChọn A khiChọn B khi
Eloquent vs Query BuilderCầ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 cursorVừa duyệt vừa sửa, hoặc dùng MySQL bufferedChỉ đọc, đã kiểm chứng driver stream thật
paginate vs cursorPaginateNgười dùng cần thấy tổng số trangBảng lớn, cuộn vô hạn, cần trang nào cũng nhanh
Cache vs sửa queryQuery đã tối ưu hết mức mà vẫn đắtQuery còn sửa được — luôn sửa trước
Đồng bộ vs queueNgười dùng cần kết quả ngay để đi bước tiếpChậm hơn 1 giây và không cần kết quả ngay
Scale dọc vs sửa codeCầ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 throughputCò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ế


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:

  1. 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.
  2. Sửa: Product::with('category:id,name')->withCount('reviews') → còn 3 query.
  3. Vẫn 900ms. EXPLAIN query chính: thiếu index trên products(shop_id, created_at) đang dùng cho where + order by. Thêm index (dùng CONCURRENTLY trên Postgres, xem tài liệu migration) → còn 180ms.
  4. Bỏ select * vì bảng có cột description dài → còn 120ms.
  5. 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ý:

  1. Hạ memory_limit về mức cũ — nâng nó chỉ che triệu chứng.
  2. Thay bằng lazyById(1000): RAM đi từ tăng tuyến tính sang phẳng.
  3. Ghi thẳng ra file qua fputcsv theo từng dòng, không dựng chuỗi trong RAM.
  4. Bỏ with('items'): báo cáo chỉ cần tổng tiền, đã có sẵn trên orders.total.
  5. Thêm select()toBase().
  6. Thêm DB::disableQueryLog() ở đầu handle().
  7. Đặt --memory=256 cho 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ý:

  1. Cầm máu: php artisan config:clear để site sống lại.
  2. grep -rn "env(" app/ routes/ resources/ tìm hết chỗ dùng sai.
  3. Chuyển sang config('database.default'), khai báo trong config/*.php.
  4. Thêm một bước CI chặn: pipeline fail nếu env( xuất hiện ngoài thư mục config/.
  5. 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ý:

  1. 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.
  2. 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.
  3. 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.
  4. 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ý:

  1. 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.
  2. Rà mọi singleton nhận Request, Auth, hay dữ liệu theo người dùng. Đổi sang bind() (tạo mới mỗi lần) hoặc lấy request tại chỗ khi cần.
  3. Rà thuộc tính static có tích dữ liệu.
  4. Chạy staging bằng Octane với 1 worker và test nhiều tài khoản đan xen nhau.
  5. 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. chunkcursor 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.