Fitur pencarian berbasis Server Component pada Next.js App Router sering mengalami degradasi performa seiring bertambahnya volume data. Pola umum menggunakan operator ILIKE '%term%' memaksa database membaca seluruh isi tabel dari disk.
Panduan ini membahas diagnosis akar masalah query lambat menggunakan EXPLAIN ANALYZE dan langkah migrasi ke native Full-Text Search (FTS) PostgreSQL menggunakan tsvector dan indeks GIN untuk menjaga latensi tetap di bawah 10 milidetik.
Akar Masalah: Inefisiensi Pola ILIKE '%term%'
Saat Server Component Next.js menerima parameter pencarian via searchParams, implementasi standar sering menggunakan query seperti berikut:
SELECT id, title, content FROM articles
WHERE title ILIKE '%database%' OR content ILIKE '%database%';Masalah utama terletak pada karakter wildcard di awal pola (%term). Struktur indeks standar B-Tree menyimpan data secara berurutan dari kiri ke kanan. Wildcard di awal string membuat B-Tree tidak dapat menentukan titik awal pencarian, sehingga planner PostgreSQL membatalkan penggunaan indeks dan menjalankan Sequential Scan (Seq Scan).
Pada tabel dengan ratusan ribu baris, Seq Scan membaca seluruh blok data ke dalam memori buffer, membebani I/O, dan meningkatkan CPU usage, yang berujung pada tingginya respons time Server-Side Rendering (SSR).
Diagnosis Bottleneck via EXPLAIN ANALYZE
Jalankan diagnosis query menggunakan EXPLAIN (ANALYZE, BUFFERS) pada psql atau database GUI:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title FROM articles
WHERE title ILIKE '%postgres%';Output tipikal pada tabel dengan 500.000 baris:
Seq Scan on articles (cost=0.00..18942.00 rows=2500 width=45) (actual time=0.082..184.215 rows=1420 loops=1)
Filter: (title ~~* '%postgres%'::text)
Rows Removed by Filter: 498580
Buffers: shared hit=6442 read=5000
Planning Time: 0.115 ms
Execution Time: 184.410 msLatensi eksekusi mencapai ~184ms murni di sisi database, belum termasuk network transit dan waktu rendering React Server Component. Beban ini memblokir worker thread database jika terjadi lonjakan trafik konkuren.
Solusi: Native tsvector dan GIN Index
PostgreSQL menyediakan tipe data tsvector (koleksi leksem yang dinormalisasi) dan operator pencarian @@ dengan tsquery. Struktur indeks GIN (Generalized Inverted Index) memetakan setiap leksem unik langsung ke lokasi baris fisik (TID), menghilangkan kebutuhan pemindaian sekuensial.
1. Buat Generated Column untuk tsvector
Gunakan kolom tsvector yang dihitung secara otomatis (generated stored column) untuk menggabungkan field pencarian:
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('indonesian', coalesce(title, '')), 'A') ||
setweight(to_tsvector('indonesian', coalesce(content, '')), 'B')
) STORED;Catatan: Sesuaikan konfigurasi bahasa (misal:
englishatauindonesian).setweightmemberi prioritas relevansi lebih tinggi pada fieldtitle('A') dibandingcontent('B').
2. Buat GIN Index
CREATE INDEX idx_articles_search_gin ON articles USING GIN (search_vector);Implementasi di Next.js Server Component
Integrasikan pencarian langsung pada Server Component Next.js menggunakan utility database (contoh menggunakan library pg):
// app/search/page.tsx
import { query } from '@/lib/db';
interface SearchPageProps {
searchParams: Promise<{ q?: string }>;
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const params = await searchParams;
const searchTerm = params.q?.trim() || '';
let articles = [];
if (searchTerm) {
// websearch_to_tsquery menangani parsing sintaks pencarian mentah secara aman
const sql = `
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM articles, websearch_to_tsquery('indonesian', $1) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
`;
const result = await query(sql, [searchTerm]);
articles = result.rows;
}
return (
<main>
<h2>Hasil Pencarian: "{searchTerm}"</h2>
<ul>
{articles.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</main>
);
}websearch_to_tsquery secara otomatis mengabaikan tanda baca khusus yang berisiko merusak parsing sintaks SQL (seperti tanda petik tunggal atau operator boolean mentah).
Evaluasi Metrik: Seq Scan vs Bitmap Index Scan
Jalankan kembali EXPLAIN ANALYZE pada query Full-Text Search:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title
FROM articles, websearch_to_tsquery('indonesian', 'postgres') query
WHERE search_vector @@ query
ORDER BY ts_rank(search_vector, query) DESC
LIMIT 20;Output metrik:
Limit (cost=42.15..42.20 rows=20 width=49) (actual time=1.821..1.845 rows=20 loops=1)
-> Sort (cost=42.15..45.70 rows=1420 width=49) (actual time=1.820..1.832 rows=20 loops=1)
Sort Key: (ts_rank(articles.search_vector, query.query)) DESC
-> Nested Loop (cost=12.25..35.10 rows=1420 width=49) (actual time=0.065..1.210 rows=1420 loops=1)
-> Function Scan on query (cost=0.00..0.01 rows=1 width=32) (actual time=0.008..0.009 rows=1 loops=1)
-> Bitmap Heap Scan on articles (cost=12.25..33.69 rows=1420 width=45) (actual time=0.054..0.850 rows=1420 loops=1)
Recheck Cond: (search_vector @@ query.query)
-> Bitmap Index Scan on idx_articles_search_gin (cost=0.00..11.90 rows=1420 width=0) (actual time=0.042..0.042 rows=1420 loops=1)
Execution Time: 1.912 msPerubahan arsitektur ini menurunkan runtime eksekusi dari 184.41 ms menjadi 1.91 ms (peningkatan efisiensi >90x) dengan transisi dari Seq Scan ke Bitmap Index Scan.
Trade-off dan Batasan
- Write Overhead: Indeks GIN memperlambat operasi
INSERTdanUPDATEkarena setiap leksem harus diindeks ulang. Pertahankan parameterfastupdate = onpada indeks untuk menampung batch update di buffer pending list sementara. - Ukuran Disk: Kolom
tsvectoryang di-store dan indeks GIN mengonsumsi ruang disk tambahan (~15-25% dari total tabel). - Typo Tolerance: FTS berbasis leksem tidak menangani saltik (typo) secara otomatis. Untuk toleransi typo parsial tanpa external search engine, kombinasikan dengan modul
pg_trgmmenggunakan Trigram GIN index.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!