Pub/Sub and Rooms
WebSocket ให้ท่อหนึ่งท่อไปยังแต่ละ client สิ่งที่ทำแบบไร้เดียงสาที่สุดกับท่อนั้นคือ ดัน ทุกอย่าง ลงไปแล้วปล่อยให้ client คัดเองว่าตัวเองสนใจอะไร นั่นใช้ได้กับ demo หนึ่งตัวเป๊ะ ๆ แล้วก็พังทันที: แอปที่มีงานเยอะมี event หลายพันรายการต่อวินาที และผู้ใช้คนเดียวต้องการเพียงเศษเสี้ยวเล็กจิ๋วเท่านั้น Publish/subscribe คือ pattern ที่แก้ปัญหานี้ และเป็นกระดูกสันหลังของผลิตภัณฑ์ real-time แทบทุกตัวที่คุณเคยใช้
แนวคิดหลัก
หัวข้อที่มีชื่อว่า “แนวคิดหลัก”Pub/sub แบ่งโลกออกเป็น topic (เรียกอีกอย่างว่า channel หรือ room) และสามบทบาท:
- Subscriber บอก server ว่า ตัวเองสนใจ topic ใดบ้าง —
room:general,prices:BTC,doc:42 - Publisher ส่ง message ไปยัง topic โดยไม่รู้และไม่สนใจว่าใครกำลังฟังอยู่
- Broker (server ของคุณ) เก็บแผนที่ว่า “ใคร subscribe อะไรบ้าง” และเมื่อ message มาถึงสำหรับ topic หนึ่ง จะส่งให้เฉพาะ subscriber ของ topic นั้นเท่านั้น
การแยกออกจากกัน (decoupling) คือแก่นของเรื่องทั้งหมด publisher ไม่เคยระบุถึงตัวบุคคล แต่ระบุถึง topic ส่วน subscriber ก็ไม่เคยเอ่ยชื่อผู้ส่ง แต่เอ่ยถึงความสนใจ เพิ่มหรือลบฝั่งใดฝั่งหนึ่งแล้วอีกฝั่งก็ไม่เปลี่ยนแปลง
flowchart LR pub["publisher<br/>sends to 'room:general'"] --> broker["broker<br/>(topic → subscribers map)"] broker -- "room:general" --> a["client A<br/>subs: room:general"] broker -- "room:general" --> b["client B<br/>subs: room:general"] broker -. "not subscribed" .-x c["client C<br/>subs: prices:BTC"]
client C ไม่เคยได้ยิน message room:general — ไม่ใช่เพราะโดนกรองที่ฝั่ง client แต่เพราะ broker ไม่เคยส่งออกไปเลย ความแตกต่างนี้สำคัญ: การกรองที่ฝั่ง client ยังต้องจ่ายทั้ง bandwidth และต้นทุนการปลุกเครื่องสำหรับทุก event Pub/sub ของจริงกรองที่ต้นทาง
Room ก็คือ topic นั่นเอง
หัวข้อที่มีชื่อว่า “Room ก็คือ topic นั่นเอง”“Room” และ “channel” ไม่ใช่กลไกที่ต่างกัน — เป็นเพียงชื่อที่เป็นมิตรของ topic ที่ขอบเขตอยู่กับกลุ่มหนึ่ง room:general คือ topic เช่นเดียวกับ room:swe-team การเข้าร่วม room คือการ subscribe การออกคือการ unsubscribe เครื่องจักรชุดเดียวกันรองรับทั้งห้องแชต เซสชันทำงานร่วมกันต่อเอกสาร และ feed ราคาต่อสัญลักษณ์ เมื่อคุณมีแกน subscribe/publish ที่สะอาดแล้ว “room” ไม่ทำให้คุณเสียอะไรเพิ่ม
รูปร่างของ message ที่ใช้
หัวข้อที่มีชื่อว่า “รูปร่างของ message ที่ใช้”Pub/sub ต้องการคำศัพท์เพียงเล็กน้อยบนสาย ฟิลด์ type บวกกับ topic ก็เพียงพอที่จะรองรับทุก operation:
type PubSubMessage = | { type: 'subscribe'; topic: string } | { type: 'unsubscribe'; topic: string } | { type: 'publish'; topic: string; data: unknown } // server -> client delivery | { type: 'message'; topic: string; data: unknown };สามอันแรกคือคำสั่งที่ client ส่ง อันสุดท้ายคือสิ่งที่ server ส่งกลับมาเมื่อ event ตรงกับ subscription ของคุณตัวใดตัวหนึ่ง นั่นคือ protocol ทั้งหมด — ที่เหลือคือการจดบัญชีภายใน broker
ดูของจริงทำงานสด ๆ
หัวข้อที่มีชื่อว่า “ดูของจริงทำงานสด ๆ”demo ด้านล่างคือ broker pub/sub ที่สมบูรณ์ในหน้านี้ ต่อสายเข้ากับ client จำลองสองตัวผ่าน WebSocket API จริง client A subscribe room:general ส่วน client B subscribe prices:BTC จากนั้นเรา publish ไปยังแต่ละ topic แล้วดูว่ามีเพียง client ที่ถูกต้องเท่านั้นที่ได้รับแต่ละ message ลองรันแล้วอ่าน log: ทุกการส่งลงไปที่ subscriber เพียงตัวเดียวเป๊ะ ๆ ไม่เคยลงทั้งสองตัว
อ่าน output จากบนลงล่าง: A ได้ยินเฉพาะ room:general B ได้ยินเฉพาะ prices:BTC และการ publish ไปยัง room:empty ถูกส่งให้ subscriber ศูนย์ราย แล้วถูกทิ้งไปเงียบ ๆ บรรทัดสุดท้ายนั้นสำคัญ — การ publish ไปยัง topic ที่ไม่มีใครต้องการคือ no-op ซึ่งนี่แหละคือสิ่งที่ทำให้ publisher ปล่อย message ได้อย่างอิสระโดยไม่ต้องรู้จักผู้ฟัง
ทำไมถึงสเกลได้
หัวข้อที่มีชื่อว่า “ทำไมถึงสเกลได้”broker ถือ Map จาก topic ไปยัง set ของ socket การส่ง event แตะเฉพาะ socket ใน set เดียวเท่านั้น ไม่เคยแตะตารางการเชื่อมต่อทั้งหมด นั่นคือเหตุผลที่ pub/sub สเกลตาม ความสนใจ แทนที่จะตาม จำนวนการเชื่อมต่อทั้งหมด: แอปที่มีผู้ใช้แสนคนซึ่งแต่ละคนดูสาม topic ก็ยังส่งแต่ละ event ให้แค่ไม่กี่คนที่ขอ และเมื่อต่อมาคุณรัน server หลาย instance โมเดลเดียวกันก็ขยายออกไปได้ — broker ที่ใช้ร่วมกัน (Redis, NATS, message bus) กระจายการ publish ข้าม instance ซึ่งก็คือ pattern การสเกล ที่คุณเห็นมาก่อนหน้านี้เป๊ะ ๆ
| Pattern | Concept | ตัวอย่าง |
|---|---|---|
| Pub/Sub | publisher ไม่รู้จัก subscriber — ส่งผ่าน channel | Slack channel, stock ticker |
| Room | กลุ่มของ connection ที่ join เข้ามา — server track explicitly | Game room, document session |
| Direct | ส่งถึง connection เดียวโดยตรง | Private message, notification |
| Broadcast | ส่งทุก connection บน server | System announcement |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”ใช้ Global Broadcast เมื่อ Room เหมาะกว่า อาการ:
- broadcast ทุก event ไปทุก connection
- user ในห้อง A เห็น event ของห้อง B
- ใช้ room: แต่ละ user join room ที่เกี่ยวข้อง, broadcast เฉพาะใน room
Pub/Sub Channel ที่ไม่มี Unsubscribe อาการ:
- user ออกจาก page แต่ยัง subscribe channel อยู่
- รับ event ที่ไม่ต้องการ — processing overhead
- unsubscribe เมื่อ component unmount หรือ user ออกจาก context
💡 ตัวอย่างจากของจริง
Slack:
- channel = pub/sub topic — ส่ง message ไปยัง channel, member ทุกคนรับ
- direct message = direct pattern — ส่งถึง user เดียว
Agar.io:
- room = game session — player แต่ละคน join game room
- event (player move, eat) broadcast เฉพาะใน room เดียวกัน