Cache key dan worker queue sering terlihat sebagai detail implementasi, padahal keduanya bisa menentukan apakah sistem terdistribusi Anda konsisten atau justru penuh bug yang sulit direproduksi. Banyak incident produksi bukan terjadi karena database salah, melainkan karena invalidation cache terlambat, job diproses tidak berurutan, atau retry menghapus data yang seharusnya masih valid.

Masalah utamanya sederhana: data utama berubah, tetapi data turunan—cache, materialized payload, hasil agregasi, atau view model—tidak ikut berubah pada waktu yang tepat. Solusinya bukan sekadar “hapus cache setelah update”, melainkan desain yang memperhitungkan ordering, idempotency, lock, dan observability. Artikel ini membahas pola yang praktis untuk menjaga konsistensi tanpa langsung melompat ke sistem yang terlalu kompleks.

Sebagai analogi, proyek seperti cnfast sering menarik perhatian karena optimasi kecil yang terlihat sepele, tetapi berdampak besar pada hasil akhir. Di backend, detail seperti format cache key, kapan invalidation dijalankan, atau bagaimana worker menangani retry juga punya efek yang sama: kecil di permukaan, besar pada reliabilitas sistem.

Mengapa cache-aside sering gagal di sistem nyata

Pola cache-aside biasanya berbunyi seperti ini:

  1. Baca dari cache.
  2. Jika miss, baca dari database.
  3. Simpan hasil ke cache.
  4. Saat data berubah, hapus cache terkait.

Di aplikasi sederhana, pola ini cukup. Di sistem terdistribusi, masalah muncul ketika ada beberapa proses yang berjalan paralel:

  • request baca dan tulis berjalan bersamaan,
  • update data utama memicu job asynchronous,
  • cache dibangun dari beberapa sumber,
  • retry queue menyebabkan efek samping ganda,
  • lebih dari satu instance aplikasi mengakses key yang sama.

Contoh kegagalan cache-aside

Misalkan ada endpoint GET /products/42 yang menyimpan hasil serialisasi produk ke Redis. Saat produk di-update:

  1. API menulis perubahan ke database.
  2. API menghapus key cache product:42.
  3. Worker terpisah menghitung ulang data turunan, misalnya harga diskon atau stok teragregasi.
  4. Sebelum worker selesai, ada request baca baru.
  5. Request itu miss ke cache, lalu membaca state yang belum lengkap atau state campuran dari database dan data turunan lama.
  6. Cache baru terisi dengan data stale.

Inilah bentuk umum stale read: cache miss justru menghasilkan cache yang salah karena sumber data belum benar-benar siap.

Tanda bahwa masalah Anda bukan “cache”, tetapi koordinasi

  • Nilai cache benar setelah beberapa menit, tetapi salah tepat setelah update.
  • Error hanya muncul saat traffic tinggi atau banyak update paralel.
  • Retry queue memperburuk inkonsistensi.
  • Bug sulit direproduksi di lokal karena urutan event di produksi lebih kompleks.

Masalah klasik pada invalidation dan worker queue

1. Stale read setelah invalidation terlalu dini

Menghapus cache segera setelah update database terdengar aman, tetapi bisa salah jika data yang dibaca API bergantung pada proses asynchronous. Jika Anda menghapus cache sebelum semua turunan selesai dihitung, request berikutnya membangun cache dari state sementara.

Prinsip penting: invalidation aman hanya jika sumber yang akan dipakai untuk mengisi ulang cache sudah konsisten.

2. Job out-of-order

Queue tidak selalu menjamin urutan global. Bahkan jika satu producer mengirim job A lalu B, worker bisa memproses B lebih dulu karena partisi, retry, atau konkurensi. Akibatnya:

  • job versi lama bisa menimpa hasil versi baru,
  • cache dihapus berdasarkan event lama,
  • snapshot turunan diregenerasi dari state usang.

Ini sering terjadi pada event seperti ProductUpdated(v1) lalu ProductUpdated(v2). Jika worker memproses v2 lebih dulu lalu v1 belakangan, hasil akhir bisa mundur ke data lama jika tidak ada mekanisme versi.

3. Retry yang menghapus cache terlalu dini atau berulang

Retry seharusnya meningkatkan ketahanan, tetapi worker yang tidak idempotent sering menghasilkan side effect berbahaya. Contoh umum:

  • job gagal setelah menghapus cache, tetapi sebelum menulis hasil baru,
  • job di-retry dan menghapus ulang cache yang sebenarnya sudah diperbarui oleh job lebih baru,
  • job lama yang datang terlambat membersihkan key yang semestinya tetap hidup.

4. Lock dengan scope yang salah

Lock sering dipakai untuk mencegah kerja ganda, tetapi lock yang terlalu lebar atau terlalu sempit sama-sama berisiko.

  • Terlalu lebar: satu lock global untuk semua produk membuat throughput jatuh dan meningkatkan antrean.
  • Terlalu sempit: lock hanya di level proses lokal tidak melindungi dari instance aplikasi lain.
  • Lease terlalu pendek: lock habis sebelum pekerjaan selesai, lalu worker lain masuk dan memproses entitas yang sama.

Karena itu, lock perlu didesain sebagai lease lock: ada pemilik, ada masa berlaku, dan operasi akhir hanya boleh dilakukan oleh pemilik lease yang masih valid.

Pola desain yang lebih aman

Versioned key: hindari overwrite terhadap state lama

Daripada selalu memakai key tetap seperti product:42, gunakan key yang memuat versi logis, misalnya:

product:42:v17

Lalu simpan pointer terpisah untuk versi aktif:

product:42:current -> v17

Saat data berubah:

  1. naikkan versi entitas, misalnya dari v17 ke v18,
  2. worker membangun cache baru di product:42:v18,
  3. setelah selesai dan tervalidasi, pointer current dipindah ke v18,
  4. key versi lama dibiarkan expired alami atau dibersihkan terjadwal.

Keuntungan pendekatan ini:

  • request tidak pernah membaca key yang sedang ditimpa,
  • job lama tidak mudah menimpa hasil baru,
  • rollback logis lebih mudah karena pointer bisa dipindah,
  • debugging lebih jelas karena tiap versi terlihat eksplisit.

Kekurangannya:

  • memakai lebih banyak memori,
  • perlu mekanisme garbage collection untuk versi lama,
  • kode baca sedikit lebih kompleks karena harus melewati pointer atau metadata versi.

Gunakan versi dari sumber kebenaran

Versi sebaiknya berasal dari sumber yang monotonik untuk satu entitas, misalnya:

  • kolom version yang di-increment saat update,
  • updated_at dengan ketelitian yang cukup dan tidak ambigu,
  • sequence event per aggregate.

Hindari mengandalkan timestamp dari mesin worker sebagai versi, karena jam antar node bisa berbeda dan tidak menjamin urutan logis.

Worker harus idempotent

Idempotent worker berarti job yang sama aman dijalankan lebih dari sekali tanpa merusak hasil akhir. Ini penting karena queue pada praktiknya biasanya memberi jaminan at-least-once delivery, bukan tepat satu kali.

Cara praktis membuat worker idempotent:

  • simpan job identity atau event identity,
  • cek apakah versi yang diproses masih relevan,
  • jangan melakukan write final jika versi job lebih lama dari versi aktif,
  • pisahkan langkah “build candidate result” dari langkah “publish result”.
// pseudocode sederhana
function rebuildProductCache(productId, targetVersion, jobId) {
  if (isJobAlreadyProcessed(jobId)) return;

  lease = acquireLease("product:" + productId, 30000);
  if (!lease) return retryLater();

  try {
    currentVersion = loadCurrentVersionFromDB(productId);
    if (targetVersion < currentVersion) {
      markJobProcessed(jobId, "stale");
      return;
    }

    payload = buildProductViewFromSources(productId);
    writeCache("product:" + productId + ":v" + targetVersion, payload, ttl=3600);

    // publish hanya jika versi masih cocok
    currentVersion = loadCurrentVersionFromDB(productId);
    if (targetVersion == currentVersion) {
      setPointer("product:" + productId + ":current", "v" + targetVersion);
    }

    markJobProcessed(jobId, "done");
  } finally {
    releaseLease(lease);
  }
}

Poin penting pada contoh di atas bukan sintaks, tetapi urutannya:

  • cek relevansi versi sebelum kerja mahal,
  • bangun hasil di key versi baru,
  • publikasikan hanya jika versi masih terbaru,
  • tandai job selesai agar retry tidak mengulang efek samping.

Dedup job: kurangi kerja ganda, bukan sekadar beban queue

Jika satu produk di-update 20 kali dalam 10 detik, memproses 20 job rebuild penuh sering tidak masuk akal. Anda dapat menambahkan deduplication berbasis entitas, misalnya:

  • satu job aktif per productId,
  • job baru hanya menggeser target versi tertinggi,
  • worker saat eksekusi selalu mengambil versi terbaru yang diketahui.

Dedup bukan hanya soal performa. Ini juga mengurangi peluang job lama menulis hasil usang. Namun ada trade-off:

  • event perantara mungkin tidak diproses satu per satu,
  • jika setiap event punya side effect bisnis tersendiri, dedup bisa salah.

Jadi dedup cocok untuk rebuild derived state, tetapi tidak selalu cocok untuk workflow bisnis yang menuntut setiap event diproses eksplisit.

Lease lock: bukan lock abadi

Untuk entitas yang sama, sering kali Anda ingin hanya satu worker yang mempublikasikan hasil pada satu waktu. Gunakan lease lock dengan aturan berikut:

  • scope lock per entitas, misalnya product:42, bukan global,
  • lease duration lebih panjang dari waktu normal kerja plus buffer,
  • dukungan perpanjangan lease jika pekerjaan bisa lama,
  • release hanya oleh pemilik lease,
  • jika lease hilang di tengah jalan, worker tidak boleh menganggap dirinya masih berwenang untuk publish.

Kesalahan umum adalah menganggap lock cukup jika “berhasil diambil di awal”. Dalam sistem terdistribusi, network stall atau pause proses bisa membuat lease kedaluwarsa tanpa disadari. Maka langkah publish final sebaiknya memverifikasi otoritas atau memakai compare-and-set.

TTL vs explicit invalidation: kapan memakai yang mana

TTL cocok sebagai pagar pengaman, bukan sumber konsistensi utama

TTL berguna untuk:

  • mencegah key hidup terlalu lama jika invalidation gagal,
  • membatasi dampak bug sementara,
  • mengendalikan penggunaan memori.

Tetapi TTL tidak menjamin kapan data menjadi benar. Jika konsistensi penting, TTL tidak boleh menjadi satu-satunya mekanisme sinkronisasi. Key bisa tetap stale sampai TTL habis.

Explicit invalidation lebih presisi, tetapi lebih rapuh

Explicit invalidation berarti Anda tahu key mana yang harus dihapus atau dipindahkan saat data berubah. Ini lebih cepat menuju state benar, tetapi rawan jika:

  • daftar dependensi key tidak lengkap,
  • invalidation terjadi sebelum data turunan siap,
  • job gagal di tengah jalan,
  • ada pembaca yang mengisi ulang cache dari state parsial.

Pola yang sering paling aman

Dalam banyak sistem, kombinasi berikut lebih realistis:

  • gunakan explicit invalidation atau republish berbasis versi sebagai mekanisme utama,
  • gunakan TTL sebagai fallback,
  • hindari delete-then-rebuild jika bisa memakai build-then-switch.

Build-then-switch biasanya lebih aman daripada delete-then-fill karena pembaca tetap punya versi lama yang konsisten sampai versi baru siap dipublikasikan.

Contoh alur incident: bagaimana inkonsistensi muncul

Skenario

Anda punya:

  • database produk,
  • cache Redis untuk payload API produk,
  • worker queue untuk menghitung harga promo dan stok agregat,
  • endpoint baca yang menggunakan cache-aside.

Kronologi incident

  1. Pukul 10:00:00, admin mengubah harga produk 42.
  2. API update menyimpan harga baru ke database.
  3. API langsung menghapus product:42.
  4. API mengirim job RebuildProductCache(productId=42, version=18).
  5. Pukul 10:00:01, request pembeli datang ke endpoint produk.
  6. Cache miss terjadi karena key sudah dihapus.
  7. Endpoint membaca data database utama yang sudah baru, tetapi harga promo agregat di tabel turunan belum selesai diperbarui.
  8. Endpoint menulis payload campuran ke cache.
  9. Pukul 10:00:03, worker memproses job versi 18 tetapi gagal timeout setelah sempat menghapus key turunan lain.
  10. Retry berjalan pukul 10:00:10.
  11. Sementara itu, update kedua terjadi dan menghasilkan versi 19.
  12. Job retry versi 18 selesai belakangan dan menimpa cache dengan state usang.

Akar masalah

  • delete-then-fill menciptakan jendela race condition,
  • cache-aside mengisi ulang dari sumber yang belum konsisten,
  • worker tidak memeriksa versi aktif saat publish,
  • retry memperparah dampak karena job tidak idempotent.

Perbaikan desain

  • gunakan versioned key, misalnya product:42:v18 dan product:42:v19,
  • jangan hapus key lama sampai versi baru siap,
  • saat worker selesai, pindahkan pointer hanya jika target versi masih versi aktif,
  • simpan identitas job dan status proses untuk idempotensi,
  • pasang metric yang mendeteksi publish job stale.

Observability yang perlu dipasang

Tanpa observability, masalah konsistensi sering terlihat seperti bug acak. Anda perlu bisa menjawab: versi mana yang dibaca, siapa yang menulis cache, job mana yang terakhir publish, dan berapa lama lag antara update utama dan data turunan.

Metric minimum

  • cache hit/miss rate per endpoint atau per jenis key,
  • rebuild latency: waktu dari update sumber hingga cache versi baru terpublish,
  • queue lag: selisih antara job dibuat dan job mulai diproses,
  • stale publish count: jumlah job versi lama yang mencoba publish,
  • dedup drop/coalesce count: berapa job yang digabung atau dibuang,
  • lease contention: seberapa sering worker gagal mengambil lease.

Logging yang benar-benar berguna

Minimal log berikut harus ada dan bisa dikorelasikan:

  • entity_id,
  • target_version,
  • current_version,
  • job_id,
  • lease_owner,
  • cache_key,
  • publish_result seperti done, stale, skipped, retry.

Trace yang perlu terlihat

Jika Anda memakai distributed tracing, hubungkan alur berikut dalam satu jejak atau korelasi ID:

  1. request update,
  2. transaksi database,
  3. enqueue job,
  4. proses worker,
  5. publish cache,
  6. request baca setelah update.

Ini membantu melihat apakah stale read terjadi karena queue lag, invalidation terlalu dini, atau job versi lama yang publish belakangan.

Alert yang realistis

  • queue lag melewati ambang yang memengaruhi SLA pembacaan,
  • jumlah stale publish meningkat tiba-tiba,
  • rasio miss melonjak setelah deployment,
  • gap antara versi database dan versi cache aktif terlalu besar.

Panduan implementasi praktis

Pola write yang lebih aman

  1. Tulis perubahan ke sumber kebenaran.
  2. Naikkan versi entitas dalam transaksi yang sama jika memungkinkan.
  3. Kirim event atau job yang membawa entityId dan targetVersion.
  4. Worker membangun hasil pada key versi baru.
  5. Worker mempublikasikan hasil dengan compare-and-set terhadap versi aktif.
  6. Biarkan versi lama kedaluwarsa dengan TTL atau dibersihkan terjadwal.

Contoh struktur key

product:{id}:current
product:{id}:v{version}
product:{id}:lock
product:{id}:rebuild:dedup

Gunakan penamaan yang konsisten dan mudah ditelusuri. Hindari key yang tidak membawa konteks entitas atau jenis payload karena akan menyulitkan invalidation dan debugging.

Kapan tetap memakai cache-aside biasa

Cache-aside sederhana masih layak jika:

  • data tidak punya banyak turunan asynchronous,
  • dampak stale read kecil dan sementara,
  • urutan event mudah dikendalikan,
  • tim belum siap mengelola kompleksitas versioned cache.

Namun jika data dibentuk oleh beberapa job atau agregasi lintas tabel, pertimbangkan pola yang lebih ketat sejak awal.

Trade-off biaya vs kompleksitas

Pendekatan sederhana

Kelebihan:

  • implementasi cepat,
  • mudah dipahami semua anggota tim,
  • biaya infrastruktur lebih rendah.

Kekurangan:

  • rentan race condition,
  • sulit dipastikan konsisten saat beban naik,
  • incident sering muncul di edge case produksi.

Pendekatan dengan versioned key, dedup, dan lease

Kelebihan:

  • lebih tahan terhadap out-of-order dan retry,
  • stale publish lebih mudah dicegah,
  • observability lebih jelas karena versi eksplisit.

Kekurangan:

  • logika aplikasi lebih kompleks,
  • lebih banyak metadata yang harus dikelola,
  • memori cache dan biaya operasional meningkat.

Keputusan yang baik bukan memilih sistem paling canggih, tetapi memilih tingkat kompleksitas yang sebanding dengan risiko bisnis dari inkonsistensi.

Kapan tim sebaiknya memilih konsistensi lebih ketat daripada performa

Pilih desain dengan konsistensi lebih ketat jika salah satu kondisi berikut berlaku:

  • data memengaruhi harga, saldo, hak akses, inventori, atau keputusan bisnis penting,
  • stale read bisa menimbulkan kerugian finansial atau pelanggaran kebijakan,
  • update pada entitas yang sama sering terjadi dalam waktu berdekatan,
  • queue lag dan retry adalah kejadian normal, bukan pengecualian,
  • sistem punya banyak konsumen yang membangun turunan dari data yang sama.

Dalam kondisi seperti ini, menambah satu network hop, satu pointer version, atau satu validasi compare-and-set sering jauh lebih murah daripada menanggung incident berulang.

Checklist implementasi

  • Gunakan versi monotonik per entitas.
  • Masukkan versi ke desain cache key untuk data turunan yang sensitif.
  • Hindari delete-then-fill jika bisa build-then-switch.
  • Pastikan worker idempotent terhadap retry dan duplikasi delivery.
  • Validasi versi saat publish agar job lama tidak menimpa hasil baru.
  • Terapkan dedup per entitas untuk job rebuild yang mahal.
  • Gunakan lease lock dengan scope per entitas, bukan lock global.
  • Tambahkan TTL sebagai fallback, bukan mekanisme konsistensi utama.
  • Pasang metric queue lag, rebuild latency, stale publish, dan lease contention.
  • Log-kan entity_id, version, job_id, dan hasil publish.
  • Uji skenario out-of-order, retry, timeout, dan worker crash.
  • Dokumentasikan dependensi key agar invalidation tidak buta.

Penutup

Cache key dan worker queue bukan sekadar alat optimasi. Di sistem terdistribusi, keduanya adalah bagian dari mekanisme konsistensi. Jika invalidation dilakukan tanpa mempertimbangkan urutan event, retry, dan scope lock, cache justru menjadi sumber bug yang paling mahal.

Pola seperti versioned key, idempotent worker, dedup job, dan lease lock membantu menggeser desain dari “semoga update berikutnya memperbaiki state” menjadi “state lama tidak bisa sembarang menimpa state baru”. Tambahkan observability yang tepat, dan tim Anda akan lebih mudah membedakan masalah performa biasa dari masalah konsistensi yang nyata.