System Design: Chat Application — System Design

Mari desain sistem chat real-time seperti WhatsApp atau Slack. Ini contoh system design yang menggabungkan banyak konsep yang sudah kita pelajari. Step 1…

Merancang aplikasi chat seperti WhatsApp artinya merancang jalan sebuah pesan: dari HP pengirim, disimpan, lalu sampai ke setiap penerima secepat mungkin, termasuk yang sedang offline. Bayangin kantor pos di satu kompleks. Setiap surat dicatat dulu di buku besar dengan nomor urut. Surat lalu diantar ke rumah yang penghuninya ada. Rumah yang kosong dapat kertas "ada kiriman" di pintu, dan suratnya diantar begitu penghuninya pulang. Catat dulu, beri nomor, antar ke yang ada, kabari yang gak ada: itu seluruh desainnya.

🧪 Tebakan cepat: Budi mengirim satu pesan ke grup "Panitia Reuni" yang beranggotakan 5 orang, termasuk Budi. Dewi sedang offline. Berapa kali pesan itu ditulis ke database, dan berapa kali dikirim lewat WebSocket? Tebak dulu, jawabannya ada di bagian "Perjalanan satu pesan".

Requirements dan estimasi Ngobrol

Di lesson ini kita merancang Ngobrol, aplikasi chat. Ini desain gaya interview yang berdiri sendiri, jadi bukan TiketNada.

Functional Non-functional
Chat 1-on-1 dan grup Pesan sampai ke penerima yang online di bawah 200 ms
Pesan untuk yang offline sampai saat dia online Pesan yang sudah "terkirim" gak boleh hilang
Status terkirim, sampai, dibaca Urutan pesan di satu percakapan sama di semua HP
Status online dan "terakhir dilihat" Riwayat terbaru terbuka cepat

Angka Ngobrol (model contoh), dihitung seperti di lesson Apa itu System Design:

Yang dihitung Hitungan Hasil
Pesan per hari 10.000.000 pengguna × 40 pesan 400.000.000
Pesan per detik 400.000.000 ÷ 86.400 ≈ 4.630
Storage per hari 400.000.000 × 200 byte 80 GB
Storage per tahun 80 GB × 365 29,2 TB
Koneksi saat puncak 30% pengguna online bersamaan 3.000.000
Server WebSocket 3.000.000 ÷ 50.000 koneksi per server 60
Heartbeat presence 3.000.000 ÷ 30 detik 100.000 per detik

Baris terakhir sering dilupakan: cuma untuk menjaga status online, sistem menerima 100.000 tulisan per detik, jauh lebih banyak dari pesannya sendiri.

Kenapa WebSocket

HTTP biasa cuma bisa menjawab: server gak bisa mengirim pesan ke HP Rina kalau Rina gak bertanya duluan. Ada tiga cara supaya pesan baru sampai:

Chat butuh dua arah dengan latency sangat rendah. Contohnya indikator "Siti sedang mengetik...": event kecil yang dikirim berkali-kali per menit. Lewat WebSocket, event itu tinggal dikirim di koneksi yang sudah terbuka. Cara kerja WebSocket-nya dibahas di lesson WebSocket (track Node.js).

Karena koneksinya menetap, server chat itu stateful: dia memegang ribuan koneksi yang terbuka. Satu server menampung sekitar 50.000 koneksi di model ini, jadi butuh 60 server, dan pengirim dan penerima sering ada di server yang berbeda.

Perjalanan satu pesan

Grup Panitia Reuni: Budi dan Siti tersambung ke server ws-1; Rina, Andi, dan Dewi ke ws-2. Dewi offline. Budi mengirim "Rapat reuni Sabtu jam 7 malam":

Langkah Yang terjadi
1 ws-1 memberi pesan nomor urut seq 1 untuk percakapan ini
2 ws-1 menyimpan pesan ke message store: 1 tulisan
3 ws-1 mengirim ack ke Budi. Di HP Budi: "terkirim"
4 Siti di ws-1 yang sama: dikirim langsung lewat WebSocket-nya
5 Rina dan Andi di ws-2: ws-1 mem-publish pesan ke channel ws-2 di Redis Pub/Sub, lalu ws-2 mengirimnya ke mereka
6 Dewi offline: pesan dicatat "tertunda" untuk Dewi, dan push notification dikirim

Jawaban tebakannya: 1 tulisan ke database, 3 kiriman WebSocket (Siti, Rina, Andi), plus 1 push notification untuk Dewi. Pesannya disimpan sekali, lalu disebar ke banyak penerima. Penyebaran ini disebut fan-out. Redis Pub/Sub adalah fitur Redis untuk mengirim pesan ke semua yang berlangganan sebuah channel; di sini tiap server chat berlangganan channel-nya sendiri.

// Ngobrol: grup "Panitia Reuni" di dua server WebSocket (model contoh)
const serverOf = { Budi: "ws-1", Siti: "ws-1", Rina: "ws-2", Andi: "ws-2", Dewi: "ws-2" };
const online = new Set(["Budi", "Siti", "Rina", "Andi"]); // Dewi offline
const pesan = []; // message store, urut per seq
const tertunda = { Dewi: [] }; // seq yang belum sampai ke Dewi

function kirim(teks) {
  const seq = pesan.length + 1; // nomor urut per percakapan
  pesan.push({ seq, teks }); // simpan dulu, baru kirim
  console.log(`seq ${seq} tersimpan, ack ke Budi`);
  const perServer = {};
  for (const nama of Object.keys(serverOf)) {
    if (nama === "Budi") continue;
    if (!online.has(nama)) {
      tertunda[nama].push(seq);
      console.log(`${nama} offline: push notification`);
    } else {
      (perServer[serverOf[nama]] ??= []).push(nama);
    }
  }
  for (const [server, penerima] of Object.entries(perServer)) {
    const jalur = server === "ws-1" ? "langsung" : "lewat Redis Pub/Sub";
    console.log(`${server} ${jalur}: ${penerima.join(", ")}`);
  }
}

function sambung(nama) {
  online.add(nama);
  console.log(`${nama} online: kirim seq ${tertunda[nama].join(", ") || "(tidak ada)"}`);
  tertunda[nama] = [];
}

kirim("Rapat reuni Sabtu jam 7 malam");
sambung("Dewi");

Output-nya:

seq 1 tersimpan, ack ke Budi
Dewi offline: push notification
ws-1 langsung: Siti
ws-2 lewat Redis Pub/Sub: Rina, Andi
Dewi online: kirim seq 1

Urutan pesan

Satu koneksi TCP menjaga urutan, tapi pesan melewati beberapa jalur: Budi bisa mengirim dari HP dan laptop, dua pesan bisa ditangani dua proses berbeda, dan publish antarserver bisa di-retry. Jadi dua pesan yang dikirim beruntun bisa tiba terbalik di HP Rina: seq 2 dulu, baru seq 1.

Obatnya: HP menampilkan pesan menurut seq, bukan menurut waktu tiba. Jangan pakai jam HP pengirim, karena jam tiap perangkat gak pernah sama (lesson Kenapa Distributed System Sulit). Seq juga membuat celah ketahuan: kalau HP punya seq 5 lalu menerima seq 7, dia tahu seq 6 belum sampai dan memintanya ke server.

Offline, push notification, dan read receipt

Pesan untuk yang offline gak dibuang dan gak dikirim ulang terus-menerus. Langkahnya:

  1. Pesan tersimpan di database dulu (sudah terjadi di langkah 2).
  2. Server mengirim push notification lewat FCM (Android) atau APNs (iPhone), layanan Google dan Apple yang bisa membangunkan aplikasi yang tertutup.
  3. Saat Dewi online, HP-nya bilang "seq terakhir yang aku punya: 0", dan server mengirim seq 1 dan seterusnya.

Tanda centang di HP Budi adalah tiga status: terkirim (ack, tersimpan di server), sampai (sudah diterima HP penerima), dibaca (penerima membuka chat-nya). Status "sampai" dan "dibaca" cuma event kecil yang dikirim balik lewat WebSocket, misalnya { type: "read", upTo: 3 }: "aku sudah membaca sampai seq 3".

Presence: online atau terakhir dilihat

HP yang online mengirim heartbeat (ping kecil) tiap 30 detik. Server menyimpannya di Redis dengan TTL 60 detik, misalnya SET presence:dewi online EX 60. Selama ping datang, key-nya diperpanjang. Kalau HP hilang sinyal tanpa sempat menutup koneksi, key-nya kedaluwarsa sendiri paling lambat 60 detik kemudian, dan statusnya jadi "terakhir dilihat".

Menyimpan riwayat

Semua pesan disimpan permanen di database yang dipartisi per conversation_id dan diurutkan per seq, misalnya Cassandra/ScyllaDB (kuat untuk tulisan banyak) atau PostgreSQL yang dipartisi. Orang hampir selalu membaca pesan terbaru, jadi sekitar 50 pesan terakhir tiap percakapan di-cache di Redis: database untuk persistensi, cache untuk pesan terbaru yang sering dibuka. Foto dan video gak masuk database chat: disimpan di object storage dan dikirim lewat CDN.

🤔 Grup besar: fan-out on write atau on read

Fan-out yang tadi disebut fan-out on write: saat pesan ditulis, langsung disebar ke tiap anggota. Grup 1.000 orang berarti 1 tulisan dan 999 kiriman per pesan. Untuk channel 100.000 anggota, satu pesan jadi 99.999 kiriman, dan kebanyakan anggota bahkan gak membuka channel-nya. Di sana dipakai fan-out on read: pesan disimpan sekali, dan anggota mengambilnya saat membuka channel. Batasnya (misalnya 100 atau 1.000 anggota) adalah keputusan desain.

⚠️ Jebakan yang sering ketemu

TL;DR: Chat app: client dan server bicara lewat WebSocket, koneksi dua arah yang tetap terbuka (pas untuk pesan dan indikator mengetik; SSE cuma satu arah, polling boros). Satu pesan: server memberi seq per percakapan, menyimpannya sekali, ack ke pengirim ("terkirim"), lalu fan-out: langsung ke penerima di server yang sama, lewat Redis Pub/Sub ke server lain, dan push notification (FCM/APNs) ke yang offline, yang menerima semua pesan tertundanya saat online lagi. Tampilkan pesan menurut seq, bukan waktu tiba. Presence: heartbeat tiap 30 detik, TTL 60 detik di Redis. Riwayat: database dipartisi per percakapan + cache Redis untuk pesan terbaru. Ngobrol: 4.630 pesan per detik, 80 GB per hari, 3 juta koneksi di 60 server, 100.000 heartbeat per detik. Grup besar pakai fan-out on read.

Yang akan kamu pelajari