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

ทำไม 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:

  • ใช้ pub/sub backplane ข้าม instance/region เพื่อให้ message ใน guild เดียวกันถึง user ทุก instance ที่ connect อยู่ (Discord Developer Docs — Gateway)

Pusher:

  • ผลิตภัณฑ์ managed WebSocket infrastructure ที่ออกแบบให้ scale connection ได้แบบ horizontal ผ่าน backplane แบบ distributed (ไม่ระบุตัวเลข concurrent connection เจาะจง เพราะไม่พบแหล่งอ้างอิงปฐมภูมิยืนยัน)
ทำไม WebSocket server จึงมักจะที่ไม่พอก่อนที่ CPU จะดูยุ่ง?
ทำไม load ที่ใช้งานอยู่จึงมักไม่สม่ำเสมอข้าม WebSocket instance?
ทำไม retry หรือ buffer ที่ใหญ่ขึ้นจึงแก้ปัญหา cross-instance broadcast ไม่ได้?