Phân trang màn danh sách sản phẩm theo category
Màn /{name}-c.{id} của project laravel-livewire là màn
danh sách sản phẩm duy nhất của storefront — khách chỉ tới được sản phẩm bằng cách
chọn một category (ADR-0003). Không có trang "tất cả sản phẩm". Nghĩa là mọi query phân trang
đều đã được thu hẹp bởi một cây category, và đó là điều kiện tiên quyết chứ không phải một
tuỳ chọn.
Bài này mổ cơ chế phân trang của màn đó: nó chạy thế nào, tại sao nó không dùng
trait WithPagination của Livewire, cái giá phải trả, và những chỗ dễ vỡ.
Môi trường đã kiểm chứng
- Code thật: Laravel
v12.64.0, Livewirev4.3.3, PHP 8.2+.- Tài liệu Laravel/Livewire tra qua Context7 (trang
livewire.laravel.com/docs/4.x/paginationvàlaravel.com/docs/13.x/pagination).- Mọi số đo SQL chạy thật trên PostgreSQL 18.4 (container
my-test-pgdb), bảngtmp_products1.000.000 dòng, category rộng nhất có 270.398 dòng active,per_page = 24,count_cap = 10.000. Output dán nguyên trong bài.- Schema thật (index list) đọc từ database
commerce_localđang chạy.
Từ khoá dùng trong bài
- offset pagination (phân trang theo số trang, dùng
LIMIT … OFFSET) — muốn lấy trang N thì database vẫn phải đi qua toàn bộ (N-1)×24 dòng trước đó rồi bỏ đi.- keyset pagination / cursor pagination (phân trang theo con trỏ, không dùng OFFSET) — trang sau bắt đầu từ giá trị cuối của trang trước.
- count cap (trần đếm) — chỉ đếm chính xác tới một mức rồi dừng, quá mức thì hiển thị "10.000+".
- tie-break (khoá phá hoà) — cột phụ thêm vào
ORDER BYđể hai dòng bằng điểm không bị sắp ngẫu nhiên.- soft 404 — trang trả HTTP 200 nhưng nội dung trống rỗng; Google coi là rác.
- LengthAwarePaginator — lớp paginator của Laravel biết tổng số dòng, nên vẽ được dãy số trang. Đối lập với
Paginator(chỉ có Prev/Next) vàCursorPaginator.
1. Nó là gì / chạy thế nào
1.1. Ba lớp, mỗi lớp một việc
flowchart TB
subgraph L1["Lớp 1 — Livewire component CategoryBrowse"]
A1["Url attribute tren public int page"]
A2["scopedCatalog: clamp page, abort 404 nếu vượt lastPage"]
A3["render: gọi paginate với page và sort"]
end
subgraph L2["Lớp 2 — Service CategoryBrowseCatalog"]
B1["baseQuery: active, subtree ids, price"]
B2["matchCount: COUNT có trần 10.001"]
B3["lastPage = ceil boundedTotal chia 24"]
B4["cardsForPage: forPage page, 24 rồi get"]
B5["dựng LengthAwarePaginator rồi appends facet"]
end
subgraph L3["Lớp 3 — pagination/storefront.blade.php"]
C1["thẻ a href thật, rel prev next, aria-current"]
end
L1 --> L2 --> L3
Điểm lạ đầu tiên: component không dùng use WithPagination.
Nó khai báo #[Url] public int $page = 1 và tự dựng
LengthAwarePaginator bằng tay trong service. Đây là lựa chọn có chủ đích, không
phải quên.
Livewire WithPagination (mặc định) | Cách project này làm | |
|---|---|---|
| Pager render ra gì | <button wire:click="nextPage"> — không có href | <a href="…?page=2"> thật, kèm wire:navigate |
| Crawler / middle-click | Ngõ cụt. Bot không thấy trang 2 trở đi | Đi được, index được |
| Chuyển trang là gì | Component update (AJAX), mount() KHÔNG chạy lại | Điều hướng SPA, component mount lại từ đầu |
| Nguồn dữ liệu | Model::paginate() — trả Eloquent models | Trả view-model ProductCard (array), Blade không chạm schema |
| Tổng số dòng | COUNT(*) không giới hạn | COUNT có trần 10.001 |
Cái giá: mất sẵn $this->resetPage(), mất $this->previousPage().
Nhưng ở màn này không cần — mọi thao tác đổi filter đều là điều hướng tới URL mới,
không phải mutate state tại chỗ.
1.2. Luồng một request đầy đủ
sequenceDiagram
autonumber
participant U as Trình duyệt
participant R as Route model binding
participant C as CategoryBrowse
participant S as CategoryBrowseCatalog
participant K as Cache 15 phút
participant D as PostgreSQL
U->>R: GET trang category, page=7, sort=price-asc
R->>D: resolveRouteBinding, scope is_active
D-->>R: row hoặc 404
R->>C: mount với Category đã resolve
C->>S: forCategory rồi withPrice
S->>K: doc key category subtree
K-->>S: mảng id cây con, từ recursive CTE đã cache
C->>S: gọi lastPage
S->>K: doc key count có trần
alt cache miss
S->>D: SELECT count trên subquery có LIMIT 10001
D-->>S: n
end
S-->>C: lastPage
C->>C: clamp page nhỏ hơn 1, và abort 404 nếu page vượt lastPage
C->>S: paginate trang 7, sort PriceAscending
S->>K: doc key page 7 của sort price-asc
alt cache miss
S->>D: ORDER BY sort, LIMIT 24 OFFSET 144
D-->>S: 24 dòng
end
S-->>C: LengthAwarePaginator, total đã bị chặn trần
C-->>U: HTML kèm pager là thẻ a href thật
Chú ý thứ tự:
lastPage()được tính trước khi lấy dữ liệu trang. Nghĩa là mỗi request đều tốn một query đếm (hoặc một lần đọc cache) trước khi tốn query lấy trang. Đây là cái giá củaLengthAwarePaginator— xem §4.
1.3. Đếm có trần — trái tim của cơ chế
Đây là đoạn quan trọng nhất, trong CategoryBrowseCatalog:
public function matchCount(): int
{
$cap = $this->countCap(); // 10.000
return $this->cached("category-browse:count:{$cap}:{$this->scopeKey()}", function () use ($cap) {
$capped = $this->baseQuery()
->select(DB::raw('1'))
->limit($cap + 1); // 10.001
return DB::query()->fromSub($capped, 't')->count();
});
}
public function boundedTotal(): int
{
return min($this->matchCount(), $this->countCap());
}
public function lastPage(): int
{
return max(1, (int) ceil($this->boundedTotal() / $this->perPage()));
}
SQL sinh ra:
SELECT count(*) FROM (
SELECT 1 FROM products
WHERE is_active AND category_id IN (…ids cây con…)
LIMIT 10001
) t;
Ba hệ quả rơi ra từ đúng một dòng LIMIT 10001:
- Trả về
10.001nghĩa là "nhiều hơn trần" → UI in "10.000+ results" (isCapped()). lastPage()tối đa làceil(10000 / 24) = 417. Pager không bao giờ vẽ trang 418.?page=400000→abort_if(page > lastPage, 404). Không ai bắt database làm việc vô hạn được nữa.
Số đo thật: đếm đầy đủ vs đếm có trần
Chạy trên category rộng 270.398 dòng active / bảng 1 triệu dòng (PostgreSQL 18.4, cache nóng):
-- A. COUNT(*) đầy đủ
Finalize Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=9346
-> Parallel Seq Scan on tmp_products (actual rows=90132.67 loops=3)
Execution Time: 245.716 ms
-- B. COUNT có trần LIMIT 10001
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=347
-> Limit (actual rows=10001.00 loops=1)
-> Seq Scan on tmp_products (actual rows=10001.00 loops=1)
Execution Time: 5.217 ms
| Cách đếm | Buffers đọc | Thời gian |
|---|---|---|
COUNT(*) đầy đủ | 9.346 | 245,7 ms |
COUNT có trần 10.001 | 347 | 5,2 ms |
Nhanh hơn ~47 lần, và quan trọng hơn: chi phí không tăng theo kích thước catalogue. Catalogue 10 triệu sản phẩm thì cột A tăng gấp 10, cột B đứng yên.
1.4. Query lấy trang, và chuyện tie-break
protected function cardsForPage(int $page, CategoryBrowseSort $sort): Collection
{
return $this->cached(
"category-browse:page:{$this->perPage()}:{$this->scopeKey()}:{$sort->value}:{$page}",
function () use ($page, $sort) {
$products = $sort->apply($this->baseQuery())
->forPage($page, $this->perPage()) // LIMIT 24 OFFSET (page-1)*24
->get();
// …map sang ProductCard…
}
);
}
CategoryBrowseSort là enum allow-list (danh sách cho phép) — 5 giá trị, không hơn.
Giá trị từ client không bao giờ chạm vào SQL dưới dạng định danh:
public static function fromRequest(mixed $value): self
{
return is_string($value)
? self::tryFrom($value) ?? self::default() // sai thì về mặc định, không lỗi
: self::default();
}
Mọi sort đều kết thúc bằng
id. Không phải cho đẹp.rating_avglànumeric(2,1)— chỉ 51 giá trị khác nhau trên hàng triệu dòng. Hoà điểm là chuyện thường ngày, không phải ngoại lệ.
Chứng minh bằng số đo. Cùng một query ORDER BY popularity_score DESC LIMIT 24 OFFSET 100,
chạy hai lần với hai plan khác nhau (một lần index scan, một lần ép seq scan + sort):
=== Không có id tie-break: đổi plan thì kết quả đổi ===
rows_a | trung_nhau
--------+------------
24 | 1 <-- chỉ 1/24 dòng trùng nhau
=== Có id tie-break: hai plan cho kết quả giống hệt ===
trung_nhau
------------
24 <-- 24/24
Không có tie-break, đổi plan là đổi kết quả. Trong thực tế plan đổi khi: dữ liệu thay đổi
làm statistics đổi, ANALYZE chạy, số dòng vượt ngưỡng parallel, hay đơn giản là
một replica có setting khác. Khách sẽ thấy sản phẩm lặp ở trang 2 và mất sản phẩm khác.
1.5. Pager: thẻ <a> thật, không phải nút
{{-- resources/views/pagination/storefront.blade.php --}}
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Pagination">
@if ($paginator->onFirstPage())
<span aria-disabled="true" …>…</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" wire:navigate …>
@endif
@foreach ($elements as $element)
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page" …>{{ $page }}</span>
@else
<a href="{{ $url }}" wire:navigate …>{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
</nav>
@endif
Gọi từ view bằng {{ $paginator->links('pagination.storefront') }} — đúng cách
Livewire 4 khuyến nghị để thay view pager.
Và URL của mỗi link phải mang theo toàn bộ trạng thái facet:
return (new LengthAwarePaginator(
$this->cardsForPage($page, $sort),
$this->boundedTotal(), // total đã bị chặn trần
$this->perPage(),
$page,
['path' => $this->basePath(), 'pageName' => 'page'],
))->appends(($this->price?->toQuery() ?? []) + $sort->queryParameters());
Quên appends() thì trang 2 rơi về sort mặc định — khách thấy sản phẩm này hai lần
và không bao giờ thấy sản phẩm kia. Ngược lại, đổi sort thì phải bỏ
page: trang 7 của thứ tự này không liên quan gì tới trang 7 của thứ tự khác.
1.6. Cache — chỉ cache trạng thái canonical
flowchart LR Q["Request có price filter?"] -->|"Không có"| C["Được cache"] Q -->|"Đúng 1 band cấu hình sẵn"| C Q -->|"Khoảng giá tự gõ"| L["Chạy thẳng DB, KHÔNG cache"]
public function withPrice(?PriceRange $price): static
{
$scoped = clone $this;
$scoped->price = $price;
$scoped->cacheable = $this->cacheable
&& ($price === null || app(PriceBands::class)->contains($price));
return $scoped;
}
Vì sao quan trọng: cache store nằm trong chính database commerce dùng chung. Nếu cache mọi
khoảng giá, một khách vô danh chỉ cần gọi ?min=1, ?min=2,
?min=3… là ghi rác không giới hạn vào database thật — một
storage-exhaustion primitive (đường để làm đầy ổ đĩa). Không gian key phải hữu hạn:
category id thật × 6 band cấu hình × 5 sort × page đã clamp [1, 417]
2. Lợi ích
| Được gì | Thật sự được trong hoàn cảnh nào |
|---|---|
| Chi phí có trần theo thiết kế | Catalogue lớn dần mà p95 của trang không đổi. Số đo §1.3: 5,2 ms bất kể 1 triệu hay 10 triệu dòng. |
| Không có đường để client bắt DB làm việc nặng | ?page=400000 chết ở tầng PHP (404), không tới DB. OWASP A04 (insecure design). |
| Crawler đi được hết catalogue | Sản phẩm chỉ nằm ở trang 5 vẫn được index. Với storefront thì đây là doanh thu, không phải chi tiết kỹ thuật. |
| Thứ tự tất định | Có tie-break id nên phân trang không lặp/sót khi plan đổi. |
| Không gian cache hữu hạn | Cache hit cao vì chỉ có vài nghìn tổ hợp canonical, thay vì vô hạn tổ hợp query string. |
| Blade không chạm schema | Paginator chứa ProductCard (array), không chứa Eloquent model → đổi cột DB không vỡ view, và không có N+1 ẩn trong Blade. |
| Validate chạy mọi request | Guard nằm trong scopedCatalog() mà cả mount() lẫn render() đều gọi — chặn được cả đường update endpoint của Livewire. |
3. Tác hại / hạn chế
3.1. Trần đếm là một lời nói dối có kiểm soát
Category có 40.000 sản phẩm hiển thị "10.000+", và 30.000 sản phẩm kia không tới được qua pager. Chúng vẫn tồn tại, vẫn có trang chi tiết, nhưng đường duy nhất tới chúng là lọc hẹp lại (chọn category con, chọn khoảng giá) hoặc search.
Đây là đánh đổi cố ý, không phải bug. Nhưng phải nói rõ với business — nếu category rộng là trường hợp phổ biến chứ không phải ngoại lệ, con số 10.000 phải chỉnh, hoặc phải đổi hẳn cách tiếp cận.
3.2. OFFSET sâu vẫn đắt, kể cả khi có index đúng
Cùng bảng 1 triệu dòng, index (category_id, popularity_score DESC, id DESC) WHERE is_active,
cache nóng:
| Trang | OFFSET | Buffers | Thời gian |
|---|---|---|---|
| 1 | 0 | 27 | 0,28 ms |
| 417 (trang cuối sau khi cap) | 9.984 | 9.917 | 28,6 ms |
| 4.000 (nếu KHÔNG cap) | 95.976 | 95.090 | 207,0 ms |
| keyset tương đương trang 4.000 | — | 27 | 0,38 ms |
-- Trang 4.000 bằng OFFSET
Limit (actual rows=24.00 loops=1)
Buffers: shared hit=95090
-> Index Scan using idx_tmp_cat_pop on tmp_products (actual rows=96000.00 loops=1)
Execution Time: 207.038 ms
-- Cùng vị trí đó bằng keyset
Limit (actual rows=24.00 loops=1)
Buffers: shared hit=27
-> Index Scan using idx_tmp_cat_pop on tmp_products (actual rows=24.00 loops=1)
Execution Time: 0.384 ms
Nhìn dòng actual rows của Index Scan: 96.000 vs 24. OFFSET không "nhảy" tới vị trí —
nó đọc rồi vứt 95.976 dòng đầu. Trần 10.000 chỉ giới hạn thiệt hại ở mức 28,6 ms,
chứ không xoá được bản chất.
3.3. Trần KHÔNG cứu được query thiếu index
Vẫn bảng đó, xoá index đi, lấy trang 1:
=== KHÔNG INDEX: page 1 (LIMIT 24 OFFSET 0) ===
-> Sort (Sort Method: top-N heapsort Memory: 27kB)
-> Parallel Seq Scan on tmp_products (actual rows=90132.67 loops=3)
Execution Time: 64.941 ms … 144.512 ms (dao động giữa các lần chạy)
Trang 1, offset 0, chỉ lấy 24 dòng — mà vẫn tốn 65–145 ms, vì PostgreSQL phải quét hết 270k dòng rồi sort mới biết 24 dòng nào đứng đầu. So với 0,28 ms khi có index đúng: chậm hơn 230–500 lần.
Thứ tự làm việc bắt buộc: index trước, cap sau. Cap là để chặn kẻ xấu và chặn bot, không phải để thay thế index. Spec của project ghi đo được 12.668 ms cho query chưa index — đúng bản chất này ở quy mô lớn hơn.
3.4. Bẫy có thật trong repo: sort một cột, index cột khác
Code sort giá đọc min_deal_price_cents (giá sau khuyến mãi):
self::PriceAscending => $query
->orderByRaw('products.min_deal_price_cents asc nulls last')
->orderBy('products.id'),
Nhưng index tổ hợp trong database commerce_local hôm nay lại nằm trên
min_price_cents (giá gốc):
"IDX_products_category_price" btree (category_id, min_price_cents, id) WHERE is_active
"IDX_products_category_price_desc" btree (category_id, min_price_cents DESC NULLS LAST, id DESC) WHERE is_active
"IDX_products_min_deal_price" btree (min_deal_price_cents) WHERE is_active -- 1 cột, không có category_id
Docblock của CategoryBrowseSort nhắc tới IDX_products_category_deal_price —
index đó chưa tồn tại. Hậu quả đo được (tạo đúng tình huống trên bảng test):
=== Sort theo cột KHÔNG có index tổ hợp tương ứng ===
-> Sort (Sort Key: min_deal_price_cents, id)
Sort Method: top-N heapsort Memory: 27kB
-> Parallel Seq Scan on tmp_products (actual rows=90132.67 loops=3)
Execution Time: 78.930 ms
Trang 1 của sort giá: 78,9 ms thay vì 0,28 ms. Chưa vỡ vì hiện min_deal_price_cents
đang NULL toàn bộ catalogue, nhưng ngày backfill chạy xong thì đây là câu chuyện p95 tăng vọt
mà không ai đổi code.
3.5. Các hạn chế còn lại
- Drift khi phân trang — dữ liệu đổi giữa lúc xem trang 1 và trang 2 thì có dòng bị lặp hoặc bị nhảy qua. Bản chất của offset pagination, không sửa được bằng cách nào ngoài đổi sang keyset.
- Cache 15 phút — sản phẩm vừa tắt vẫn hiện tối đa 15 phút. Không có cơ chế invalidate theo sự kiện; chỉ có TTL.
- 404 khi catalogue co lại — bookmark
?page=200hôm nay còn hợp lệ, tuần sau category bị dọn xuống 20 trang thì link đó 404 (chứ không redirect về trang cuối). - Mỗi request 2 lần chạm dữ liệu — một cho count, một cho page. Với
simplePaginatechỉ cần một. - Đổi tên cache prefix = xoá sạch cache — request đầu tiên sau deploy phải chạy
count và page không cache trên toàn bộ catalogue. Đây là lý do repo giữ prefix cũ
shop:rất lâu dù đã đổi tên class. - Redis chưa chạy (issue #16) — cache store rơi về database dùng chung, tức là mỗi cache write là một INSERT vào Postgres commerce. Đây là lý do quy tắc "chỉ cache trạng thái canonical" không phải sự cẩn thận thừa.
4. Trade-off
4.1. Bốn cách phân trang, chọn cái nào
| Offset + LengthAware (đang dùng) | simplePaginate (Prev/Next) | Keyset / cursorPaginate | Search engine (Meilisearch, ES) | |
|---|---|---|---|---|
| Query mỗi request | 2 (count + page) | 1 | 1 | 1 (ngoài Postgres) |
| Chi phí trang sâu | Tăng tuyến tính theo offset | Tăng tuyến tính | Hằng số | Hằng số (có trần riêng) |
| Hiện tổng số kết quả | Có (đến trần) | Không | Không | Có (ước lượng) |
| Nhảy thẳng tới trang 12 | Có | Không | Không | Có |
| URL chia sẻ / SEO | Tốt, ?page=N ổn định | Tốt | Kém — cursor là chuỗi mờ, không stable | Tuỳ triển khai |
| Lặp/sót khi data đổi | Có | Có | Không | Có |
| Đổi sort tuỳ ý | Dễ | Dễ | Khó — mỗi sort cần cursor riêng, phải là cột index và có tie-break | Dễ |
| Hạ tầng thêm | Không | Không | Không | Có — thêm 1 service phải đồng bộ |
4.2. Tiêu chí chọn
flowchart TB A["Người dùng có cần nhảy tới trang bất kỳ, và bot có cần index từng trang?"] -->|"Có"| B["Có cần hiện tổng số kết quả không?"] A -->|"Không, ví dụ feed hoặc API nội bộ"| K["Keyset hoặc cursorPaginate"] B -->|"Có"| C["Offset, LengthAware, count có trần"] B -->|"Không"| S["simplePaginate, bớt 1 query"] C --> D["Trang sâu có phải traffic thật không?"] D -->|"Không, chỉ bot"| E["Cap cộng 404 là đủ, đang làm"] D -->|"Có, khách đi sâu thật"| F["Cần search engine hoặc facet hẹp hơn"]
Vì sao màn này chọn offset: nó là màn storefront công khai. Ba yêu cầu ép nó:
(1) bot phải đi được từng trang → cần URL ?page=N ổn định, cursor không làm được;
(2) thiết kế có dòng "X results" → cần biết tổng, simplePaginate loại;
(3) khách bấm được số trang. Keyset thắng về hiệu năng nhưng thua cả ba yêu cầu trên.
Khi nào KHÔNG chọn cách này: API cho mobile app cuộn vô hạn, job export dữ liệu,
màn admin duyệt log — ở đó không ai bấm số trang, không ai cần tổng, và trang sâu là chuyện
bình thường. Dùng cursorPaginate(), hoặc chunkById() cho job.
Nói thẳng: "cap 10.000" là cách mua thời gian, không phải cách giải. Nó biến bài toán "trang sâu đắt" thành "trang sâu không tồn tại". Đúng cho storefront vì không khách nào lật tới trang 417. Sai nếu bạn bê nguyên sang màn admin cần duyệt hết 200.000 đơn hàng.
5. Khó khăn khi vận hành thực tế
5.1. Thứ phải theo dõi
| Chỉ số | Ngưỡng cảnh báo | Nó nói lên điều gì |
|---|---|---|
| p95 thời gian render màn category | > 300 ms | Index sai hoặc cache miss hàng loạt |
Tỉ lệ cache hit của key category-browse:* | < 80% | Không gian key bị phình (ai đó bỏ điều kiện cacheable) |
Số dòng bảng cache trong Postgres | Tăng tuyến tính theo traffic | Đang bị mint cache vô hạn — sự cố bảo mật, không phải sự cố hiệu năng |
| Tỉ lệ 404 trên route category | Tăng đột ngột | Bot đang thử ?page= lớn, hoặc catalogue vừa co lại |
pg_stat_statements: query có OFFSET lớn | mean_time > 50 ms | Có đường nào đó bỏ qua clamp |
Số index chưa dùng trên products | — | Bảng 10 triệu dòng, mỗi index thừa là thuế lên mọi lần ghi |
5.2. Chi phí ẩn
- Index nặng đường ghi. Bảng
productshiện có 13 index. MỗiUPDATE popularity_scorephải cập nhật các index chứa cột đó. Jobstorefront:recompute-popularitychạm toàn bảng — đó là lúc write amplification (một lần ghi dữ liệu kéo theo nhiều lần ghi thật xuống đĩa) lộ ra. - Index chết vẫn tính tiền.
IDX_products_popularitytạo cho màn "toàn catalogue" đã bị xoá — không còn query nào dùng, nhưng vẫn phải maintain mỗi lần ghi. Xoá nó là schema change, mà schema thuộc repo commerce (ADR-0009) → phải phối hợp hai team. - Debug lúc 3h sáng khó ở chỗ có cache. Thấy số liệu lạ trên production thì
câu hỏi đầu tiên luôn là "đây là dữ liệu thật hay cache 15 phút trước". Không có cách bypass
cache từ URL (cố ý — thêm
?nocache=1là mở lại đúng lỗ storage-exhaustion). Cách duy nhất là đọc thẳng DB bằng tinker. - Con người. Ba quy tắc dưới đây không hiện ra khi đọc code lướt, và mất cái nào
cũng là bug im lặng: mọi sort phải kết thúc bằng
id;NULLS LASTphải viết ra (Postgres và SQLite ngược nhau); pager phảiappends()đủ facet. Nên có test giữ từng quy tắc — repo đang cóCategoryBrowseSortNullOrderingTestđúng cho mục đích này.
6. Case thực tế và cách xử lý
Case 1 — "Khách báo thấy cùng một sản phẩm ở cả trang 1 và trang 2"
Triệu chứng: chỉ xảy ra với sort "Best Rating", chỉ trên production, không tái hiện được trên local.
flowchart TB
A["Nhận báo lỗi"] --> B["Lấy đúng URL trang 1 và trang 2 khách gửi"]
B --> C["Chạy 2 query trên replica, so sánh tập id"]
C --> D{"Có id trùng?"}
D -->|"Không"| E["Nghi cache, 2 trang cache ở 2 thời điểm khác nhau"]
D -->|"Có"| F["EXPLAIN cả hai, xem plan"]
F --> G{"ORDER BY có tie-break duy nhất?"}
G -->|"Không"| H["Đây là gốc rễ"]
G -->|"Có"| I["Xem plan 2 trang có khác nhau không"]
Gốc rễ thường gặp: ORDER BY rating_avg DESC mà thiếu tie-break.
Local có 50 sản phẩm nên không hoà; production có hàng trăm nghìn dòng cùng rating_avg = 4.5.
Số đo §1.4 cho thấy chỉ 1/24 dòng trùng khi plan đổi.
Xử lý:
- Thêm tie-break vào enum sort — sửa một chỗ, vì mọi
ORDER BYcủa màn này đều đi quaCategoryBrowseSort::apply(). Không đi vá từng caller. - Viết test: cùng filter, lấy trang 1 và trang 2, khẳng định
$page1->intersect($page2)->isEmpty(). - Xoá cache
category-browse:page:*— dữ liệu cũ trong cache vẫn sai thứ tự. - Kiểm tra index có phủ đúng tie-break mới không. Thêm
id DESCvào ORDER BY mà index chỉ cóid ASCthì được incremental sort — chạy được nhưng không miễn phí.
Case 2 — "Database CPU 100% lúc 2h sáng, không có traffic khách"
Triệu chứng: pg_stat_activity đầy query
SELECT … ORDER BY … LIMIT 24 OFFSET 9600000. Access log cho thấy một user-agent lạ
đang quét ?page=1 → ?page=999999.
Xử lý theo thứ tự (dừng khi hết cháy):
- Chặn máu ngay:
pg_cancel_backend()các query offset lớn đang chạy. Chặn IP ở tầng nginx nếu vẫn dồn dập. - Xác nhận guard có chạy không. Nếu clamp đang hoạt động thì mấy query đó không
thể tồn tại — nghĩa là có đường vòng. Kiểm hai chỗ:
- Có endpoint/route nào khác đọc
?page=mà không quascopedCatalog()? abort_ifcó nằm trong cảmount()vàrender()không? Guard chỉ ởmount()thì Livewire update endpoint đi thẳng qua được —mount()không chạy lại ở request đó.
- Có endpoint/route nào khác đọc
- Phòng dài hạn: rate limit ~120 req/phút/IP (đủ rộng để không chặn nhầm
Googlebot), thêm
robots.txtchặn?page=lớn, và alert khi tỉ lệ 404 trên route category vượt ngưỡng.
Bẫy Livewire đáng nhớ nhất ở đây: public property là client-writable (client ghi được). Kẻ tấn công POST thẳng vào update endpoint để set
$page = 999999, bỏ qua mọi route constraint. Vì thế$categoryphải#[Locked], còn$pagephải được validate mỗi request — không phải chỉ ởmount().
Case 3 — "Đổi sort xong ra trang trắng"
Triệu chứng: khách đang ở trang 7, bấm đổi sang "Price: Low to High", ra trang trống hoặc 404.
Gốc rễ: link đổi sort mang theo page=7. Nếu code dùng
WithPagination thì đây chính là lý do Livewire có resetPage().
Cách project này đã chặn sẵn: mọi link facet dựng qua một hàm duy nhất
urlFor(), và hàm đó không nhận page:
private function urlFor(?PriceRange $price, CategoryBrowseSort $sort): string
{
$path = $this->category->url();
$query = ($price?->toQuery() ?? []) + $sort->queryParameters();
return $query === [] ? $path : $path.'?'.http_build_query($query);
}
Đổi sort hay đổi price → về trang 1. Còn link pager đi đường khác
(appends()) nên vẫn giữ nguyên sort và price. Hai đường, hai quy tắc ngược nhau, cố ý.
Nếu vẫn gặp: kiểm tra có ai thêm page vào urlFor()
"cho tiện" không. Viết test: bấm sort ở trang 7 → URL đích không chứa page.
Case 4 — "Sau deploy, mọi category đều chậm 3 phút rồi tự hết"
Gốc rễ: ai đó đổi cache key prefix (hoặc CACHE_PREFIX, hoặc đổi
per_page — vì per_page nằm trong key). Toàn bộ cache mất hiệu lực cùng
lúc; request đầu tiên của mỗi category phải chạy count + page không cache. Nhiều request cùng
category tới cùng lúc thì cùng chạy — cache stampede (nhiều request cùng dựng
lại một entry cache).
Xử lý:
- Ngắn hạn: chấp nhận và theo dõi, nếu chỉ 3 phút và p95 không vượt SLA.
- Nếu đau: dùng
Cache::lock()quanh phần dựng lại, hoặc warm cache cho top-N category ngay sau deploy. - Nguyên tắc: đổi cache key là một thao tác vận hành, phải nằm trong checklist
deploy, không phải một dòng sửa vô hại. Repo đã ý thức chuyện này — prefix cũ
shop:được giữ lại rất lâu sau khi đổi tên class, chỉ đổi khi có thay đổi vốn đã làm mọi URL cũ vô nghĩa.
7. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
Vì sao không dùng WithPagination của Livewire? |
Pager mặc định render <button wire:click>, không có href — crawler thấy ngõ cụt, middle-click chết. Màn storefront cần từng trang được index nên phải là thẻ <a> thật, kèm wire:navigate để vẫn nhanh như SPA. |
COUNT(*) chậm thì cache lại là xong chứ gì? |
Cache giấu chi phí chứ không bỏ chi phí. Request đầu tiên và mỗi lần cache hết hạn vẫn trả giá đầy đủ, và với cache miss đồng loạt thì trả giá cùng lúc. LIMIT 10001 làm chi phí có trần theo thiết kế — không cache thì vẫn 5 ms. |
Vì sao LIMIT cap + 1 chứ không phải LIMIT cap? |
Để phân biệt "đúng 10.000" với "nhiều hơn 10.000". Trả về 10.001 là tín hiệu để UI in "10.000+". |
| Trang vượt trần thì 404 hay redirect về trang cuối? | 404. Redirect sẽ tạo ra vô số URL cùng trả một nội dung (trùng lặp cho crawler), còn trả 200 với trang trống là soft 404 — Google phạt. 404 là câu trả lời trung thực: trang đó không tồn tại. |
Trang 2 có nên canonical về trang 1 không? |
Không. Làm vậy là de-index mọi sản phẩm chỉ xuất hiện từ trang 2 trở đi. Mỗi trang canonical về chính nó. Ngược lại, trang có filter/sort thì noindex, follow — không index nhưng vẫn cho bot đi tiếp tới sản phẩm. |
| Vì sao filter category là cả cây con chứ không phải một node? | Sản phẩm gắn ở mọi cấp. Lấy đúng node thì chỉ khớp ~2,6% cái mà khách hiểu là "category đó". Cây con lấy bằng một recursive CTE, cache 15 phút theo id. |
sort sai thì 404, còn min sai thì sao? |
Ngược nhau, và cố ý. sort lạ → về mặc định (link cũ vẫn dùng được, không đáng 404). min/max sai → 404 chứ không clamp, vì clamp sẽ trả về tập kết quả khác với cái URL hứa hẹn. |
| Nếu phải bỏ trần đếm thì làm gì? | Ba hướng, theo thứ tự rẻ dần về hạ tầng: (1) giữ offset nhưng đẩy trần lên và ép có index phủ; (2) đổi sang keyset cho trang sâu, giữ offset cho vài trang đầu; (3) đưa listing sang search engine. Với storefront, thường đáng làm hơn là ép khách lật 400 trang — hãy làm facet tốt hơn. |
| Keyset nhanh hơn 500 lần, sao không dùng luôn? | Vì mất khả năng nhảy tới trang bất kỳ và mất tổng số kết quả. Cursor cũng không phải URL chia sẻ được. Đây là đánh đổi UX/SEO chứ không phải đánh đổi kỹ thuật. |
Vì sao sort phải nằm trong cache key mà không nằm trong count key? |
Thứ tự không đổi tập kết quả, chỉ đổi sản phẩm nào rơi vào trang nào. Nên count và lastPage dùng chung cho mọi sort; chỉ nội dung trang mới cần tách theo sort. |
| Public property của Livewire nguy hiểm chỗ nào ở màn này? | Client ghi được thẳng qua update endpoint, nơi mount() không chạy. Nên $category phải #[Locked], và validate page/min/max phải nằm ở chỗ cả mount() lẫn render() đều gọi. |
8. Checklist rà lại một màn phân trang bất kỳ
- [ ]
ORDER BYcó kết thúc bằng một cột duy nhất không? - [ ]
NULLS LAST/NULLS FIRSTcó viết ra không (đừng tin mặc định)? - [ ] Có index tổ hợp khớp đúng cột và đúng chiều của
ORDER BY, kể cả cột tie-break? - [ ]
COUNTcó trần chưa, hay đangCOUNT(*)toàn bảng? - [ ]
pagecó clamp dưới 1 và chặn trênlastPagechưa? Guard có chạy ở mọi request, không chỉmount()? - [ ] Vượt trang cuối trả 404, không phải 200 trang trống?
- [ ] Link pager có mang đủ filter + sort không (
appends())? - [ ] Link đổi filter/sort có bỏ
pagekhông? - [ ] Pager có phải
<a href>thật, córel="prev"/"next"vàaria-current="page"? - [ ] Không gian cache key có hữu hạn không — hay client tự do mint key mới?
- [ ] Trang 2+ có canonical về chính nó, không về trang 1?
- [ ] Có test giữ ba quy tắc dễ mất: tie-break, NULLS ordering, và facet trong link pager?
Chưa verify được: con số 12.668 ms cho query chưa index và quy mô ~10 triệu sản phẩm là số ghi trong spec của project, đo trên môi trường của họ — tôi không tái hiện được ở đây. Mọi số khác trong bài đều chạy thật trên PostgreSQL 18.4 với bảng 1 triệu dòng và dán nguyên output.