Aplikasi React Native yang mengandalkan SQLite untuk arsitektur offline-first sering kali mengalami anomali performa: operasi pembacaan (SELECT) yang semula membutuhkan waktu 1–2 milidetik tiba-tiba melonjak hingga 100–300 milidetik saat sinkronisasi latar belakang berlangsung. Latensi tinggi ini memblokir bridge native atau thread JavaScript sehingga memicu frame drop (UI stutter) dan merusak pengalaman pengguna.
Penyebab utama dari degradasi ini adalah pembengkakan file Write-Ahead Log (WAL) atau WAL bloat. Akumulasi frame yang belum dipindahkan ke file basis data utama memaksa SQLite memindai log secara intensif setiap kali mengeksekusi query pembacaan.
Mengapa WAL Bloat Memperlambat Read Latency
Saat SQLite beroperasi dalam mode PRAGMA journal_mode=WAL;, setiap transaksi tulis (INSERT, UPDATE, DELETE) tidak langsung dimutasi ke file database utama (.db), melainkan ditambahkan secara sekuensial ke file terpisah berakhiran -wal.
Mekanisme pembacaan data berjalan dengan alur berikut:
- Ketika koneksi menjalankan
SELECT, SQLite membaca file WAL-index (.shm) berbasis shared-memory untuk memetakan halaman database terbaru. - SQLite mencari halaman tersebut di file WAL terlebih dahulu secara mundur (dari frame terbaru ke frame terlama).
- Jika halaman ditemukan di WAL, SQLite mengabaikan halaman lama yang ada di file database utama. Jika tidak ada di WAL, barulah SQLite membaca dari file
.db.
Masalah muncul ketika proses pemindahan frame dari WAL ke file utama—disebut checkpoint—terhambat. Apabila file WAL membengkak dari ukuran normal (~2-4 MB) menjadi puluhan atau ratusan megabita (ribuan hingga puluhan ribu frame uncheckpointed), ukuran indeks .shm membesar dan pencarian frame mengalami hash collisions serta cache miss. Dalam kondisi ekstrem, SQLite terpaksa melakukan pembacaan I/O disk sekuensial terhadap file WAL untuk merekonstruksi data, yang secara drastis menaikkan latensi pembacaan.
Penyebab Checkpoint Starvation
Secara default, SQLite menjalankan autocheckpoint setiap kali file WAL mencapai 1000 halaman (sekitar 4 MB dengan ukuran halaman standar 4096 byte). Namun, operasi checkpoint tidak dapat memindahkan frame melampaui posisi pembaca (reader) aktif tertua.
Jika aplikasi React Native membuka transaksi baca (misalnya, query observabel, rendering komponen daftar yang panjang, atau transaksi SELECT yang tidak ditutup), reader tersebut akan menahan titik checkpoint (checkpoint starvation). Jika proses sinkronisasi terus memompa ribuan transaksi tulis ke dalam WAL sementara reader aktif menahannya, file WAL akan terus tumbuh tanpa batas.
Mendiagnosis Ukuran File WAL pada Runtime
Untuk memastikan apakah penurunan performa disebabkan oleh WAL bloat, periksa ukuran file -wal di direktori penyimpanan aplikasi. Berikut contoh fungsi diagnosa menggunakan library berbasis fast I/O atau file system native:
import { Platform } from 'react-native';
import RNFS from 'react-native-fs';
interface DBStats {
dbSizeBytes: number;
walSizeBytes: number;
isBloated: boolean;
}
export async function inspectDatabaseFiles(dbName: string): Promise<DBStats> {
const basePath = Platform.OS === 'ios'
? `${RNFS.DocumentDirectoryPath}/../Library/Application Support`
: `${RNFS.DocumentDirectoryPath}`;
const dbPath = `${basePath}/${dbName}`;
const walPath = `${basePath}/${dbName}-wal`;
let dbSize = 0;
let walSize = 0;
if (await RNFS.exists(dbPath)) {
const stat = await RNFS.stat(dbPath);
dbSize = Number(stat.size);
}
if (await RNFS.exists(walPath)) {
const stat = await RNFS.stat(walPath);
walSize = Number(stat.size);
}
return {
dbSizeBytes: dbSize,
walSizeBytes: walSize,
// WAL lebih besar dari 10 MB atau melebihi 2x ukuran DB mengindikasikan bloat
isBloated: walSize > 10 * 1024 * 1024 || (dbSize > 0 && walSize > dbSize * 2),
};
}
Solusi 1: Pemecahan Batch Sinkronisasi (Yield Event Loop)
Kesalahan paling umum adalah menjalankan ribuan mutasi dalam satu transaksi raksasa tanpa jeda, atau mengeksekusinya berturut-turut tanpa memberikan kesempatan kepada SQLite untuk menjalankan autocheckpoint dan melepaskan lock reader aktif.
Pecah transaksi tulis ke dalam chunk berukuran 500–1000 entri, dan berikan jeda mikro (tick yield) agar event loop dan background thread SQLite dapat memproses antrean checkpoint:
import { open } from '@op-engineering/op-sqlite';
const db = open({ name: 'app_cache.db' });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function syncInChunks<T>(
items: T[],
batchSize: number = 500,
insertFn: (batch: T[]) => Promise<void>
) {
for (let i = 0; i < items.length; i += batchSize) {
const chunk = items.slice(i, i + batchSize);
// Eksekusi insert batch dalam satu transaksi terisolasi
await insertFn(chunk);
// Berikan ruang bagi SQLite worker & runtime JS untuk bernapas
// Ini memberi kesempatan bagi pembaca lain untuk menutup lock
await sleep(10);
}
}
Solusi 2: Tuning PRAGMA wal_autocheckpoint
Secara default nilai autocheckpoint adalah 1000 halaman. Jika memori perangkat terbatas dan Anda ingin SQLite memindahkan frame lebih agresif ke file utama sebelum file WAL membesar, turunkan nilai ini saat inisialisasi koneksi basis data:
-- Set autocheckpoint setiap 250 halaman (~1 MB bila page_size = 4096)
PRAGMA wal_autocheckpoint = 250;
-- Pastikan page size optimal untuk performa mobile I/O
PRAGMA page_size = 4096;
Trade-off: Menurunkan wal_autocheckpoint terlalu kecil (misal di bawah 100) akan meningkatkan frekuensi I/O tulis ke disk, yang berpotensi mengurangi kecepatan mutasi batch data secara agregat.
Solusi 3: Eksekusi Manual PRAGMA wal_checkpoint Terjadwal
SQLite menyediakan beberapa mode checkpoint manual melalui query PRAGMA wal_checkpoint(MODE):
- PASSIVE: Memindahkan sebanyak mungkin frame tanpa memblokir pembaca atau penulis. Jika ada reader aktif, checkpoint berhenti di frame tersebut. Cocok dijalankan berkala selama runtime aplikasi aktif.
- RESTART: Menunggu hingga semua reader selesai, memindahkan seluruh frame ke database utama, lalu me-restart penulisan ke awal file WAL.
- TRUNCATE: Serupa dengan RESTART, namun setelah memindahkan seluruh data, ukuran file
-waldipangkas (di-truncate) menjadi 0 byte di disk fisik.
Gunakan strategi kombinasi: jalankan PASSIVE secara berkala selama sinkronisasi, dan jalankan TRUNCATE saat proses sinkronisasi selesai atau saat aplikasi berpindah ke status background (menggunakan AppState):
import { AppState, AppStateStatus } from 'react-native';
import { open } from '@op-engineering/op-sqlite';
const db = open({ name: 'app_cache.db' });
export function runPassiveCheckpoint() {
try {
// Mengembalikan: [busy, log, checkpointed]
const result = db.execute('PRAGMA wal_checkpoint(PASSIVE);');
// Bila kolom busy bernilai 1, checkpoint terhalang oleh active reader
} catch (error) {
console.error('Passive checkpoint failed:', error);
}
}
export function runTruncateCheckpoint() {
try {
db.execute('PRAGMA wal_checkpoint(TRUNCATE);');
} catch (error) {
console.error('Truncate checkpoint failed:', error);
}
}
// Mendaftarkan lifecycle listener untuk pembersihan WAL saat aplikasi idle/background
export function setupWALMaintenance() {
AppState.addEventListener('change', (nextState: AppStateStatus) => {
if (nextState === 'background' || nextState === 'inactive') {
// Waktu terbaik memangkas WAL karena read/write dari UI sudah berhenti
runTruncateCheckpoint();
}
});
}
Ringkasan Konfigurasi Rekomendasi
- Koneksi Awal: Aktifkan
PRAGMA journal_mode = WAL;dan setPRAGMA synchronous = NORMAL;(cukup aman untuk WAL dan jauh lebih cepat daripadaFULL). - Batching: Batasi ukuran batch insert maksimal 500–1000 item per transaksi.
- Life Cycle Checkpoint: Panggil
PRAGMA wal_checkpoint(TRUNCATE);saat aplikasi beralih ke background atau setelah transaksi sinkronisasi masif berakhir. - Hindari Long-lived Read Transactions: Pastikan cursor atau query select ditutup dan tidak digantung dalam status transaksi aktif yang memblokir checkpoint SQLite.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!