ข้ามไปยังเนื้อหา

Pub/Sub Backplane

บทเรียนก่อนหน้าทิ้งคำระบุปัญหาที่ชัดเจนไว้ให้เรา: instance A มองไม่เห็น socket ที่ instance B ถือไว้ ดังนั้น broadcast บน A จึงไม่มีวันไปถึง client บน B ทางแก้คือเลิกพยายามไปถึง socket ของ instance อื่น โดยตรง และไปถึง instance อื่น แทน ให้ทุก instance มีสายที่แชร์ร่วมกันซึ่งทุกตัวฟังอยู่ และให้แต่ละ instance รับผิดชอบ client ภายในของตัวเอง

สายที่แชร์ร่วมกันนั้นคือ pub/sub backplane

flow นี้มีการเคลื่อนไหวเพียงสองท่า และการทำให้ลำดับถูกต้องคือเคล็ดลับทั้งหมด:

  1. publish อย่า send เมื่อ client ส่ง message เข้ามา instance จะไม่ loop ผ่าน socket ภายในก่อน แต่จะ publish message นั้นไปยัง channel ที่แชร์กันบน bus
  2. 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"]
publish ไปยัง bus ครั้งเดียว ทุก instance fan-out ไปยัง client ของตัวเอง

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 ตัวเดียวกัน

ทั้งสามตัวขนส่ง 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

demo ด้านล่างจำลองทั้งรูปแบบใน browser ของคุณ: in-memory bus ที่แชร์กันหนึ่งตัวและ server instance สองตัว แต่ละตัวถือ client ภายในของตัวเอง ดูลำดับใน log — message ถูก publish ครั้งเดียว จากนั้นทั้งสอง instance fan-out ต่อ และ client บน instance อื่น ก็ได้รับด้วย นั่นคือช่องว่าง cross-instance ที่ถูกปิดแล้ว

JavaScript

log พิสูจน์ประเด็นนี้: message ของ client-1 ถูก publish ครั้งเดียวบน instance A แล้ว bus ส่งต่อให้ subscriber ทั้งสอง ตัว client 3 กับ 4 บน instance B จึงได้รับด้วย ทั้งที่ instance A ไม่เคยเห็น socket ของพวกเขาเลย เปลี่ยน Bus ในหน้านี้เป็น Redis client จริง แล้วรูปแบบเดียวกันนี้ก็รันข้ามเครื่องได้ทันที

BackplaneLatencyDurabilityเหมาะกับ
Redis Pub/Subต่ำมากไม่มี — fire-and-forgetLive chat, presence, real-time feed
NATSต่ำมากoptional (JetStream)Microservice messaging
Kafkaปานกลางสูง — log durableEvent sourcing, replay, audit

Publish Loop ผ่าน Socket โดยตรงแทน Bus อาการ:

  • connections.forEach(conn => conn.socket.send(msg)) ใน onClientMessage handler
  • 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
ในรูปแบบ backplane instance ทำอะไรในวินาทีที่ local client ส่งข้อความ?
ทำไมจึงไม่มี branch พิเศษว่า "ส่งให้ client ของตัวเองด้วย" ใน publish handler?
เมื่อใดคุณจึงจะเลือก Kafka แทน Redis pub/sub สำหรับ backplane?