II — Praktik
Artikel 4 dari 9

Praktik: Caching dengan Redis

Menambahkan caching layer dengan Redis lewat cache-aside pattern, TTL, cache invalidation eksplisit, dan mengukur dampaknya nyata lewat before/after benchmark.

10 Juli 2026

Kita sudah punya baseline dan tahu salah satu titik lemah task-tracker-api di Bagian 3. Sekarang optimasi pertama: menambahkan caching layer dengan Redis, supaya request baca yang berulang tidak selalu membebani database.

Kenapa Endpoint Baca Cocok Di-cache

GET /api/tasks dan GET /api/tasks/{id} punya karakteristik yang pas untuk caching: dibaca jauh lebih sering daripada diubah. Kalau seratus orang membuka daftar task yang sama dalam satu menit, tidak ada alasan kuat untuk query database seratus kali — datanya (hampir pasti) identik. Ini pola paling umum yang mendapat manfaat besar dari cache.

Cache-Aside Pattern

Pola caching yang kita pakai disebut cache-aside (kadang disebut lazy loading): aplikasi yang mengatur sendiri kapan baca dari cache, kapan baca dari database, dan kapan mengisi cache — bukan database atau cache-nya yang otomatis sinkron sendiri. Alurnya:

1. Request masuk, cek cache dulu
2. Ada di cache (cache HIT)?  → langsung balikin, SELESAI, tidak sentuh database
3. Tidak ada di cache (cache MISS)? → query database → simpan hasilnya ke cache → balikin
@router.get("", response_model=List[TaskRead])
def list_tasks(
    done: Optional[bool] = None,
    session: Session = Depends(get_session),
    cache: redis.Redis = Depends(get_redis),
):
    key = list_cache_key(done)
    cached = cache.get(key)
    if cached is not None:
        return json.loads(cached)          # cache HIT — tidak sentuh database

    query = select(Task)
    if done is not None:
        query = query.where(Task.done == done)
    results = session.exec(query).all()

    data = [TaskRead.model_validate(task).model_dump(mode="json") for task in results]
    cache.set(key, json.dumps(data), ex=settings.cache_ttl_seconds)   # isi cache untuk request berikutnya
    return data

Perhatikan cache: redis.Redis = Depends(get_redis) — pola yang sama persis dengan Depends(get_session) untuk database. Ini kenapa dependency injection yang kita pelajari di seri FastAPI sangat berguna: menambah dependency baru (cache) ke endpoint yang sudah ada tidak mengubah cara endpoint itu dipanggil sama sekali, cuma menambah satu parameter.

TTL — Kenapa Cache Tidak Boleh Hidup Selamanya

TTL (Time To Live) adalah durasi sebelum data di cache otomatis dianggap kedaluwarsa dan dihapus. Di kode kita, ini diatur lewat parameter ex (detik) saat cache.set(...), dikontrol dari settings.cache_ttl_seconds (default 30 detik).

Kenapa tidak cache selamanya? Karena data di cache bisa jadi stale (kedaluwarsa/tidak sinkron dengan database) begitu ada perubahan. TTL adalah jaring pengaman sederhana: walaupun mekanisme invalidasi kita punya bug atau ada perubahan data lewat jalur lain (misalnya query manual ke database), paling lama data stale itu akan bertahan sesuai TTL, lalu otomatis "sembuh sendiri" begitu cache-nya kedaluwarsa dan diisi ulang dari database.

Cache Invalidation — Bagian Tersulit dari Caching

Ada pepatah terkenal di dunia software engineering: "There are only two hard things in Computer Science: cache invalidation and naming things." TTL saja tidak cukup untuk kasus kita — bayangkan user membuat task baru, tapi endpoint list masih menampilkan versi cache lama selama 30 detik ke depan. Itu bug yang nyata dirasakan user.

Solusinya: begitu ada operasi tulis (create/update/delete), kita hapus cache yang relevan secara eksplisit, supaya request berikutnya otomatis ambil data segar dari database:

def invalidate_task_cache(client, task_id: Optional[int] = None) -> None:
    if task_id is not None:
        client.delete(task_cache_key(task_id))
    client.delete(*LIST_CACHE_KEYS)


@router.patch("/{task_id}", response_model=TaskRead)
def update_task(task_id: int, payload: TaskUpdate, session: Session = Depends(get_session), cache: redis.Redis = Depends(get_redis)):
    ...
    session.commit()
    session.refresh(task)

    invalidate_task_cache(cache, task_id)   # hapus cache detail task ini DAN semua cache list
    return task

Perhatikan kita menghapus DUA jenis cache saat update: cache detail task itu sendiri (task:{id}), DAN semua cache list (tasks:list:*) — karena task yang berubah bisa saja mempengaruhi hasil list (misalnya filter ?done=true). Untuk aplikasi sekecil ini, menghapus semua kunci list sekaligus itu sederhana dan cukup murah. Di aplikasi yang jauh lebih besar dengan pola filter kompleks, strategi invalidasi bisa lebih canggih (invalidasi granular per-filter, atau memakai cache tags) — tapi prinsip dasarnya tetap sama.

Mengukur Dampaknya

Sekarang saatnya membuktikan caching ini benar-benar membantu, bukan cuma "kelihatan lebih canggih". Jalankan aplikasi (kali ini butuh Postgres dan Redis — bisa lewat docker compose up -d postgres redis dulu), isi cukup banyak data (kita pakai 5.000 task supaya query-nya cukup berat untuk kelihatan bedanya), lalu bandingkan performa cache dingin (baru di-flush, hampir semua request kena database) vs cache hangat (langsung diulang, hampir semua request kena Redis):

docker exec <container-redis> redis-cli FLUSHALL

python benchmark/load_test.py --url http://localhost:8000/api/tasks --requests 100 --concurrency 10
# ulangi persis sama, TANPA flush lagi:
python benchmark/load_test.py --url http://localhost:8000/api/tasks --requests 100 --concurrency 10

Hasilnya, di mesin yang sama dengan Bagian 3:

Cache dinginCache hangat
Throughput18.4 req/detik24.9 req/detik
Latency rata-rata529.5 ms389.7 ms
Latency median390.2 ms388.2 ms
Latency p951847.0 ms524.9 ms
Latency max1903.4 ms605.7 ms
Perhatikan baik-baik: median hampir tidak berubah, tapi p95 turun drastis dari 1847ms ke 524.9ms. Ini temuan yang penting dan sering meleset dari ekspektasi pemula: manfaat utama caching di sini bukan membuat request "rata-rata" jauh lebih cepat, tapi menghilangkan ekor lambat (tail latency) yang muncul akibat variasi beban di database — request yang kebetulan harus antre koneksi database atau kena query lebih berat. Cache menghindarkan request dari variasi itu sama sekali, karena tidak pernah menyentuh database.

Rangkuman

  • Cache-aside pattern: cek cache dulu, kalau miss baru query database dan isi cache.
  • TTL adalah jaring pengaman terhadap data stale, bukan strategi invalidasi utama.
  • Cache invalidation eksplisit (hapus cache saat data berubah) tetap wajib, supaya user tidak melihat data basi.
  • Manfaat caching yang paling terasa sering kali bukan di latency rata-rata, tapi di penurunan tail latency (p95) — pengalaman terburuk yang masih umum dialami user.
  • Topik

    ScalabilityRedisCaching