Backpressure
การ broadcast รู้สึกเหมือนฟรีจนกระทั่ง client หนึ่งดื่มไม่เร็วเท่าที่คุณริน โทรศัพท์บนเครือข่ายอ่อน, แท็บที่ OS throttle ไว้, แล็ปท็อปที่หลับไป — client เหล่านี้อ่านช้า และ server ที่เร็วของคุณก็เรียก send ต่อไป ข้อมูลที่ยังไม่ได้ส่งต้องไปกองอยู่ ที่ไหนสักแห่ง และที่นั่นคือ memory ของ server นี่คือ backpressure และการมองข้ามเรื่องนี้คือเหตุผลที่ client ช้าตัวเดียวล้ม server ได้
byte ไปกองอยู่ที่ไหน
หัวข้อที่มีชื่อว่า “byte ไปกองอยู่ที่ไหน”ws.send(data) ไม่ block รอจน client รับข้อมูล — แค่ยัด byte ให้ OS socket buffer แล้ว return ทันที ถ้า client อ่านช้า buffer นั้นจะเต็ม และข้อมูลที่ยังไม่ได้ส่งเพิ่มเติมจะ queue อยู่ภายใน Node process ของคุณ ส่ง byte ไปยัง client ที่ติดขัดใน loop แน่น ๆ แล้วคุณก็กำลังสร้าง in-memory queue ที่ไม่มีขอบเขตขึ้นมาอย่างแท้จริง:
flowchart LR PROD["server<br/>produces fast"] --> SEND["ws.send()"] SEND --> BUF["per-socket send buffer<br/>(ws.bufferedAmount grows)"] BUF -- "drains slowly" --> SLOW["slow client"] BUF -. "if it keeps growing" .-> OOM["server memory exhausted"]
อันตรายไม่ได้อยู่ที่ client ช้าตัวเดียว — แต่อยู่ที่ไม่มีอะไรหยุด queue ไม่ให้โตไม่มีขอบเขต ทางแก้คือ วัด backlog แล้ว ตอบสนอง เมื่อโตเกินไป
การวัดด้วย bufferedAmount
หัวข้อที่มีชื่อว่า “การวัดด้วย bufferedAmount”ทุก ws socket เปิดเผย ws.bufferedAmount — จำนวน byte ที่ถูก queue ไว้โดย send แต่ยังไม่ได้ flush ไปยัง OS นี่คือมาตรวัด backpressure ของคุณ ก่อนส่งข้อมูลที่ไม่สำคัญ ให้ตรวจค่านี้ก่อน:
const HIGH_WATER_MARK = 1 << 20; // 1 MiB of pending bytes
function safeSend(ws, data) { if (ws.bufferedAmount > HIGH_WATER_MARK) { // This client is behind. Do not pile more on. return false; // dropped } ws.send(data); return true;}เมื่อ bufferedAmount ข้ามเกณฑ์ของคุณ client ตามไม่ทัน และการเคลื่อนไหวที่ถูกต้องมักจะ ไม่ใช่ “ส่งไปแหละ”
กลยุทธ์เมื่อ client ตามไม่ทัน
หัวข้อที่มีชื่อว่า “กลยุทธ์เมื่อ client ตามไม่ทัน”สิ่งที่คุณทำที่ high-water mark ขึ้นอยู่กับข้อมูล:
- Drop. สำหรับ feed สด (ราคา, telemetry, ตำแหน่ง cursor) การอัปเดตเก่าไร้ค่าทันทีที่มีตัวใหม่กว่า แค่ข้าม send นั้น client จะตามทันในข้อความถัดไป
- Coalesce. แทนที่จะส่งทุก tick เก็บเฉพาะ state ล่าสุด ต่อ client แล้วส่งตัวนั้นเมื่อ buffer drain การอัปเดตราคาสิบรายการที่ queue ไว้ยุบรวมเป็นราคาปัจจุบันเดียว
- Disconnect. ถ้า client ยังตามไม่ทันอย่างสิ้นหวังเกิน grace period ให้ปิด socket (เช่น code
1013, “try again later”) การ reconnect พร้อม snapshot ใหม่ดีกว่า buffer ที่โตขึ้นไม่หยุด - Flow control. สำหรับข้อมูลที่คุณ drop ไม่ได้ (การ transfer ไฟล์) หยุดการผลิตจนกว่า buffer จะ drain — ให้ผู้บริโภคที่ช้ากำหนดจังหวะ
หลักการที่รวมเป็นหนึ่ง: bound the memory ไม่ว่าคุณจะเลือกอะไร queue สำหรับ client หนึ่งคนใดก็ตามต้องมีเพดาน
send callback
หัวข้อที่มีชื่อว่า “send callback”ws.send รับ callback ที่ fire เมื่อข้อมูลส่งมอบแล้ว (หรือเกิด error) นี่คือสัญญาณระดับ message ที่ใช้กำหนดจังหวะผู้ผลิตได้ — queue chunk ถัดไปก็ต่อเมื่อตัวก่อนหน้าถูก flush แล้วเท่านั้น:
function pump(ws, chunks, i = 0) { if (i >= chunks.length) return; if (ws.readyState !== ws.OPEN) return; ws.send(chunks[i], (err) => { if (err) return; // socket gone // Only schedule the next chunk once this one has drained. if (ws.bufferedAmount < (1 << 20)) pump(ws, chunks, i + 1); else setTimeout(() => pump(ws, chunks, i + 1), 50); });}นี่คือสิ่งเทียบเท่ากับการเคารพ drain event ของ stream ในโลก WebSocket: อย่าล้ำหน้าผู้บริโภคที่ช้าที่สุดเกินกว่าจำนวนที่มีขอบเขต
ดู client ช้าค่อย ๆ ถูกถม
หัวข้อที่มีชื่อว่า “ดู client ช้าค่อย ๆ ถูกถม”เดโมด้านล่างจำลองผู้ผลิตที่ผลิตในอัตราสูงซึ่งตรวจ bufferedAmount ก่อน send ทุกครั้ง แล้ว drop การอัปเดตเมื่อ buffer (ที่จำลองไว้) เกินขีดจำกัด พร้อมรายงานว่าส่งไปกี่ตัวเทียบกับ drop ไปกี่ตัว — พฤติกรรมแบบเป๊ะ ๆ ที่ feed จริงต้องการ
Needs the Node.js runtime — open in StackBlitz to run.
client ที่สุขภาพดี drain เร็วพอจน bufferedAmount อยู่ใกล้ศูนย์และแทบไม่มีอะไรถูก drop ลองหน่วงผู้บริโภคแล้วคุณจะเห็น dropped ไต่ขึ้นขณะที่ memory ยังคงราบเรียบ — ซึ่งคือประเด็นทั้งหมด สังเกต clearInterval ตอน close: ผู้ผลิตที่กำพร้าและยังรันต่อหลัง client จากไปแล้วก็เป็น leak อีกชนิดหนึ่ง การปกป้อง memory คือความรับผิดชอบข้อสุดท้ายของ WebSocket server และเป็นตัวตัดสินว่าจะรอดวันที่เครือข่ายแย่หรือไม่
| Backpressure Strategy | ทำงานอย่างไร | เหมาะกับ |
|---|---|---|
| Drop message | ทิ้ง message เมื่อ buffer เต็ม | Real-time data ที่ stale ไม่มีประโยชน์ |
| Queue message | เก็บ queue จำกัด, drop oldest | Message ที่ต้องการ ordering |
| Slow down sender | แจ้ง sender ให้ส่งช้าลง | Controlled stream |
| Pause + resume | pause input, resume เมื่อพร้อม | Stream processing |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”ไม่ Check ws.bufferedAmount ก่อนส่ง
อาการ:
- ส่ง message ต่อเนื่องโดยไม่รอ buffer drain
bufferedAmountสูงขึ้นเรื่อย ๆ — memory leak- check
bufferedAmount < THRESHOLDก่อนส่ง หรือใช้drainevent
Drop Message โดยไม่แจ้ง Client อาการ:
- server drop message เมื่อ queue เต็ม — client ไม่รู้
- client เห็นข้อมูลหาย โดยไม่รู้ว่าเกิดอะไร
- ส่ง signal กลับ client เมื่อ drop:
{ type: "overflow", dropped: N }
💡 ตัวอย่างจากของจริง
Binance Market Data:
- ส่ง price update ทุก 100ms — ถ้า client ช้า drop update เก่า
- client เห็น current price เสมอ แม้ miss บาง update
Google Docs:
- backpressure สำคัญมากสำหรับ collaborative editing
- operation queue ที่ server ป้องกัน conflict เมื่อ client ส่งพร้อมกัน