Strategi Caching untuk FastAPI: dari Memori Lokal sampai Redis
Perbandingan empat layer caching untuk FastAPI: in-process, Redis, HTTP headers, dan query cache — strategi invalidasi dan jebakan production.
Caching sering dijual sebagai solusi cepat untuk performa API. Padahal, tanpa memahami scope dan strategi invalidasi, cache justru bisa jadi sumber bug yang sulit dilacak: data basi, permission bocor, atau database justru kena beban lebih berat saat cache stampede terjadi.
Di FastAPI, caching bukan satu fitur bawaan, melainkan serangkaian pilihan di layer berbeda. Tulisan ini membandingkan empat layer utama, kapan masing-masing dipakai, dan bagaimana merancang invalidasi sebelum menulis decorator caching pertama. Kalau kamu baru mulai dengan FastAPI, cek juga perbandingan FastAPI vs Django REST untuk konteks lebih lanjut.
Empat Layer Caching untuk API
Mari bedakan scope-nya:
- In-process memory cache: berada di dalam worker Uvicorn, cepat, tapi tidak dibagi antar worker atau instance.
- External shared cache seperti Redis: dibagi antar worker dan instance, cocok untuk data yang sering dibaca dan jarang berubah.
- HTTP cache headers:
Cache-Control,ETag, validator; memberitahu browser, CDN, atau reverse proxy cara menyimpan response. - Database/query cache atau materialized view: cache hasil query, tapi seringnya lebih baik dioptimasi dengan index dan query plan dulu.
Setiap layer punya trade-off yang berbeda. Memilihnya tanpa konteks sama dengan membeli obat tanpa diagnosis.
In-Process Cache: Cepat tapi Terisolasi
Layer paling sederhana adalah functools.lru_cache. FastAPI sendiri menggunakannya untuk caching settings/dependency di dokumentasi resmi, bukan untuk full API response caching.
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
redis_url: str = "redis://localhost:6379/0"
@lru_cache
def get_settings() -> Settings:
return Settings()
Dengan @lru_cache, setiap pemanggilan get_settings() setelah yang pertama akan mengembalikan objek yang sama tanpa membaca ulang environment. Ini ideal untuk konfigurasi, reference data kecil, dan pure function.
Jebakan umum: menggunakan in-process cache untuk response API lalu heran kenapa worker A punya data berbeda dengan worker B. Uvicorn/Gunicorn dengan multiple worker berarti ada multiple process terpisah, masing-masing dengan cache sendiri. Jangan pakai in-process cache untuk data yang harus konsisten antar request.
Redis sebagai Shared Cache
Untuk multi-worker atau multi-instance, Redis adalah pilihan praktis. Berbeda dengan in-process cache, Redis menjadi sumber kebenaran tunggal yang bisa diakses semua instance.
Berikut contoh endpoint FastAPI yang membaca data produk dari Redis dengan TTL dan header HTTP:
from contextlib import asynccontextmanager
import json
from fastapi import Depends, FastAPI, Request, Response
from redis.asyncio import Redis
async def load_product(product_id: int) -> dict:
# Ganti dengan query database sesungguhnya.
return {"id": product_id, "name": "Keyboard", "price": 750000}
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.redis = Redis.from_url(
"redis://localhost:6379/0",
decode_responses=True,
)
yield
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
def get_redis(request: Request) -> Redis:
return request.app.state.redis
@app.get("/products/{product_id}")
async def get_product(
product_id: int,
response: Response,
redis: Redis = Depends(get_redis),
) -> dict:
key = f"product:{product_id}:v1"
if cached := await redis.get(key):
response.headers["X-Cache"] = "HIT"
response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=30"
return json.loads(cached)
product = await load_product(product_id)
await redis.set(key, json.dumps(product), ex=300)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=30"
return product
@app.post("/products/{product_id}")
async def update_product(product_id: int, redis: Redis = Depends(get_redis)) -> dict:
# Setelah update database, invalidate cache untuk produk tersebut.
await redis.delete(f"product:{product_id}:v1")
return {"ok": True}
Perhatikan beberapa detail penting:
- Satu Redis client per aplikasi: client dibuat sekali di lifespan dan disimpan di
app.state, bukan dibuat ulang tiap request. - Namespace dan versi key:
product:{product_id}:v1memudahkan invalidasi massal saat schema berubah. - TTL + invalidasi manual: data di-cache 5 menit, tapi langsung dihapus saat ada update.
- Header HTTP terpisah:
Cache-Controlmemberi instruksi kepada reverse proxy atau browser, meskipun server-side caching sudah ada di Redis.
HTTP Caching adalah Layer Tersendiri
Banyak developer menganggap caching cuma soal Redis. Padahal header HTTP seperti Cache-Control, ETag, dan Last-Modified adalah standar yang sudah didukung browser dan CDN. Dengan header yang tepat, response bisa disimpan di edge tanpa pernah mengenai server aplikasi.
Tapi hati-hati dengan kata kunci public. Kalau response mengandung data user-specific atau sensitif terhadap otorisasi, gunakan private atau jangan cache sama sekali. Cache authorization-sensitive data dengan header publik adalah cara cepat membuat data pengguna A terlihat pengguna B.
Invalidasi: Lebih Penting dari Penyimpanan
Menyimpan data di cache mudah; memastikan data tidak basi yang sulit. Strategi umum:
- TTL-only: sederhana, tapi data bisa basi sampai TTL habis. Cocok untuk data yang tidak kritis atau read-heavy.
- Delete-on-write: saat data diubah, langsung hapus key bersangkutan. Contohnya pada endpoint
update_productdi atas. - Write-through: setiap write langsung update cache dan database secara bersamaan. Lebih konsisten tapi menambah latency write.
- Versioned keys: saat schema atau struktur data berubah, naikkan versi di key. Ini menghindari cache lama dengan format usang.
- Event-driven invalidation: service lain mengirim event (misalnya lewat message queue) untuk membersihkan cache terkait.
- Stale-while-revalidate: response boleh disajikan dari cache meski sudah lewat TTL, sambil background refresh dijalankan. Berguna untuk data read-heavy yang sedikit basi tidak masalah.
Versi key bisa diatur seperti ini:
def product_cache_key(product_id: int, schema_version: int = 1) -> str:
return f"product:{product_id}:schema:{schema_version}"
Saat format produk berubah, tingkatkan schema_version dan key lama akan ditinggalkan secara natural.
Jangan Buru-Buru Cache Query Database
Sebelum menambahkan cache di query database, tanyakan dulu:
- Apakah index sudah tepat?
- Apakah masih ada N+1 query?
- Apakah pagination sudah dibatasi?
- Apakah bisa pakai materialized view atau denormalized read model?
Cache query sering menyembunyikan masalah performa yang sebenarnya ada di query itu sendiri. Cache adalah aspirin, bukan operasi jantung.
Risiko Operasional yang Sering Terabaikan
- Cache stampede: banyak request menembak database bersamaan saat key cache expired. Solusinya bisa locking, prefetch, atau stale-while-revalidate.
- Serialization incompatibility: menyimpan objek ORM langsung ke Redis sering bermasalah. Selalu serialize ke bentuk stabil seperti dict atau JSON.
- Cache outage: apa yang terjadi kalau Redis mati? Fail open (bypass cache), fail closed (error), atau fallback ke database masing-masing punya konsekuensi.
- Key collision: lupa memasukkan locale, tenant, user scope, pagination, atau filter ke dalam cache key.
- Memory pressure: Redis bukan tempat penyimpanan abadi. Tanpa TTL, cache bisa memenuhi memori dan mengusir data penting.
Kapan Caching Memang Dibutuhkan?
Pertimbangkan caching kalau:
- Data sering dibaca dan relatif jarang berubah.
- Komputasi atau query di belakang endpoint mahal.
- Traffic cukup tinggi sehingga penghematan latency signifikan.
- Kamu sudah punya strategi invalidasi yang jelas.
Hindari caching kalau:
- Datanya unik per pengguna dan sensitif terhadap otorisasi.
- Rate request rendah sehingga overhead cache tidak sebanding dengan manfaatnya.
- Belum ada cara jelas membersihkan cache saat data berubah.
Kesimpulan
Caching di FastAPI bukan soal memasang satu decorator. Ini adalah keputusan arsitektur: pilih layer yang sesuai (memori, Redis, HTTP, atau database), desain invalidasi sebelum menyimpan data pertama, dan sadari bahwa cache yang salah bisa lebih berbahaya daripada tidak ada cache sama sekali.
Mulailah dari yang paling sederhana: @lru_cache untuk settings, Redis untuk shared data read-heavy, dan header HTTP untuk memanfaatkan CDN atau browser. Lalu evaluasi metrics sebelum menambahkan kompleksitas lebih lanjut.
Pernah mengalami cache stampede atau data basi di production? Ceritakan di komentar; peringatan dari pengalaman nyata sering lebih berharga dari teori.
