Toko kini membaca data sungguhan dari PostgreSQL, tetapi setiap kunjungan pelanggan menempuh jalur yang sama mahalnya: proxy, backend, koneksi database, kueri, jawaban. Siti yang mengamati halaman depan selama satu jam sore menemukan pola yang mengganggu — daftar produk yang sama persis diminta ratusan kali, dan setiap permintaan menagih satu kueri penuh ke database untuk data yang tidak berubah sejak pagi. Chapter ini memasang service kelima toko — cache Redis di backend-net — beserta pola bacaan yang menjalaninya, cache-aside, lengkap dengan disiplin invalidasi dan ukuran rasio hit. Kemudian kita meminjam benda yang sama untuk peran kedua yang tampak serupa padahal berlawanan: antrian, yang tidak mempercepat pembacaan melainkan memutus pekerjaan lambat dari jalur permintaan — dan menutup dengan kandidat naik kelasnya, RabbitMQ.

Kueri yang Sama, Berulang Kali #

Angka-angka berikut ilustratif untuk membandingkan besaran, bukan hasil pengukuran mesin kita, tetapi rasio di antaranya bersifat umum:

OperasiTempat kerjaLatensi kira-kira (ilustratif)
GET produk:1 di Redismemori container cachedi bawah 1 ms
SELECT ... WHERE id = 1 di PostgreSQLkoneksi, parser, disk, perakit barisbelasan hingga puluhan ms

Selisih satu sampai dua besaran itu adalah bahan bakar cache. Data yang dibaca seratus kali dan berubah sekali sehari hampir seluruhnya menempuh jalur murah pertama bila kita menyimpan salinannya di memori — dan satu-satunya yang tetap menempuh jalur mahal adalah permintaan pertama setelah data berubah.

Service cache di compose.yaml #

Redis hadir sebagai penghuni baru backend-net, dan backend diberi alamatnya lewat environment — dua perubahan yang menempel pada pola yang sudah tiga kali kita pakai di section ini:

yaml
  cache:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    networks:
      - backend-net

  backend:
    build: ./api
    image: toko-api:dev
    environment:
      DATABASE_URL: postgres://toko:rahasia123@db:5432/toko
      REDIS_URL: redis://cache:6379
    networks:
      - frontend-net
      - backend-net
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

Mari kita telaah empat keputusan pada cuplikan tersebut:

  1. image: redis:7-alpine — varian stabil yang sama dengan catatan riset Course ini; Redis 8 tersedia, tetapi 7-alpine cukup dan lebih teruji untuk kebutuhan kita.
  2. command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru — menimpa CMD image (pola Chapter Command Override) dengan dua batas: maxmemory 128mb membatasi memori aplikasi Redis untuk datanya — perlu digarisbawahi bahwa ini bukan batas cgroup container, melainkan batas internal yang dijaga Redis sendiri — dan allkeys-lru menyuruh Redis membuang kunci yang paling lama tidak dipakai begitu batas tercapai. Kombinasi ini adalah jati diri cache: boleh penuh, boleh kehilangan isi, tidak boleh membengkak.
  3. healthcheck dengan redis-cli ping — probe paling jujur untuk Redis: bertanya ke server sungguhan dan menuntut jawaban PONG; gerbang service_healthy backend kini menjaga dua service sekaligus, db dan cache.
  4. REDIS_URL: redis://cache:6379 — alamat koneksi memakai nama service dan port internal, sepenuhnya seiman dengan DATABASE_URL; tanpa ports, cache tidak pernah terpapar ke host.

Stack menyala dengan backend yang diciptakan ulang dan cache yang melewati masa uji kesehatannya:

bash
$ docker compose up -d

Output yang Dihasilkan (dipotong):

text
[+] Running 7/7
 ✔ Network toko_frontend-net    Running    0.0s
 ✔ Network toko_backend-net     Running    0.0s
 ✔ Container toko-db-1          Healthy    0.0s
 ✔ Container toko-cache-1       Healthy    3.2s
 ✔ Container toko-backend-1     Recreated  0.3s
 ✔ Container toko-backend-1     Started    0.6s
 ...

Jawaban PONG dari dalam container membuktikan hidupnya secara langsung:

bash
$ docker compose exec cache redis-cli ping

Output yang Dihasilkan:

text
PONG

Pola Cache-Aside #

Cache tidak memasang dirinya sendiri di jalur aplikasi — aplikasi yang harus menggunakannya secara sadar, dan pola paling umum bernama cache-aside: aplikasi mencoba membaca dari cache, hanya turun ke database ketika meleset, lalu menaruh hasilnya kembali ke cache untuk yang berikutnya. Wujudnya pada backend toko — pseudo-code yang memakai klien Redis apa pun di Node.js:

js
async function ambilProduk(id) {
  const kunci = `produk:${id}`;

  const tersimpan = await redis.get(kunci);
  if (tersimpan !== null) {
    return JSON.parse(tersimpan);
  }

  const hasil = await pool.query(
    "SELECT id, nama, harga FROM produk WHERE id = $1",
    [id]
  );
  const produk = hasil.rows[0];

  await redis.setex(kunci, 300, JSON.stringify(produk));
  return produk;
}

Mari kita telaah alur pada pseudo-code tersebut:

  1. redis.get(kunci) lebih dulu — setiap pembacaan menanyakan cache sebelum database; kunci produk:1 sengaja dibangun dari nama tabel dan primary key agar mudah dirunut saat men-debug.
  2. if (tersimpan !== null) — jalur hit — jawaban kembali dari memori dalam hitungan sub-milidetik, tanpa menyentuh database sama sekali; inilah baris yang menanggung ratusan permintaan berulang Siti.
  3. Jalur miss turun ke pool.query — kegagalan menemukan kunci bukan galat melainkan undangan bekerja: baca dari sumber kebenaran.
  4. redis.setex(kunci, 300, ...) — hasil ditaruh kembali dengan masa hidup 300 detik; TTL inilah jaring pengaman utama cache — data basi paling lama berumur lima menit walau invalidasi kita lupa dijalankan.

Invalidasi adalah separuh kejujuran pola ini. Begitu harga sebuah produk berubah, kuncinya harus dibuang secara eksplisit, bukan menunggu TTL — lima menit menjual harga lama tetap lima menit terlalu lama untuk toko:

js
await redis.del(`produk:${id}`);

Satu DEL untuk satu kunci yang menjadi sumbernya — bukan FLUSHDB yang mengosongkan seluruh cache dan mengundang banjir permintaan miss serentak ke database.

Pembukuan sore pertama toko terbaca langsung dari dalam container cache:

bash
$ docker compose exec cache redis-cli INFO stats

Output yang Dihasilkan (dipotong):

text
keyspace_hits:214
keyspace_misses:9
...

214 jawaban dari memori berbanding 9 turunan ke database pada sore pertama toko — rasio yang sehat untuk data produk.

Antrian untuk Pekerjaan yang Lambat #

Cache dan antrian sering disebut berdua karena sama-sama memakai penyimpanan cepat, tetapi keduanya menyelesaikan masalah yang berlawanan. Cache menyembunyikan pembacaan yang mahal; antrian menyembunyikan pekerjaan yang lambat. Contoh nyatanya di toko: pelanggan menyelesaikan checkout dan layak menerima jawaban seketika, sementara email struk pembayaran harus dikirim lewat SMTP yang bisa memakan belasan detik. Bila pengiriman email hidup di jalur checkout, pelanggan menunggu lama untuk hal yang bahkan tidak ia minta ditunggu — dan bila SMTP mati, checkout ikut gagal padahal pesanan sudah sah.

Keputusan desainnya disebut decoupling: endpoint checkout hanya mencatat pesanan dan menitipkan tugas ke antrian, lalu menjawab 201 seketika. Pengiriman email dipindahkan ke pekerja yang mengambil tugas dari antrian kapan pun ia sanggup. Redis sudah kita pegang, dan antrian paling ringan di atasnya cukup memakai satu struktur LIST — tanpa service baru sama sekali:

js
app.post("/checkout", async (req, res) => {
  const pesanan = await simpanPesanan(req.body);
  await redis.lpush(
    "antrian:email-checkout",
    JSON.stringify({ email: req.body.email, pesanan: pesanan.id })
  );
  res.sendStatus(201);
});
js
async function pekerjaEmail() {
  while (true) {
    const [, isi] = await redis.brpop("antrian:email-checkout", 0);
    const tugas = JSON.parse(isi);
    await kirimEmail(tugas.email, tugas.pesanan);
  }
}

Mari kita telaah dua potongan yang saling melengkapi tersebut:

  1. LPUSH di sisi produksi — endpoint checkout menyelipkan satu pesan JSON ke kepala list; operasi ini selesai dalam hitungan sub-milidetik, sehingga respon pelanggan tidak pernah menunggu SMTP.
  2. BRPOP dengan timeout 0 di sisi pekerja — pekerja memblokir menunggu ekor list, selamanya bila perlu; sepasang LPUSH-BRPOP menjadikan list sebuah antrian sungguhan — pesanan masuk dari satu ujung, dikerjakan dari ujung lain, urut dan satu per satu.
  3. Pekerja mati tidak memusnahkan tugas — pesan yang sudah masuk list tetap menunggu; begitu pekerja hidup kembali, BRPOP melanjutkan dari ekor yang tersisa.
  4. Beban puncak terserap — saat seribu checkout datang bersamaan, list menampung seribu tugas dan pekerja menghabiskannya dengan irama kotak masuk SMTP, bukan irama panik pengunjung.

Karakter antrian Redis ini jujur sebatas itu: bila container Redis mati sebelum pekerja selesai, pesan yang masih di list ikut lenyap — untuk email struk itu risiko yang bisa diterima, untuk pembayaran tidak.

RabbitMQ ketika Antrian Menjadi Serius #

Kebutuhan yang lebih berat — pengakuan pesan (acknowledgment), pesan yang gagal diantar ulang, dead-letter queue untuk tugas yang selalu gagal, routing satu pesan ke banyak konsumen, dan persistensi yang dijamin — adalah wilayah message broker sungguhan. RabbitMQ hadir di toko sebagai service opsional yang tidak menyala pada up biasa, memakai profiles — mekanisme yang akan dibedah tuntas pada Chapter Profiles:

yaml
services:
  rabbitmq:
    image: rabbitmq:4-management
    profiles:
      - queue
    ports:
      - "15672:15672"
    volumes:
      - rmq-data:/var/lib/rabbitmq
    networks:
      - backend-net

volumes:
  db-data:
  rmq-data:

Mari kita telaah empat baris kuncinya: image rabbitmq:4-management membawa UI manajemen bawaan; profiles: [queue] membuat service ini absen dari docker compose up sehari-hari dan hanya hadir bila profil queue diaktifkan; port host 15672 menerbitkan UI manajemen — satu-satunya alasan RabbitMQ boleh punya ports, karena UI-nya memang ditujukan dibuka dari browser; dan volume rmq-data menjaga definisi antrean serta pesan persisten tetap hidup dari down. Aplikasi yang memakainya cukup menyambung ke amqp://rabbitmq:5672 — port internal 5672, tanpa penerbitan, seperti layanan internal mana pun:

bash
$ docker compose --profile queue up -d

Output yang Dihasilkan:

text
[+] Running 2/2
 ✔ Volume toko_rmq-data       Created    0.4s
 ✔ Container toko-rabbitmq-1  Started    3.1s

Hanya dua entitas yang bergerak — volume baru dan container baru; seluruh service lain tidak tersentuh karena deklarasinya tidak berubah, persis karakter idempoten yang kita saksikan pada Chapter Port dan Network. UI manajemen kini terbuka di localhost:15672 dari browser mana pun di mesin host.

Cache, Queue, dan Database dalam Satu Tabel #

Ketiga penyimpanan itu kini berdiri berdampingan di backend-net, dan batas keputusan antar mereka sebaiknya dipegang sebagai tabel:

Aspekdb (PostgreSQL)cache (Redis)queue (LIST Redis / RabbitMQ)
Peransumber kebenaranmempercepat pembacaan berulangmemutus pekerjaan lambat dari respon
Isi khasdata transaksisalinan data panastugas yang menunggu dikerjakan
Boleh hilang?tidak bolehboleh — dibangun ulang dari dbsebagian — pesan hilang = pekerjaan hilang
Volumewajib (db-data)opsionaldisarankan (rmq-data untuk RabbitMQ)
Ukurantumbuh terusdibatasi maxmemorykecil dan terus mengalir

Baris ketiga adalah pemisah paling penting: cache dirancang untuk bisa musnah kapan saja, queue menampung janji yang belum ditebus, dan database menampung kebenaran yang tidak boleh hilang — tiga tingkat komitmen yang menuntut tiga tingkat perlakuan.

Toko kini lengkap lima service yang saling menyapa dengan nama. Tetapi seluruh percakapan itu bertumpu pada satu asumsi yang belum pernah kita uji secara sadar: bahwa nama db, cache, dan backend selalu menyelesaikan menjadi alamat yang benar, saat ini dan setiap kali container berganti. Chapter Service Discovery membongkar mesin di balik asumsi itu — kapan nama terdaftar, kapan dicabut, batas resolusi per network — lalu melengkapi aplikasi dengan satu kebiasaan terakhir yang membedakan stack yang tahan banting: retry dengan backoff eksponensial.

Referensi #