Phân trang màn danh sách sản phẩm theo category

Màn /{name}-c.{id} của project laravel-livewiremà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, Livewire v4.3.3, PHP 8.2+.
  • Tài liệu Laravel/Livewire tra qua Context7 (trang livewire.laravel.com/docs/4.x/paginationlaravel.com/docs/13.x/pagination).
  • Mọi số đo SQL chạy thật trên PostgreSQL 18.4 (container my-test-pgdb), bảng tmp_products 1.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-clickNgõ 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ệuModel::paginate() — trả Eloquent modelsTrả view-model ProductCard (array), Blade không chạm schema
Tổng số dòngCOUNT(*) không giới hạnCOUNT 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ủa LengthAwarePaginator — 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:

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 đếmBuffers đọcThời gian
COUNT(*) đầy đủ9.346245,7 ms
COUNT có trần 10.0013475,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_avgnumeric(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 catalogueSả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 địnhCó tie-break id nên phân trang không lặp/sót khi plan đổi.
Không gian cache hữu hạnCache 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 schemaPaginator 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 requestGuard 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:

TrangOFFSETBuffersThời gian
10270,28 ms
417 (trang cuối sau khi cap)9.9849.91728,6 ms
4.000 (nếu KHÔNG cap)95.97695.090207,0 ms
keyset tương đương trang 4.000270,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

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 / cursorPaginateSearch engine
(Meilisearch, ES)
Query mỗi request2 (count + page)111 (ngoài Postgres)
Chi phí trang sâuTăng tuyến tính theo offsetTăng tuyến tínhHằng sốHằng số (có trần riêng)
Hiện tổng số kết quảCó (đến trần)KhôngKhôngCó (ước lượng)
Nhảy thẳng tới trang 12KhôngKhông
URL chia sẻ / SEOTốt, ?page=N ổn địnhTốtKém — cursor là chuỗi mờ, không stableTuỳ triển khai
Lặp/sót khi data đổiKhông
Đổi sort tuỳ ýDễDễKhó — mỗi sort cần cursor riêng, phải là cột index và có tie-breakDễ
Hạ tầng thêmKhôngKhôngKhông — 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áoNó nói lên điều gì
p95 thời gian render màn category> 300 msIndex 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 PostgresTă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 categoryTăng đột ngộtBot đang thử ?page= lớn, hoặc catalogue vừa co lại
pg_stat_statements: query có OFFSET lớnmean_time > 50 msCó đường nào đó bỏ qua clamp
Số index chưa dùng trên productsBả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

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ý:

  1. Thêm tie-break vào enum sort — sửa một chỗ, vì mọi ORDER BY của màn này đều đi qua CategoryBrowseSort::apply(). Không đi vá từng caller.
  2. Viết test: cùng filter, lấy trang 1 và trang 2, khẳng định $page1->intersect($page2)->isEmpty().
  3. Xoá cache category-browse:page:* — dữ liệu cũ trong cache vẫn sai thứ tự.
  4. Kiểm tra index có phủ đúng tie-break mới không. Thêm id DESC vào ORDER BY mà index chỉ có id ASC thì đượ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):

  1. 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.
  2. 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 qua scopedCatalog()?
    • abort_if có nằm trong cả mount()render() không? Guard chỉ ở mount() thì Livewire update endpoint đi thẳng qua được — mount() không chạy lại ở request đó.
  3. 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.txt chặ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ế $category phải #[Locked], còn $page phả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ý:

  1. 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.
  2. 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.
  3. 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ỏiTrả 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ỳ

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.