OPcache dari chapter sebelumnya menghilangkan biaya kompilasi; sayangnya engine tetap menjalankan opcode yang sama berulang-ulang setiap permintaan. Application caching mengambil langkah berikutnya: menyimpan hasil perhitungan itu sendiri — laporan agregat, hasil query berat, render partial — agar pemanggilan berikutnya tinggal membaca. Pada chapter ini kita membangun cache bertahap: dari memoization di tingkat fungsi, class cache dengan TTL yang dapat diuji, hingga arsitektur berlapis yang bersiap menemui Redis pada chapter berikutnya.

Apa yang Layak Di-cache #

Cache bukan tempat pembuangan untuk semua data; ia tepat untuk data dengan tiga sifat: mahal dihitung, sering dibaca, dan boleh sedikit basu (stale) tanpa menimbulkan kerugian. Tabel berikut memetakan kandidat yang umum:

KandidatLayak?Alasan
Hasil query laporan agregatYamahal dihitung, dibaca ribuan kali, toleran beberapa menit basu
Render partial yang beratYaCPU mahal, konten jarang berubah
Konfigurasi yang dimuat dari databaseYahampir tidak pernah berubah
Saldo rekening real-timeTidakharus akurat saat itu juga
Keputusan izin tanpa identitas pengguna di kunciTidakberisiko membocorkan data antar pengguna
Data yang hanya dipakai sekali per permintaanTidakcache tidak akan pernah kena hit

Perhatikan bahwa dua baris terakhir bukan soal performa melainkan soal kebenaran dan kegunaan — cache yang salah tempat lebih berbahaya daripada tidak ada cache sama sekali.

Memoization: Cache di Tingkat Fungsi #

Bentuk cache paling kecil adalah memoization: fungsi menyimpan hasil pemanggilan lalu mengembalikan salinannya untuk pemanggilan berikutnya dengan masukan sama. Rekursi Fibonacci adalah contoh klasiknya — versi polos menghitung ulang submasalah yang sama ribuan kali. Agar buktinya terukur tanpa angka waktu yang berubah-ubah, kita menyisipkan pencacah panggilan:

php
<?php
function fibonacciPolos(int $n, int &$pencacah = 0): int
{
    $pencacah++;
    if ($n < 2) {
        return $n;
    }
    return fibonacciPolos($n - 1, $pencacah)
        + fibonacciPolos($n - 2, $pencacah);
}

function fibonacciMemo(int $n, array &$cache, int &$pencacah): int
{
    $pencacah++;
    if ($n < 2) {
        return $n;
    }
    if (array_key_exists($n, $cache)) {
        return $cache[$n];
    }
    $hasil = fibonacciMemo($n - 1, $cache, $pencacah)
        + fibonacciMemo($n - 2, $cache, $pencacah);
    $cache[$n] = $hasil;
    return $hasil;
}

$panggilanPolos = 0;
$hasilPolos = fibonacciPolos(18, $panggilanPolos);

$cache = [];
$panggilanMemo = 0;
$hasilMemo = fibonacciMemo(18, $cache, $panggilanMemo);

echo "Versi polos : hasil=$hasilPolos, jumlah panggilan=$panggilanPolos\n";
echo "Versi memo  : hasil=$hasilMemo, jumlah panggilan=$panggilanMemo\n";

Output yang Dihasilkan:

text
Versi polos : hasil=2584, jumlah panggilan=8361
Versi memo  : hasil=2584, jumlah panggilan=35

Mari kita bedah kode di atas:

  1. Baris 2-10 (fibonacciPolos): parameter $pencacah dikirim lewat referensi (int &$pencacah) dan dinaikkan pada setiap pemanggilan, sehingga kita bisa menghitung berapa kali fungsi benar-benar dieksekusi.
  2. Baris 12-25 (fibonacciMemo): array $cache juga lewat referensi. Baris 18-19 mengembalikan hasil yang sudah ada; baris 23 menyimpan hasil submasalah sebelum dikembalikan — sejak itu cabang rekursi yang sama tinggal membaca.
  3. Baris 27-35: kedua versi menghitung fibonacci(18). Hasilnya identik (2584), tetapi versi polos menempuh 8.361 panggilan sementara versi memo cukup 35 — berkurang hampir 240 kali lipat, terlihat jelas dari pencacah yang deterministik ini tanpa perlu mencetak waktu.

Memoization seperti ini hidup selama satu permintaan saja — konsisten dengan karakteristik shared-nothing pada chapter Memahami Performance. Untuk hasil yang ingin bertahan lintas permintaan, kita membutuhkan cache dengan umur simpan eksplisit.

ArrayCache dengan TTL dan Jam yang Disuntikkan #

Cache sungguhan butuh dua kemampuan tambahan: kunci bernama dan TTL (time to live) — umur simpan dalam detik setelah mana entri dianggap basi. Kedua kemampuan itu diwujudkan class berikut, lengkap dengan satu keputusan desain yang membuat pengujian jadi mudah: sumber waktu disuntikkan lewat class JamTiruan, bukan diambil langsung dari time():

php
<?php
class JamTiruan
{
    private int $detikSaatIni = 0;

    public function sekarang(): int
    {
        return $this->detikSaatIni;
    }

    public function majukan(int $detik): void
    {
        $this->detikSaatIni += $detik;
    }
}

class ArrayCache
{
    public function __construct(
        private array $simpanan = [],
        private JamTiruan $jam = new JamTiruan(),
    ) {
    }

    private function masihSegar(string $kunci): bool
    {
        return isset($this->simpanan[$kunci])
            && $this->jam->sekarang() < $this->simpanan[$kunci]["kedaluwarsa"];
    }

    public function set(string $kunci, mixed $nilai, int $ttlDetik): void
    {
        $this->simpanan[$kunci] = [
            "nilai" => $nilai,
            "kedaluwarsa" => $this->jam->sekarang() + $ttlDetik,
        ];
    }

    public function get(string $kunci): mixed
    {
        if (!$this->masihSegar($kunci)) {
            return null;
        }
        return $this->simpanan[$kunci]["nilai"];
    }

    public function getOrCompute(
        string $kunci,
        int $ttlDetik,
        callable $penghitung,
    ): mixed {
        if ($this->masihSegar($kunci)) {
            return $this->simpanan[$kunci]["nilai"];
        }
        $nilai = $penghitung();
        $this->set($kunci, $nilai, $ttlDetik);
        return $nilai;
    }
}

$jam = new JamTiruan();
$cache = new ArrayCache(jam: $jam);

$jumlahQuery = 0;
$queryHargaProduk = function () use (&$jumlahQuery): int {
    $jumlahQuery++; // simulasi satu query SQL yang mahal
    usleep(2000);   // simulasi I/O database 2 ms
    return 25000;
};

$cache->getOrCompute("produk:17:harga", 300, $queryHargaProduk);
echo "Setelah panggilan 1: jumlahQuery=$jumlahQuery\n";

$cache->getOrCompute("produk:17:harga", 300, $queryHargaProduk);
echo "Setelah panggilan 2: jumlahQuery=$jumlahQuery\n";

$cache->getOrCompute("produk:17:harga", 300, $queryHargaProduk);
echo "Setelah panggilan 3: jumlahQuery=$jumlahQuery\n";

$jam->majukan(301); // waktu berjalan melewati TTL 300 detik

$harga = $cache->getOrCompute("produk:17:harga", 300, $queryHargaProduk);
echo "Setelah TTL lewat : jumlahQuery=$jumlahQuery, harga=$harga\n";

// Negative caching: hasil "tidak ditemukan" (null) pun tersimpan
$jumlahLookup = 0;
$cariPengguna = function () use (&$jumlahLookup): ?string {
    $jumlahLookup++; // simulasi lookup yang selalu gagal
    return null;
};

$nilai1 = $cache->getOrCompute("pengguna:[email protected]", 60, $cariPengguna);
$nilai2 = $cache->getOrCompute("pengguna:[email protected]", 60, $cariPengguna);

echo "Nilai pencarian pengguna tidak dikenal: ";
var_dump($nilai1);
echo "Pencarian kedua juga null: ";
var_dump($nilai2 === null);
echo "Lookup tetap dijalankan hanya sekali: ";
var_dump($jumlahLookup === 1);

Output yang Dihasilkan:

text
Setelah panggilan 1: jumlahQuery=1
Setelah panggilan 2: jumlahQuery=1
Setelah panggilan 3: jumlahQuery=1
Setelah TTL lewat : jumlahQuery=2, harga=25000
Nilai pencarian pengguna tidak dikenal: NULL
Pencarian kedua juga null: bool(true)
Lookup tetap dijalankan hanya sekali: bool(true)

Komponen dan alurnya bekerja sebagai berikut:

  1. Class JamTiruan (baris 2-15): jam palsu yang hanya bergerak saat kita memanggil majukan(). Berkat suntikan ini, pengujian TTL menjadi deterministik — tidak perlu sleep(301) yang memperlambat pengujian, dan tidak ada waktu nyata yang bisa berubah di tengah jalan.
  2. Method masihSegar() (baris 25-29): satu-satunya penjaga kesegaran; entri dianggap segar bila ada dan waktu sekarang masih di bawah batas kedaluwarsanya.
  3. Method set() dan get() (baris 31-45): menyimpan nilai bersama batas kedaluwarsa, lalu mengembalikannya hanya bila masih segar.
  4. Method getOrCompute() (baris 47-58): pola kerja cache yang paling aman — bila segar, kembalikan simpanan; bila tidak, jalankan $penghitung (di sini meniru query mahal dengan usleep(2000)), simpan hasilnya, lalu kembalikan.
  5. Alur demo (baris 71-83): tiga panggilan berturut-turut hanya menjalankan query sekali; setelah majukan(301) melewati TTL 300 detik, panggilan berikutnya menjalankan query lagi — TTL bekerja persis seperti dirancang.
  6. Bagian negative caching (baris 85-100): hasil null dari pencarian pengguna yang tidak ada ikut tersimpan, sehingga pencarian kedua tidak menyentuh database sama sekali — inilah negative caching, dan alasan getOrCompute() memeriksa masihSegar() alih-alih sekadar get() === null.

Desain Kunci Cache #

Kunci adalah identitas data di cache: satu kunci salah isi, dan seluruh pengguna yang memakainya menerima data yang salah pula. Kunci yang baik memuat semua masukan yang memengaruhi nilai hasil — entitas, pengenal, dan parameter:

php
<?php
function kunciCacheHarga(int $idProduk, string $mataUang): string
{
    return sprintf("produk:%d:harga:%s", $idProduk, $mataUang);
}

echo kunciCacheHarga(17, "IDR"), "\n";
echo kunciCacheHarga(17, "USD"), "\n";
echo kunciCacheHarga(23, "IDR"), "\n";
echo kunciCacheHarga(23, "USD"), "\n";

Output yang Dihasilkan:

text
produk:17:harga:IDR
produk:17:harga:USD
produk:23:harga:IDR
produk:23:harga:USD

Keempat kunci pada output di atas semuanya berbeda — dan memang seharusnya begitu, karena harga produk 17 dalam IDR dan USD adalah dua data yang berbeda. Struktur entitas:id:aspek:param membuat kunci mudah dicari saat debugging dan sulit tertukar antar jenis data. Dua praktik pelengkapnya: sisipkan versi skema di depan kunci (misalnya app:v2:produk:17:harga) agar pembaruan format data dapat membatalkan seluruh cache lama sekaligus, dan jangan pernah menyusun kunci dari data mentah pengguna tanpa dinormalisasi — "Budi" dan "budi " akan menjadi dua kunci berbeda untuk data yang sama. Strategi pembatalan yang lebih rinci menjadi bahasan khusus pada chapter Cache Invalidation.

Cache Stampede #

Cache mengubah satu query mahal menjadi satu kali hitung dan ribuan kali baca — sampai kunci panas itu kedaluwarsa tepat saat traffic sedang ramai. Ratusan permintaan tiba bersamaan, semuanya mendapati cache kosong, dan semuanya menjalankan query mahal yang sama dalam waktu yang hampir bersamaan; database yang tadinya sehat tiba-tiba tersengat beban seratus kali lipat. Fenomena ini disebut cache stampede (atau dog-piling), dan ada tiga penawar yang lazim dipakai sendiri-sendiri maupun kombinasinya.

Pertama, single-flight lock: permintaan pertama yang mendapati cache kosong mengunci kunci tersebut dan menghitung ulang, sementara permintaan lain menunggu atau sementara memakai nilai lama. Kedua, stale-while-revalidate: nilai basi tetap disajikan selama satu proses belakang layar menyegarkannya, sehingga pengguna tidak pernah menunggu. Ketiga, TTL jitter: tambahkan variasi acak kecil (misalnya ±10%) pada setiap TTL agar kunci-kunci yang dibuat bersamaan tidak berakhir bersamaan pula. Ketiganya hanya relevan untuk kunci panas — profil dari chapter Profiling PHP akan memberi tahu Anda kunci mana yang benar-benar panas.

Cache Berlapis: L1 Lokal dan L2 Terbagikan #

ArrayCache kita hidup di memori satu proses: membacanya nyaris gratis, tetapi isinya tidak terlihat oleh worker PHP-FPM lain dan hilang begitu permintaan selesai. Aplikasi berskala serius karena itu memakai dua lapis: L1 berupa array per proses untuk data yang sangat panas, dan L2 berupa penyimpanan eksternal seperti Redis yang dibagi semua worker dan bertahan lintas permintaan. Ekstensi redis sendiri sudah tersedia pada build course ini — mari kita periksa sampai sejauh mana ia bisa dipakai tanpa server Redis yang menyala:

php
<?php
echo "Ekstensi redis termuat: ";
var_dump(extension_loaded("redis"));

echo "Class Redis tersedia: ";
var_dump(class_exists("Redis"));

$redis = new Redis();
try {
    $terhubung = $redis->connect("127.0.0.1", 6379, 0.5);
    echo "L2 Redis siap: ";
    var_dump($terhubung);
} catch (RedisException $e) {
    echo "L2 Redis tidak tersedia (";
    echo get_class($e);
    echo ") - aplikasi melanjutkan dengan L1 array\n";
}

Output yang Dihasilkan:

text
Ekstensi redis termuat: bool(true)
Class Redis tersedia: bool(true)
L2 Redis tidak tersedia (RedisException) - aplikasi melanjutkan dengan L1 array

Baris 2-5 memastikan ekstensi dan class-nya ada; baris 8-16 mencoba koneksi ke server lokal dengan batas waktu setengah detik. Pada build course ini tidak ada server Redis yang menyala, sehingga connect() melempar RedisException — dan tangkapan itu justru pelajaran pentingnya: cache adalah akselerator, bukan penopang. Aplikasi yang sehat menangkap kegagalan L2, turun sementara ke L1 atau langsung ke sumber data, dan tetap melayani pengguna. Pola baca lengkapnya: periksa L1, lalu L2, lalu hitung dari sumber, kemudian isi kedua lapisan — TTL L1 dibuat lebih pendek agar perubahan data di L2 segera terlihat. Penggunaan Redis secara utuh, termasuk serialisasi dan struktur datanya, menjadi materi chapter Redis Caching.

Apa yang Tidak Boleh Di-cache #

Terakhir, ada data yang tidak boleh disentuh cache tanpa syarat. Keputusan otentikasi dan otorisasi hanya boleh di-cache bila identitas pengguna menjadi bagian dari kunci dan masa berlakunya sangat pendek atau dibatalkan eksplisit saat peran berubah — tanpa itu, Budi bisa menerima hasil milik akun Siti. Langkah-langkah pembayaran, saldo, dan satu-kali-token wajib selalu segar karena konsekuensi kebalapannya nyata uang. Prinsip umumnya: makin besar kerugian akibat data basi, makin pendek TTL yang boleh Anda berikan — hingga titik di mana jawabannya adalah tidak mencache sama sekali dan kembali mengoptimalkan sumber datanya.

Ringkasan #

Application caching menyimpan hasil perhitungan mahal agar tidak diulang: memoization menjaganya dalam satu permintaan, ArrayCache dengan TTL menjaganya dengan umur simpan eksplisit dan jam yang disuntikkan agar mudah diuji, dan arsitektur L1-L2 membawanya lintas proses dengan Redis. Kunci yang dirancang benar adalah identitas data, negative caching meredam pencarian sia-sia, dan stampede ditenangkan dengan lock, stale-while-revalidate, serta jitter. Dua pertanyaan yang belum kita jawab — kapan dan bagaimana membatalkan cache dengan benar — menjadi pembuka chapter Cache Invalidation, dan operasi Redis yang sesungguhnya menunggu di chapter Redis Caching.

Referensi #