Pada sistem pembayaran dan integrasi ERP dengan volume transaksi tinggi, fitur pencarian (search bar) di menu Daftar Transaksi dan Laporan Rekonsiliasi adalah fitur yang paling sering diakses oleh tim Finance, Customer Support, maupun Partner Merchant.
Tantangannya: pengguna mengharapkan satu kotak pencarian tunggal (unified search bar) yang mampu mencari transaksi berdasarkan 9 jenis identifier sekaligus:
Invoices), yaitu Nomor Faktur dan ID Pelanggan.Ketika jumlah data transaksi membesar hingga jutaan baris, pendekatan query ORM konvensional yang menggabungkan ILIKE '%...%' di banyak kolom beserta filter relasi bersarang (nested relation filter) membuat waktu respons database melambung tinggi dan membebani CPU server.
Dalam artikel kedua dari seri portofolio engineering ini, saya akan membedah bagaimana saya merancang ulang arsitektur pencarian dan indexing di PostgreSQL menggunakan tiga teknik utama: Eliminasi Subquery EXISTS via Pre-Lookup Relasi O(1), Case-Variant B-Tree Matching, dan Targeted GIN Trigram Index (pg_trgm).
Ketika developer menulis query pencarian multi-kolom menggunakan operator case-insensitive substring (ILIKE '%keyword%') pada seluruh kolom sekaligus menggabungkan tabel relasi di dalam blok OR, terjadi dua bottleneck besar di PostgreSQL:
ILIKE '%keyword%' di Semua KolomIndeks B-Tree standar bekerja dengan mengurutkan string dari karakter paling kiri. Ketika kita menggunakan pencarian wildcard di awal string (%keyword%) pada 7 kolom berbeda, PostgreSQL tidak dapat menggunakan B-Tree index sama sekali dan terpaksa melakukan Sequential Scan (Full Table Scan) membaca seluruh baris tabel dari disk.
EXISTS Per Baris Akibat Filter Relasi di Dalam Blok ORKetika kondisi filter ke tabel relasi (Invoices) disisipkan di dalam blok OR milik tabel utama (Transactions), query planner PostgreSQL sering kali menghasilkan eksekusi Correlated SubPlan (EXISTS) yang dievaluasi berulang-ulang untuk setiap kandidat baris transaksi.
Untuk menyelesaikan masalah ini tanpa harus menambah beban infrastruktur eksternal (seperti cluster Elasticsearch), saya melakukan perombakan pada Query Builder dan Database Indexing Strategy.
EXISTS dengan Pre-Lookup Relasi O(1)Alih-alih memasukkan kondisi tabel relasi Invoices ke dalam blok OR tabel Transactions, kita memisahkan pencarian relasi menjadi langkah Pre-Lookup kecil di awal.
Karena kolom nomor faktur dan ID pelanggan pada tabel Invoices telah memiliki B-Tree index, mencari ID tagihan yang cocok hanya membutuhkan waktu < 2 milidetik. Jika ditemukan, daftar ID tersebut langsung disuntikkan ke query utama sebagai pemeriksaan foreign key sederhana (invoice_id IN (...)) tanpa JOIN!
IN) vs GIN Trigram (pg_trgm)Dari analisis pola pencarian pengguna di lapangan, saya menemukan fakta penting:
order_code).Jika kita menggunakan pencarian case-insensitive bawaan ORM pada kolom-kolom tersebut, database akan mengeksekusi UPPER(col) = UPPER($1) yang menggagalkan pemakaian B-Tree index standar.
Solusi yang saya terapkan adalah teknik Case-Variant Array (IN [original, lower, upper]):
[keyword, keyword.toLowerCase(), keyword.toUpperCase()] ke dalam operator IN (...), PostgreSQL murni mengeksekusi B-Tree Index Scan dengan kecepatan penuh!order_code, kita menerapkan Trigram Threshold Guard: pencarian substring (ILIKE '%...%' yang di-cover oleh GIN Index pg_trgm) hanya diaktifkan jika panjang kata kunci $\ge 3$ karakter (karena trigram membutuhkan minimal 3 karakter). Jika kata kunci kurang dari 3 karakter, otomatis dialihkan ke IN (variants) agar tidak memicu Full Table Scan.// Prototype Konseptual: Hybrid Search Filter Builder
async function buildOptimizedSearchFilter(rawKeyword: string) {
const keyword = rawKeyword.trim();
const caseVariants = Array.from(
new Set([keyword, keyword.toLowerCase(), keyword.toUpperCase()]),
);
// 1. O(1) Pre-Lookup pada tabel relasi (menghindari subquery EXISTS per baris)
const matchedBills = await db.invoices.findMany({
where: {
OR: [
{ invoiceNo: { in: caseVariants } },
{ customerNo: { in: caseVariants } },
],
},
select: { id: true },
take: 20,
});
const billIds = matchedBills.map((b) => b.id);
// 2. Guard minimal 3 karakter untuk GIN Trigram Index pada orderCode
const orderCodeFilter =
keyword.length >= 3
? { contains: keyword, mode: 'insensitive' } // Dipercepat oleh GIN pg_trgm
: { in: caseVariants };
// 3. Gunakan B-Tree Index Scan (IN variants) untuk seluruh kolom referensi lainnya
return {
OR: [
{ orderCode: orderCodeFilter },
{ transactionRef: { in: caseVariants } },
{ externalRef: { in: caseVariants } },
{ gatewayRef: { in: caseVariants } },
...(billIds.length > 0 ? [{ invoiceId: { in: billIds } }] : []),
],
};
}
Agar setiap cabang pada klausa OR dan filter laporan harian memiliki jalur indeks (index path) di PostgreSQL, skema database dilengkapi dengan kombinasi indeks berikut:
-- 1. Aktifkan ekstensi Trigram & pasang GIN Index khusus untuk kolom substring search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY idx_orders_code_trgm
ON transactions USING gin (order_code gin_trgm_ops);
-- 2. Single-column B-Tree Indexes untuk exact-variant lookup
CREATE INDEX CONCURRENTLY idx_orders_external_ref ON transactions (external_ref);
CREATE INDEX CONCURRENTLY idx_orders_gateway_ref ON transactions (gateway_ref);
CREATE INDEX CONCURRENTLY idx_orders_va_number ON transactions (va_number);
-- 3. Composite Indexes untuk filter laporan & rekonsiliasi berdasarkan status dan waktu
CREATE INDEX CONCURRENTLY idx_orders_status_paid_at ON transactions (status, paid_at DESC);
| Skenario Pencarian / Filter | Sebelum Optimasi | Sesudah Optimasi |
|---|---|---|
Cari potongan order_code ($\ge 3$ huruf) | Seq Scan karena B-Tree tidak mendukung %keyword% | Bitmap Index Scan pada GIN Index gin_trgm_ops |
Cari order_code pendek ($< 3$ huruf) | Seq Scan karena trigram kekurangan karakter | Otomatis beralih ke IN (variants) via B-Tree Index |
Cari external_ref / gateway_ref | ILIKE memicu Seq Scan di setiap kolom | IN (term, lower, upper) mengeksekusi B-Tree Index Scan murni |
| Cari Nomor Faktur / ID Pelanggan | Subquery EXISTS berulang untuk setiap baris transaksi | Pre-Lookup tabel relasi (< 2ms) + filter invoice_id IN (...) |
Rekap Laporan Harian (status + paid_at) | Filter lambat pada jutaan histori transaksi | Composite Index (status, paid_at DESC) |
ILIKE '%...%' pada Semua Kolom:pg_trgm index di sana), serta gunakan exact match berbasis B-Tree pada kolom kode referensi lainnya.pg_trgm):pg_trgm tidak akan efektif. Selalu tambahkan guard keyword.length >= 3 di kode aplikasi Anda.[term, lower, upper]):IN (original, lower, upper) jauh lebih ramah terhadap B-Tree index standar dibandingkan membungkus kolom dengan fungsi LOWER() atau ILIKE.OR Utama (Pre-Lookup Pattern):Pada Part 3 selanjutnya, kita akan membedah bagaimana saya menangani masalah putusnya koneksi soket RPC ke Core ERP secara tiba-tiba (socket hang up / ECONNRESET) serta solusi untuk kasus unik "Late Payment" pada pembayaran Kartu Kredit yang melewati batas waktu kadaluwarsa lokal.