Pub/Sub (publish/subscribe) adalah pola kirim pesan: pengirim mem-publish pesan ke sebuah channel (nama), tanpa tahu siapa yang mendengar, dan setiap yang sedang subscribe ke channel itu menerimanya saat itu juga. Redis bisa jadi perantaranya (message broker) antar proses atau server. Bayangin pengeras suara di pasar: petugas mengumumkan "cucian #12 siap diambil" di saluran pengumuman, dan semua yang ada di pasar mendengar. Yang sedang pulang gak dengar apa-apa, karena pengumuman gak direkam. Kalau perlu rekaman, pakai buku catatan yang bisa dibaca belakangan: itu Redis Streams.
🧪 Tebakan cepat: Laundry Bu Rini makin ramai, jadi server realtime-nya sekarang dua proses Node: A di port 3000 dan B di port 3001, di belakang load balancer. Tablet Mas Eko tersambung ke A, HP Pak Darto kebetulan ke B. Proses A menjalankan
io.to('order:12').emit('order:status', ...). Sampaikah kabarnya ke HP Pak Darto? Jawabannya ada di bagian "Satu proses cuma kenal socket miliknya".
Satu proses cuma kenal socket miliknya
Room di Socket.IO (pelajaran Rooms) disimpan di memori proses, sebagai Map dari nama room ke kumpulan socket. Proses A gak punya daftar socket milik B, dan gak ada jalur antara keduanya.
Dicoba dengan socket.io 4.8.4, dua server di dua port, masing-masing punya satu client di room order:12: emit dari A cuma sampai ke client yang tersambung ke A. HP Pak Darto di B gak menerima apa pun, dan gak ada error. Itu jawaban tebakannya. Dengan satu proses semuanya jalan, jadi masalah ini baru muncul saat aplikasinya dibesarkan.
Obatnya: setiap proses mengirim kabar ke perantara, dan setiap proses mendengarkan perantara itu lalu meneruskan ke socket miliknya sendiri.
PUBLISH dan SUBSCRIBE di Redis
Dua terminal redis-cli (Redis 7.2.7). Terminal 1 berlangganan:
127.0.0.1:6379> SUBSCRIBE papan
1) "subscribe"
2) "papan"
3) (integer) 1
Terminal 2 mengirim:
127.0.0.1:6379> PUBLISH papan '{"id":12,"status":"Siap diambil"}'
(integer) 1
Di terminal 1 muncul:
1) "message"
2) "papan"
3) "{\"id\":12,\"status\":\"Siap diambil\"}"
PUBLISHmembalas jumlah subscriber yang menerima:(integer) 1.- Subscriber-nya dimatikan, lalu
PUBLISH papan ...lagi: balasannya(integer) 0. Saat ia subscribe lagi, pesan itu gak pernah datang. Redis gak menyimpan apa pun: fire-and-forget. Yang sedang mati melewatkan pesannya. - Koneksi yang sedang
SUBSCRIBE(dengan protokol RESP2, bawaanredis-cli) cuma boleh menjalankan perintah langganan.GETditolak:ERR Can't execute 'get': only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this context. Karena itu kode Node memakai dua koneksi.
import { createClient } from 'redis' // redis 6.3.0
const pub = createClient({ url: 'redis://localhost:6379' })
const sub = pub.duplicate() // koneksi kedua, khusus mendengar
await pub.connect()
await sub.connect()
// setiap proses (A dan B) mendengar, lalu meneruskan ke socket miliknya
await sub.subscribe('papan', (pesan) => {
const d = JSON.parse(pesan)
io.to(`order:${d.id}`).emit('order:status', d)
})
// proses mana pun yang menerima perubahan dari Mas Eko
await pub.publish('papan', JSON.stringify({ id: 12, status: 'Siap diambil' }))
Channel dan pola
Channel cuma nama, dibuat saat dipakai. PSUBSCRIBE berlangganan ke pola. Terminal 1 menjalankan PSUBSCRIBE order:*, terminal 2 menjalankan PUBLISH order:12 '{"status":"Siap diambil"}' (balasannya (integer) 1). Yang diterima subscriber pola punya satu baris tambahan, polanya:
1) "pmessage"
2) "order:*"
3) "order:12"
4) "{\"status\":\"Siap diambil\"}"
Di Node: await sub.pSubscribe('order:*', (pesan, channel) => ...). Setiap PUBLISH dicocokkan ke semua pola yang terdaftar, jadi pakai pola seperlunya.
Kalau perlu memutar ulang: Redis Streams
Redis Streams adalah daftar pesan yang disimpan di Redis, masing-masing dengan id <milidetik>-<urutan>. Pembaca bisa mulai dari id mana pun, jadi yang tadi mati bisa melanjutkan dari id terakhir yang ia baca.
127.0.0.1:6379> XADD papan:log * id 12 status "Siap diambil"
"1791686460000-0"
127.0.0.1:6379> XREAD COUNT 10 STREAMS papan:log 0
1) 1) "papan:log"
2) 1) 1) "1791686460000-0"
2) 1) "id"
2) "12"
3) "status"
4) "Siap diambil"
XADD papan:log * ...: tambah pesan,*artinya Redis yang membuat id.MAXLEN ~ 1000membatasi panjangnya.XREAD ... STREAMS papan:log 0: baca dari awal. Ganti0dengan id terakhir yang sudah dibaca untuk melanjutkan.BLOCK 5000menunggu pesan baru paling lama 5 detik.- Consumer group membagi pekerjaan:
XGROUP CREATE papan:log notifikasi 0, laluXREADGROUP GROUP notifikasi worker-1 COUNT 10 STREAMS papan:log >. Setiap pesan diberikan ke satu anggota grup dan tercatat pending (XPENDING) sampai di-XACK. Worker yang mati sebelumXACKgak menghilangkan pesannya.
Pub/Sub untuk "siapa pun yang sedang mendengar, sekarang". Streams untuk "jangan sampai ada yang terlewat".
Socket.IO di banyak proses: Redis adapter
Gak perlu menulis jembatan sendiri. Adapter Socket.IO adalah bagian yang menyimpan room dan menyiarkan emit; adapter Redis meneruskan setiap emit lewat Redis.
import { createClient } from 'redis'
import { createAdapter } from '@socket.io/redis-adapter' // 8.3.0
const pub = createClient({ url: 'redis://localhost:6379' })
const sub = pub.duplicate()
await Promise.all([pub.connect(), sub.connect()])
io.adapter(createAdapter(pub, sub))
io.to('order:12').emit('order:status', { id: 12, status: 'Siap diambil' }) // sampai ke A dan B
- Dicoba dengan percobaan dua proses tadi: sekarang HP Pak Darto di B ikut menerima. Adapter mem-publish ke channel
socket.io#/#order:12#, dan proses lain mendengarkannya lewat polasocket.io#/#*. - Adapter ini memakai Pub/Sub, jadi gak ada rekaman. Dicoba: proses B di-restart, A emit ke
order:12selama B mati, HP Pak Darto tersambung lagi: kabarnya hilang. Adapter ini juga gak mendukung connection state recovery (di kodenya,restoreSessiongak diimplementasikan). @socket.io/redis-streams-adapter(0.3.1) menulis ke streamsocket.io(bawaanmaxLen10.000) dan mendukung recovery:new Server(3000, { adapter: createAdapter(redis), connectionStateRecovery: {} }), dengancreateAdapterdari paket itu dan satu klien Redis. Dicoba: HP Pak Darto tersambung lagi membawa offset terakhirnya, server membaca stream dari offset itu, kabar yang terlewat sampai, dansocket.recoveredbernilaitrue.
⚠️ Jebakan yang sering ketemu
- Menguji dengan satu proses. Semua jalan di laptop, lalu di produksi dengan dua proses separuh client gak dapat kabar. Pasang adapter sejak awal.
- Mengira Pub/Sub menyimpan pesan. Subscriber yang mati, restart, atau telat subscribe melewatkannya. Butuh rekaman: Streams.
- Satu koneksi untuk publish dan subscribe. Koneksi yang sedang subscribe menolak perintah lain. Pakai
duplicate(). - Stream tanpa batas. Tanpa
MAXLEN, stream terus tumbuh di memori Redis.
TL;DR: Room Socket.IO hidup di memori satu proses, jadi emit dari A gak sampai ke socket di B. Redis Pub/Sub jadi perantara antar proses: PUBLISH membalas jumlah penerima, SUBSCRIBE/PSUBSCRIBE (pola) mendengar, dan semuanya fire-and-forget: yang mati melewatkan pesan. Redis Streams (XADD, XREAD dari id terakhir, consumer group dengan XREADGROUP dan XACK) menyimpan pesan untuk diputar ulang. @socket.io/redis-adapter 8.3.0 menyambungkan semua proses lewat Pub/Sub (tanpa recovery); @socket.io/redis-streams-adapter 0.3.1 lewat stream, dengan connection state recovery.