Kembali ke jurnalCATATAN FAJAR
Software Engineering8 menit baca

Strategi Caching: Dari Cache-Aside sampai Write-Behind

Membandingkan pattern cache dari read path, write path, perilaku saat gagal, dan risiko stale data.

Di artikel ini 11 bagian

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:

StrategiRead PathWrite PathPaling Cocok Untuk
Cache-AsideApp mengecek cache, fallback ke DBApp menulis ke DB, meng-invalidate cacheKebutuhan umum, read-heavy
Read-ThroughCache mengambil dari DB saat missN/A (pattern khusus read)Menyederhanakan logika read
Write-ThroughDipasangkan dengan read-throughCache menulis ke DB secara synchronousButuh strong consistency
Write-BehindDipasangkan dengan read-throughCache menulis ke DB secara asynchronousWrite throughput tinggi
Write-AroundCache-aside untuk readApp menulis langsung ke DB, melewati cacheWrite-heavy, jarang dibaca ulang

1. Cache Aside (Lazy Loading)

Dengan cache aside, aplikasi yang mengatur pembacaan cache, pengisian cache, dan invalidation.

mermaid
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:

  1. Aplikasi mengecek cache lebih dulu
  2. Kalau datanya ada (cache hit), kembalikan data itu
  3. Kalau nggak ada (cache miss), ambil dari database
  4. Simpan hasilnya di cache untuk berikutnya

Saat write, aplikasi menulis langsung ke database, lalu meng-invalidate entry cache-nya atau meng-update-nya.

typescript
// 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.

mermaid
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:

  1. Aplikasi meminta data ke cache
  2. Kalau udah di-cache, langsung kembalikan
  3. Kalau belum, cache (bukan aplikasi) yang mengambil data dari database, menyimpannya, lalu mengembalikannya
typescript
// 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.

mermaid
sequenceDiagram
    participant App
    participant Cache
    participant DB
    App->>Cache: Write data
    Cache->>DB: Write data (sync)
    DB-->>Cache: Confirm
    Cache-->>App: Confirm

Cara kerjanya:

  1. Aplikasi menulis ke cache
  2. Cache langsung menulis data yang sama ke database
  3. Cache baru mengonfirmasi write setelah database mengonfirmasi
typescript
// 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.

mermaid
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:

  1. Aplikasi menulis ke cache
  2. Cache langsung mengonfirmasi (cepat!)
  3. Di background, cache memasukkan write tadi ke antrean
  4. Sebuah proses background mem-flush write yang mengantre ke database dalam batch
typescript
// 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:

  1. Mem-buffer record yang masuk di Redis selagi record itu di-parse dari file SFTP
  2. Mem-flush ke database dalam batch yang dioptimalkan (bulk insert, bukan write satu per satu)
  3. 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).

mermaid
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:

  1. Aplikasi menulis langsung ke database
  2. Jangan isi cache saat write. Invalidate entry yang udah ada kalau read path-nya butuh data yang fresh.
  3. Saat cache miss, baca nilai terkini dari database.
typescript
// 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:

SkenarioContohStrategi Caching
Strong ConsistencySistem keuangan, manajemen stokCache-Aside, Read-Through
Workload Read-HeavyBerita populer di websiteCache-Aside, Read-Through
Workload Write-HeavySistem dengan update yang seringWrite-Around, Write-Behind
Kesegaran DataPlatform trading sahamWrite-Through, Read-Through
Data StatisFile CSS, gambar, konten CDNCache-Aside
Data yang Sering DiaksesUpdate stok e-commerceWrite-Through

Flowchart keputusannya seperti ini:

mermaid
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].

TOPIK

MAKASIH UDAH BACA

Gimana menurutmu?

Reaksi atau obrolan, dua-duanya selalu ditunggu.

Memuat reaksi…

Bagikan

Memuat komentar...

LANJUT JELAJAH

Satu pikiran bawa ke pikiran lain.

Semua tulisan
Kembali ke semua tulisanSatu catatan, pelan-pelan.