Ada satu jenis halaman yang hampir selalu lambat di aplikasi: halaman dengan banyak filter. Pengguna memilih merek, transmisi, rentang harga, lalu menekan terapkan. Setiap kombinasi menjalankan satu query yang menyapu ratusan ribu baris.
Selama trafiknya sepi, tidak ada yang mengeluh. Begitu halaman itu dibuka beberapa orang bersamaan, waktu muatnya merangkak ke dua puluh detik, dan Cloudflare mulai mengirim halaman 504.
Cara pertama yang biasanya diambil adalah Cache::remember. Hasilnya kelihatan memuaskan sampai cache-nya kedaluwarsa, lalu semua permintaan pada detik yang sama menemukan cache kosong dan bersama-sama menjalankan query berat itu. Halaman tetap lambat, hanya saja sekarang lambatnya terjadi serempak.
Cache::flexible dirancang untuk situasi itu.
Apa bedanya dengan Cache::remember
Dua-duanya menyimpan hasil query. Bedanya ada di apa yang terjadi ketika waktu simpan habis.
| Hal | Cache::remember | Cache::flexible |
|---|---|---|
| Parameter waktu | satu angka | dua angka: segar dan basi |
| Saat waktu habis | semua request menunggu callback selesai | nilai lama langsung dikembalikan |
| Perhitungan ulang | di depan, memblokir respons | di belakang, setelah respons dikirim |
| Risiko lonjakan | tinggi kalau callback lambat | rendah, karena dijalankan satu proses |
| Data yang disajikan | selalu hasil terbaru | boleh beberapa detik atau menit lebih tua |
Pemakaiannya cuma berubah sedikit:
use Illuminate\Support\Facades\Cache;
$transmisi = Cache::flexible('filter.transmissions', [300, 600], function (): array {
return CarListing::query()
->whereNotNull('transmission')
->where('transmission', '!=', '')
->distinct()
->pluck('transmission')
->sort()
->values()
->toArray();
});
Angka pertama, 300, adalah jendela segar dalam detik. Selama umur cache belum lewat lima menit, nilainya dianggap terbaru dan langsung dipakai.
Angka kedua, 600, adalah batas umur total. Antara menit kelima dan menit kesepuluh, nilai itu disebut basi tapi masih boleh disajikan. Pada rentang itulah flexible bekerja: nilai lama dikirim ke pengguna, sementara satu proses di belakang layar menghitung ulang.
Lewat menit kesepuluh, nilai benar-benar hilang dan permintaan berikutnya harus menunggu perhitungan seperti biasa.
Isi sebenarnya dari method ini
Kalau kamu penasaran, implementasinya di Laravel 13 cukup pendek dan layak dibaca. Yang dilakukan flexible kira-kira begini:
- Ambil dua kunci sekaligus: nilai cache, dan kunci pendamping
illuminate:cache:flexible:created:<key>yang menyimpan waktu pembuatan. - Kalau salah satunya kosong, jalankan callback sekarang, lalu simpan nilai dan waktunya dengan TTL sebesar angka kedua.
- Kalau
created + angka pertamamasih lebih besar dari sekarang, nilainya masih segar. Kembalikan langsung. - Kalau sudah lewat, jadwalkan refresh lewat
defer(), lalu kembalikan nilai lama tanpa menunggu.
Bagian keempat itu yang penting. Refresh-nya dibungkus defer(), artinya dijalankan setelah respons dikirim ke pengguna. Di dalamnya ada lock:
$this->store->lock(
"illuminate:cache:flexible:lock:{$this->itemKey($key)}",
$lock['seconds'] ?? 0,
$lock['owner'] ?? null,
)->get(function () use ($key, $callback, $created, $ttl) {
if ($created !== $this->get(self::FLEXIBLE_CREATED_KEY_PREFIX.$key)) {
return;
}
$this->putMany([
$key => value($callback),
self::FLEXIBLE_CREATED_KEY_PREFIX.$key => Carbon::now()->getTimestamp(),
], $ttl[1]);
});
Ada dua pengaman di situ. Lock memastikan hanya satu proses yang menghitung ulang, dan pengecekan created memastikan proses yang menang benar-benar masih memegang versi cache yang sama. Kalau ada proses lain yang sudah memperbarui sebelum lock didapat, pekerjaannya dibatalkan.
Hasil akhirnya: sepuluh pengguna yang membuka halaman pada detik yang sama semuanya mendapat respons cepat, dan hanya satu query berat berjalan di latar belakang.
Syarat yang mudah terlewat
Store harus mendukung lock. Baris $this->store->lock(...) akan gagal pada store yang tidak menyediakan lock, dan array store termasuk di situ. Redis, Memcached, database, dan file sama-sama mendukung lock, jadi untuk produksi pilihan ini aman. Untuk tes dengan CACHE_STORE=array, siapkan store lain atau uji di lingkungan yang memakai redis.
Refresh terjadi setelah respons. Itu kelebihan sekaligus keterbatasan. Kalau proses PHP dimatikan paksa tepat setelah respons dikirim, refresh bisa tidak pernah selesai. Di PHP-FPM dan Octane normal, defer() berjalan setelah respons selesai, jadi pekerjaannya tetap dieksekusi.
Parameter tambahan yang jarang dipakai. Signature lengkapnya menerima argumen keempat dan kelima:
Cache::flexible(
key: 'filter.transmissions',
ttl: [300, 600],
callback: fn () => hitungTransmisi(),
lock: ['seconds' => 10], // berapa lama menunggu lock
alwaysDefer: true, // selalu refresh di belakang, walau belum basi
);
alwaysDefer berguna kalau callback-nya cepat dan kamu ingin cache selalu diperbarui tanpa pernah memblokir.
Memilih dua angka itu
Ini bagian yang menentukan berhasil atau tidaknya, dan jawabannya bergantung pada satu pertanyaan: seberapa basi data ini boleh dilihat pengguna?
| Jenis data | Segar | Basi | Alasan |
|---|---|---|---|
| Opsi filter, kategori, merek | 5 menit | 30 menit | perubahannya jarang, dan salah satu merek muncul belakangan tidak merusak apa pun |
| Statistik dashboard | 1 menit | 10 menit | angka yang telat semenit masih bisa dipakai untuk melihat tren |
| Daftar artikel atau produk | 30 detik | 5 menit | perubahan terasa cepat, tapi tidak perlu real-time |
| Pencarian populer | 10 menit | 1 jam | sifatnya agregat dan tidak sensitif |
| Saldo, stok, kuota, izin akses | jangan pakai | jangan pakai | kesalahan kecil di sini menjadi keluhan nyata |
Baris terakhir penting. flexible sengaja menyajikan data yang mungkin sudah tidak benar, dan itu pertukaran yang buruk untuk angka uang atau hak akses.
Kapan sebaiknya tidak dipakai
- Setelah pengguna menulis data. Kalau user baru menyimpan perubahan lalu halaman dimuat ulang, dia harus melihat perubahannya. Cache basi akan terasa seperti bug, bukan seperti performa.
- Angka yang dijumlahkan atau diagregasi untuk keputusan. Total tagihan, sisa stok, jumlah kuota.
- Data yang punya tenggat hukum atau audit. Laporan yang harus mencerminkan keadaan pada detik tertentu.
- Query yang sudah cepat. Kalau query-nya 20 milidetik, menambah lapisan cache hanya menambah kompleksitas dan satu titik gagal.
Untuk kasus-kasus itu, Cache::remember dengan invalidasi eksplisit setelah penulisan masih lebih tepat, meski harus menerima risiko lonjakan saat kedaluwarsa.
Cache bukan pengganti index
Di kasus nyata yang saya baca, waktu muat turun dari sekitar 23,5 detik menjadi 5 detik setelah dua hal dikerjakan bersamaan: cache untuk query filter, dan penambahan index di kolom yang dipakai menyaring.
Kalau hanya cache yang dikerjakan, query pertama setelah cache basi tetap lambat, dan refresh di belakang layar tetap menghabiskan CPU. Kalau hanya index yang dikerjakan, lonjakan trafik masih bisa membuat database kewalahan.
| Gejala | Penyebab | Tindakan |
|---|---|---|
| Halaman lambat hanya sesekali, bersamaan | cache kedaluwarsa serempak | flexible, atau lock saat mengisi cache |
| Lambat konsisten walau cache hangat | query memang berat | index, atau ubah bentuk query |
| Lambat saat filter tertentu dipakai | kombinasi tidak terindex | index komposit sesuai pola filter |
| Database sibuk walau halaman cepat | terlalu banyak kunci cache berbeda | kurangi kardinalitas kunci |
Untuk soal index, saya pernah menulis lebih panjang di artikel trik SQL. Dua-duanya perlu, dan cache tidak akan menyelamatkan query yang memang salah bentuk.
Jebakan yang muncul setelah dipakai
Kunci cache yang meledak jumlahnya. Ini kesalahan paling umum. Kalau kunci dibuat dari seluruh kombinasi filter yang mungkin, filter.transmissions.brand=3.type=2.year=2024 dan seterusnya, hit rate-nya akan rendah dan cache-nya jadi beban, bukan bantuan.
Yang lebih baik: satu kunci per dimensi, bukan per kombinasi.
foreach (['brands', 'transmissions', 'fuels', 'years'] as $dimensi) {
$opsi[$dimensi] = Cache::flexible("filter.{$dimensi}", [300, 1800], fn () => ambilOpsi($dimensi));
}
Dengan begitu, empat kunci kecil melayani ratusan kombinasi filter.
Invalidasi setelah mengubah data. Cache::forget('filter.brands') tetap bekerja, tapi ingat bahwa nilai pendamping waktu pembuatan masih ada. Pada permintaan berikutnya, nilai utama kosong sehingga callback langsung dijalankan, jadi hasilnya benar. Yang perlu kamu pastikan hanya: lupakan kunci yang tepat, bukan kunci lain.
Deploy yang mengubah bentuk data. Kalau struktur hasil callback berubah, cache lama bisa membuat kode baru menerima bentuk lama. Biasakan menyertakan versi di nama kunci, misalnya v2.filter.brands, setiap kali bentuk datanya berubah.
Mengukur dengan cara yang salah. Membandingkan satu kali muat halaman tidak membuktikan apa pun. Ukur persentil ke-95 sebelum dan sesudah, hitung hit ratio, dan pastikan angka yang disajikan masih benar secara bisnis.
Uji juga skenario cache kosong: hapus cache, lalu tembak halaman itu dengan beberapa permintaan bersamaan. Kalau semuanya menjalankan callback sekaligus, lock-mu tidak bekerja seperti yang kamu kira.
Checklist sebelum memakai
- Data ini boleh basi berapa lama sebelum terasa salah?
- Store yang dipakai mendukung lock?
- Kunci cache-nya sedikit dan stabil, bukan hasil perkalian semua filter?
- Ada invalidasi setelah penulisan data?
- Query dasarnya sudah diindex?
- Sudah diukur p95 sebelum dan sesudah, bukan hanya perasaan lebih cepat?
Kalau nomor satu dijawab "tidak boleh basi sama sekali", berhenti di sini dan pakai remember dengan invalidasi.
Penutup
Cache::flexible bukan fitur yang mengubah arsitektur. Dia hanya memindahkan satu pekerjaan dari depan ke belakang layar, dan memberi izin pada data untuk terlihat sedikit lebih tua.
Tapi efeknya di halaman yang ramai cukup besar: tidak ada lagi semua orang menunggu query yang sama, dan tidak ada lagi lonjakan tepat saat cache kedaluwarsa. Untuk halaman filter, daftar kategori, dan statistik dashboard, itu kombinasi yang sulit ditolak.
Mulailah dari satu halaman yang paling lambat di aplikasimu. Ukur dulu, pilih dua angka yang masuk akal, lalu pasang. Kalau setelah itu halaman terasa lebih cepat tapi datanya terasa aneh, berarti angka pertamamu terlalu panjang.







