TTL yang kita atur di chapter sebelumnya membatasi umur cache, tetapi tidak tahu kapan data sumber benar-benar berubah. Dengan TTL lima menit, tulisan yang baru diedit masih tampil versi lamanya sampai lima menit berlalu — bagi editor yang ingin koreksinya segera terlihat, itu terlalu lama. Invalidasi aktif menutup celah ini: begitu data berubah, cache terkait langsung dibuang atau disegarkan pada saat yang sama. Pada chapter penutup section ini kita rangkai semuanya: dari pemicu invalidasi berbasis event model, kunci berversi, sampai pilihan pola cache-aside versus write-through.
Masalah Data Basi #
Mari kita lihat dulu betapa mudahnya data basi muncul. Feature test berikut meng-cache judul satu post, lalu mengubah judulnya lewat query builder:
$post = Post::create(['title' => 'Belajar Laravel']);
Cache::remember("posts:detail:{$post->id}", 300, fn () => $post->title);
// Update lewat query builder: event Eloquent tidak terpicu
DB::table('posts')->where('id', $post->id)->update(['title' => 'Judul Direvisi']);
$dariCache = Cache::get("posts:detail:{$post->id}");
$dariDb = DB::table('posts')->where('id', $post->id)->value('title');
fwrite(STDERR, "stale: cache={$dariCache} db={$dariDb}\n");Output yang Dihasilkan:
stale: cache=Belajar Laravel db=Judul DirevisiDatabase sudah menyimpan Judul Direvisi, tetapi cache masih memegang Belajar Laravel dan akan terus memegangnya sampai TTL 300 detik habis. Pesannya tegas: invalidasi tidak terjadi dengan sendirinya — ia harus dipicu oleh kode kita, dan pemicunya hanya sekuat jalur data yang kita pantau.
Observer Model sebagai Pemicu #
Perubahan yang melewati Eloquent memicu event saved dan deleted — momen sempurna untuk membuang cache yang kini usang. Observer menangkap event tersebut, dan attribute ObservedBy mendaftarkannya langsung pada model:
// app/Observers/PostObserver.php
namespace App\Observers;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
class PostObserver
{
public function saved(Post $post): void
{
Cache::forget('posts:semua');
Cache::forget('stats:jumlah-post');
}
public function deleted(Post $post): void
{
Cache::forget('posts:semua');
Cache::forget('stats:jumlah-post');
}
}// app/Models/Post.php
namespace App\Models;
use App\Observers\PostObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy(PostObserver::class)]
class Post extends Model
{
protected $fillable = ['title'];
}Event saved terpilih karena menutupi created sekaligus updated — dua perubahan yang sama-sama membuat cache daftar usang. Feature test berikut memutar siklusnya penuh:
Post::create(['title' => 'Belajar Laravel']);
Post::create(['title' => 'Tulisan Pertama']);
$panggilan = 0;
$ambilDaftar = function () use (&$panggilan) {
$panggilan++;
return Post::orderBy('title')->pluck('title')->all();
};
$sebelum = Cache::remember('posts:semua', 300, $ambilDaftar);
Cache::remember('posts:semua', 300, $ambilDaftar);
$panggilanSebelumUbah = $panggilan;
Post::create(['title' => 'Tulisan Baru']); // observer memicu forget()
$cacheSetelahSimpan = Cache::get('posts:semua');
$sesudah = Cache::remember('posts:semua', 300, $ambilDaftar);
fwrite(STDERR, 'observer: daftar_awal=' . json_encode($sebelum)
. " panggilan_closure={$panggilan}"
. " ({$panggilanSebelumUbah} sebelum simpan)\n");
fwrite(STDERR, 'observer: cache_setelah_simpan='
. var_export($cacheSetelahSimpan, true)
. ' daftar_baru=' . json_encode($sesudah) . "\n");Output yang Dihasilkan:
observer: daftar_awal=["Belajar Laravel","Tulisan Pertama"] panggilan_closure=2 (1 sebelum simpan)
observer: cache_setelah_simpan=NULL daftar_baru=["Belajar Laravel","Tulisan Baru","Tulisan Pertama"]Alurnya terbaca dari angka-angka itu: closure berjalan sekali, panggilan kedua dilayani cache, lalu Post::create() mengosongkan posts:semua (cache_setelah_simpan=NULL), dan panggilan remember() berikutnya menghitung ulang sampai datangnya Tulisan Baru. Penghapusan juga tertangkap — cache_sebelum_delete=true, cache_sesudah_delete=false — dan data turunan ikut aman: counter: awal=1 cache_setelah_create=NULL dihitung_ulang=2.
Kunci Berversi #
Cara invalidasi lain yang tidak menghapus apa pun: naikkan versi di dalam nama kunci. Semua pembaca memakai nama baru, dan entri lama dibiarkan mati sendiri saat TTL habis:
Cache::flush();
$versi = 1;
Cache::remember("posts:v{$versi}:semua", 300, fn () => ['Belajar Laravel']);
$isiV1 = Cache::get("posts:v1:semua");
// Data berubah: naikkan versi alih-alih menghapus satu-satu
$versi = 2;
$isiV2 = Cache::get("posts:v2:semua");
Cache::remember("posts:v{$versi}:semua", 300,
fn () => ['Belajar Laravel', 'Tulisan Pertama']);
echo 'v1=' . $isiV1[0]
. ' v2_sebelum_ada=' . var_export($isiV2 === null, true)
. ' v2_sesudah=' . json_encode(Cache::get("posts:v2:semua"))
. ' entri_v1_tetap_ada=' . var_export(Cache::has("posts:v1:semua"), true) . "\n";Output yang Dihasilkan:
v1=Belajar Laravel v2_sebelum_ada=true v2_sesudah=["Belajar Laravel","Tulisan Pertama"] entri_v1_tetap_ada=true (menunggu TTL)Pola ini bersinar saat jumlah kunci sangat banyak: “invalidasi” hanyalah menaikkan satu angka, tanpa perlu tahu kunci apa yang pernah ditulis. Konsekuensinya terlihat di akhir baris output — posts:v1:semua masih menempati memori sampai TTL-nya lewat, jadi pola ini cocok justru untuk kunci ber-TTL pendek.
Cache-Aside atau Write-Through #
Dua pola dasar menjaga agar cache dan data sumber tetap seirama:
| Aspek | Cache-aside (lazy) | Write-through |
|---|---|---|
| Alur tulis | Tulis DB, buang cache | Tulis DB, tulis juga cache |
| Request berikutnya | Miss dulu, lalu dihitung ulang | Langsung hit dengan nilai segar |
| Isi cache | Hanya yang benar-benar diminta | Termasuk yang belum pernah dibaca |
| Cocok untuk | Mayoritas kasus, komputasi mahal | Data panas yang pasti dibaca lagi |
Observer di atas adalah cache-aside: cache dikosongkan dan request berikutnya yang mengisi ulang. Write-through menulis nilai segar segera setelah data berubah:
// app/Services/PostService.php
namespace App\Services;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
class PostService
{
public function perbaruiJudul(Post $post, string $judul): Post
{
$post->update(['title' => $judul]);
// Pola write-through: nilai segar langsung ditulis ke cache
Cache::put("posts:detail:{$post->id}", $judul, 300);
return $post;
}
}Feature test berikut membuktikan cache langsung segar tanpa satu pun komputasi ulang:
$post = Post::create(['title' => 'Belajar Laravel']);
$panggilan = 0;
$ambilJudul = function () use ($post, &$panggilan) {
$panggilan++;
return $post->fresh()->title;
};
Cache::remember("posts:detail:{$post->id}", 300, $ambilJudul);
app(PostService::class)->perbaruiJudul($post, 'Judul Direvisi');
$setelahUbah = Cache::get("posts:detail:{$post->id}");
fwrite(STDERR, "write_through: cache={$setelahUbah}"
. " panggilan_closure={$panggilan}\n");Output yang Dihasilkan:
write_through: cache=Judul Direvisi panggilan_closure=1Closure hanya dipanggil sekali — untuk pengisian awal — karena pembaruan cache dilakukan langsung oleh service. Hati-hati dengan pembacaan $post->title di closure: nilai model di memori bisa lebih tua daripada database, sehingga fresh() menjadi keharusan di sini.
TTL sebagai Jaring Pengaman #
Invalidasi yang canggih pun tidak kebal kelalaian — test pembuka chapter tadi membuktikan query builder lolos dari observer. Di situlah TTL memainkan peran terakhirnya: membatasi seberapa lama kesalahan bisa bertahan. Feature test berikut membiarkan cache basi, lalu memajukan waktu melewati TTL:
$post = Post::create(['title' => 'Belajar Laravel']);
Cache::remember("posts:detail:{$post->id}", 600, fn () => $post->title);
DB::table('posts')->where('id', $post->id)->update(['title' => 'Judul Direvisi']);
$sebelumTtl = Cache::get("posts:detail:{$post->id}");
$this->travel(601)->seconds(); // maju melewati TTL 600 detik
$setelahTtl = Cache::get("posts:detail:{$post->id}");
$dihitungUlang = Cache::remember("posts:detail:{$post->id}", 600,
fn () => DB::table('posts')->where('id', $post->id)->value('title'));
fwrite(STDERR, "ttl: sebelum={$sebelumTtl} setelah_601_detik="
. var_export($setelahTtl, true) . " dihitung_ulang={$dihitungUlang}\n");Output yang Dihasilkan:
ttl: sebelum=Belajar Laravel setelah_601_detik=NULL dihitung_ulang=Judul DirevisiPraktik terbaiknya menggabungkan keduanya: invalidasi event untuk kesegaran seketika, TTL pendek sebagai batas kerusakan bila satu jalur perubahan terlewat. Cache lalu tetap sederhana dipakai — remember() di satu tempat — sementara kebersihannya dijaga dari dua arah. Observer yang kita tulis di sini sebenarnya adalah contoh kecil dari sistem event Laravel yang jauh lebih luas; mesinnya kita bedah tuntas di section Events dan Listeners
. Rujukan resmi pola-pola di atas tersedia di dokumentasi Cache
.