Migrasi Data Besar Tanpa Mengunci Tabel Berjam-Jam
Kalau sudah pernah pegang aplikasi yang datanya bertumbuh terus-menerus, cepat atau lambat pasti ketemu situasi ini: perlu mengubah struktur tabel, mengisi kolom baru dari hasil kalkulasi, atau memindahkan data ke tabel lain — tapi tabelnya sudah berisi jutaan baris. Kalau migrasi dijalankan dengan cara "lama", satu query UPDATE atau ALTER TABLE besar sekaligus, yang terjadi bisa lock berkepanjangan di tabel itu. Di sistem yang trafiknya harus tetap jalan 24 jam, seperti aplikasi di lingkungan klinik atau rumah sakit yang saya geluti sehari-hari, ini bukan sekadar lambat — bisa bikin antrean request menumpuk dan aplikasi terasa "hang" dari sisi pengguna.
Kenapa migrasi sekali tembak itu berbahaya
Database seperti MySQL/MariaDB atau PostgreSQL punya mekanisme locking untuk menjaga konsistensi data. Masalahnya, semakin besar transaksi yang dijalankan, semakin lama lock itu dipegang, dan semakin besar juga replication lag kalau ada replica. Query UPDATE tabel_besar SET kolom = ... tanpa WHERE yang membatasi baris akan mengunci (atau minimal membebani) seluruh tabel selama proses berjalan. Kalau tabel itu sedang aktif dibaca-tulis oleh aplikasi produksi, request lain akan antre menunggu lock dilepas. Ini yang sering jadi biang insiden "aplikasi lambat tiba-tiba" padahal tidak ada perubahan kode.
Prinsip migrasi bertahap
Solusinya sederhana secara konsep: pecah pekerjaan besar jadi banyak transaksi kecil. Alih-alih memproses semua baris sekaligus, migrasi dijalankan per batch — misalnya 1.000 baris setiap kali jalan — dengan jeda singkat di antara batch supaya database sempat "bernapas" dan melayani trafik normal. Kuncinya ada tiga: batasi ukuran transaksi, gunakan kolom berindeks (biasanya primary key) untuk menentukan batas batch, dan beri jeda antar batch supaya tidak terus-menerus membebani database.
Contoh pola umum di Laravel, memakai chunkById yang memang dirancang aman untuk iterasi tabel besar karena tidak terganggu saat baris berubah selama proses:
DB::table('pasien')
->whereNull('kode_wilayah')
->orderBy('id')
->chunkById(1000, function ($rows) {
foreach ($rows as $row) {
DB::table('pasien')
->where('id', $row->id)
->update(['kode_wilayah' => hitung_kode($row)]);
}
usleep(200000); // jeda 200ms antar batch
});Untuk migrasi yang butuh waktu lama (misalnya jutaan baris), pola ini biasanya saya dorong ke job queue supaya bisa jalan di background, bisa dipantau progresnya, dan bisa dihentikan-lanjutkan tanpa mengganggu request HTTP biasa. Setiap batch dijalankan sebagai satu job terpisah, dengan penanda baris terakhir yang sudah diproses disimpan di tempat lain (tabel kecil atau cache), sehingga kalau proses terhenti di tengah jalan, bisa dilanjutkan dari titik terakhir tanpa mengulang dari awal.
Hal yang perlu dijaga saat eksekusi
Beberapa hal yang selalu saya cek sebelum menjalankan migrasi bertahap di produksi: pastikan kolom yang dipakai untuk membatasi batch punya index, karena tanpa index, setiap batch justru melakukan full table scan dan malah lebih berat. Pantau replication lag kalau ada replica database, karena batch yang terlalu besar atau jeda yang terlalu pendek bisa bikin replica tertinggal. Jalankan dulu di lingkungan staging dengan volume data yang mendekati produksi untuk mengukur, sebagai ilustrasi, berapa lama satu batch biasanya berjalan, sebelum menentukan ukuran batch dan jeda yang pas. Terakhir, siapkan cara untuk menghentikan proses dengan aman kalau ternyata beban database naik lebih tinggi dari perkiraan.
Takeaway
Migrasi data besar bukan soal mempercepat satu query raksasa, tapi soal memecahnya jadi banyak transaksi kecil yang ramah terhadap trafik produksi. Batching dengan kolom berindeks, jeda antar batch, dan eksekusi lewat job queue adalah kombinasi yang hampir selalu bisa diandalkan, apa pun database dan frameworknya.