Pada aplikasi dengan trafik tinggi, bottleneck performa sering bukan hanya berasal dari database, tetapi juga dari pola akses data yang berulang, rendering respons yang mahal, dan integrasi ke layanan eksternal. Caching bertingkat membantu mengurangi beban tersebut dengan menempatkan hasil komputasi atau data yang sering dibaca di beberapa lapisan akses, sehingga permintaan berikutnya tidak selalu harus mengeksekusi proses yang sama dari awal.

Di CodeIgniter 4, pendekatan caching yang efektif bukan sekadar memanggil service cache lalu menyimpan nilai. Desain yang baik perlu mempertimbangkan jenis data, frekuensi perubahan, TTL, strategi invalidasi, serta observability untuk mengukur apakah cache benar-benar membantu. Artikel ini membahas strategi praktis dan production-ready untuk menerapkan caching bertingkat di CodeIgniter 4.

Memahami caching bertingkat di CodeIgniter 4

Secara sederhana, caching bertingkat berarti kita tidak mengandalkan satu jenis cache untuk semua kebutuhan. Beberapa lapisan yang umum dipakai adalah:

  • Cache data: menyimpan hasil query, agregasi, konfigurasi, atau objek DTO yang sering dipakai.
  • Query/result caching: menyimpan hasil pembacaan dari database pada level repository/service.
  • Output caching: menyimpan respons HTTP penuh untuk endpoint tertentu.
  • Fragment caching: menyimpan sebagian output, misalnya blok sidebar, widget statistik, atau komponen halaman.

Pada aplikasi sibuk, kombinasi beberapa lapisan ini biasanya lebih efektif daripada hanya meng-cache semuanya di level controller. Misalnya, endpoint API katalog produk dapat menggunakan cache data untuk daftar produk populer, lalu output cache singkat untuk respons endpoint publik, sementara fragment cache dipakai untuk blok HTML yang mahal dirender.

Prinsip penting: cache bukan sumber data utama. Database atau sistem asal tetap menjadi source of truth. Cache hanya mempercepat pembacaan.

Memilih driver cache: file, Redis, atau Memcached

File cache

Driver file cocok untuk:

  • lingkungan development atau staging,
  • aplikasi kecil hingga menengah,
  • server tunggal dengan kebutuhan sederhana.

Kelebihannya adalah mudah dipakai dan tidak membutuhkan layanan tambahan. Kekurangannya, performa file system akan menjadi batas pada trafik tinggi, terutama jika cache sering dibaca/tulis, ukuran data besar, atau aplikasi berjalan di banyak node. Pada deployment multi-server, file cache lokal juga berisiko inkonsisten karena setiap node memiliki cache sendiri.

Redis

Redis cocok untuk:

  • trafik tinggi,
  • kebutuhan shared cache lintas beberapa instance aplikasi,
  • TTL yang ketat dan invalidasi yang lebih terkontrol,
  • kebutuhan struktur data tambahan atau operasi atomik sederhana.

Redis umumnya menjadi pilihan utama untuk production karena cepat, mendukung expirasi key, dan mudah dipakai sebagai cache terpusat. Namun, Anda tetap perlu mengelola memory limit, eviction policy, dan konektivitas jaringan.

Memcached

Memcached cocok untuk:

  • cache sederhana berbasis key-value,
  • workload baca tinggi dengan kebutuhan latency rendah,
  • kasus ketika persistensi bukan prioritas.

Memcached sering dipilih bila Anda hanya butuh cache volatile yang sederhana. Dibanding Redis, fiturnya lebih minimal, tetapi justru bisa cukup untuk workload cache murni.

Kapan memilih yang mana?

  • File: local development, fallback sederhana, aplikasi single node.
  • Redis: default terbaik untuk production high traffic.
  • Memcached: cocok jika tim sudah menggunakannya dan kebutuhan hanya cache key-value cepat.

Untuk produksi, pola yang umum adalah driver utama Redis dan fallback file atau dummy, tergantung toleransi terhadap degradasi layanan.

Konfigurasi cache driver dan fallback di CodeIgniter 4

CodeIgniter 4 menyediakan konfigurasi cache melalui file app/Config/Cache.php. Gunakan konfigurasi yang eksplisit agar perilaku di production konsisten dan mudah diaudit.

<?php

namespace Config;

use CodeIgniter\Config\BaseConfig;

class Cache extends BaseConfig
{
    public string $handler = 'redis';
    public string $backupHandler = 'file';

    public string $prefix = 'sibuk_app:';
    public int $ttl = 300;

    public array $file = [
        'storePath' => WRITEPATH . 'cache/',
        'mode' => 0640,
    ];

    public array $redis = [
        'host' => '127.0.0.1',
        'password' => null,
        'port' => 6379,
        'timeout' => 0,
        'database' => 0,
    ];

    public array $memcached = [
        'host' => '127.0.0.1',
        'port' => 11211,
        'weight' => 1,
        'raw' => false,
    ];
}

Beberapa praktik penting:

  • Gunakan prefix untuk menghindari bentrok key antar aplikasi atau environment.
  • Tetapkan backupHandler agar aplikasi tetap berjalan jika driver utama gagal.
  • Jangan terlalu mengandalkan default TTL global. TTL sebaiknya disesuaikan per use case.

Jika memakai environment berbeda, pindahkan nilai seperti host Redis, port, dan prefix ke environment file agar konfigurasi tidak hard-coded.

Desain key cache, TTL, dan namespace invalidasi

Penamaan key yang konsisten

Penamaan key memengaruhi maintainability. Hindari key generik seperti users atau homepage. Gunakan format yang terstruktur, misalnya:

app:env:domain:entity:identifier:variant

Contoh:

  • sibuk:prod:catalog:product:42:detail
  • sibuk:prod:user:15:profile
  • sibuk:prod:homepage:widget:popular-products:v2

Masukkan konteks yang penting seperti ID, locale, page number, role user, atau versi schema jika hasilnya bergantung pada parameter tersebut.

Menentukan TTL

TTL tidak boleh ditentukan sembarangan. Beberapa panduan praktis:

  • 30-60 detik untuk data yang sering berubah tetapi dibaca sangat sering, seperti metrik ringkas dashboard publik.
  • 5-15 menit untuk daftar atau agregasi yang tidak perlu real-time.
  • 1 jam atau lebih untuk konfigurasi, referensi statis, atau konten yang jarang berubah.

TTL pendek mengurangi risiko stale data, tetapi menurunkan hit rate. TTL panjang meningkatkan hit rate, tetapi memperbesar peluang data usang. Pilihan terbaik tergantung toleransi bisnis terhadap keterlambatan pembaruan.

Versi key untuk invalidasi massal

Karena tidak semua driver mendukung penghapusan berdasarkan pola dengan aman dan efisien, pendekatan yang sering dipakai adalah versioned key atau namespace versioning. Misalnya, simpan versi katalog di satu key, lalu gabungkan versi itu ke key utama.

<?php

$cache = cache();
$catalogVersion = $cache->get('sibuk:prod:catalog:version') ?? 1;
$key = "sibuk:prod:catalog:v{$catalogVersion}:product:{$id}:detail";

Saat ada perubahan besar pada katalog, Anda cukup menaikkan nilai versi. Semua key lama otomatis menjadi tidak terpakai tanpa harus menghapus satu per satu.

Implementasi cache data pada repository dan service layer

Tempat terbaik untuk cache data biasanya bukan langsung di controller, melainkan di repository atau service layer. Alasannya, lapisan ini memahami bentuk data, sumber data, serta kondisi invalidasinya. Controller cukup memanggil service tanpa mengetahui detail cache.

<?php

namespace App\Repositories;

use App\Models\ProductModel;

class ProductRepository
{
    public function __construct(
        private ProductModel $productModel
    ) {}

    public function findPublishedById(int $id): ?array
    {
        $cache = cache();
        $key = "sibuk:prod:catalog:product:{$id}:detail";

        $cached = $cache->get($key);
        if ($cached !== null) {
            return $cached;
        }

        $product = $this->productModel
            ->where('id', $id)
            ->where('status', 'published')
            ->first();

        if ($product !== null) {
            $cache->save($key, $product, 300);
        }

        return $product;
    }

    public function invalidateProduct(int $id): void
    {
        cache()->delete("sibuk:prod:catalog:product:{$id}:detail");
    }
}

Pola ini menjaga agar logika cache dekat dengan logika akses data. Jika nanti Anda mengganti driver atau strategi TTL, perubahan cukup dilakukan di lapisan ini.

Integrasi dengan service untuk mencegah stale data

Saat data diubah, lakukan invalidasi di service yang menangani penulisan.

<?php

namespace App\Services;

use App\Models\ProductModel;
use App\Repositories\ProductRepository;

class ProductService
{
    public function __construct(
        private ProductModel $productModel,
        private ProductRepository $productRepository
    ) {}

    public function updateProduct(int $id, array $payload): bool
    {
        $updated = $this->productModel->update($id, $payload);

        if ($updated) {
            $this->productRepository->invalidateProduct($id);
        }

        return $updated;
    }
}

Jangan menunda invalidasi terlalu lama setelah operasi tulis berhasil. Jika ada beberapa key turunan seperti detail produk, daftar kategori, atau widget populer, invalidasi semuanya secara eksplisit atau gunakan namespace versioning.

Query/result caching, output caching, dan fragment caching

Query/result caching

Dalam praktik CodeIgniter 4, yang lebih aman adalah meng-cache hasil query di level aplikasi, bukan mengandalkan perilaku khusus engine database. Ini memberi Anda kontrol atas TTL, serialisasi, dan invalidasi. Cocok untuk:

  • halaman daftar dengan filter terbatas,
  • lookup referensi,
  • agregasi statistik yang mahal.

Perhatikan agar key memasukkan semua parameter query yang memengaruhi hasil. Kesalahan umum adalah memakai satu key untuk query yang sebenarnya berbeda karena page, sort, locale, atau role tidak dimasukkan ke key.

Output caching

Output caching cocok untuk endpoint yang responsnya sama bagi banyak pengguna, misalnya landing page publik atau endpoint API publik dengan parameter terbatas. Dengan menyimpan respons penuh, Anda menghindari eksekusi controller, service, dan render berulang.

Namun, hindari output cache untuk respons yang bersifat personal, bergantung pada sesi, atau memuat token dinamis kecuali variasinya dikelola dengan sangat hati-hati.

Fragment caching

Fragment caching menyimpan bagian output tertentu. Ini berguna saat satu halaman memiliki beberapa komponen dengan karakteristik cache berbeda. Misalnya, layout utama dirender normal, tetapi widget “produk terlaris” dan “kategori populer” di-cache 5 menit.

Pendekatan ini sering lebih fleksibel dibanding output cache penuh, terutama jika sebagian halaman personal dan sebagian lagi publik.

Contoh integrasi pada API response

Untuk API dengan trafik tinggi, Anda bisa menggabungkan cache data dan cache respons terstruktur. Contoh berikut meng-cache payload API produk publik:

<?php

namespace App\Controllers\Api;

use App\Controllers\BaseController;
use App\Repositories\ProductRepository;

class Products extends BaseController
{
    public function show(int $id, ProductRepository $repository)
    {
        $cache = cache();
        $key = "sibuk:prod:api:product:{$id}:response:v1";

        $payload = $cache->get($key);
        if ($payload === null) {
            $product = $repository->findPublishedById($id);

            if ($product === null) {
                return $this->response->setStatusCode(404)
                    ->setJSON(['message' => 'Produk tidak ditemukan']);
            }

            $payload = [
                'data' => [
                    'id' => $product['id'],
                    'name' => $product['name'],
                    'price' => $product['price'],
                    'updated_at' => $product['updated_at'],
                ]
            ];

            $cache->save($key, $payload, 120);
        }

        return $this->response->setJSON($payload);
    }
}

Di sini ada dua level cache: repository menyimpan hasil data mentah, lalu controller menyimpan payload API yang sudah dibentuk. Strategi ini berguna bila pembentukan respons juga cukup mahal atau digunakan berulang kali.

Mencegah stale data dan cache stampede

Stale data

Stale data adalah risiko utama caching. Beberapa mitigasi yang bisa diterapkan:

  • Invalidasi saat write: hapus atau ganti key terkait segera setelah update berhasil.
  • TTL yang realistis: jangan terlalu panjang untuk data yang sensitif terhadap perubahan.
  • Versioned key: efektif untuk invalidasi massal.
  • Cache warming: isi ulang key penting setelah deploy atau setelah invalidasi besar.

Cache stampede

Stampede terjadi ketika banyak request serentak mendapati cache expired dan semuanya memukul database bersamaan. Untuk menguranginya:

  • gunakan TTL acak kecil (jitter) agar key tidak habis bersamaan,
  • prioritaskan caching untuk query termahal,
  • jika perlu, terapkan locking ringan atau single-flight pattern di layer aplikasi,
  • sajikan stale cache sementara pada kasus tertentu jika bisnis mengizinkan.

CodeIgniter 4 tidak otomatis menyelesaikan stampede untuk semua kasus, jadi desain aplikasi tetap memegang peran penting.

Observability: ukur hit rate, latency, dan efektivitas cache

Cache yang tidak diukur berisiko hanya menambah kompleksitas. Minimal, pantau metrik berikut:

  • Hit rate: persentase request yang berhasil dilayani dari cache.
  • Miss rate: seberapa sering aplikasi jatuh kembali ke database atau backend.
  • Latency cache get/set: apakah akses cache tetap lebih cepat dari sumber data.
  • Error rate: kegagalan koneksi Redis/Memcached atau error serialisasi.
  • DB fallback pressure: lonjakan query saat cache gagal atau expired massal.

Praktik yang berguna adalah menambahkan logging terstruktur pada titik cache hit/miss untuk endpoint penting. Jika Anda memakai APM atau sistem metrics seperti Prometheus, ekspor counter hit/miss per domain bisnis, bukan hanya per endpoint. Dengan begitu Anda bisa melihat area mana yang paling diuntungkan atau justru paling bermasalah.

<?php

$start = microtime(true);
$value = cache()->get($key);
$durationMs = (microtime(true) - $start) * 1000;

log_message('info', 'cache_lookup', [
    'key' => $key,
    'hit' => $value !== null,
    'duration_ms' => round($durationMs, 2),
]);

Hindari logging key sensitif atau payload besar. Cukup log namespace, status hit/miss, dan durasi.

Best practice production-ready

  • Gunakan Redis sebagai default production cache untuk aplikasi multi-instance dan trafik tinggi.
  • Simpan cache logic di repository/service layer, bukan tersebar di banyak controller.
  • Buat konvensi key yang baku sejak awal agar invalidasi lebih mudah.
  • Dokumentasikan TTL per jenis data dan alasan bisnisnya.
  • Pastikan fallback handler aman, tetapi jangan menyembunyikan error cache kritis tanpa observability.
  • Jangan cache data personal tanpa variasi key yang benar, seperti user ID, role, locale, atau tenant.
  • Audit ukuran objek cache agar tidak menyimpan payload terlalu besar.
  • Uji skenario invalidasi saat create, update, delete, dan bulk import.
  • Monitor hit rate dan beban database setelah cache diaktifkan; tujuan cache adalah mengurangi latency dan pressure ke backend, bukan sekadar menambah lapisan teknis.

Penutup

Caching bertingkat di CodeIgniter 4 paling efektif ketika diterapkan sebagai bagian dari desain arsitektur, bukan tempelan belakangan. Cache data, output, dan fragment memiliki peran berbeda, dan pemilihan driver seperti file, Redis, atau Memcached harus disesuaikan dengan pola deployment serta karakteristik trafik.

Untuk aplikasi sibuk, fokus utama seharusnya bukan hanya mempercepat request yang berhasil di-cache, tetapi juga memastikan invalidasi benar, stale data terkendali, dan sistem tetap stabil saat cache miss atau cache backend bermasalah. Jika Anda menempatkan cache di repository/service layer, memakai key yang konsisten, TTL yang realistis, serta metrik hit rate dan latency yang dapat dipantau, maka strategi caching Anda akan jauh lebih siap untuk production.