Aplikasi React Native offline-first atau aplikasi dengan dataset lokal besar (chat message, log transaksi, sinkronisasi cache) sering mengalami micro-stutter atau UI frame drop saat membaca database lokal. Masalah ini bukan berasal dari runtime JavaScript atau bridge, melainkan dari SQLite engine yang melakukan full scan atau traversal B-tree berulang untuk mengambil data kolom.
Penggunaan binding JSI berkecepatan tinggi seperti @op-engineering/op-sqlite atau react-native-quick-sqlite memangkas overhead serialisasi bridge. Namun, optimasi native bridge menjadi sia-sia jika query engine mengeksekusi operasi SCAN TABLE. Solusi paling efisien untuk memangkas latensi pembacaan adalah mengeliminasi akses tabel utama sepenuhnya menggunakan Covering Index.
Anatomi Masalah: SCAN TABLE vs Rowid Lookup
Saat mengeksekusi query SELECT, SQLite memiliki tiga jalur umum untuk mengambil data:
- SCAN TABLE: Membaca setiap halaman (page) dari B-tree tabel dari awal hingga akhir. Kompleksitas: O(N). Jika tabel berisi 50.000 baris, seluruh 50.000 record dimuat ke memori untuk dievaluasi terhadap klausa
WHERE. Operasi ini memicu frame drop pada thread native/JS. - SEARCH TABLE USING INDEX (Non-Covering): SQLite mencari baris yang cocok di B-tree index (O(log N)), mengambil pointer
rowid, lalu melakukan navigasi B-tree kedua ke tabel utama untuk mengambil kolom yang diminta di klausaSELECT. Navigasi kedua ini disebut rowid lookup atau table fetch. - SEARCH TABLE USING COVERING INDEX: Seluruh kolom yang dibutuhkan oleh klausa
WHERE,ORDER BY, danSELECTberada di dalam node daun B-tree index itu sendiri. SQLite tidak menyentuh tabel utama sama sekali. Latensi I/O terpangkas ke batas minimum.
Identifikasi Bottleneck via EXPLAIN QUERY PLAN
Evaluasi efisiensi query dilakukan menggunakan perintah native SQLite: EXPLAIN QUERY PLAN. Periksa rencana eksekusi sebelum berasumsi mengenai performa query.
-- Skema tabel transaksi
CREATE TABLE transactions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
amount INTEGER NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL
);Query umum untuk menampilkan riwayat transaksi pengguna:
EXPLAIN QUERY PLAN
SELECT category, amount, created_at
FROM transactions
WHERE user_id = 'usr_9981'
ORDER BY created_at DESC
LIMIT 20;Jika tabel tidak memiliki indeks yang tepat, SQLite mengembalikan output berikut:
SCAN TABLE transactions
USE TEMP B-TREE FOR ORDER BYOutput ini menunjukkan dua masalah besar: SQLite memindai seluruh baris tabel (SCAN TABLE) dan mengalokasikan memori sementara untuk melakukan pengurutan manual (USE TEMP B-TREE).
Mengapa Single-Column Index Belum Cukup
Penambahan indeks standar pada user_id tidak menyelesaikan seluruh bottleneck:
CREATE INDEX idx_transactions_user_id ON transactions(user_id);Jalankan kembali EXPLAIN QUERY PLAN:
SEARCH TABLE transactions USING INDEX idx_transactions_user_id (user_id=?)
USE TEMP B-TREE FOR ORDER BYSQLite menggunakan indeks untuk memfilter baris, tetapi operasi ini masih memiliki dua defisiensi performa:
- Rowid Lookup Overhead: Untuk setiap baris yang lolos filter
user_id, SQLite membaca node indeks, mengambilrowid, lalu melakukan random access I/O ke tabel utama guna mengambil nilaicategory,amount, dancreated_at. - Sorting Cost: Kolom
created_atbelum terurut di dalam indeks, sehingga SQLite tetap membutuhkan sorting di memori lokal.
Implementasi Covering Index Multikolom
Covering index dibangun dengan memasukkan seluruh kolom filter, kolom pengurutan, dan kolom proyeksi ke dalam satu indeks komposit. Urutan kolom dalam deklarasi indeks bersifat krusial:
- Kolom Kesetaraan (Equality): Kolom pada klausa
WHERE =diletakkan paling awal (contoh:user_id). - Kolom Pengurutan (Ordering): Kolom pada klausa
ORDER BYdiletakkan berikutnya dengan arah urutan yang tepat (contoh:created_at DESC). - Kolom Payload (Projection): Kolom sisa yang hanya ada pada klausa
SELECTdiletakkan di akhir (contoh:category,amount).
Hapus indeks lama dan buat covering index:
DROP INDEX IF EXISTS idx_transactions_user_id;
CREATE INDEX idx_transactions_covering
ON transactions(user_id, created_at DESC, category, amount);Jalankan kembali profiling eksekusi:
EXPLAIN QUERY PLAN
SELECT category, amount, created_at
FROM transactions
WHERE user_id = 'usr_9981'
ORDER BY created_at DESC
LIMIT 20;Hasil rencana eksekusi yang optimal:
SEARCH TABLE transactions USING COVERING INDEX idx_transactions_covering (user_id=?)Penjelasan hasil:
- Tabel utama
transactionstidak diakses sama sekali. - Klausa
USE TEMP B-TREEhilang karena indeks sudah menyimpan data dengan urutancreated_at DESC. - SQLite menyelesaikan query hanya dengan satu traversal contiguous range pada leaf node index.
Verifikasi Latensi pada React Native (op-sqlite)
Pengujian profiling langsung pada thread JavaScript/JSI menggunakan library @op-engineering/op-sqlite:
import { open } from '@op-engineering/op-sqlite';
const db = open({ name: 'finance.db' });
function benchmarkQuery() {
const query = `
SELECT category, amount, created_at
FROM transactions
WHERE user_id = 'usr_9981'
ORDER BY created_at DESC
LIMIT 20;
`;
// Pastikan database telah diisi minimal 50,000 records realistis
const start = performance.now();
const result = db.execute(query);
const end = performance.now();
console.log(`Latency: ${(end - start).toFixed(3)} ms`);
console.log(`Rows returned: ${result.rows?.length}`);
}Profil Perbandingan Latensi Eksekusi (Dataset 50.000 Baris)
- Tanpa Indeks (SCAN TABLE): ~38.50 ms – 72.00 ms (potensi memicu drop 2–4 frame animasi UI).
- Indeks Tunggal (user_id): ~4.20 ms – 7.80 ms (mengeliminasi full scan, tetapi terhambat sorting & random disk access per baris).
- Covering Index: ~0.45 ms – 1.10 ms (eksekusi murni sekuensial dari memory page cache indeks).
Optimasi covering index memberikan reduksi waktu eksekusi hingga ~90% dibanding indeks standar, dan ~98% dibanding full scan.
Trade-off dan Engineering Constraints
Covering index bukan solusi tanpa biaya. Pahami trade-off arsitektural berikut sebelum menerapkannya di semua tabel:
- Write Amplification (INSERT/UPDATE/DELETE Overhead): Setiap operasi tulis yang memodifikasi nilai
amount,category,created_at, atauuser_idmewajibkan SQLite memperbarui tabel utama dan B-tree covering index. Hal ini meningkatkan durasi transaksi tulis. - Ukuran File Database (Storage Footprint): Karena kolom duplikat disimpan di struktur indeks, ukuran file database di penyimpanan internal perangkat pengguna akan bertambah. Pada perangkat mobile dengan kapasitas penyimpanan terbatas, buat covering index hanya untuk query yang berada di jalur kritis (hot-path).
- Indeks Tidak Reusable untuk Pola Query Berbeda: Indeks
(user_id, created_at DESC, category, amount)tidak dapat dimanfaatkan optimal oleh query yang memfiltercategorytanpa menyertakanuser_iddi awal klausa pencarian (prinsip Leftmost Prefix Rule).
Gunakan Covering Index khusus untuk query frekuensi tinggi (infinite scroll list, dashboard utama, sinkronisasi pesan chat) di mana latensi baca wajib berada di bawah ambang batas 16ms (1 frame budget pada 60 FPS).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!