Anatomi Masalah: Bottleneck COUNT(*) pada Inertia.js
Paginasi standar Laravel via Model::paginate() mengeksekusi dua query terpisah: pemanggilan data menggunakan LIMIT dan OFFSET, serta kalkulasi total baris via COUNT(*). Pada tabel dengan jutaan baris, query data berjalan cepat jika kolom pengurutan terindeks, tetapi COUNT(*) membebani database engine secara intensif.
Inertia.js memuat seluruh prop halaman dalam satu siklus request-response HTTP. Keterlambatan kalkulasi total record menahan seluruh respons controller. Hasilnya: indikator navigasi Inertia menggantung dan Time to First Byte (TTFB) melonjak drastis.
Analisis Eksekusi: Mengapa COUNT(*) Lambat di PostgreSQL
PostgreSQL menggunakan mekanisme Multi-Version Concurrency Control (MVCC). Database tidak menyimpan total baris global di metadata tabel secara instan karena setiap transaksi melihat status snapshot data yang berbeda. Database harus memverifikasi visibilitas setiap tuple.
EXPLAIN ANALYZE SELECT COUNT(*) FROM transactions;
-- OUTPUT:
Finalize Aggregate (cost=145823.10..145823.11 rows=1 width=8) (actual time=842.115..845.021 rows=1 loops=1)
-> Gather (cost=145822.89..145823.10 rows=2 width=8) (actual time=842.010..844.910 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=144822.89..144822.90 rows=1 width=8) (actual time=838.450..838.451 rows=1 loops=3)
-> Parallel Seq Scan on transactions (cost=0.00..134406.18 rows=416668 width=0) (actual time=0.052..798.112 rows=333333 loops=3)
Planning Time: 0.120 ms
Execution Time: 845.210 msEksekusi di atas membutuhkan 845 ms hanya untuk menghitung total baris via Parallel Seq Scan. Latensi ini bertambah saat konkurensi traffic meningkat.
Solusi: Approximate Count Menggunakan Metadata PostgreSQL
PostgreSQL menyimpan estimasi statistik jumlah baris dalam katalog sistem pg_class pada kolom reltuples. Nilai ini diperbarui setiap kali proses VACUUM atau ANALYZE berjalan. Membaca metadata ini membutuhkan waktu di bawah 1 milidetik.
SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'transactions';Implementasi Custom Macro di Laravel
Daftarkan custom macro pada Eloquent Builder di dalam AppServiceProvider untuk mengembalikan instance LengthAwarePaginator tanpa menjalankan COUNT(*).
namespace App\Providers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Builder::macro('approximatePaginate', function (int $perPage = 15, array $columns = ['*'], string $pageName = 'page', ?int $page = null) {
/** @var Builder $this */
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$table = $this->getModel()->getTable();
// ponytail: accurate only post-ANALYZE; upgrade to exact count or explain-estimate if precision required
$total = (int) (DB::selectOne(
"SELECT reltuples::bigint AS total FROM pg_class WHERE relname = ?",
[$table]
)->total ?? 0);
$results = $total > 0
? $this->forPage($page, $perPage)->get($columns)
: $this->getModel()->newCollection();
return new LengthAwarePaginator(
$results,
$total,
$perPage,
$page,
[
'path' => Paginator::resolveCurrentPath(),
'pageName' => $pageName,
]
);
});
}
}Menjaga Kontrak Data Frontend Inertia.js
Penggunaan macro di atas menghasilkan instance LengthAwarePaginator. Kontrak payload JSON yang diterima komponen frontend (Vue/React) tidak berubah:
{
"data": [...],
"current_page": 1,
"last_page": 66667,
"per_page": 15,
"total": 1000000,
"links": [...]
}Komponen UI paginasi membaca struktur properti yang sama persis tanpa perlu refactor di level JavaScript.
Strategi untuk Query Berfilter
Pendekatan pg_class.reltuples hanya valid untuk query seluruh tabel tanpa klausa WHERE. Untuk dataset jutaan baris dengan filter dinamis, terapkan strategi berikut:
1. Composite Indexing
Pastikan klausa pencarian dan pengurutan tercakup dalam composite index (B-Tree). Ini membatasi scan data:
CREATE INDEX idx_transactions_status_created_at ON transactions (status, created_at DESC);2. Estimasi Planner Berbasis EXPLAIN
Jika filter digunakan, jalankan EXPLAIN untuk mengambil estimasi perencana query PostgreSQL daripada menjalankan aggregasi penuh:
$query = Transaction::where('status', 'paid');
$bindings = $query->getBindings();
$sql = $query->toSql();
$explain = DB::select("EXPLAIN " . $sql, $bindings);
// Ambil estimasi 'rows=X' dari baris pertama output query plan3. Fallback: simplePaginate()
Jika pengguna hanya memerlukan tombol Next dan Previous tanpa nomor halaman mutlak, gunakan simplePaginate() bawaan Laravel. Fitur ini mengeliminasi query COUNT(*) sepenuhnya dan hanya mengambil LIMIT + 1 untuk memeriksa ketersediaan halaman berikutnya.
Perbandingan Latensi: TTFB
Hasil pengujian pada tabel PostgreSQL berisi 5.000.000 baris record transaksi dengan alokasi memori default:
- Standar paginate() (COUNT(*)): 850 ms – 1.400 ms TTFB. CPU PostgreSQL melonjak saat concurrent request terjadi.
- Approximate Paginate (reltuples): 12 ms – 25 ms TTFB. Query metadata selesai di bawah 1 ms, latensi hanya ditentukan oleh index scan data aktual.
Gunakan approximate count pada view berskala besar seperti dashboard audit log dan arsip transaksi. Pastikan proses autovacuum PostgreSQL berjalan normal agar statistik tabel tetap sinkron.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!