Menyimpan data ke cache itu mudah; memastikan data tersebut masih benar adalah cerita yang jauh lebih sulit. Phil Karlton pernah berkelakar bahwa hanya ada dua masalah sulit dalam ilmu komputer: cache invalidation dan memberi nama. Lelucon itu terus dikutip puluhan tahun karena intinya akurat — aplikasi yang menampilkan harga lama setelah admin mengubah data adalah bug nyata yang menggerus kepercayaan pengguna. Pada chapter ini kita akan membedah strategi invalidasi cache dari yang paling sederhana sampai yang paling terstruktur: TTL sebagai jaring pengaman, invalidasi berbasis event, key versioning, invalidasi berbasis tag, serta pola penulisan write-through versus write-behind.
Masalah Klasik: Invalidasi Cache #
Invalidasi cache sulit karena satu perubahan kecil di sumber data dapat mempengaruhi banyak key sekaligus. Ketika harga sebuah produk diubah, key cache halaman detail produk, key daftar produk, key hasil pencarian, dan key agregat seperti “produk terlaris” bisa seluruhnya menjadi basi bersamaan. Menghapus satu key dan melupakan tiga key lainnya berarti data basi tetap muncul di sebagian halaman — bug yang sulit dilacak karena hanya terlihat di tempat tertentu.
Kesulitan kedua adalah waktu: request yang menulis data dan request yang membaca cache berjalan bersamaan. Tanpa kehati-hatian, dua proses bisa saling menimpa sehingga cache justru terisi ulang dengan data lama sesaat setelah dihapus. Kesulitan ketiga adalah trade-off: invalidasi yang terlalu agresif membuat cache hampir tidak pernah kena (hit rate rendah), sehingga kita memelihara cache yang mahal tetapi jarang dipakai.
Target yang realistis bukan “cache selalu segar”, melainkan “cukup segar untuk kebutuhan bisnis”. Dengan definisi itu, kita bisa memilih strategi paling sederhana yang memenuhi syarat. Bagian-bagian berikut menyusun strategi tersebut dari yang termurah ke yang paling terstruktur.
TTL sebagai Jaring Pengaman #
TTL (time to live) adalah batas umur sebuah entry cache: setelah TTL habis, entry dianggap kedaluwarsa dan pembaca berikutnya mengambil data segar dari sumber. TTL adalah baseline yang wajib ada pada hampir semua cache karena bekerja tanpa peduli bagaimana dan lewat jalur mana data berubah — dalam skenario terburuk, data basi hanya bertahan selama sisa TTL.
Untuk menguji logika kedaluwarsa tanpa menunggu waktu nyata berjalan, kita menyuntikkan sebuah “jam” dari luar. Kode produksi memakai time(), sedangkan pengujian memakai jam palsu yang bisa dimajukan sesuka hati:
<?php
class ArrayCache
{
/** @var array<string, array{mixed, int}> */
private array $storage = [];
public function __construct(private readonly Closure $clock) {}
public function set(string $key, mixed $value, int $ttlDetik): void
{
$sekarang = ($this->clock)();
$this->storage[$key] = [$value, $sekarang + $ttlDetik];
}
public function get(string $key): mixed
{
if (!array_key_exists($key, $this->storage)) {
return null; // cache miss: key tidak pernah ada
}
[$nilai, $kedaluwarsa] = $this->storage[$key];
if (($this->clock)() >= $kedaluwarsa) {
unset($this->storage[$key]); // hapus entry kedaluwarsa
return null; // cache miss karena TTL habis
}
return $nilai; // cache hit
}
}
$waktu = 1000; // "detik saat ini" — dikendalikan manual
$clock = function () use (&$waktu): int {
return $waktu;
};
$cache = new ArrayCache($clock);
$cache->set("produk:1", "Laptop Gaming", 60);
$waktu = 1020; // 20 detik kemudian
echo ($cache->get("produk:1") ?? "MISS") . "\n";
$waktu = 1061; // 61 detik kemudian: melewati TTL
echo ($cache->get("produk:1") ?? "MISS") . "\n";Output yang Dihasilkan:
Laptop Gaming
MISSMari kita telaah bagian-bagian penting kode di atas:
- Property
$clock: constructor menerima sebuahClosureyang mengembalikan waktu “sekarang”; seluruh logika class membaca jam hanya melalui($this->clock)(). set(): menyimpan pasangan[nilai, waktuKedaluwarsa]— waktu kedaluwarsa dihitung dari jam saat penyimpanan ditambah TTL.get(): mengembalikannullbila key tidak ada; bila jam sudah melewati waktu kedaluwarsa, entry dihapus lalu dilaporkan sebagai miss.$waktu = 1020: karena closure menangkap$waktulewat referensi (use (&$waktu)), mengubah variabel tersebut sama dengan memajukan waktu.
Pada detik ke-1020 entry masih segar sehingga pembacaan menghasilkan Laptop Gaming. Pada detik ke-1061 (61 detik setelah disimpan), TTL 60 detik sudah terlampaui sehingga pembacaan menghasilkan miss. Kelemahan pendekatan TTL juga terlihat dari sini: bila harga berubah pada detik ke-1005, pembaca tetap menerima nilai lama hingga detik ke-1060. Jendela konsistensi terpanjang sama dengan panjang TTL — angka yang harus dipilih berdasarkan toleransi bisnis, bukan diambil asal.
Invalidasi Berbasis Event #
Bila aplikasi kita sendiri yang mengubah data, kita tahu persis kapan perubahan terjadi — manfaatkan momen itu untuk menghapus key yang terpengaruh. Aturan praktisnya: hapus, jangan perbarui. Daripada menulis salinan data baru ke cache (yang bisa saling menimpa antar request), cukup hapus key-nya; pembaca berikutnya menemukan miss lalu mengisi ulang dari sumber:
<?php
class SimpleCache
{
public function __construct(private array $storage = []) {}
public function set(string $key, mixed $value): void
{
$this->storage[$key] = $value;
}
public function get(string $key): mixed
{
return $this->storage[$key] ?? null;
}
public function delete(string $key): void
{
unset($this->storage[$key]);
}
}
function updateHargaProduk(SimpleCache $cache, int $id, int $hargaBaru): void
{
// 1. Simpan harga baru ke database (di contoh ini dilewati).
// 2. Segera hapus SEMUA key cache yang terpengaruh perubahan ini.
$cache->delete("produk:detail:$id"); // halaman detail produk
$cache->delete("produk:daftar"); // daftar harga di halaman utama
}
$cache = new SimpleCache();
$cache->set("produk:detail:7", ["nama" => "Monitor 27 inci", "harga" => 2500000]);
$cache->set("produk:daftar", ["total" => 1]);
echo ($cache->get("produk:detail:7") !== null ? "HIT" : "MISS") . "\n";
echo ($cache->get("produk:daftar") !== null ? "HIT" : "MISS") . "\n";
updateHargaProduk($cache, 7, 2350000);
echo ($cache->get("produk:detail:7") !== null ? "HIT" : "MISS") . "\n";
echo ($cache->get("produk:daftar") !== null ? "HIT" : "MISS") . "\n";Output yang Dihasilkan:
HIT
HIT
MISS
MISSFungsi updateHargaProduk() mewakili satu transaksi bisnis: simpan ke database (dilewati pada contoh), lalu bersihkan key cache yang dipengaruhi. Dua key dihapus sekaligus — detail produk dan daftar harga — karena keduanya sama-sama menampilkan harga. Setelah pemanggilan, kedua pembacaan menghasilkan miss sehingga request berikutnya dipaksa mengambil data segar dari database.
Kekuatan pola ini adalah kesegaran seketika; kelemahannya adalah kerapuhan manusia. Daftar key yang terpengaruh harus dirawat di kode, dan satu key yang terlupakan berarti data basi tetap muncul. Karena itu pola event selalu dipasangkan dengan TTL sebagai jaring pengaman terakhir.
Key Versioning #
Masalah lain muncul saat struktur data yang dicache berubah antar versi aplikasi: entry lama tersimpan dalam format lama, sedangkan kode baru mengharapkan format baru. Solusi yang sederhana dan aman adalah menyematkan nomor versi pada key. Saat struktur berubah, cukup naikkan versi — seluruh key lama otomatis tidak terlihat oleh kode baru tanpa perlu menghapusnya satu per satu:
<?php
$storage = [];
$versi = "v1";
function keyFor(string $versi, int $id): string
{
return "produk:{$versi}:{$id}";
}
// Aplikasi lama menulis banyak data di bawah versi 1
$storage[keyFor($versi, 42)] = ["nama" => "Mouse Wireless"];
echo isset($storage[keyFor($versi, 42)]) ? "HIT" : "MISS";
echo "\n";
// Struktur data produk berubah → naikkan versi secara global
$versi = "v2";
// Semua key v1 otomatis "tidak terlihat" oleh pembaca versi baru
echo isset($storage[keyFor($versi, 42)]) ? "HIT" : "MISS";
echo "\n";Output yang Dihasilkan:
HIT
MISSFungsi keyFor() membangun key dengan pola nama:versi:id dari satu tempat tunggal, sehingga format versi selalu konsisten di seluruh aplikasi. Setelah $versi dinaikkan ke "v2", pembacaan dengan versi baru menemukan miss meskipun storage masih menampung data versi lama. Entry v1 tidak perlu dihapus saat deploy — biarkan TTL yang membersihkannya secara bertahap.
Harga yang dibayar adalah hit rate yang turun ke nol seketika setelah versi dinaikkan, karena semua pembaca harus mengisi ulang cache. Karena itu versioning dipakai untuk perubahan besar (misalnya migrasi format data saat deploy), bukan untuk perubahan data harian.
Invalidasi Berbasis Tag #
Semakin banyak key yang saling terkait, semakin mudah satu-dua key terlupakan pada pola event. Invalidasi berbasis tag menyelesaikannya dengan mengelompokkan key menurut topik: setiap key diberi satu atau lebih tag, dan satu perintah invalidasi memusnahkan seluruh key dalam tag tersebut. Berikut implementasi idenya pada cache array:
<?php
class TaggedCache
{
private array $data = [];
/** @var array<string, list<string>> */
private array $tagKeKey = [];
public function set(string $key, mixed $value, array $tags): void
{
$this->data[$key] = $value;
foreach ($tags as $tag) {
$this->tagKeKey[$tag][] = $key;
}
}
public function get(string $key): mixed
{
return $this->data[$key] ?? null;
}
public function invalidateTag(string $tag): int
{
$jumlah = 0;
foreach ($this->tagKeKey[$tag] ?? [] as $key) {
unset($this->data[$key]);
$jumlah++;
}
unset($this->tagKeKey[$tag]);
return $jumlah;
}
}
$cache = new TaggedCache();
$cache->set("produk:detail:7", ["nama" => "Monitor 27 inci"], ["produk"]);
$cache->set("produk:daftar", ["total" => 1], ["produk"]);
$cache->set("banner:utama", ["teks" => "Diskon Hari Ini"], ["banner"]);
echo "Key dihapus oleh invalidateTag('produk'): "
. $cache->invalidateTag("produk") . "\n";
echo ($cache->get("produk:detail:7") !== null ? "HIT" : "MISS") . "\n";
echo ($cache->get("banner:utama") !== null ? "HIT" : "MISS") . "\n";Output yang Dihasilkan:
Key dihapus oleh invalidateTag('produk'): 2
MISS
HITMetode set() menerima daftar tag dan mencatat pemetaan tag → key pada property $tagKeKey. Pemanggilan invalidateTag("produk") menghapus semua key yang pernah diberi tag produk — dua buah — lalu mengembalikan jumlahnya. Key banner:utama bertahan karena tag-nya berbeda; invalidasi bersifat per topik, bukan global. Dengan begitu, fungsi updateHargaProduk() pada bagian sebelumnya cukup memanggil satu invalidateTag("produk") alih-alih mendaftar key satu per satu.
Redis tidak memiliki perintah bawaan “hapus per tag”; implementasi nyata biasanya menyimpan daftar key per tag (misalnya pada set Redis) atau menyimpan tag di dalam nilai, lalu memindainya ketika invalidasi dibutuhkan.
Pola Penulisan: Write-Through vs Write-Behind #
Selain memutuskan kapan menghapus, kita juga perlu memutuskan bagaimana cache diisi ulang berhubungan dengan penulisan database. Dua pola yang paling umum:
| Aspek | Write-Through | Write-Behind |
|---|---|---|
| Alur | tulis database, lalu isi cache, dalam satu request | isi cache dulu, database menyusul lewat queue/worker |
| Konsistensi | tinggi — cache segar begitu request selesai | longgar — ada jeda sampai worker selesai |
| Kecepatan request | lebih lambat (dua penulisan per request) | lebih cepat (penulisan database keluar dari jalur request) |
| Risiko utama | menambah latensi setiap penulisan | data hilang bila worker gagal sebelum menulis database |
<?php
// Pola write-through: database dan cache diperbarui berurutan
function simpanProdukWriteThrough(PDO $db, SimpleCache $cache, array $produk): void
{
$stmt = $db->prepare("UPDATE produk SET harga = :harga WHERE id = :id");
$stmt->execute(["harga" => $produk["harga"], "id" => $produk["id"]]);
// Cache langsung diisi ulang setelah database sukses
$cache->set("produk:detail:{$produk["id"]}", $produk);
}
// Pola write-behind: cache lebih dulu, database menyusul lewat queue
function simpanProdukWriteBehind(SimpleCache $cache, array $produk): void
{
$cache->set("produk:detail:{$produk["id"]}", $produk);
// Penulisan ke database ditunda ke worker, bukan di jalur request
}Pada write-through, request menunggu database dan cache selesai sehingga pembaca berikutnya langsung melihat data baru. Pada write-behind, request selesai cepat karena penulisan database dititipkan ke worker di latar belakang — pembaca mendapat data segar dari cache, tetapi database sendiri menunggu. Pilih write-through untuk data yang harus konsisten (pesanan, pembayaran); write-behind layak untuk data yang boleh menunggu (penghitung kunjungan, statistik).
Jendela Konsistensi dan Pragmatisme #
Setiap strategi menyisakan jendela waktu ketika pembaca masih bisa menerima data basi. TTL menyisakan jendela sepanjang sisa umur entry; write-behind menyisakan jeda sampai worker menulis; bahkan invalidasi berbasis event menyisakan selisih mikrodetik antara commit database dan penghapusan key. Tugas kita bukan menghapus jendela ini, melainkan menyempitkannya sampai memenuhi kebutuhan bisnis dengan biaya sesederhana mungkin.
| Strategi | Paling cocok untuk | Kekuatan | Kelemahan |
|---|---|---|---|
| TTL | data yang boleh basi beberapa saat | sangat sederhana, tahan semua jalur perubahan | jendela konsistensi sepanjang TTL |
| Event | entitas yang diubah aplikasi sendiri | segar seketika | daftar key dirawat manual |
| Key versioning | perubahan format saat deploy | sekali ubah, semua key lama mati | hit rate reset ke nol |
| Tag | banyak key lintasan untuk satu topik | invalidasi berkelompok rapi | butuh pemetaan tambahan |
Untuk banyak aplikasi, kombinasi TTL pendek (misalnya 30–300 detik) plus invalidasi event pada entitas penting sudah lebih dari cukup. Bila data boleh basi selama satu menit, membangun sistem invalidasi cerdas yang rumit hanya menambah permukaan bug — dalam praktik, TTL pendek sering mengalahkan invalidasi yang kompleks.
Ringkasan #
Kita telah mengenal empat strategi menjaga cache tetap segar: TTL sebagai jaring pengaman yang wajib ada, invalidasi berbasis event untuk kesegaran seketika, key versioning untuk perubahan format, dan tag untuk invalidasi berkelompok. Pilihan strategi ditentukan oleh seberapa sempit jendela konsistensi yang dibutuhkan bisnis, bukan oleh kecanggihan tekniknya. Contoh pada chapter ini menyimulasikan cache dengan array PHP di dalam satu proses; ketika aplikasi tumbuh lintas server, kita membutuhkan penyimpan cache eksternal yang dibagi bersama — itulah pembahasan chapter Redis Caching berikutnya.