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

Backpressure

การ broadcast รู้สึกเหมือนฟรีจนกระทั่ง client หนึ่งดื่มไม่เร็วเท่าที่คุณริน โทรศัพท์บนเครือข่ายอ่อน, แท็บที่ OS throttle ไว้, แล็ปท็อปที่หลับไป — client เหล่านี้อ่านช้า และ server ที่เร็วของคุณก็เรียก send ต่อไป ข้อมูลที่ยังไม่ได้ส่งต้องไปกองอยู่ ที่ไหนสักแห่ง และที่นั่นคือ memory ของ server นี่คือ backpressure และการมองข้ามเรื่องนี้คือเหตุผลที่ client ช้าตัวเดียวล้ม server ได้

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"]
ผู้อ่านที่ช้าเปลี่ยนผู้ผลิตที่เร็วให้กลายเป็น memory queue ที่โตขึ้นเรื่อย ๆ

อันตรายไม่ได้อยู่ที่ client ช้าตัวเดียว — แต่อยู่ที่ไม่มีอะไรหยุด queue ไม่ให้โตไม่มีขอบเขต ทางแก้คือ วัด backlog แล้ว ตอบสนอง เมื่อโตเกินไป

ทุก 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 ตามไม่ทัน และการเคลื่อนไหวที่ถูกต้องมักจะ ไม่ใช่ “ส่งไปแหละ”

สิ่งที่คุณทำที่ 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 หนึ่งคนใดก็ตามต้องมีเพดาน

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: อย่าล้ำหน้าผู้บริโภคที่ช้าที่สุดเกินกว่าจำนวนที่มีขอบเขต

เดโมด้านล่างจำลองผู้ผลิตที่ผลิตในอัตราสูงซึ่งตรวจ bufferedAmount ก่อน send ทุกครั้ง แล้ว drop การอัปเดตเมื่อ buffer (ที่จำลองไว้) เกินขีดจำกัด พร้อมรายงานว่าส่งไปกี่ตัวเทียบกับ drop ไปกี่ตัว — พฤติกรรมแบบเป๊ะ ๆ ที่ feed จริงต้องการ

Node.js

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 oldestMessage ที่ต้องการ ordering
Slow down senderแจ้ง sender ให้ส่งช้าลงControlled stream
Pause + resumepause input, resume เมื่อพร้อมStream processing

ไม่ Check ws.bufferedAmount ก่อนส่ง อาการ:

  • ส่ง message ต่อเนื่องโดยไม่รอ buffer drain
  • bufferedAmount สูงขึ้นเรื่อย ๆ — memory leak
  • check bufferedAmount < THRESHOLD ก่อนส่ง หรือใช้ drain event

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 ส่งพร้อมกัน
`ws.bufferedAmount` บอกอะไรคุณ?
ทำไม client ช้าจึงเป็นความเสี่ยงด้าน memory สำหรับ server?
สำหรับ price feed สด กลยุทธ์ใดเหมาะกับ client ที่ตามไม่ทัน?