1. Alarm Tengah Malam: Saat Layanan Produksi Mulai Tumbang
Pukul 02:14 WIB. PagerDuty saya menjerit melengking, membangunkan tidur lelap saya malam itu. Di layar ponsel, grafik metrik dari Cloudflare dan Prometheus menunjukkan lonjakan response time API backend Go kami dari rata-rata 45 milidetik langsung meroket menembus 8.500 milidetik. Koneksi Nginx di front-end mulai mencatat lonjakan error 502 Bad Gateway, sementara layanan webhook pihak ketiga berjatuhan karena mengalami timeout. Layanan kami, sebuah platform SaaS dengan beban ribuan request per detik, sedang sekarat.
Skenario horor ini berawal dari keputusan beberapa bulan lalu saat kami memilih SQLite untuk mempercepat MVP (Minimum Viable Product). Pada skala awal, SQLite adalah sebuah keajaibanโcepat, tanpa konfigurasi, dan tersimpan rapi dalam satu file lokal di disk NVMe. Namun, seiring bertambahnya pengguna aktif, sistem kami mulai beralih dari aplikasi monolitik sederhana menjadi arsitektur terdistribusi. Backend Go di-scale secara horizontal menjadi 6 pod, Nginx membagi beban, dan PHP-FPM menangani panel admin secara terpisah. Masalahnya? SQLite didesain untuk embedded single-writer. Ketika belasan container backend mencoba menulis data secara bersamaan ke satu file SQLite yang sama, mekanisme database lock (SQLITE_BUSY) langsung melumpuhkan seluruh aplikasi.
[!WARNING] Jangan pernah membiarkan aplikasi berbasis web dengan arsitektur multi-instance atau containerized menggunakan SQLite sebagai database utama produksi. Batasan file-locking tingkat OS akan menghancurkan konkurensi aplikasi Anda begitu trafik merangkak naik.
Kami tahu migrasi ke MySQL skala produksi adalah harga mati, tetapi tantangan sebenarnya bukanlah menulis skrip SQL-nya, melainkan bagaimana memindahkan gigabyte data transaksional dari file lokal SQLite ke cluster MySQL tanpa mematikan layanan sedetik pun. Dan tepat di tengah malam ini, migrasi setengah matang yang dilakukan tim junior tanpa zero-downtime strategy justru menjadi bumerang yang melumpuhkan sistem.
2. Jejak Digital: Menelusuri Anomali Log & Lonjakan Metrik
Begitu membuka terminal dan masuk ke server bastion, saya langsung mengarahkan pandangan ke log sistem untuk melacak akar masalah. Perintah pertama yang saya eksekusi adalah memantau error log Nginx dan log aplikasi Go secara bersamaan menggunakan tmux.
# Memantau log akses Nginx untuk melihat pola HTTP status code
sudo tail -f /var/log/nginx/access.log | grep -E '502|504|500'
# Memeriksa error log backend Go yang terhubung ke SQLite
journalctl -u api-backend.service -n 100 --no-pager | grep -i "database is locked"
Layar terminal langsung dibanjiri oleh ratusan baris pesan error: database is locked, diikuti oleh connection timeout dari Redis dan kegagalan Supervisor dalam mempertahankan proses worker queue. Saya membuka Grafana untuk melihat korelasi metrik. Terlihat jelas bahwa penggunaan CPU pada node database SQLite mencapai 100% karena busy waiting, sementara koneksi MySQL baru yang disiapkan setengah jalan mengalami connection pool exhaustion karena konfigurasi max_connections yang terlalu kecil.
// Potongan kode lama yang membuka koneksi SQLite secara naif di setiap HTTP request
func GetDBConnection() *sql.DB {
db, err := sql.Open("sqlite3", "./production.db?_busy_timeout=5000&_journal=WAL")
if err != nil {
log.Fatalf("Gagal membuka database: %v", err)
}
// Kesalahan fatal: tidak menerapkan connection pooling yang sehat
db.SetMaxOpenConns(1)
return db
}
Melihat kode di atas, jelas mengapa sistem langsung kolaps. Dengan memaksa SetMaxOpenConns(1), kami menciptakan antrean panjang yang membuat ribuan goroutine di Go saling berebut akses eksklusif ke file SQLite. Transisi dari skrip migrasi asinkron yang salah konfigurasi membuat tabel-tabel di MySQL mengalami inkonsistensi data, memaksa aplikasi melempar panic secara massal.
3. Membongkar Biang Kerok: Akar Masalah yang Sebenarnya
Setelah menggali lebih dalam, kami menemukan tiga akar masalah utama yang saling berkelindan hingga menciptakan malapetaka ini:
- Inkonsistensi Skema (Schema Mapping & Dialect Differences): Ada perbedaan mendasar antara tipe data SQLite yang dinamis (manifest typing) dengan MySQL yang ketat. SQLite mengizinkan kolom bertipe
DATETIMEdiisi string teks bebas atau timestamp integer, sementara MySQL menolaknya mentah-mentah jika tidak sesuai format ketat. Skrip migrasi awal gagal melakukan type casting pada kolom JSON dan boolean. - Ketiadaan Mekanisme Double-Write / Dual-Run: Tim mencoba memindahkan data secara instan (big-bang migration) tanpa fase sinkronisasi bertahap. Seharusnya, sistem menerapkan pola dual-write di level aplikasiโdi mana setiap perintah
INSERTatauUPDATEdikirim ke SQLite dan MySQL secara simultan, sebelum akhirnya memindahkan sumber pembacaan (read traffic) sepenuhnya ke MySQL. - Salah Kaprah Konfigurasi Connection Pooling: Pada MySQL, kegagalan mengatur parameter
max_connections,wait_timeout, danmax_allowed_packetdi server MySQL dan sisi backend Go menyebabkan lonjakan Too many connections saat ribuan request mencoba terhubung secara bersamaan setelah restart darurat.
[!TIP] Gunakan alat bantu seperti
pgloaderatau skrip kustom berbasis Go/Python yang melakukan pemetaan tipe data secara eksplisit (explicit type casting) alih-alih mengandalkan konversi otomatis saat migrasi dari SQLite ke MySQL.
4. Tindakan Darurat: Menghidupkan Kembali Layanan dalam Hitungan Menit
Situasi tidak boleh dibiarkan berlarut-larut. Pelanggan mulai mengeluh di media sosial. Saya harus mengambil tindakan cepat untuk menstabilkan infrastruktur dalam waktu kurang dari 60 detik.
Langkah pertama adalah mengisolasi database, menghentikan sementara proses migrasi yang rusak, mengembalikan read traffic ke SQLite dengan mode read-only, dan menaikkan batas kapasitas koneksi MySQL sambil membersihkan antrean worker.
Berikut adalah rangkaian perintah darurat yang saya eksekusi langsung di terminal server:
# 1. Segera hentikan service migrasi data yang sedang looping error
sudo systemctl stop data-migrator.service
# 2. Aktifkan mode Read-Only di Nginx untuk mencegah write baru yang merusak data
sudo sed -i 's/proxy_pass http://backend_pool;/return 503 "Maintenance";/' /etc/nginx/sites-available/app.conf
sudo nginx -t && sudo systemctl reload nginx
# 3. Masuk ke MySQL CLI dan reset koneksi serta flush table yang terkunci
mysql -u root -p -e "SET GLOBAL max_connections = 1000; SHOW PROCESSLIST;"
# 4. Jalankan skrip pembersih koneksi Redis dan restart Supervisor untuk antrean queue
sudo supervisorctl restart all
# 5. Kembalikan Nginx ke normal setelah backend Go dikonfigurasi ulang untuk membaca dari MySQL
sudo sed -i 's/return 503 "Maintenance";/proxy_pass http://backend_pool;/' /etc/nginx/sites-available/app.conf
sudo systemctl reload nginx
Hanya dalam kurun waktu 3 menit setelah eksekusi perintah darurat tersebut, grafik metrik di Grafana kembali melandai, latency turun drastis ke angka normal, dan error 502 lenyap dari layar pemantauan.
5. Kebijakan Retrospektif: Menambal Celah agar Tak Pernah Terulang
Insiden pukul dua pagi ini memberikan pelajaran mahal. Kami tidak boleh lagi meremehkan kompleksitas perpindahan basis data. Evaluasi mendalam melahirkan arsitektur migrasi baru yang jauh lebih defensif dan terstruktur.
Pertama, kami mendesain ulang strategi Schema Mapping. Sebelum data disentuh, kami membuat pemetaan tipe data yang kaku antara SQLite dan MySQL:
-- Contoh penyesuaian skema eksplisit dari SQLite ke MySQL InnoDB
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(36) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
attributes JSON, -- Menggantikan teks JSON di SQLite
is_active TINYINT(1) DEFAULT 1, -- Menggantikan boolean SQLite
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Kedua, kami menerapkan pola Zero-Downtime Data Sync menggunakan pendekatan empat fase:
- Fase Shadow Write: Aplikasi menulis ke SQLite sebagai master, dan secara asinkron (fire-and-forget lewat Redis Queue) mereplikasi data ke MySQL.
- Fase Dual-Write: Aplikasi menulis ke SQLite dan MySQL secara bersamaan, dengan transaksi utama berada di MySQL.
- Fase Switch Read: Pembacaan data sepenuhnya dialihkan ke MySQL, sementara SQLite masih menerima write sebagai cadangan.
- Fase Depcommission: SQLite dilepas total dari sistem.
Ketiga, perbaikan total pada Connection Pooling Configuration di sisi backend Go agar tangguh menghadapi lonjakan trafik:
package main
import (
"database/sql"
"time"
_ "github.com/go-sql-driver/mysql"
)
func InitMySQLPool() (*sql.DB, error) {
dsn := "app_user:secure_password@tcp(mysql-cluster.internal:3306)/production_db?parseTime=true&charset=utf8mb4"
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
// Konfigurasi connection pooling yang sehat untuk skala produksi
db.SetMaxOpenConns(100) // Batas maksimum koneksi terbuka
db.SetMaxIdleConns(25) // Batas koneksi nganggur di dalam pool
db.SetConnMaxLifetime(5 * time.Minute) // Mencegah koneksi stale akibat timeout firewall
db.SetConnMaxIdleTime(1 * time.Minute)
if err := db.Ping(); err != nil {
return nil, err
}
return db, nil
}
6. Pasang Alarm: Otomasi Deteksi Dini untuk Tim On-Call
Agar kejadian serupa terdeteksi sebelum berdampak luas ke pengguna, kami memperketat sistem observabilitas. Kami memasang aturan alerting otomatis di Prometheus dan Alertmanager yang terhubung langsung ke webhook PagerDuty dan Slack tim engineering.
Berikut adalah aturan alerting Prometheus (prometheus/alerts.yml) untuk mendeteksi dini masalah pada connection pool MySQL dan lonjakan latency database:
groups:
- name: database_production_alerts
rules:
- alert: MySQLHighConnectionUsage
expr: (mysql_global_status_threads_connected / mysql_global_variables{variable="max_connections"}) * 100 > 80
for: 2m
labels:
severity: critical
annotations:
summary: "Penggunaan koneksi MySQL melebihi 80%!"
description: "Koneksi aktif ke cluster MySQL kritis. Segera periksa connection pool pada backend aplikasi."
- alert: DatabaseQueryLatencyHigh
expr: rate(mysql_global_status_slow_queries[5m]) > 5
for: 1m
labels:
severity: warning
annotations:
summary: "Lonjakan slow query terdeteksi pada MySQL"
description: "Terdapat lebih dari 5 slow query per detik dalam 5 menit terakhir. Periksa indeks tabel."
- alert: BackendGoroutineLeak
expr: go_goroutines{job="api-backend"} > 50000
for: 3m
labels:
severity: critical
annotations:
summary: "Potensi Goroutine Leak pada Backend Go"
description: "Jumlah goroutine melampaui batas normal akibat antrean database lock atau network stall."
Dengan arsitektur defensif, konfigurasi connection pooling yang teruji, strategi sinkronisasi bertahap, dan sistem alarm dini yang presisi, proses migrasi dari SQLite ke MySQL kini dapat kami jalankan dengan mulus di siang bolong tanpa satupun pengguna yang merasakan kedipan gangguan pada layanan.
[!IMPORTANT] Infrastruktur yang tangguh bukanlah infrastruktur yang tidak pernah mengalami kegagalan, melainkan infrastruktur yang dirancang sedemikian rupa sehingga mampu pulih dengan cepat dan belajar secara permanen dari setiap insiden.