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 dingin | Cache hangat | |
|---|---|---|
| Throughput | 18.4 req/detik | 24.9 req/detik |
| Latency rata-rata | 529.5 ms | 389.7 ms |
| Latency median | 390.2 ms | 388.2 ms |
| Latency p95 | 1847.0 ms | 524.9 ms |
| Latency max | 1903.4 ms | 605.7 ms |