Strategi cache menentukan gimana sebuah aplikasi membaca, menulis, dan memperbarui data yang di-cache. Dulu saya biasa menjelaskan caching sebagai "put stuff in Redis so your app goes faster." Belakangan saya baru paham gimana strategi itu memengaruhi consistency, latency, dan write throughput.
Kenapa Caching Itu Penting
Setiap kali aplikasi kamu ngobrol dengan database, ada biayanya: network latency, disk I/O, tekanan di connection pool, waktu eksekusi query. Kalau ada ribuan request per detik, biaya itu cepat banget menumpuk. Caching menaruh storage layer yang lebih cepat (biasanya in memory, kayak Redis atau Memcached) di antara aplikasi dan database, supaya kamu bisa menyajikan data tanpa harus lewat jalur yang lambat setiap saat.
Aplikasinya butuh aturan soal gimana cache dan database berinteraksi.
Lima Strategi Utama
Strategi utamanya adalah:
| Strategi | Read Path | Write Path | Paling Cocok Untuk |
|---|---|---|---|
| Cache-Aside | App mengecek cache, fallback ke DB | App menulis ke DB, meng-invalidate cache | Kebutuhan umum, read-heavy |
| Read-Through | Cache mengambil dari DB saat miss | N/A (pattern khusus read) | Menyederhanakan logika read |
| Write-Through | Dipasangkan dengan read-through | Cache menulis ke DB secara synchronous | Butuh strong consistency |
| Write-Behind | Dipasangkan dengan read-through | Cache menulis ke DB secara asynchronous | Write throughput tinggi |
| Write-Around | Cache-aside untuk read | App menulis langsung ke DB, melewati cache | Write-heavy, jarang dibaca ulang |
1. Cache Aside (Lazy Loading)
Dengan cache aside, aplikasi yang mengatur pembacaan cache, pengisian cache, dan invalidation.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt Cache Hit
Cache-->>App: Return data
else Cache Miss
Cache-->>App: null
App->>DB: SELECT * FROM table
DB-->>App: Return data
App->>Cache: SET key = data
end
Cara kerjanya:
- Aplikasi mengecek cache lebih dulu
- Kalau datanya ada (cache hit), kembalikan data itu
- Kalau nggak ada (cache miss), ambil dari database
- Simpan hasilnya di cache untuk berikutnya
Saat write, aplikasi menulis langsung ke database, lalu meng-invalidate entry cache-nya atau meng-update-nya.
// Cache-Aside: Read
async function getUser(userId: string): Promise<User> {
// Step 1: Check cache
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Step 2: Cache miss, fetch from DB
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
// Step 3: Populate cache for next time
await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
return user;
}
// Cache-Aside: Write
async function updateUser(userId: string, data: Partial<User>): Promise<void> {
// Write to DB first
await db.query("UPDATE users SET name = $1 WHERE id = $2", [
data.name,
userId,
]);
// Invalidate cache (next read will repopulate)
await redis.del(`user:${userId}`);
}
Kelebihan:
- Gampang diimplementasikan
- Cuma meng-cache data yang diminta (nggak ada memory yang terbuang)
- Aplikasi punya kontrol penuh atas logika cache
- Kalau cache gagal, aplikasinya nggak rusak (cuma jadi lebih lambat)
Kekurangan:
- Request pertama untuk key apa pun selalu cache miss (cold start)
- Ada risiko stale data kalau cache nggak di-invalidate dengan benar saat write
- Kode aplikasi harus mengurus cache dan database sekaligus
Kapan dipakai: workload yang read heavy, di mana kamu bisa menoleransi cache miss sesekali. Pakai ini sebagai titik awal default.
2. Read Through
Mirip dengan cache aside, tapi cache itu sendiri yang bertanggung jawab mengambil data dari database saat terjadi miss. Aplikasi cuma ngobrol dengan cache, nggak pernah langsung ke database untuk read.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt Cache Hit
Cache-->>App: Return data
else Cache Miss
Cache->>DB: Fetch data
DB-->>Cache: Return data
Cache->>Cache: Store data
Cache-->>App: Return data
end
Cara kerjanya:
- Aplikasi meminta data ke cache
- Kalau udah di-cache, langsung kembalikan
- Kalau belum, cache (bukan aplikasi) yang mengambil data dari database, menyimpannya, lalu mengembalikannya
// Read-Through: The cache layer handles DB fetching
class ReadThroughCache {
private redis: Redis;
private db: Database;
async get(key: string, loader: () => Promise<unknown>): Promise<unknown> {
const cached = await this.redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Cache handles the DB fetch
const data = await loader();
await this.redis.set(key, JSON.stringify(data), "EX", 3600);
return data;
}
}
// Application code becomes simpler
const cache = new ReadThroughCache(redis, db);
const user = await cache.get(`user:${userId}`, () =>
db.query("SELECT * FROM users WHERE id = $1", [userId]),
);
Kelebihan:
- Kode aplikasi lebih bersih (nggak ada logika pengelolaan cache yang tersebar di mana-mana)
- Perilaku caching konsisten di seluruh aplikasi
Kekurangan:
- Request pertama tetap cache miss
- Coupling antara cache dan sumber data jadi lebih erat
- Layer cache jadi lebih kompleks
Kapan dipakai: saat kamu mau memusatkan logika caching, bukan mengulang pattern cache aside di mana-mana. Ini cache aside dengan separation of concerns yang lebih baik.
3. Write Through
Cache juga berada di depan database untuk write. Setiap write masuk ke cache lebih dulu, lalu cache menulisnya ke database secara synchronous sebelum memberi konfirmasi.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: Write data
Cache->>DB: Write data (sync)
DB-->>Cache: Confirm
Cache-->>App: Confirm
Cara kerjanya:
- Aplikasi menulis ke cache
- Cache langsung menulis data yang sama ke database
- Cache baru mengonfirmasi write setelah database mengonfirmasi
// Write-Through: Cache handles both cache + DB write
class WriteThroughCache {
async set(
key: string,
value: unknown,
dbWriter: () => Promise<void>,
): Promise<void> {
// Write to DB first (synchronous)
await dbWriter();
// Then update cache
await this.redis.set(key, JSON.stringify(value), "EX", 3600);
}
}
// Usage
await cache.set(`user:${userId}`, updatedUser, () =>
db.query("UPDATE users SET name = $1 WHERE id = $2", [
updatedUser.name,
userId,
]),
);
Kelebihan:
- Write yang berhasil meng-update kedua storage, asalkan semua writer lewat jalur ini dan implementasinya menangani partial failure
- Write yang terkoordinasi bisa mengurangi stale read, tapi concurrency dan penanganan failure tetap butuh consistency policy yang terdefinisi
- Read berikutnya bisa memakai nilai yang di-cache, kecuali entry-nya expired atau di-evict oleh cache
Kekurangan:
- Write latency lebih tinggi (setiap write menunggu cache DAN database)
- Kurang ideal untuk workload yang write heavy
Kapan dipakai: saat konsistensi data itu krusial dan kamu bisa menerima write latency yang lebih tinggi. Pasangkan dengan read through untuk solusi caching yang lengkap.
4. Write Behind (Write Back)
Write behind langsung menyerap write dan mem-flush-nya ke database secara asynchronous dalam batch. Aplikasi dapat konfirmasi write yang cepat tanpa menunggu database.
sequenceDiagram
participant App
participant Cache
participant Queue
participant DB
App->>Cache: Write data
Cache-->>App: Confirm (immediate)
Cache->>Queue: Queue for DB write
Queue->>DB: Batch write (async)
DB-->>Queue: Confirm
Cara kerjanya:
- Aplikasi menulis ke cache
- Cache langsung mengonfirmasi (cepat!)
- Di background, cache memasukkan write tadi ke antrean
- Sebuah proses background mem-flush write yang mengantre ke database dalam batch
// Write-Behind: Buffer writes and flush in batches
class WriteBehindCache {
private writeBuffer: Map<string, { value: unknown; timestamp: number }> =
new Map();
private flushInterval: NodeJS.Timer;
constructor(
private redis: Redis,
private db: Database,
) {
// Flush every 5 seconds
this.flushInterval = setInterval(() => this.flush(), 5000);
}
async set(key: string, value: unknown): Promise<void> {
// Write to cache immediately
await this.redis.set(key, JSON.stringify(value), "EX", 3600);
// Buffer for async DB write
this.writeBuffer.set(key, { value, timestamp: Date.now() });
}
private async flush(): Promise<void> {
if (this.writeBuffer.size === 0) return;
const entries = Array.from(this.writeBuffer.entries());
this.writeBuffer.clear();
// Batch write to database
const batch = entries.map(([key, { value }]) => this.writeToDB(key, value));
try {
await Promise.all(batch);
} catch (error) {
// Re-queue failed writes
entries.forEach(([key, entry]) => {
this.writeBuffer.set(key, entry);
});
console.error("Write-behind flush failed, entries re-queued:", error);
}
}
private async writeToDB(key: string, value: unknown): Promise<void> {
// Extract entity type and ID from key pattern
const [entity, id] = key.split(":");
await this.db.query(
`INSERT INTO ${entity} VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET data = $2`,
[id, value],
);
}
}
Kelebihan:
- Write latency lebih rendah (aplikasi nggak menunggu DB)
- Batch write bisa mengurangi beban database
- Berguna untuk menyerap lonjakan write
Kekurangan:
- Risiko kehilangan data: kalau cache crash sebelum flush, write yang ada di buffer hilang
- Eventual consistency (cache dan DB bisa sementara nggak sinkron)
- Lebih rumit untuk diimplementasikan dengan benar (error handling, retry logic, ordering)
Kapan dipakai: skenario dengan write throughput tinggi, di mana kamu bisa menoleransi celah kecil untuk potensi kehilangan data. Contohnya logging, metrics, analytics, atau situasi apa pun di mana jumlah write jauh melebihi read.
Alternatif yang mungkin untuk batch ingestion
Di Bank BTPN, kami punya sistem rekonsiliasi kartu debit yang menerima data dari 16 sumber berbeda. Tiap sumber berisi 30.000 sampai 600.000 record. Sistemnya harus memproses file-file ini setiap hari untuk operasional back office.
Waktu itu, kami memproses record kurang lebih secara synchronous: baca satu batch dari file SFTP, validasi, tulis ke database, ulangi. Database jadi bottleneck saat kami meng-import data dari beberapa sumber sekaligus.
Salah satu alternatif yang mungkin adalah mem-buffer record sebelum ditulis ke database:
- Mem-buffer record yang masuk di Redis selagi record itu di-parse dari file SFTP
- Mem-flush ke database dalam batch yang dioptimalkan (bulk insert, bukan write satu per satu)
- Memisahkan ingestion dari write ke database supaya ke-16 sumber bisa mengirim pekerjaan secara independen, selama masih dalam kapasitas buffer
Akhirnya kami memakai Kafka dan consumer group untuk mengatasi masalah throughput itu. Alternatif cache tadi bakal butuh buffering yang durable dan proses recovery untuk record rekonsiliasi yang udah diterima. Alternatif itu juga butuh workload test dulu sebelum kami bisa membandingkan kapasitasnya.
5. Write Around
Aplikasi menulis langsung ke database, melewati cache. Cache cuma diisi saat read (lewat cache aside atau read through).
sequenceDiagram
participant App
participant Cache
participant DB
App->>DB: Write data (bypass cache)
DB-->>App: Confirm
Note over Cache: Cache not updated
App->>Cache: Later: GET key (cache miss)
Cache-->>App: null
App->>DB: Fetch data
DB-->>App: Return data
App->>Cache: Populate cache
Cara kerjanya:
- Aplikasi menulis langsung ke database
- Jangan isi cache saat write. Invalidate entry yang udah ada kalau read path-nya butuh data yang fresh.
- Saat cache miss, baca nilai terkini dari database.
// Write-Around: Write to DB only, let cache populate on next read
async function createLogEntry(entry: LogEntry): Promise<void> {
// Write directly to DB, skip cache entirely
await db.query("INSERT INTO logs VALUES ($1, $2, $3)", [
entry.id,
entry.message,
entry.timestamp,
]);
// Cache is not touched. If someone reads this entry later,
// cache-aside will populate it.
}
Kelebihan:
- Cache nggak tercemar data yang mungkin nggak pernah dibaca
- Write path-nya sederhana
- Bagus untuk data yang write heavy dan jarang langsung di-query
Kekurangan:
- Read setelah write mungkin butuh request ke database kalau belum ada entry cache yang valid
- Nggak cocok kalau kamu sering read setelah write
Kapan dipakai: saat data yang ditulis jarang langsung dibaca lagi. Audit log, record historis, event stream. Kamu nggak mau cache kamu penuh dengan entry log yang nggak dilihat siapa pun.
Memilih Strategi yang Tepat
Nggak ada jawaban yang universal. Semuanya tergantung rasio read/write, kebutuhan consistency, dan seberapa toleran kamu terhadap kompleksitas.
Tabel berikut mengelompokkan use case, tapi nama pattern saja nggak menjamin consistency. Tentukan batas staleness yang diizinkan, perilaku invalidation, dan penanganan concurrent write. Panduan cache aside dari Azure menjelaskan keterbatasan ini:
| Skenario | Contoh | Strategi Caching |
|---|---|---|
| Strong Consistency | Sistem keuangan, manajemen stok | Cache-Aside, Read-Through |
| Workload Read-Heavy | Berita populer di website | Cache-Aside, Read-Through |
| Workload Write-Heavy | Sistem dengan update yang sering | Write-Around, Write-Behind |
| Kesegaran Data | Platform trading saham | Write-Through, Read-Through |
| Data Statis | File CSS, gambar, konten CDN | Cache-Aside |
| Data yang Sering Diakses | Update stok e-commerce | Write-Through |
Flowchart keputusannya seperti ini:
graph TD
A[What's your workload?] --> B{Read-heavy?}
B -->|Yes| C{Need simple code?}
C -->|Yes| D[Cache-Aside]
C -->|No| E[Read-Through]
B -->|No| F{Write-heavy?}
F -->|Yes| G{Tolerate data loss risk?}
G -->|Yes| H[Write-Behind]
G -->|No| I{Data rarely re-read?}
I -->|Yes| J[Write-Around]
I -->|No| K[Write-Through]
F -->|Balanced| L{Consistency critical?}
L -->|Yes| M[Write-Through + Read-Through]
L -->|No| N[Cache-Aside]
Pegangan umum:
- Mulai dengan cache aside. Ini pattern yang paling sederhana dan paling banyak dipahami. Pindah ke yang lain cuma kalau kamu ketemu masalah yang spesifik.
- Kalau kamu butuh strong consistency: tentukan atomicity, concurrent access, dan penanganan failure untuk keseluruhan read path dan write path. Pattern cache saja nggak cukup.
- Kalau kamu butuh write throughput tinggi: write behind. Tapi pahami risiko kehilangan datanya dan bangun mekanisme retry/recovery.
- Kalau sebagian besar write nggak pernah dibaca ulang: write around. Jangan cemari cache kamu dengan data yang nggak diminta siapa pun.
- Kamu bisa menggabungkan strategi. Pakai cache aside untuk sebagian besar hal, write behind untuk ingestion dengan throughput tinggi, dan write around untuk audit log. Nggak ada aturan yang bilang seluruh sistem kamu harus memakai satu pattern.
Kesalahan yang Sering Terjadi
Saya mengecek kasus kegagalan ini setiap kali memilih strategi cache:
1. Tentukan invalidation. Set time to live (TTL) yang sesuai dengan batas staleness yang diizinkan. Expiration membatasi berapa lama stale data bertahan, tapi nggak menjamin consistency seketika.
2. Thundering herd saat cache miss. Saat sebuah cache key yang populer expired, ratusan request bisa miss cache secara bersamaan dan semuanya menghantam database. Solusinya: cache warming, request coalescing (cuma satu request yang mengambil data, yang lain menunggu), atau staggered TTL.
3. Overhead serialization. JSON.stringify/parse di setiap operasi cache lama-lama terasa. Pertimbangkan MessagePack atau Protocol Buffers untuk object besar yang sering diakses.
4. Pengelolaan ukuran cache. Cache tanpa batas bakal menghabiskan memory kamu. Pakai LRU eviction policy, set batas max memory, dan pantau hit rate.
Penutup
Pilih strategi berdasarkan kebutuhan read dan write. Lalu tes cache miss, concurrent write, expiration, dan recovery. Kasus-kasus inilah yang menentukan apakah cache-nya memenuhi consistency dan latency yang dibutuhkan.
Kontak: [email protected].

Memuat komentar...