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

ทำไม Scaling จึงยาก

การ scale stateless HTTP เป็นปัญหาที่แก้ได้แล้วและแทบจะน่าเบื่อ: request เป็นอิสระต่อกัน ดังนั้นคุณแค่วาง server ที่เหมือนกันเพิ่มไว้หลัง load balancer ก็เสร็จ WebSocket ทำลายภาพที่สบายใจนั้นด้วยวิธีเฉพาะเจาะจงสี่แบบ บทเรียนนี้ตั้งชื่อให้ทั้งสี่แบบ เพราะคุณออกแบบเลี่ยงปัญหาที่รู้สึกได้แค่คลุมเครือไม่ได้

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 อื่นอ่านได้เลย

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 หมด

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
การ restart ตัด connection ที่ใช้งานอยู่ ทั้งหมด reconnect พร้อมกัน

ตอนนี้เราสามารถระบุปัญหาหลักได้โดยไม่ต้องพูดลอย ๆ

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 ในบทเรียนถัดไปนั่นเอง

ChallengeWebSocketHTTP 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 ทั่วโลก
ทำไม WebSocket server จึงมักจะที่ไม่พอก่อนที่ CPU จะดูยุ่ง?
ทำไม load ที่ใช้งานอยู่จึงมักไม่สม่ำเสมอข้าม WebSocket instance?
ทำไม retry หรือ buffer ที่ใหญ่ขึ้นจึงแก้ปัญหา cross-instance broadcast ไม่ได้?