Aplikasi web modern berbasis Laravel dan Inertia.js sering menggunakan pagination standar (LengthAwarePaginator). Masalah muncul saat volume data menembus jutaan baris dan pengguna menavigasi ke halaman dalam (deep offset). Query bawaan memaksa database memuat seluruh kolom ke memory sebelum membuang sebagian besar baris tersebut.
Akar Masalah: Buffer Pool Bloat pada Deep Offset
Query offset pagination standar dieksekusi dengan struktur berikut:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 15 OFFSET 500000;Database tidak langsung melompat ke baris ke-500.001. Mesin database harus membaca 500.015 baris data dari disk atau buffer pool, mengurutkannya, lalu membuang 500.000 baris pertama hanya untuk mengambil 15 baris sisanya.
Jika tabel memiliki puluhan kolom atau tipe data besar seperti TEXT atau JSON, ukuran I/O melonjak drastis. Seluruh halaman data harus dibaca ke memori (buffer pool), mendepak data penting lain dari cache dan memicu bottleneck CPU serta storage.
Pola Solusi: Deferred Join (Late Row Lookup)
Deferred join memisahkan pagination menjadi dua tahap:
- Index-Only Scan: Subquery hanya mengambil kolom Primary Key (
id) menggunakan index penutup (covering index) yang berukuran ringkas di memori. - Late Row Lookup: Hasil 15
idtersebut di-JOIN kembali ke tabel utama untuk mengambil kolom lengkap hanya untuk 15 baris yang akan dirender.
Query deferred join:
SELECT orders.*
FROM orders
INNER JOIN (
SELECT id FROM orders
ORDER BY created_at DESC
LIMIT 15 OFFSET 500000
) AS batch ON orders.id = batch.id
ORDER BY orders.created_at DESC;Subquery beroperasi sepenuhnya di index cache. Pembacaan tabel utama hanya terjadi untuk 15 baris akhir.
Perbandingan EXPLAIN ANALYZE
Berikut perbandingan rencana eksekusi pada tabel PostgreSQL berisi 2.000.000 baris.
1. Query Konvensional
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 15 OFFSET 500000;
-- Execution Time: 487.312 ms
-- Buffers: shared hit=42100 read=18240Query membaca puluhan ribu blok halaman buffer karena harus mengakses seluruh data baris dari disk/buffer pool.
2. Query Deferred Join
EXPLAIN (ANALYZE, BUFFERS)
SELECT orders.* FROM orders
INNER JOIN (
SELECT id FROM orders ORDER BY created_at DESC LIMIT 15 OFFSET 500000
) AS batch ON orders.id = batch.id
ORDER BY orders.created_at DESC;
-- Execution Time: 18.420 ms
-- Buffers: shared hit=1420 read=0Latensi turun drastis. I/O berkurang karena pemindaian offset dilakukan murni pada B-Tree index.
Implementasi Macro Eloquent Reusable
Daftarkan macro pada app/Providers/AppServiceProvider.php agar dapat digunakan secara global pada model Eloquent.
namespace App\Providers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Builder::macro('deferredPaginate', function (int $perPage = 15, array $columns = ['*'], string $pageName = 'page', ?int $page = null) {
/** @var Builder $this */
$page = $page ?: LengthAwarePaginator::resolveCurrentPage($pageName);
$total = (clone $this)->toBase()->getCountForPagination();
if ($total === 0) {
return new LengthAwarePaginator([], 0, $perPage, $page, [
'path' => LengthAwarePaginator::resolveCurrentPath(),
'pageName' => $pageName,
]);
}
$primaryKey = $this->model->getQualifiedKeyName();
$keyName = $this->model->getKeyName();
// Subquery ambil ID via index
$ids = (clone $this)
->select($primaryKey)
->forPage($page, $perPage)
->pluck($keyName);
// Late row lookup
$items = $this->model->newQuery()
->whereIn($primaryKey, $ids)
->with($this->getEagerLoads())
->orderBy($this->model->getQualifiedKeyName(), 'desc')
->get($columns);
return new LengthAwarePaginator($items, $total, $perPage, $page, [
'path' => LengthAwarePaginator::resolveCurrentPath(),
'pageName' => $pageName,
]);
});
}
}skipped: dynamic secondary order preservation inside macro, add when complex multi-column sorting is required.
Integrasi Inertia Tanpa Breaking Changes
Di controller, ganti paginate() dengan deferredPaginate():
namespace App\Http\Controllers;
use App\Models\Order;
use Inertia\Inertia;
use Inertia\Response;
class OrderController extends Controller
{
public function index(): Response
{
$orders = Order::query()
->select(['id', 'order_number', 'customer_id', 'total_amount', 'status', 'created_at'])
->with('customer:id,name')
->orderBy('created_at', 'desc')
->deferredPaginate(15);
return Inertia::render('Orders/Index', [
'orders' => $orders,
]);
}
}Karena macro mengembalikan instance LengthAwarePaginator, struktur JSON yang diterima komponen Vue atau React tetap identik:
{
"data": [...],
"current_page": 33334,
"last_page": 66667,
"per_page": 15,
"total": 1000000,
"links": [...]
}Komponen frontend tidak memerlukan refactoring sama sekali:
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
orders: Object
});
</script>
<template>
<div>
<table>
<tr v-for="order in orders.data" :key="order.id">
<td>{{ order.order_number }}</td>
<td>{{ order.total_amount }}</td>
</tr>
</table>
<div class="pagination">
<Link
v-for="(link, index) in orders.links"
:key="index"
:href="link.url || '#'"
v-html="link.label"
:class="{ 'active': link.active }"
/>
</div>
</div>
</template>Batasan dan Alternatif
Pertimbangkan aspek berikut sebelum menerapkan deferred join:
- Index Requirement: Kolom yang digunakan pada klausul
WHEREdanORDER BYwajib memiliki composite index yang mencakup primary key. Tanpa index penutup, database tetap melakukan table scan. - Halaman Pertama (Page 1): Deferred join memiliki overhead marginal (eksekusi subquery tambahan) dibanding query standar pada offset 0. Keuntungannya baru terlihat nyata saat offset melewati ribuan baris.
- Cursor Pagination: Jika aplikasi tidak membutuhkan navigasi lompat halaman acak (misalnya infinite scroll), gunakan
cursorPaginate()bawaan Laravel. Kelemahan deferred join tetap ada pada pemanggilanCOUNT(*)untuk menghitung total baris.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!