Race condition pada cache eviction Redis sering terlihat seperti bug acak: request update sukses, tetapi beberapa request berikutnya masih membaca data lama. Pada sistem Spring Boot yang berjalan di banyak instance, gejalanya bisa lebih membingungkan karena satu instance menampilkan data baru, sementara instance lain masih menyajikan nilai stale dari cache atau hasil load ulang yang datang terlambat.
Masalah ini jarang mudah direproduksi di lokal karena butuh kombinasi timing, konkurensi, latensi jaringan, dan urutan event tertentu. Dalam artikel ini, kita bahas studi kasus debugging backend Spring Boot ketika invalidasi cache Redis memicu data stale atau bahkan menimpa nilai baru, lalu menurunkannya ke langkah investigasi dan perbaikan yang praktis.
Arsitektur singkat dan gejala yang muncul
Arsitektur kasusnya cukup umum:
- Aplikasi Spring Boot berjalan dalam beberapa instance di belakang load balancer.
- Data utama disimpan di database relasional.
- Redis dipakai sebagai cache untuk endpoint baca yang sering diakses.
- Pola cache yang dipakai adalah cache-aside: aplikasi membaca dari cache, lalu fallback ke database jika cache miss.
- Saat data di-update, aplikasi mengubah database lalu menghapus key cache terkait.
Secara teori, alurnya terlihat aman: update database, lalu eviction cache, sehingga request baca berikutnya akan memuat nilai terbaru dari database. Namun di production muncul gejala berikut:
- Respons API kadang masih menampilkan data lama beberapa detik setelah update berhasil.
- Ada mismatch antar instance: instance A sudah mengembalikan nilai baru, instance B masih mengembalikan nilai lama.
- Bug sulit direproduksi di lokal, tetapi lebih sering muncul saat traffic tinggi atau batch update berjalan bersamaan.
- Pada beberapa kasus, nilai baru justru tertimpa lagi oleh nilai lama yang masuk ke cache belakangan.
Gejala terakhir adalah petunjuk penting: problemnya bukan sekadar cache terlambat terhapus, tetapi ada kemungkinan urutan operasi baca-tulis pada cache dan database tidak atomik.
Kronologi insiden: bagaimana data lama bisa muncul lagi
Berikut kronologi yang sering terjadi pada implementasi cache-aside yang keliru.
Alur normal yang diharapkan
- Client memanggil API update.
- Service menulis data baru ke database.
- Service menghapus key Redis, misalnya
user:42. - Request baca berikutnya mengalami cache miss.
- Aplikasi membaca nilai terbaru dari database dan menaruhnya kembali ke Redis.
Alur race condition yang bermasalah
- Request R1 melakukan
GET /users/42, cache miss, lalu mulai membaca nilai lama dari database. - Sebelum R1 selesai menyimpan hasil ke cache, request R2 melakukan
PUT /users/42dan menulis nilai baru ke database. - R2 lalu melakukan eviction key
user:42. - Setelah eviction selesai, R1 yang masih membawa hasil query lama menulis nilai lama ke Redis.
- Cache sekarang berisi data stale lagi, meskipun database sudah berisi nilai baru.
Inilah bentuk klasik race condition pada cache eviction: eviction terjadi, tetapi ada reader lama yang masih bisa menulis ulang data stale setelah eviction.
Jika sistem memakai beberapa instance, efeknya makin sulit dipahami. Satu instance mungkin baru saja memuat ulang data baru dari database, sedangkan instance lain menulis ulang hasil lama yang query-nya dimulai sebelum update commit. Karena Redis adalah shared cache, siapa pun yang menulis terakhir bisa menang.
Contoh pola kode/service yang bermasalah
Berikut contoh pola service yang terlihat wajar tetapi rawan race condition.
public class UserService {
private final UserRepository userRepository;
private final RedisTemplate<String, UserDto> redisTemplate;
public UserDto getUser(Long id) {
String key = "user:" + id;
UserDto cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
UserDto dto = map(user);
redisTemplate.opsForValue().set(key, dto);
return dto;
}
@Transactional
public UserDto updateUser(Long id, UpdateUserRequest req) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
user.setName(req.getName());
userRepository.save(user);
redisTemplate.delete("user:" + id);
return map(user);
}
}Masalah utamanya bukan pada sintaks, melainkan pada gap waktu antar operasi:
getUser()bisa membaca database dengan snapshot lama lalu menulis ke cache belakangan.updateUser()hanya menghapus cache, tidak mencegah reader lama mengisi ulang dengan data stale.- Jika commit transaksi database dan eviction cache tidak terkoordinasi dengan baik, pembaca bisa melihat state yang belum konsisten.
Pola invalidasi cache yang keliru
Beberapa variasi implementasi sering memperburuk masalah:
- Evict sebelum commit transaksi selesai. Jika cache dihapus sebelum data benar-benar committed, request lain bisa reload dari database dan masih mendapat nilai lama.
- Read-through manual tanpa proteksi konkurensi. Banyak request cache miss sekaligus bisa memicu cache stampede dan memperbesar peluang stale write.
- Update cache dengan objek hasil lama. Misalnya service update mengembalikan entity yang belum benar-benar sinkron dengan hasil final di database atau event lain.
- Eviction berbasis pattern secara agresif. Selain mahal, ini tidak menyelesaikan masalah ordering; stale writer masih bisa mengisi ulang key yang sudah dihapus.
Root cause teknis
Pada insiden seperti ini, akar masalah biasanya gabungan dari beberapa hal berikut.
1. Cache eviction bukan operasi atomik terhadap lifecycle data
Menghapus key Redis setelah update database tidak otomatis membuat seluruh sistem konsisten. Selama ada request baca lain yang sudah berjalan lebih dulu dan belum menulis hasilnya ke cache, stale value masih bisa masuk kembali.
2. Urutan event tidak terjaga
Pada sistem terdistribusi, urutan logis menurut bisnis tidak selalu sama dengan urutan aktual eksekusi. Request update mungkin secara bisnis terjadi “setelah” request baca dimulai, tetapi operasi write ke cache oleh request baca bisa selesai “setelah” eviction. Redis hanya melihat write terakhir.
3. Boundary transaksi database dan cache terpisah
Database transaction dan operasi ke Redis tidak berada dalam satu transaksi ACID yang sama. Karena itu, aplikasi harus sengaja merancang urutan operasi yang aman, bukan mengandalkan asumsi bahwa delete cache setelah save sudah cukup.
4. Observabilitas kurang detail
Tanpa correlation ID, timestamp yang presisi, dan event log yang konsisten, race condition terlihat seperti bug acak. Padahal urutannya bisa dibuktikan jika tiap langkah dicatat dengan benar.
Langkah investigasi: dari dugaan ke bukti
Karena bug ini sulit direproduksi di lokal, pendekatan terbaik adalah mengumpulkan bukti dari production atau staging yang menyerupai production.
Tambahkan log terstruktur di titik kritis
Jangan hanya log “cache miss” atau “update success”. Log harus cukup kaya untuk merekonstruksi urutan kejadian. Minimal catat:
correlationIdatautraceIdentityIdatau cache key- nama operasi: cache_get, db_read, db_update, cache_set, cache_evict
- timestamp dengan presisi milidetik atau lebih baik
- instance/pod name
- durasi operasi
- versi data jika tersedia, misalnya
updatedAtatauversion
Contoh format log JSON:
{
"traceId": "7fa1...",
"operation": "cache_set",
"key": "user:42",
"entityId": 42,
"dataVersion": "2026-04-13T10:15:21.123Z",
"instance": "api-pod-3",
"durationMs": 12
}Dengan format seperti ini, Anda bisa mencari pola: apakah ada cache_set dengan versi lebih lama yang terjadi setelah db_update dan cache_evict?
Gunakan correlation ID end-to-end
Jika request melewati gateway, service, database, dan Redis, correlation ID harus dibawa sepanjang alur. Di Spring Boot, praktik umum adalah menaruh ID ini di MDC agar otomatis ikut pada setiap log. Ini membantu menghubungkan request API, query database, dan operasi cache dalam satu timeline.
Tambah metrik untuk hit/miss dan rewrite anomali
Beberapa metrik yang berguna:
- rasio cache hit/miss per key group
- jumlah cache evictions per endpoint update
- latensi baca database setelah cache miss
- jumlah concurrent loader untuk key yang sama
- jumlah overwrite cache dengan versi lebih tua jika Anda bisa mendeteksinya
Lonjakan cache miss setelah update massal bisa normal, tetapi jika diikuti kenaikan respons stale, kemungkinan ada stampede atau stale repopulation.
Tracing untuk melihat ordering antar operasi
Distributed tracing membantu melihat bahwa satu span GET memulai db_read sebelum span PUT, tetapi melakukan cache_set setelah span update menyelesaikan cache_evict. Pola semacam ini sulit terlihat hanya dari log aplikasi biasa.
Catatan: Jika belum punya tracing penuh, log terstruktur dengan trace ID dan timestamp yang konsisten sudah cukup untuk membuktikan banyak race condition.
Perbaikan yang bisa dipilih
Tidak ada satu solusi yang selalu paling benar. Pilih berdasarkan kebutuhan konsistensi, throughput, dan kompleksitas operasional.
1. Ubah urutan dan waktu invalidasi: evict setelah commit
Jika invalidasi dilakukan sebelum transaksi database benar-benar committed, pembaca lain bisa memuat nilai lama. Karena itu, eviction sebaiknya dipicu setelah commit berhasil, bukan di tengah transaksi.
Di Spring, pendekatan praktis adalah menjalankan eviction melalui event yang diproses setelah transaction commit, bukan langsung di dalam flow update. Tujuannya sederhana: cache baru boleh dianggap invalid hanya setelah sumber kebenaran benar-benar berubah.
Namun perlu dicatat, ini hanya menyelesaikan sebagian masalah. Stale reader yang sudah telanjur membaca nilai lama masih bisa menulis ulang cache jika tidak ada proteksi tambahan.
2. Gunakan versioned cache write
Ini salah satu perbaikan yang paling efektif untuk kasus overwrite nilai baru oleh nilai lama. Intinya, setiap data membawa versi monotonik, misalnya:
- kolom
versiondari optimistic locking - timestamp
updated_atyang konsisten - nomor event sequence
Saat menulis ke cache, aplikasi menyertakan versi tersebut dan hanya mengizinkan write jika versi yang ditulis tidak lebih tua dari versi yang sudah ada. Dengan begitu, stale reader tidak bisa menimpa nilai baru.
Secara konsep:
- Update ke database menghasilkan versi baru, misalnya 11.
- Cache eviction atau cache refresh membawa versi 11.
- Reader lama yang masih memegang versi 10 mencoba
cache_set. - Write ditolak karena versi 10 lebih tua dari versi 11.
Implementasinya bisa memakai operasi atomik di Redis melalui Lua script atau pola compare-and-set berbasis metadata versi. Ini menambah kompleksitas, tetapi sangat berguna saat konsistensi cache penting.
3. Ganti dari delete-only ke write-through terbatas
Alih-alih hanya menghapus cache saat update, service update bisa langsung menulis nilai terbaru ke cache setelah commit. Ini mengurangi jendela waktu cache miss dan menurunkan peluang pembaca lain memuat nilai lama.
Namun write-through tidak otomatis aman. Jika ada reader lama yang selesai belakangan, ia masih bisa menimpa cache kecuali ada mekanisme versi atau ordering. Karena itu, write-through lebih aman jika digabung dengan versioned write.
4. Lindungi cache miss dengan single-flight atau distributed lock
Jika banyak request mengalami miss untuk key yang sama, Anda bisa membatasi hanya satu loader yang boleh mengambil data dari database lalu mengisi cache. Request lain menunggu atau menggunakan hasil yang sama. Ini mengurangi stampede dan mempersempit peluang multiple writer.
Pendekatan ini bisa berupa:
- lock per-key di level aplikasi jika hanya satu instance
- distributed lock berbasis Redis jika multi-instance
Trade-off-nya jelas:
- lebih aman untuk mencegah banyak loader bersamaan
- menambah latensi dan kompleksitas failure handling
- harus hati-hati dengan timeout, deadlock semu, dan lock yang kedaluwarsa
Distributed lock sebaiknya dipakai jika benar-benar perlu, bukan sebagai solusi pertama untuk semua problem cache.
5. Perbaiki strategi cache-aside
Pola cache-aside masih valid, tetapi perlu disiplin implementasi. Beberapa penyesuaian yang sering membantu:
- gunakan TTL yang masuk akal agar stale data tidak bertahan terlalu lama jika bug lolos
- simpan metadata versi bersama payload cache
- hindari menulis cache dari hasil baca yang tidak punya versi
- untuk data sangat sensitif terhadap konsistensi, pertimbangkan tidak meng-cache objek granular itu sama sekali
Contoh perbaikan alur service
Berikut contoh yang lebih aman secara konsep: update database menghasilkan versi baru, lalu setelah commit menulis cache terbaru. Di sisi baca, write ke cache hanya dilakukan jika versi yang akan ditulis tidak lebih tua.
public class UserService {
private final UserRepository userRepository;
private final CacheRepository cacheRepository;
private final ApplicationEventPublisher publisher;
public UserDto getUser(Long id) {
CacheValue<UserDto> cached = cacheRepository.getUser(id);
if (cached != null) {
return cached.payload();
}
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
UserDto dto = map(user);
cacheRepository.putUserIfNewer(id, dto, user.getVersion());
return dto;
}
@Transactional
public UserDto updateUser(Long id, UpdateUserRequest req) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
user.setName(req.getName());
userRepository.save(user);
publisher.publishEvent(new UserUpdatedEvent(user.getId(), user.getVersion()));
return map(user);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserUpdated(UserUpdatedEvent event) {
User user = userRepository.findById(event.userId())
.orElseThrow(() -> new NotFoundException("User not found"));
cacheRepository.putUserIfNewer(user.getId(), map(user), user.getVersion());
}
}Poin penting dari contoh di atas:
- cache tidak dihapus begitu saja lalu dibiarkan kosong terlalu lama
- penulisan cache dilakukan setelah commit
- semua write ke cache menghormati urutan versi
Detail implementasi putUserIfNewer tergantung teknologi yang dipakai, tetapi prinsipnya adalah operasi atomik: baca versi saat ini, bandingkan, lalu tulis hanya jika versi baru lebih besar atau sama sesuai aturan Anda.
Debugging tips yang sering terlewat
Bandingkan payload cache dengan sumber database
Saat insiden aktif, ambil satu entity yang bermasalah dan bandingkan:
- nilai di database
- nilai di Redis
- timestamp/versi pada keduanya
- instance mana yang terakhir menulis cache
Jika Redis menyimpan versi lebih tua daripada database, problemnya hampir pasti ada pada ordering atau stale writer.
Periksa apakah update dan read memakai replica yang berbeda
Jika database read diarahkan ke replica yang punya replication lag, gejalanya bisa sangat mirip cache race condition: update sukses, tetapi read sesudahnya masih mendapat data lama. Ini bukan pengganti analisis cache, tetapi penting untuk dieliminasi agar tidak salah diagnosis.
Jangan hanya mengandalkan log level debug sesaat
Race condition yang sporadis sering hilang saat sistem diberi logging terlalu berat karena timing berubah. Karena itu, lebih baik gunakan log terstruktur ringkas yang selalu aktif, bukan debug verbose yang dinyalakan saat insiden sudah berjalan.
Checklist pencegahan
- Pastikan invalidasi atau refresh cache terjadi setelah transaksi database commit.
- Gunakan versi data untuk semua write ke cache yang sensitif terhadap ordering.
- Hindari pola delete-only jika data sering dibaca segera setelah update.
- Tambahkan proteksi stampede untuk key yang sangat hot.
- Pastikan observabilitas mencatat key, versi, trace ID, instance, dan durasi.
- Untuk data kritis, evaluasi apakah cache memang layak dipakai pada level entity tersebut.
- Jika menggunakan event asynchronous, pastikan ordering event dipahami dan diuji.
Testing concurrency dan integration yang perlu dibuat
Bug seperti ini tidak cukup diuji dengan unit test biasa. Tambahkan pengujian yang menekan urutan operasi.
1. Integration test dengan Redis nyata
Gunakan environment test yang menjalankan Redis sungguhan, bukan mock. Mock tidak akan menangkap problem timing dan atomicity.
2. Concurrency test untuk read-update-read
Buat skenario di mana satu thread memulai read dan ditahan sebelum cache_set, lalu thread lain melakukan update. Setelah itu, lepaskan thread pertama dan verifikasi bahwa cache tidak berisi versi lama.
3. Test multi-instance atau simulasi beberapa writer
Jika aplikasi berjalan di banyak pod, uji perilaku beberapa service instance yang menulis key yang sama. Ini bisa dilakukan dengan test integration yang menjalankan beberapa konteks aplikasi atau simulasi worker paralel.
4. Contract test untuk versioned write
Jika Anda memakai compare-and-set atau Lua script di Redis, uji aturan berikut secara eksplisit:
- versi lebih baru boleh menimpa versi lama
- versi lama tidak boleh menimpa versi baru
- write pertama pada key kosong harus berhasil
Sinyal observabilitas yang perlu dipantau
Setelah perbaikan dirilis, pantau sinyal berikut agar regresi cepat terlihat:
- jumlah respons yang membawa versi data lebih tua daripada hasil update terakhir
- frekuensi cache miss sesaat setelah endpoint update dipanggil
- latensi p95/p99 pada endpoint read setelah perubahan strategi cache
- jumlah lock contention jika memakai distributed lock
- jumlah penolakan stale write jika memakai versioned cache write
- selisih versi antara payload Redis dan database pada sampling berkala
Jika setelah perbaikan Anda melihat banyak stale write ditolak, itu justru sinyal baik: artinya mekanisme proteksi bekerja dan memang ada race yang sebelumnya tidak terlihat.
Penutup
Debugging Spring Boot: race condition pada cache eviction Redis pada dasarnya adalah soal membuktikan urutan kejadian, bukan sekadar menebak-nebak bug cache. Gejala seperti API kadang menampilkan data lama setelah update, mismatch antar instance, dan insiden yang sulit direproduksi hampir selalu menunjuk pada masalah ordering antara read database, write cache, dan eviction.
Perbaikan yang paling sering efektif adalah memastikan operasi cache terjadi setelah commit, menambahkan versi pada payload cache, dan mencegah stale writer menimpa nilai baru. Distributed lock bisa membantu pada kasus tertentu, tetapi jangan dijadikan solusi default jika akar masalahnya adalah ordering yang tidak aman.
Jika observabilitas Anda cukup baik, race condition semacam ini bisa diubah dari “bug acak” menjadi urutan event yang dapat dibuktikan, diuji, dan diperbaiki secara sistematis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!