ทำไม Scaling จึงยาก
การ scale stateless HTTP เป็นปัญหาที่แก้ได้แล้วและแทบจะน่าเบื่อ: request เป็นอิสระต่อกัน ดังนั้นคุณแค่วาง server ที่เหมือนกันเพิ่มไว้หลัง load balancer ก็เสร็จ WebSocket ทำลายภาพที่สบายใจนั้นด้วยวิธีเฉพาะเจาะจงสี่แบบ บทเรียนนี้ตั้งชื่อให้ทั้งสี่แบบ เพราะคุณออกแบบเลี่ยงปัญหาที่รู้สึกได้แค่คลุมเครือไม่ได้
1. connection state อยู่ใน memory
หัวข้อที่มีชื่อว่า “1. connection state อยู่ใน memory”handler แบบ request/response สามารถจบงานแล้วลืมทุกอย่างได้ แต่ WebSocket handler ทำแบบนั้นไม่ได้ — จุดสำคัญทั้งหมดคือ connection นั้นคงอยู่ และการคงอยู่หมายถึงการถือ state ไว้ตลอดอายุของ socket
state นั้นโดยทั่วไปรวมถึง:
- ตัว socket เอง (OS file descriptor และ kernel send/receive buffer)
- send queue ใด ๆ ที่ library ของคุณเก็บไว้สำหรับ backpressure
- application data ที่คุณผูกติดไว้ — user id, authenticated session, ชุดของ room หรือ channel ที่ client นี้ subscribe ไว้
// A per-connection record. Multiply this by every open socket.interface Conn { socket: WebSocket; // OS descriptor + buffers underneath userId: string; rooms: Set<string>; // which channels this client joined lastSeen: number; // for heartbeat/expiry bookkeeping}
// This map IS the instance's knowledge of the world.// It exists only here, in this process's heap.const connections = new Map<string, Conn>();map ด้านบนคือหัวใจของทั้งโมดูล เป็นที่เดียวที่ instance นี้รู้ว่าใครเชื่อมต่ออยู่ — และไม่มี instance อื่นอ่านได้เลย
2. CPU และ memory มีพื้นต่อ connection
หัวข้อที่มีชื่อว่า “2. CPU และ memory มีพื้นต่อ connection”connection HTTP ที่ idle แทบไม่มีต้นทุนเลยเพราะแทบไม่มีตัวตนระหว่าง request ส่วน WebSocket ที่ idle ต่างออกไป: แต่ละตัวเปิดอยู่จริง ๆ กิน memory ก้อนหนึ่งและ file descriptor หนึ่งตัว ไม่ว่าจะกำลังส่งอะไรอยู่หรือไม่
- Memory kernel socket buffer บวกกับ record ต่อ connection ของคุณรวมกันแล้วก็มากขึ้น ไม่กี่กิโลไบต์ต่อตัวฟังดูเล็กน้อย จนกว่าคุณจะนึกได้ว่า 50,000 connection หมายถึงหลายร้อยเมกะไบต์ก่อนที่แอปของคุณจะทำงานอะไรเลย
- File descriptor ทุก socket คือ descriptor และ limit ของ OS (
ulimit -n) มักเป็นค่าเริ่มต้นที่ต่ำอย่าง 1024 พอชนเพดานแล้ว connection ใหม่ก็เปิดไม่ได้โดยไม่มี application bug ที่ชัดเจน - CPU ตอน fan-out การส่งหนึ่งข้อความไปยัง client 10,000 ตัวคือการ write แยกกัน 10,000 ครั้ง ต้นทุนการ broadcast เพิ่มตามขนาดผู้รับ และห้องที่คึกคักหนึ่งห้องสามารถดึง core หนึ่งให้ทำงานเต็มที่ได้
ใจความสำคัญ: เพดานของ WebSocket server ถูกกำหนดโดยจำนวน connection ที่เปิดพร้อมกัน ไม่ใช่ request ต่อวินาที คุณต้อง scale out ก่อนที่ CPU จะดูยุ่งซะอีก เพียงเพราะคุณ memory หรือ descriptor หมด
3. connection ที่มีอายุยืน balance ได้แย่
หัวข้อที่มีชื่อว่า “3. connection ที่มีอายุยืน balance ได้แย่”load balancer กระจาย connection ใหม่ ไปยัง instance ต่าง ๆ แต่ WebSocket มีอายุยืน พอ client มาลงที่ instance ไหน ก็มักจะอยู่ที่นั่นยาวเป็นนาทีหรือเป็นชั่วโมง
สิ่งนี้มีผลพลอยได้ที่น่าอึดอัดสองอย่าง:
- load สะท้อนอดีต ไม่ใช่ปัจจุบัน ถ้า instance A ออนไลน์ระหว่าง traffic พุ่ง ก็อาจถือ connection ไว้มากกว่า instance B ที่เริ่มทีหลังมาก — และ balancer ไม่สามารถ rebalance socket ที่ใช้งานอยู่ได้โดยไม่ตัดผู้คนออก
- การ deploy สร้างความปั่นป่วน การ restart instance ตัดทุก connection ที่ถืออยู่ทิ้ง client ชุดนั้นก็ reconnect พร้อมกัน แล้ว balancer ก็ผลักไปยัง instance ที่เหลือ เกิดเป็น stampede (มีรายละเอียดเพิ่มในบทเรียน presence)
flowchart TB lb["Load balancer"] A["Instance A — 8k sockets"] B["Instance B — 2k sockets"] lb --> A lb --> B A -- "deploy / crash" --> X["A's 8k sockets drop"] X == "all reconnect at once" ==> lb lb == "stampede onto survivors" ==> B
4. ปัญหา cross-instance broadcast อย่างแม่นยำ
หัวข้อที่มีชื่อว่า “4. ปัญหา cross-instance broadcast อย่างแม่นยำ”ตอนนี้เราสามารถระบุปัญหาหลักได้โดยไม่ต้องพูดลอย ๆ
broadcast บน instance A iterate ผ่าน
A.connectionsส่วน client ที่เชื่อมต่อกับ instance B อยู่ในB.connectionsซึ่ง A อ่านไม่ได้ ดังนั้น broadcast จึงไม่สามารถไปถึง client นั้นได้ — ไม่ใช่ช้า ไม่ใช่ไม่น่าเชื่อถือ แต่ไม่มีวันเลย เพราะไม่มีเส้นทางcodeใดอยู่ระหว่าง heap ทั้งสอง
นี่ไม่ใช่ bug ที่แก้ได้ด้วย retry หรือ buffer ที่ใหญ่ขึ้น แต่เป็นข้อเท็จจริงเชิงโครงสร้าง: process สองตัวไม่แชร์ memory กัน ทางแก้เดียวคือเพิ่ม channel ระหว่าง instance เพื่อให้ message ที่ publish ที่ไหนก็ตามไปถึงทุกที่ — ซึ่งก็คือ backplane ในบทเรียนถัดไปนั่นเอง
| Challenge | WebSocket | HTTP REST |
|---|---|---|
| State ต่อ connection | สูง — connection state อยู่ใน memory | ต่ำ — stateless request |
| Cross-instance broadcast | ไม่สามารถทำโดยตรง — ต้องมี backplane | ไม่จำเป็น — CDN cache |
| Load balance | ยาก — connection อายุยืน | ง่าย — round-robin |
| Scale limit | จำนวน connection (fd, memory) | RPS (CPU, bandwidth) |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”ใช้ Single Instance สำหรับ Production WebSocket Server อาการ:
- connection 50,000 ตัว → memory หมด หรือ file descriptor limit ชน
- deploy ใหม่ → ทุก user หลุดพร้อมกัน
- scale ออก horizontal, ใช้ backplane สำหรับ cross-instance message
Broadcast ด้วยการ Loop Socket ใน Instance เดียว อาการ:
connections.forEach(conn => conn.socket.send(msg))— ไม่ไปถึง instance อื่น- user บน instance B ไม่ได้รับ message จาก instance A
- ใช้ pub/sub backplane (Redis, NATS) ให้ทุก instance subscriber ฟัง channel เดียวกัน
💡 ตัวอย่างจากของจริง
Discord:
- แต่ละ Gateway server รองรับ connection นับแสน
- ใช้ pub/sub backplane ข้าม region — message ใน server chat ถึง user ทุก instance
Pusher:
- managed WebSocket infrastructure — scale connection 100M+ พร้อมกัน
- backplane distributed ข้าม data center ทั่วโลก