Read replica lag bukan sekadar masalah database yang sedikit terlambat. Dalam sistem yang memakai cache, queue, dan worker, lag beberapa ratus milidetik saja bisa membuat aplikasi terlihat salah: user baru saja menyimpan data tetapi API masih menampilkan nilai lama, status job berubah maju lalu tampak mundur, atau worker mengeksekusi keputusan berdasarkan data yang belum tersinkron dari primary ke replica.

Masalah ini sering terasa acak karena bug-nya bukan murni di satu komponen. Penyebab utamanya adalah urutan kejadian yang tidak sinkron antara write ke primary, replication ke read replica, cache invalidation, dan consumer queue. Jika alur ini tidak dirancang dengan asumsi bahwa data bisa sementara tidak konsisten, sistem akan menampilkan gejala operasional yang sulit direproduksi.

Apa yang Sebenarnya Terjadi pada Read Replica Lag

Pola umumnya sederhana:

  1. Aplikasi menulis data ke primary database.
  2. Primary mengonfirmasi transaksi berhasil.
  3. Read replica menerima perubahan setelahnya, bukan pada saat yang sama.
  4. Di jeda waktu itu, service lain mungkin membaca dari replica, mengisi cache, atau memproses job dari queue.

Akibatnya, komponen yang membaca dari replica dapat melihat snapshot lama walaupun write sudah sukses. Jika hasil baca lama itu dipakai untuk mengisi cache atau mengambil keputusan di worker, stale data menyebar lebih jauh daripada sekadar database.

Intinya: write sudah benar, tetapi pembacanya belum semua melihat kebenaran yang sama pada waktu yang sama.

Gejala Operasional yang Paling Sering Muncul

1. Data baru belum terbaca setelah berhasil disimpan

Contoh paling umum adalah pola write lalu read:

  • User mengubah profil.
  • API mengembalikan status sukses.
  • Permintaan berikutnya membaca dari replica.
  • Respons masih menampilkan profil lama.

Secara teknis, aplikasi tidak salah menulis. Masalahnya ada pada asumsi bahwa pembacaan berikutnya akan langsung konsisten jika diarahkan ke replica.

2. Status job tampak mundur

Misalnya ada job pipeline dengan status: queued -> processing -> done. Salah satu service menulis status processing ke primary, lalu worker lain atau endpoint status membaca dari replica yang belum ter-update dan masih melihat queued. Dari sisi operator atau user, status terlihat mundur.

Gejala ini makin buruk jika status tersebut ikut di-cache. Replica membaca status lama, lalu cache menyimpan nilai lama itu beberapa detik lagi. Kini masalah bukan hanya lag database, tetapi juga lag yang diperpanjang oleh cache.

3. Cache menghidangkan nilai lama setelah invalidasi

Banyak tim berasumsi bahwa menghapus cache setelah write sudah cukup. Padahal urutannya bisa seperti ini:

  1. Write ke primary sukses.
  2. Aplikasi menghapus key cache.
  3. Request berikutnya cache miss.
  4. Aplikasi memuat ulang data dari replica.
  5. Replica belum menerima perubahan.
  6. Nilai lama masuk lagi ke cache.

Ini adalah bentuk race condition yang sangat umum. Cache sudah di-invalidasi dengan benar, tetapi sumber data untuk rebuild cache masih stale.

4. Worker mengambil keputusan dari data stale

Contoh nyata:

  • Order ditandai paid di primary.
  • Event diterbitkan ke queue.
  • Worker konsumsi event dan membaca detail order dari replica.
  • Replica masih melihat order sebagai pending.
  • Worker memutuskan untuk menunda fulfillment atau malah mengirim sinyal yang salah.

Di sini, queue bukan penyebab utama, tetapi queue membuat stale read lebih berbahaya karena keputusan otomatis sudah telanjur diambil.

Diagram Alur Race Condition antara Primary, Replica, Cache, dan Queue

Client
  |
  | 1. UPDATE order status = 'paid'
  v
API Service
  |
  | 2. write ke Primary DB
  v
Primary DB ------------------------> Queue/Event Bus
  |                                    |
  | 3. replikasi async                 | 4. event dikonsumsi worker
  v                                    v
Read Replica                        Worker
  ^                                    |
  |                                    | 5. baca order dari Replica
  |                                    |    (masih 'pending')
  |                                    v
Cache <----------- 6. cache miss, rebuild dari Replica
  |
  | 7. API berikutnya baca dari cache, nilai lama tersaji
  v
Client melihat data salah/stale

Masalah terjadi karena beberapa alur berjalan paralel:

  • replikasi database bersifat asinkron,
  • cache invalidation terjadi segera,
  • queue consumer bisa bekerja lebih cepat daripada replica mengejar perubahan.

Jika sistem tidak memiliki guardrail, stale read dari replica akan terlihat seperti bug logika bisnis.

Mengapa Cache dan Queue Membuat Replica Lag Terasa Lebih Parah

Cache memperpanjang umur data stale

Tanpa cache, stale read mungkin hanya terjadi sesaat selama replica belum sinkron. Dengan cache, stale value bisa hidup selama TTL berlangsung atau sampai invalidasi berikutnya. Jadi keterlambatan 300 ms di level database bisa berubah menjadi 30 detik di level aplikasi.

Queue mempercepat keputusan sebelum data siap

Queue mendorong arsitektur menjadi asinkron. Itu bagus untuk throughput, tetapi berarti consumer dapat bereaksi sebelum semua sistem pembaca melihat state terbaru. Jika worker membaca dari replica, keputusan yang salah bisa terjadi walaupun event-nya benar.

Sumber kebenaran jadi tidak seragam

Dalam satu rentang waktu singkat:

  • primary tahu state terbaru,
  • replica tahu state lama,
  • cache mungkin tahu state lebih lama lagi,
  • worker bisa mengambil tindakan dari salah satu sumber itu.

Semakin banyak lapisan baca, semakin penting mendefinisikan kapan sistem butuh freshness tinggi dan kapan stale data masih dapat diterima.

Pola Mitigasi yang Praktis

1. Read-after-write consistency untuk alur kritis

Untuk request yang baru saja melakukan write, jangan langsung membaca dari replica jika hasil baca dipakai untuk respons yang harus akurat. Arahkan pembacaan berikutnya ke primary selama jendela waktu tertentu atau selama konteks request yang sama.

Contoh kasus yang cocok:

  • setelah update profil, tampilkan profil dari primary,
  • setelah membuat order, baca ringkasan order dari primary,
  • setelah mengubah status job, endpoint detail status sementara membaca dari primary.

Ini bekerja karena Anda menghindari komponen yang belum tentu sudah menerima write terbaru.

Trade-off: beban baca ke primary meningkat. Karena itu, gunakan secara selective, bukan untuk semua query.

2. Selective read ke primary untuk data yang menentukan keputusan

Tidak semua read harus konsisten kuat. Namun beberapa read bersifat decision-making read, misalnya:

  • apakah order sudah dibayar,
  • apakah stok masih tersedia,
  • apakah user berhak mengeksekusi aksi tertentu,
  • apakah job sudah final atau masih boleh diproses ulang.

Query seperti ini sebaiknya diarahkan ke primary atau ke jalur baca yang menjamin konsistensi lebih baik. Replica cocok untuk query analitik ringan, daftar, pencarian, atau data yang toleran stale.

3. Versioning atau monotonic state

Tambahkan version, updated_at, atau sequence number pada data yang sering berubah dan dipakai lintas komponen. Tujuannya agar cache, consumer, atau service lain bisa menolak update yang lebih lama.

Contoh: status job tidak boleh mundur dari versi 10 ke versi 9, walaupun replica atau event lama datang belakangan.

function applyStatusUpdate(current, incoming) {
  if (incoming.version < current.version) {
    return current; // tolak update stale
  }
  return incoming;
}

Pola ini tidak menghilangkan replica lag, tetapi mencegah stale data menimpa state yang lebih baru.

4. Delayed invalidation atau delayed rebuild cache

Jika cache dibangun dari replica, invalidasi tepat setelah write kadang justru berbahaya. Salah satu mitigasi adalah memberi jeda singkat sebelum cache diisi ulang, atau melakukan rebuild dari primary untuk key tertentu yang sensitif.

Pendekatan yang umum:

  • delete lalu tunggu sebentar sebelum warm-up dari replica,
  • write-through/update cache dengan nilai baru dari hasil transaksi,
  • cache stampede protection agar banyak request tidak berlomba membangun cache dari replica stale.

Catatan: delayed invalidation hanyalah kompromi. Ia mengurangi peluang race, tetapi tidak menjamin konsistensi absolut jika lag lebih panjang dari perkiraan.

5. Retry dengan backoff di worker

Jika worker menerima event yang bergantung pada data terbaru, jangan langsung gagal permanen saat membaca state yang belum sesuai. Gunakan retry dengan exponential backoff atau jeda kecil yang masuk akal.

function processPaymentConfirmed(event) {
  for (attempt = 1; attempt <= 5; attempt++) {
    order = readOrderForDecision(event.orderId); // idealnya primary

    if (order.status == 'paid') {
      fulfillOrder(order);
      return;
    }

    sleep(backoff(attempt));
  }

  sendToManualReview(event.orderId, 'order state not visible yet');
}

Pola ini efektif ketika masalahnya adalah keterlambatan propagasi, bukan korupsi data. Namun jangan menjadikannya alasan untuk semua worker tetap membaca dari replica pada keputusan kritis. Retry membantu, tetapi sumber baca yang benar tetap lebih penting.

6. Bawa data yang cukup di dalam event

Sering kali worker membaca ulang dari database karena event terlalu tipis. Jika aman dan wajar, sertakan field penting dalam payload event, misalnya order_id, status, version, dan paid_at. Dengan begitu worker tidak selalu perlu melakukan read segera ke replica untuk memutuskan langkah berikutnya.

Trade-off: payload event menjadi lebih besar dan ada risiko duplikasi sumber data. Karena itu, sertakan hanya data yang stabil dan relevan untuk aksi downstream.

Contoh Pseudocode Alur yang Lebih Aman

Write, cache update, dan event publish

function updateUserProfile(userId, patch) {
  tx = beginTransaction()

  user = primaryDb.getUserForUpdate(userId)
  updated = applyPatch(user, patch)
  updated.version = user.version + 1

  primaryDb.save(updated)
  commit(tx)

  cache.set("user:" + userId, updated, ttl=60)

  queue.publish({
    type: "user.profile.updated",
    userId: userId,
    version: updated.version,
    profileSummary: updated.profileSummary
  })

  return updated
}

Poin penting dari contoh di atas:

  • write dilakukan ke primary,
  • cache diisi dengan nilai baru hasil write, bukan dibaca ulang dari replica,
  • event membawa versi agar consumer bisa mendeteksi urutan state.

Consumer yang menolak state stale

function handleUserProfileUpdated(event) {
  current = cache.get("projection:user:" + event.userId)

  if (current != null && current.version > event.version) {
    return // event lama, abaikan
  }

  projection = {
    userId: event.userId,
    version: event.version,
    profileSummary: event.profileSummary
  }

  cache.set("projection:user:" + event.userId, projection, ttl=300)
}

Consumer ini tidak bergantung pada read segera ke replica untuk membangun view sederhana. Hasilnya, peluang membaca data stale berkurang.

Observability yang Perlu Dipasang

Replica lag sulit didiagnosis jika Anda hanya melihat error rate aplikasi. Yang perlu dipantau bukan cuma kesehatan database, tetapi juga hubungan antar-komponen.

Metrik minimum

  • replication lag per replica,
  • cache hit/miss untuk key penting,
  • age of cached value atau timestamp saat cache dibangun,
  • queue delay: selisih waktu event dipublish dan diproses,
  • worker retry count dan alasan retry,
  • read source ratio: berapa banyak request membaca dari primary vs replica.

Logging yang membantu

Saat mendebug, log berikut sering sangat berguna:

  • ID entitas, misalnya order_id atau user_id,
  • sumber baca: primary, replica tertentu, atau cache,
  • versi/state yang dibaca dan ditulis,
  • timestamp event diterbitkan, diproses, dan cache dibangun,
  • attempt number untuk worker retry.

Tanpa ini, Anda hanya melihat “kadang data salah” tanpa bisa membuktikan apakah stale data berasal dari replica, cache, atau event ordering.

Tracing lintas komponen

Jika memungkinkan, pasang distributed tracing agar satu alur bisa diikuti dari API write sampai worker consume. Ini sangat membantu untuk menemukan bahwa:

  • write selesai pada T0,
  • cache dihapus pada T0+5ms,
  • cache dibangun ulang dari replica pada T0+20ms,
  • replica baru sinkron pada T0+300ms.

Dari situ Anda bisa melihat penyebab sebenarnya, bukan menebak-nebak.

Checklist Debugging saat Data Terlihat Salah

  1. Pastikan write benar-benar masuk ke primary. Cek log transaksi atau audit trail.
  2. Ukur replication lag pada saat kejadian. Jangan hanya melihat kondisi normal.
  3. Identifikasi sumber baca request yang salah. Apakah dari cache, replica, atau primary?
  4. Cek apakah cache diisi ulang dari replica setelah invalidasi.
  5. Bandingkan timestamp dan version. Apakah state lama menimpa state baru?
  6. Periksa event ordering di queue. Apakah event lebih lama diproses belakangan?
  7. Lihat retry worker. Apakah worker gagal karena state belum terlihat?
  8. Audit query kritis. Apakah keputusan bisnis penting masih diarahkan ke replica?
  9. Periksa TTL cache. Stale value mungkin hidup lebih lama dari replication lag itu sendiri.
  10. Simulasikan race condition di staging. Tambahkan delay buatan antara primary dan replica/read path bila perlu.

Kapan Masalah Ini Sering Muncul di Produksi

Replica lag yang berdampak ke cache dan queue biasanya muncul pada kondisi berikut:

  • traffic write meningkat, misalnya saat promo, batch import, atau sinkronisasi besar,
  • query berat di replica membuat apply replication tertinggal,
  • job queue melonjak sehingga consumer memproses event sangat cepat setelah write,
  • cache invalidation agresif pada entitas yang sering berubah,
  • state machine kompleks dengan banyak perubahan status dalam waktu singkat,
  • arsitektur microservices yang memisahkan write path dan read path tanpa aturan konsistensi yang jelas.

Masalah ini juga sering baru terlihat setelah sistem tumbuh. Pada trafik rendah, jeda replikasi terlalu kecil untuk memunculkan gejala. Ketika beban meningkat, jeda itu cukup untuk menabrak cache rebuild atau worker consume.

Trade-off Biaya vs Konsistensi

Tidak ada solusi gratis. Mengurangi dampak read replica lag biasanya berarti memilih salah satu atau kombinasi berikut:

  • lebih banyak read ke primary - konsistensi membaik, biaya dan beban primary naik,
  • cache lebih cerdas - implementasi lebih kompleks, tetapi bisa menjaga performa tanpa terlalu banyak stale read,
  • event payload lebih kaya - mengurangi read tambahan, tetapi memperbesar coupling dan ukuran pesan,
  • versioning dan idempotency - meningkatkan keamanan state, tetapi menambah disiplin implementasi,
  • retry dan backoff - menoleransi propagasi lambat, tetapi menambah latensi dan kompleksitas operasional.

Pilihan yang baik bergantung pada jenis data:

  • Untuk halaman daftar atau data analitik ringan, stale beberapa detik mungkin dapat diterima.
  • Untuk pembayaran, stok, otorisasi, dan status final job, lebih aman membaca dari primary atau menggunakan mekanisme konsistensi yang lebih kuat.

Rekomendasi Praktis yang Paling Sering Efektif

  • Jangan anggap cache invalidation otomatis menyelesaikan stale data jika sumber rebuild adalah replica.
  • Kelompokkan query menjadi critical read dan tolerant read.
  • Untuk critical read, arahkan ke primary atau terapkan read-after-write consistency.
  • Tambahkan version pada entitas yang sering berubah status.
  • Pastikan worker tidak mengambil keputusan penting dari replica tanpa guardrail.
  • Gunakan retry dengan backoff hanya sebagai mitigasi propagasi, bukan sebagai pengganti desain yang benar.
  • Pasang metrik replication lag, queue delay, cache age, dan tracing lintas komponen.

Penutup

Read replica lag menjadi berbahaya bukan hanya karena replica terlambat, tetapi karena cache dan queue dapat memperbanyak serta memperpanjang efeknya. Data baru belum terbaca, status job tampak mundur, cache menyajikan nilai lama, dan worker mengambil keputusan dari state stale semuanya sering berasal dari race condition yang sama.

Pendekatan yang efektif biasanya bukan menghapus replica atau cache, melainkan menentukan dengan tegas jalur baca yang butuh konsistensi, mencegah stale state menimpa state baru, dan memasang observability yang cukup untuk melihat urutan kejadian sebenarnya. Jika tiga hal ini dilakukan, gejala yang tampak acak akan jauh lebih mudah dikendalikan di produksi.