เลือก Rust สำหรับ fan-out เหตุการณ์แบบเรียลไทม์
สิ่งที่ผลักบริการ fan-out เรียลไทม์ออกจาก runtime ที่มี GC คือ tail latency ตอน burst ไม่ใช่ throughput เฉลี่ย และ channel ของ Tokio ให้อะไรมาฟรีบ้าง
ลักษณะงาน
ลองนึกถึงบริการที่อยู่ระหว่าง feed เหตุการณ์อัตราสูงกับ dashboard จำนวนหนึ่ง มันรับ event เข้ามา normalize เก็บค่าสะสม แล้ว fan out ผลลัพธ์ไปยังทุก client ที่ต่ออยู่ภายใน frame budget คนที่จ้องจออยู่คาดหวังว่าตัวเลขต้องถูก เดี๋ยวนี้ ไม่ใช่อีก 1 วินาที
ข้อจำกัดที่น่าสนใจของระบบกลุ่มนี้ไม่ใช่ throughput เฉลี่ย แต่คือ burst สภาวะปกติคือน้ำหยด แล้วอยู่ ๆ ต้นทางมีอะไรเกิดขึ้น อัตรากระโดดขึ้นสิบเท่านานหนึ่งถึงสองนาที ระบบต้องซับ burst นั้นไว้โดยไม่ทำ event หายและไม่ทำให้เส้นทางปกติสะดุด
ทำไม runtime ที่มี GC ถึงลำบากกับงานนี้
Prototype บน Node รับสภาวะปกติได้สบาย สิ่งที่พังตอน burst ไม่ใช่ throughput — V8 serialize JSON ได้เหลือเฟือ — แต่คือ tail latency
พอ event rate กระโดด 10 เท่า GC pause จะกลายเป็นอาการที่ปลายทางมองเห็น สะดุดครั้งละไม่กี่ร้อยมิลลิวินาที และมาในจังหวะที่คนกำลังจ้องที่สุดพอดี บรรเทาได้ — pre-allocate, pool buffer, เลี่ยง closure ใน hot path — และช่วยได้จริง แต่พื้นถูกกำหนดโดยตัว collector และสำหรับงานนี้พื้นนั้นยังไม่ต่ำพอ
ทำไม Rust, Axum, Tokio
เหตุผลแคบกว่าคำว่า "Rust เร็วกว่า":
- พฤติกรรมหน่วยความจำคาดเดาได้ ไม่มี stop-the-world ให้ต้องออกแบบหลบ ความต้องการจริงไม่เคยเป็น "ไม่มี pause" แต่เป็น "ไม่มี pause ที่คาดเดาไม่ได้"
- Scheduler ของ Tokio เข้ากับ fan-out ที่มี burst หนึ่ง task ต่อหนึ่ง connection แล้ว multiplex บน thread pool คือหน้าตาของงานนี้พอดี ไม่ต้องประดิษฐ์อะไรใหม่
- Axum ไม่มาขวาง มี tower middleware, routing, และตัวช่วย WebSocket ไม่มีอะไรเวทมนตร์ และไม่มีอะไรให้ต้องสู้
ส่วนผสมนี้ทำให้ hot path เขียนเป็นฟังก์ชันกระชับที่ควบคุมการ allocate ได้ ส่วน cold path อย่าง admin endpoint, health check, config ยังเป็น handler ธรรมดา
รูปร่างของสถาปัตยกรรม
upstream feed ──► ingest task ──► ring buffer (bounded mpsc)
│
▼
normalizer task pool
│
┌──────────┴──────────┐
▼ ▼
aggregate actor fan-out broadcaster
(single owner) (broadcast::Sender)
│ │
└──────────┬──────────┘
▼
connected clients3 การตัดสินใจที่ควรพูดถึง:
State ที่แชร์กันต้องมีเจ้าของเดียว ค่าสะสมคือ mutable state ที่แชร์กันจริงเพียงชิ้นเดียว วิธีที่ง่ายที่สุดที่ยังถูกคือวางไว้หลัง task ตัวเดียวที่เป็นเจ้าของ การอัปเดตเข้าทาง mpsc การอ่านตอบกลับทาง oneshot ในเชิงแนวคิดมันช้ากว่า RwLock แต่ในทางปฏิบัติมักเร็วกว่า และ ง่ายกว่า เพราะไม่มีการแย่ง lock และไม่มีใครเห็น state ตอนแก้ค้างอยู่
Fan-out ใช้ tokio::sync::broadcast client แต่ละตัว subscribe ส่วน task ที่ถือค่าสะสมเป็นคน publish เวลา client ช้าตามไม่ทัน channel จะรายงาน Lagged แล้วเราส่ง snapshot ไปให้กู้คืน ไม่ต้องประดิษฐ์ semantics ของ backpressure เอง เพราะ channel มีให้แล้ว
อันนี้ลองเล่นง่ายกว่าอ่าน:
broadcast::Sender
0
events emitted · 0 pending across subs
subscribers
sub_00
livecursor 0 / 0sub_01
livecursor 0 / 0sub_02
slowcursor 0 / 0sub_03
livecursor 0 / 0
Each subscriber holds an independent cursor into the channel. Slow it down past the buffer (16 events) → it goes Lagged. Speed it back up → snapshot recovery jumps the cursor back to head. No backpressure on the sender.
ลองเพิ่ม rate แล้วหน่วง subscriber ให้ช้าลง ดู buffer มันเต็มจนขึ้น Lagged — นั่นคือ channel บอกว่า "นายตามไม่ทันเกินกว่าที่ buffer ฉันเก็บได้ นี่คือสิ่งที่พลาดไป แบบรวมยอด" พอเร่งกลับมา snapshot recovery จะกระโดด cursor ไปที่หัว ฝั่งส่งไม่เคยถูกบล็อกโดยตัวอ่านที่ช้าที่สุด
Ingest ring ต้องมีขอบเขต bounded mpsc แปลว่าถ้าต้นทางมาเร็วกว่า normalizer เราได้ backpressure แทนที่จะได้หน่วยความจำโตไม่หยุด ตั้ง capacity ให้เก็บ burst ระดับพีคได้สักสองวินาที แล้วตั้งขนาด normalizer pool ให้สองวินาทีนั้นเป็นระยะหายใจที่พอ
หลุมที่ควรรู้ไว้ก่อน
Cancellation safety ถ้า client หลุดกลางการอัปเดต critical section ที่เขียนแบบตรงไปตรงมาอาจทิ้ง state ไว้ครึ่ง ๆ วิธีแก้คือวิธีมาตรฐาน — คำนวณค่าใหม่ให้เสร็จก่อน แล้วค่อยสลับเข้าไปแบบ atomic บั๊กกลุ่มนี้จะไม่เคยเจอใน runtime ที่ไม่มี async cancellation จึงควรตั้งใจไปหามัน ไม่ใช่รอให้มันมาหา
Observability ไม่ได้มาฟรี panic ใน Rust task เงียบพอ ๆ กับ unhandled rejection ใน Node ถ้าไม่มีอะไรต่อไว้รายงาน ใส่ structured logging ด้วย tracing ตั้งแต่ commit แรก ไม่ใช่ commit ที่สิบ
ถือว่า schema ขาเข้าเชื่อไม่ได้ feed ต้นทางเพิ่มฟิลด์โดยไม่บอก ความเข้มงวดของ Rust ผลักให้เรานิยาม serde type ตั้งแต่แรก ซึ่งดี แต่ deserializer ที่เข้มเกินจะเปลี่ยนฟิลด์ใหม่ให้กลายเป็นความล้มเหลวทันที ให้ผ่อนปรนกับฟิลด์ที่ไม่รู้จัก แล้วไป validate เฉพาะสิ่งที่เราใช้จริง
ความเร็วในการวนแก้ วงจร แก้ → compile → test ช้ากว่า scripting runtime สำหรับงานสภาวะปกติไม่เป็นไร แต่ตอน prototype handler ใหม่มันหนืด ทำ binary เล็ก ๆ ไว้เป็น playground ที่ยิง handler เดิมด้วย JSON สำเร็จรูป ช่วยให้วงในยังกระชับ
ถ้าเลือกใหม่จะเลือกเหมือนเดิมไหม
สำหรับงานลักษณะนี้ เลือกเหมือนเดิม เหตุผลแคบและควรพูดตรง ๆ — ความต้องการคือ latency ที่คาดเดาได้ตอน burst และภาษาให้สิ่งนั้นมาตรง ๆ ถ้าโจทย์เป็น "ให้บริการ JSON API ที่ p99 100ms" การอยู่กับ Node ต่อจะประหยัดเวลาวิศวกรรมไปไม่น้อย
"Rust เร็วกว่า" เป็นเหตุผลที่อ่อน เพราะเวอร์ชันที่มี GC มักเร็วพออยู่แล้ว "Rust ให้โปรไฟล์ tail latency ที่ผมให้เหตุผลกับมันได้" คือเหตุผลจริง ถ้าจะเลือก ให้เลือกด้วยเหตุผลนั้น