Server route pada Nuxt 3 dieksekusi melalui engine Nitro. Saat endpoint SSR (Server-Side Rendering) atau API route melakukan query database yang tidak optimal, latensi server akan melonjak drastis. Masalah umum terjadi ketika query melakukan filtering pada tabel bervolume besar, menghasilkan pembacaan disk I/O tinggi karena database harus mengambil data baris secara utuh dari disk (heap fetch).

Solusi deterministik untuk masalah ini adalah menerapkan covering index. Dengan memasukkan semua kolom yang dibutuhkan klausa WHERE dan SELECT langsung ke dalam struktur leaf node indeks, engine database dapat melayani request sepenuhnya melalui Index Only Scan tanpa menyentuh tabel utama.

Diagnosa Bottleneck SQL pada Server Route Nitro

Perhatikan kasus endpoint server/api/orders.get.ts berikut yang memuat riwayat transaksi berdasarkan status akun:

import { defineEventHandler, getQuery } from 'h3';
import { db } from '~/server/utils/db';
import { orders } from '~/server/database/schema';
import { eq, desc } from 'drizzle-orm';

export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const customerId = String(query.customerId);

  // Anti-pattern: mengambil seluruh kolom secara implisit
  const result = await db.select()
    .from(orders)
    .where(eq(orders.customerId, customerId))
    .orderBy(desc(orders.createdAt))
    .limit(20);

  return result;
});

Ketika tabel orders mencapai jutaan baris, indeks tunggal pada customer_id sering kali belum cukup. Database masih perlu melakukan Bitmap Heap Scan untuk mengambil kolom-kolom lain yang dipanggil oleh SELECT *.

Analisis Query Plan dengan EXPLAIN (ANALYZE, BUFFERS)

Jalankan query SQL representatif di PostgreSQL console untuk memetakan alokasi buffer dan bottleneck I/O:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, created_at, total_amount, status
FROM orders
WHERE customer_id = 'c123'
ORDER BY created_at DESC
LIMIT 20;

Output tipikal dengan index standar (B-Tree hanya pada customer_id):

Limit (cost=0.42..150.20 rows=20 width=64) (actual time=0.080..4.810 rows=20 loops=1)
  Buffers: shared hit=4 read=18
  -> Index Scan using idx_orders_customer_id on orders (cost=0.42..7500.12 rows=1000 width=64) (actual time=0.078..4.802 rows=20 loops=1)
        Index Cond: (customer_id = 'c123'::text)
        Buffers: shared hit=4 read=18
Planning Time: 0.150 ms
Execution Time: 4.850 ms

Pada kondisi data dingin (cold cache), read=18 mengindikasikan pembacaan blok langsung dari storage (disk I/O). Jika konkurensi request tinggi di Nitro, konkurensi heap fetch ini akan memblokir pool koneksi dan meningkatkan TTFB (Time to First Byte) Nuxt 3.

Mekanisme Covering Index dan Klausa INCLUDE

Indeks komposit biasa memaksa semua kolom menjadi search key, yang meningkatkan kedalaman dan ukuran B-Tree. PostgreSQL menyediakan klausa INCLUDE untuk membuat covering index. Kolom dalam INCLUDE disimpan hanya pada level leaf, bukan sebagai navigation key di root/branch nodes.

-- DDL PostgreSQL
CREATE INDEX idx_orders_covering_customer_search 
ON orders (customer_id, created_at DESC) 
INCLUDE (id, total_amount, status);

Struktur di atas memungkinkan engine database memenuhi kueri filtering, sorting, dan proyeksi kolom langsung dari leaf page indeks.

Syarat Index Only Scan: Database Visibility Map harus up-to-date. Pastikan autovacuum berjalan normal agar halaman yang diakses berstatus all-visible (Heap Fetches: 0).

Refaktor Query Menggunakan Drizzle ORM di Nitro

Covering index mensyaratkan query hanya meminta kolom yang tersedia di dalam indeks. Refaktor skema Drizzle ORM dan handler Nitro untuk memastikan proyeksi kolom secara eksplisit.

1. Deklarasi Schema Drizzle

// server/database/schema.ts
import { pgTable, text, timestamp, numeric, index } from 'drizzle-orm/pg-core';

export const orders = pgTable('orders', {
  id: text('id').primaryKey(),
  customerId: text('customer_id').notNull(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  totalAmount: numeric('total_amount', { precision: 12, scale: 2 }).notNull(),
  status: text('status').notNull(),
  payloadMetadata: text('payload_metadata'), // Kolom besar diabaikan
}, (table) => ({
  customerCoveringIdx: index('idx_orders_covering_customer_search')
    .on(table.customerId, table.createdAt.desc())
    .include(table.id, table.totalAmount, table.status),
}));

2. Implementasi Event Handler Nitro

// server/api/orders.get.ts
import { defineEventHandler, getQuery, createError } from 'h3';
import { db } from '~/server/utils/db';
import { orders } from '~/server/database/schema';
import { eq, desc } from 'drizzle-orm';

export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const customerId = query.customerId;

  if (typeof customerId !== 'string') {
    throw createError({ statusCode: 400, message: 'Invalid customerId parameter' });
  }

  // Explicit projection: hanya ambil kolom yang terdaftar di covering index
  const records = await db.select({
    id: orders.id,
    customerId: orders.customerId,
    createdAt: orders.createdAt,
    totalAmount: orders.totalAmount,
    status: orders.status,
  })
  .from(orders)
  .where(eq(orders.customerId, customerId))
  .orderBy(desc(orders.createdAt))
  .limit(20);

  return records;
});

Verifikasi Hasil dan Metrik Eksekusi

Jalankan kembali EXPLAIN (ANALYZE, BUFFERS) setelah membuat covering index dan membatasi kolom query:

Limit (cost=0.42..1.85 rows=20 width=64) (actual time=0.015..0.035 rows=20 loops=1)
  Buffers: shared hit=3
  -> Index Only Scan using idx_orders_covering_customer_search on orders (cost=0.42..72.15 rows=1000 width=64) (actual time=0.014..0.031 rows=20 loops=1)
        Index Cond: (customer_id = 'c123'::text)
        Heap Fetches: 0
        Buffers: shared hit=3
Planning Time: 0.085 ms
Execution Time: 0.052 ms

Perubahan metrik penting:

  • Scan Method: Berubah dari Index Scan menjadi Index Only Scan.
  • Heap Fetches: Bernilai 0, membuktikan blok tabel di storage tidak diakses sama sekali.
  • Shared Buffers: Berkurang signifikan dan seluruhnya hit di RAM tanpa disk read.
  • Latensi Eksekusi: Turun dari 4.85 ms ke 0.05 ms, mengeliminasi bottleneck I/O pada layer Nitro SSR.

Batasan dan Trade-Off

Covering index bukan solusi tanpa biaya. Perhitungkan trade-off berikut sebelum menerapkannya di lingkungan produksi:

  • Write Overhead: Setiap operasi INSERT, UPDATE, atau DELETE pada kolom berindeks mengharuskan pembaruan leaf page B-Tree.
  • Storage Footprint: Klausa INCLUDE menduplikasi data kolom ke dalam file indeks. Hindari memasukkan kolom berukuran besar seperti JSONB atau string teks panjang yang tidak terukur.
  • Index Invalidation oleh Wildcard Query: Satu pemanggilan db.select() tanpa argumen (ekuivalen SELECT *) akan langsung membatalkan Index Only Scan dan memaksa engine melakukan heap lookup.