Pub/Sub Backplane
บทเรียนก่อนหน้าทิ้งคำระบุปัญหาที่ชัดเจนไว้ให้เรา: instance A มองไม่เห็น socket ที่ instance B ถือไว้ ดังนั้น broadcast บน A จึงไม่มีวันไปถึง client บน B ทางแก้คือเลิกพยายามไปถึง socket ของ instance อื่น โดยตรง และไปถึง instance อื่น แทน ให้ทุก instance มีสายที่แชร์ร่วมกันซึ่งทุกตัวฟังอยู่ และให้แต่ละ instance รับผิดชอบ client ภายในของตัวเอง
สายที่แชร์ร่วมกันนั้นคือ pub/sub backplane
รูปแบบ: publish ครั้งเดียว fan-out ทุกที่
หัวข้อที่มีชื่อว่า “รูปแบบ: publish ครั้งเดียว fan-out ทุกที่”flow นี้มีการเคลื่อนไหวเพียงสองท่า และการทำให้ลำดับถูกต้องคือเคล็ดลับทั้งหมด:
- publish อย่า send เมื่อ client ส่ง message เข้ามา instance จะไม่ loop ผ่าน socket ภายในก่อน แต่จะ publish message นั้นไปยัง channel ที่แชร์กันบน bus
- subscribe และ fan-out ทุก instance — รวมถึงตัวที่ publish — จะ subscribe channel นั้นไว้ เมื่อ bus ส่งข้อความมา แต่ละ instance จะ loop ผ่าน client ภายในของตัวเองแล้วส่ง
instance ที่ publish ปฏิบัติต่อตัวเองเหมือน subscriber อื่น ๆ ไม่มี branch พิเศษว่า “และส่งภายในด้วย” การส่งภายในเกิดขึ้นเพราะ publisher เป็น subscriber ด้วยเช่นกัน เส้นทางcodeเดียว ไปถึง client ทุกตัว
flowchart TB a1["client 1"] -- "sends message" --> A["Instance A"] A == "1. publish to channel" ==> bus[["Shared bus (Redis / NATS / Kafka)"]] bus == "2. deliver to all subscribers" ==> A bus == "2. deliver to all subscribers" ==> B["Instance B"] A -- "3. fan out locally" --> a1 A -- "3. fan out locally" --> a2["client 2"] B -- "3. fan out locally" --> b1["client 3"] B -- "3. fan out locally" --> b2["client 4"]
สิ่งนี้หน้าตาเป็นอย่างไรในcode
หัวข้อที่มีชื่อว่า “สิ่งนี้หน้าตาเป็นอย่างไรในcode”handler หดเล็กลงแทนที่จะโตขึ้น การรับข้อความจาก client กลายเป็นการ publish ครั้งเดียว ตรรกะการส่งทั้งหมดย้ายเข้าไปอยู่ใน callback ของ subscription
// One bus connection per instance, shared by all its sockets.// `bus` here stands in for a Redis/NATS/Kafka client.const channel = 'room:general';
// When a local client sends, publish — do NOT loop sockets here.function onClientMessage(text: string, fromUserId: string) { bus.publish(channel, JSON.stringify({ fromUserId, text }));}
// Every instance runs this once at startup.bus.subscribe(channel, (raw: string) => { const msg = JSON.parse(raw) as { fromUserId: string; text: string }; // Fan out to THIS instance's local clients only. for (const conn of connections.values()) { if (conn.rooms.has('general')) { conn.socket.send(raw); } }});สังเกตว่า onClientMessage ไม่เคยแตะ connections เลย จึงส่งซ้ำซ้อนโดยบังเอิญหรือพลาด remote client ไม่ได้ เพราะไม่ได้ส่งเองตั้งแต่แรก — แค่ publish เท่านั้น การส่งทั้งหมดเป็นงานของ subscriber และทุก instance รัน subscriber ตัวเดียวกัน
bus ไหนดี? Redis, NATS, หรือ Kafka
หัวข้อที่มีชื่อว่า “bus ไหนดี? Redis, NATS, หรือ Kafka”ทั้งสามตัวขนส่ง message ระหว่าง instance ได้หมด ต่างกันที่ guarantee ที่แต่ละตัวเพิ่มให้
- Redis pub/sub — ค่าเริ่มต้นที่พบบ่อย เรียบง่ายสุด ๆ latency ต่ำมาก แบบ fire-and-forget: ถ้า instance ไม่ได้ subscribe อยู่ตอนที่ message ถูก publish ก็จะไม่มีวันเห็น message นั้นเลย เหมาะมากกับ live chat และ presence ที่พลาด message ระหว่างทางไปบ้างก็ไม่ใช่เรื่องร้ายแรง
- NATS — messaging น้ำหนักเบาที่สร้างมาเพื่อจุดประสงค์นี้ มีแกน fire-and-forget คล้ายกัน พร้อม persistence ที่เป็น option (JetStream) เมื่อคุณต้องการ replay
- Kafka — log ที่ durable, มีลำดับ, และ replay ได้ รันหนักกว่า แต่ message ถูกเก็บไว้ instance ที่เพิ่ง restart จึงตามเก็บส่วนที่พลาดไปได้ เลือกใช้เมื่อ event เหล่านั้นต้องถูกจัดเก็บ ตรวจสอบ หรือให้ระบบอื่น consume ต่อด้วย
สำหรับ real-time fan-out ล้วน ๆ Redis pub/sub มักเป็นตัวเลือกแรกที่ถูกต้อง คุณค่อยขยับไปใช้ NATS หรือ Kafka เมื่อคุณต้องการ persistence, ลำดับ, หรือ replay
ดูสอง instance แชร์ bus เดียวกัน
หัวข้อที่มีชื่อว่า “ดูสอง instance แชร์ bus เดียวกัน”demo ด้านล่างจำลองทั้งรูปแบบใน browser ของคุณ: in-memory bus ที่แชร์กันหนึ่งตัวและ server instance สองตัว แต่ละตัวถือ client ภายในของตัวเอง ดูลำดับใน log — message ถูก publish ครั้งเดียว จากนั้นทั้งสอง instance fan-out ต่อ และ client บน instance อื่น ก็ได้รับด้วย นั่นคือช่องว่าง cross-instance ที่ถูกปิดแล้ว
log พิสูจน์ประเด็นนี้: message ของ client-1 ถูก publish ครั้งเดียวบน instance A แล้ว bus ส่งต่อให้ subscriber ทั้งสอง ตัว client 3 กับ 4 บน instance B จึงได้รับด้วย ทั้งที่ instance A ไม่เคยเห็น socket ของพวกเขาเลย เปลี่ยน Bus ในหน้านี้เป็น Redis client จริง แล้วรูปแบบเดียวกันนี้ก็รันข้ามเครื่องได้ทันที
| Backplane | Latency | Durability | เหมาะกับ |
|---|---|---|---|
| Redis Pub/Sub | ต่ำมาก | ไม่มี — fire-and-forget | Live chat, presence, real-time feed |
| NATS | ต่ำมาก | optional (JetStream) | Microservice messaging |
| Kafka | ปานกลาง | สูง — log durable | Event sourcing, replay, audit |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Publish Loop ผ่าน Socket โดยตรงแทน Bus อาการ:
connections.forEach(conn => conn.socket.send(msg))ในonClientMessagehandler- client บน instance อื่นไม่ได้รับ message
- publish ไปยัง bus เท่านั้น ให้ bus fan-out ผ่าน subscriber
Subscribe Channel ซ้ำซ้อนหลายครั้ง อาการ:
- subscribe
room:generalทุกครั้งที่ client join room - client ได้รับ message ซ้ำเมื่อมี 3 subscriber
- subscribe ครั้งเดียวตอน instance start — ไม่ใช่ตอน client connect
💡 ตัวอย่างจากของจริง
Pusher:
- ใช้ Redis pub/sub backplane ระหว่าง instance — message publish ครั้งเดียว ถึง subscriber ทั้งหมด
Ably:
- multi-region backplane — publish ใน region Asia ถึง subscriber ใน region EU
- real-time event routing ข้าม data center