Bottleneck Heap Fetch pada Dashboard Next.js RSC
React Server Components (RSC) di Next.js mengeksekusi query database langsung di server tanpa perantara REST atau GraphQL endpoint. Pola ini memangkas overhead jaringan client-server, namun memindahkan beban latency sepenuhnya ke database engine. Pada dashboard analitik dengan filter multi-kolom dan agregasi read-heavy, query lambat memblokir streaming server-side rendering (SSR) dan meningkatkan Time to First Byte (TTFB).
Masalah umum pada query analitik relasional adalah ketergantungan pada Index Scan standar. Saat index B-Tree digunakan untuk memfilter baris (misalnya kolom organization_id dan status), PostgreSQL tetap harus membaca tabel utama (table heap) untuk mengambil data dari kolom non-kunci (seperti total_amount dan created_at) yang diminta oleh klausa SELECT. Operasi pembacaan heap ini memicu disk I/O tinggi dan memenuhi buffer cache.
Diagnosa Query Analitik dengan EXPLAIN (ANALYZE, BUFFERS)
Ambil contoh query dashboard analitik pada Next.js Server Component yang merangkum transaksi per organisasi:
-- Query dasar analitik dashboard
SELECT status, SUM(total_amount), MAX(created_at)
FROM orders
WHERE organization_id = 'org_abc123' AND status = 'COMPLETED'
GROUP BY status;Jalankan diagnosa menggunakan EXPLAIN (ANALYZE, BUFFERS) di PostgreSQL untuk mengevaluasi konsumsi buffer memori dan akses heap:
EXPLAIN (ANALYZE, BUFFERS)
SELECT status, SUM(total_amount), MAX(created_at)
FROM orders
WHERE organization_id = 'org_abc123' AND status = 'COMPLETED'
GROUP BY status;Hasil eksekusi tipikal dengan index standar CREATE INDEX idx_orders_org_status ON orders(organization_id, status);:
GroupAggregate (cost=125.40..128.55 rows=1 width=48) (actual time=8.412..8.415 rows=1 loops=1)
Buffers: shared hit=42 read=856
-> Index Scan using idx_orders_org_status on orders (cost=0.43..120.15 rows=1050 width=24) (actual time=0.045..6.120 rows=1050 loops=1)
Index Cond: ((organization_id = 'org_abc123'::text) AND (status = 'COMPLETED'::text))
Buffers: shared hit=12 read=856
Planning Time: 0.185 ms
Execution Time: 8.462 msMetrik shared read=856 menandakan PostgreSQL membaca 856 page (masing-masing 8 KB) dari storage disk ke shared buffers karena kolom total_amount dan created_at tidak tersedia di node index. PostgreSQL terpaksa melakukan pointer lookup ke heap untuk setiap baris yang lolos kondisi index.
Arsitektur Covering Index: B-Tree Key vs Payload INCLUDE
Covering index adalah index yang memuat seluruh kolom yang dibutuhkan oleh query (kondisi pencarian, join, dan proyeksi SELECT). Sejak versi 11, PostgreSQL menyediakan klausa INCLUDE.
Perbedaan struktural antara composite key index dan klausa INCLUDE:
- Index Key Column: Berada di leaf dan non-leaf nodes. Digunakan untuk pencarian biner, sorting (B-tree ordering), dan penegakan keunikan (unique constraints). Memiliki batas ukuran payload per entry.
- Included Column (Payload): Hanya disimpan di leaf pages. Kolom ini tidak digunakan dalam operasi sorting atau filtering, melainkan disematkan langsung untuk eliminasi akses heap. Kolom dengan tipe data besar atau data non-terurut tidak membebani struktur pohon pencarian.
Definisikan covering index untuk orders:
CREATE INDEX idx_orders_analytic_covering
ON orders (organization_id, status)
INCLUDE (total_amount, created_at);Transisi Index Scan ke Index Only Scan
Setelah covering index diterapkan, PostgreSQL mengubah eksekusi query menjadi Index Only Scan:
GroupAggregate (cost=4.45..4.60 rows=1 width=48) (actual time=0.180..0.182 rows=1 loops=1)
Buffers: shared hit=6
-> Index Only Scan using idx_orders_analytic_covering on orders (cost=0.42..4.25 rows=1050 width=24) (actual time=0.021..0.115 rows=1050 loops=1)
Index Cond: ((organization_id = 'org_abc123'::text) AND (status = 'COMPLETED'::text))
Heap Fetches: 0
Buffers: shared hit=6
Planning Time: 0.142 ms
Execution Time: 0.210 msSignifikansi Metrik Buffers dan Visibility Map
Perhatikan dua metrik kritis hasil optimasi:
- Buffers: Penggunaan berkurang drastis dari
read=856menjadishared hit=6. Query membaca page sepenuhnya dari memori cache index tanpa menyentuh heap page sama sekali. Latensi terpangkas dari 8.46 ms ke 0.21 ms. - Heap Fetches: 0: Syarat utama
Index Only Scantidak menyentuh heap adalah Visibility Map (VM). PostgreSQL menggunakan arsitektur MVCC. Index pointer tidak menyimpan metadata transaction visibility (XMIN/XMAX). PostgreSQL memeriksa Visibility Map untuk memvalidasi apakah page tabel all-visible bagi semua transaksi aktif. Jika VM valid, heap fetch bernilai 0. Jika tabel jarang di-vacuum, nilaiHeap Fetchesakan naik meski query plan tetap bertuliskanIndex Only Scan.
Peringatan: Pastikan parameter
autovacuumberjalan optimal di production. Jika autovacuum tertinggal, VM tidak terupdate dan PostgreSQL tetap melakukan disk hit ke heap.
Implementasi di Next.js RSC (Drizzle ORM & Prisma)
1. Implementasi Menggunakan Drizzle ORM
Drizzle mendukung deklarasi index secara eksplisit pada schema definition.
// schema.ts
import { pgTable, uuid, varchar, numeric, timestamp, index } from 'drizzle-orm/pg-core';
export const orders = pgTable('orders', {
id: uuid('id').defaultRandom().primaryKey(),
organizationId: varchar('organization_id', { length: 64 }).notNull(),
status: varchar('status', { length: 32 }).notNull(),
totalAmount: numeric('total_amount', { precision: 12, scale: 2 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => ({
analyticCoveringIdx: index('idx_orders_analytic_covering')
.on(table.organizationId, table.status)
// Drizzle mendukung custom SQL via migrations jika method chaining include belum terpasang
}));Jika versi Drizzle Kit yang digunakan belum mengekspos helper .include() pada driver terkait, buat migration manual:
-- migrations/0002_add_covering_index.sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_analytic_covering
ON orders (organization_id, status)
INCLUDE (total_amount, created_at);2. Implementasi Menggunakan Prisma ORM
Prisma schema belum mendukung klausa INCLUDE langsung pada deklarasi @@index. Gunakan fitur Prisma Migrate custom SQL:
npx prisma migrate dev --create-only --name add_orders_covering_indexEdit file migration.sql yang dihasilkan:
-- Drop index lama jika ada
DROP INDEX IF EXISTS "idx_orders_org_status";
-- Buat covering index dengan klausa INCLUDE
CREATE INDEX "idx_orders_analytic_covering"
ON "orders" ("organization_id", "status")
INCLUDE ("total_amount", "created_at");Jalankan migrasi:
npx prisma migrate deploy3. Query pada Next.js Server Component
Eksekusi query analitik di Server Component dengan proyeksi field yang presisi sesuai isi covering index:
// app/dashboard/[orgId]/page.tsx
import { db } from '@/lib/db';
import { orders } from '@/lib/db/schema';
import { eq, and, sql } from 'drizzle-orm';
interface DashboardProps {
params: { orgId: string };
}
export default async function DashboardPage({ params }: DashboardProps) {
// Query hanya mengambil kolom yang di-cover oleh index
const metrics = await db
.select({
status: orders.status,
sumAmount: sql<number>`sum(${orders.totalAmount})`,
lastTransaction: sql<Date>`max(${orders.createdAt})`,
})
.from(orders)
.where(
and(
eq(orders.organizationId, params.orgId),
eq(orders.status, 'COMPLETED')
)
)
.groupBy(orders.status);
return (
<main className="p-6">
<h1 className="text-xl font-bold">Metrik Organisasi: {params.orgId}</h1>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="p-4 border rounded">
<p className="text-sm text-gray-500">Total Volume</p>
<p className="text-2xl font-semibold">${metrics[0]?.sumAmount ?? 0}</p>
</div>
<div className="p-4 border rounded">
<p className="text-sm text-gray-500">Transaksi Terakhir</p>
<p className="text-2xl font-semibold">
{metrics[0]?.lastTransaction ? new Date(metrics[0].lastTransaction).toLocaleDateString() : '-'}
</p>
</div>
</div>
</main>
);
}Trade-Off: Write Amplification dan HOT Update Invalidation
Covering index bukan solusi tanpa biaya. Pertimbangkan kompensasi performa berikut sebelum menerapkannya di production:
1. Degradasi Write Amplification
Setiap operasi INSERT dan DELETE harus memperbarui covering index selain index primary key. Ukuran leaf node index menjadi lebih gemuk karena menyimpan payload tambahan, meningkatkan pemakaian shared buffer pool untuk index memory caching.
2. Pembatalan Heap-Only Tuples (HOT) Updates
PostgreSQL memiliki optimasi HOT (Heap-Only Tuples). Jika perintah UPDATE tidak menyentuh kolom yang terdaftar di index mana pun dan tuple muat di page heap yang sama, PostgreSQL tidak memperbarui file index sama sekali.
Jika kolom yang sering diubah (seperti status transaksi berulang) dimasukkan ke dalam klausa INCLUDE, optimasi HOT rusak. Setiap pembaruan baris memicu pembuatan entri baru di covering index, memicu bloat tabel dan meningkatkan frekuensi maintenance autovacuum.
Strategi Monitoring Performa di Production
Aktifkan ekstensi pg_stat_statements di PostgreSQL untuk memverifikasi efektivitas covering index secara berkelanjutan di level production.
Jalankan query audit berkala untuk melacak query dengan ratio shared read tinggi:
SELECT
query,
calls,
mean_exec_time,
shared_blks_hit,
shared_blks_read,
round(100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 2) AS cache_hit_ratio
FROM pg_stat_statements
WHERE query ILIKE '%FROM orders%'
ORDER BY mean_exec_time DESC
LIMIT 5;Cari query dengan cache_hit_ratio di bawah 95% atau shared_blks_read yang terus meningkat. Evaluasi kembali ukuran work_mem dan efektivitas vacuuming visibility map menggunakan tabel statistik sistem:
SELECT
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'orders';Kombinasi covering index yang terisolasi pada workload read-heavy dan autovacuum yang terawat menjaga Next.js RSC tetap responsif di bawah batas latensi kritis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!