Rust ใน Production: ทำไมเราถึงเขียน Signal Processor ขึ้นใหม่
เราเขียน signal processing engine ขึ้นใหม่จาก Go เป็น Rust นี่คือเหตุผล สิ่งที่เราได้เรียนรู้ และการปรับ throughput ให้ดีขึ้น 4 เท่าที่เราทำได้
หกเดือนก่อน เราตัดสินใจเขียน signal processing engine ของเราขึ้นใหม่ — คอมโพเนนต์ที่แปลงสัญญาณดิบจากเบราว์เซอร์ให้เป็น feature vector ที่ผ่านการ normalize และ hash ได้ — จาก Go เป็น Rust นี่ไม่ใช่การตัดสินใจที่เราทำอย่างง่าย ๆ implementation แบบ Go ของเราทำงานได้ ผ่านการทดสอบมาแล้ว และถูก deploy ใช้งานจริง แต่มันได้กลายเป็นคอขวดใน pipeline ของเรา และเราต้องการการปรับปรุง throughput แบบก้าวกระโดด นี่คือสิ่งที่เกิดขึ้น
ทำไมเราถึงโตเกิน Go
Signal processor ของเราทำงานที่ใช้การคำนวณหนัก: parse JSON payload, ใช้ฟังก์ชัน normalization กับสัญญาณกว่า 130 รายการ, คำนวณ hash เฉพาะของเรา และสร้าง identification hash vector ใน Go งานนี้เป็นแบบ CPU-bound และ garbage collector ของ Go กลายเป็นปัญหาเมื่อขยายสเกล ทุกรอบของการประมวลผลสัญญาณจะจองอ็อบเจ็กต์ระหว่างทาง — JSON node ที่ parse แล้ว, ค่าสตริงที่ normalize แล้ว, hash buffer — ซึ่งสร้างแรงกดดันต่อ GC
ที่ 30K events/วินาที signal processor แบบ Go ของเราแสดง GC pause ที่ 2-5ms ทุกไม่กี่วินาที การหยุดเหล่านี้ยังยอมรับได้ ที่ 50K events/วินาที GC pause เพิ่มเป็น 8-15ms และเกิดถี่ขึ้น ที่ 80K events/วินาที — โหลดที่เราคาดการณ์ไว้สำหรับ Q3 — GC pause จะทำให้ p99 latency เกิน SLA ของเรา เราจึงต้องการเซิร์ฟเวอร์เพิ่ม (แพง) หรือ implementation ที่มีประสิทธิภาพกว่าเดิม
ทำไมต้อง Rust
เราประเมินสามทางเลือก: ปรับแต่ง implementation แบบ Go (sync.Pool, arena allocation, ปรับ GOGC), เขียนใหม่เป็น C++ และเขียนใหม่เป็น Rust การปรับแต่ง Go ให้ผลดีขึ้น 30% แต่ไม่ได้แก้ปัญหา GC อย่างพื้นฐาน C++ ถูกปฏิเสธเพราะกังวลเรื่อง memory safety ในระบบที่สำคัญด้านความปลอดภัย ส่วน Rust ให้ zero-cost abstractions ไม่มี garbage collector และการันตี memory safety ที่บังคับตั้งแต่ตอน compile
ระบบนิเวศของ Rust ยังมีไลบรารีที่พร้อมใช้สำหรับทุกอย่างที่เราต้องการ: serde สำหรับ parse JSON, crate สำหรับ hashing ประสิทธิภาพสูง และ tokio สำหรับ async I/O เส้นโค้งการเรียนรู้เป็นเรื่องจริง — ทีมของเรามีประสบการณ์ Go ลึกซึ้งแต่มีประสบการณ์ Rust จำกัด — แต่คุณลักษณะด้านประสิทธิภาพคือสิ่งที่เราต้องการพอดี
กระบวนการเขียนใหม่
เราเขียน signal processor ขึ้นใหม่เป็นบริการแบบ standalone ที่สื่อสารกับส่วนที่เหลือของ pipeline ผ่าน gRPC สิ่งนี้ทำให้เรา deploy มันควบคู่ไปกับ implementation แบบ Go และค่อย ๆ ย้าย traffic ได้ การเขียนใหม่ใช้วิศวกร 3 คนเป็นเวลา 4 สัปดาห์ — สองสัปดาห์สำหรับ implementation หลัก และสองสัปดาห์สำหรับการทดสอบ, benchmark และการจัดการ edge case
ส่วนที่ท้าทายที่สุดไม่ใช่ตัวภาษาเอง แต่คือการทำให้พฤติกรรมตรงกัน (parity) กับ implementation แบบ Go เราสร้าง comparison harness ที่รันทั้งสอง implementation ด้วย input เดียวกัน และตรวจสอบว่าให้ output เหมือนกัน เราค้นพบความแตกต่างเล็ก ๆ 14 จุดในกระบวนการนี้ — ส่วนใหญ่เกี่ยวข้องกับการจัดการ floating-point, การ normalize Unicode และ edge case ของการ parse JSON
ผลลัพธ์ด้านประสิทธิภาพ
Implementation แบบ Rust ประมวลผลสัญญาณที่เฉลี่ย 0.8ms เทียบกับ 3.2ms ของ Go — ดีขึ้น 4 เท่า การใช้หน่วยความจำลดจาก 2.1GB เหลือ 340MB สำหรับ workload เดียวกัน ไม่มี GC pause เพราะไม่มี garbage collector การใช้งาน CPU ลดลง 60% ที่ throughput เท่าเดิม หมายความว่าแต่ละเซิร์ฟเวอร์รองรับ traffic ได้มากขึ้น 4 เท่า
ที่ 80K events/วินาที implementation แบบ Rust รักษาเวลาประมวลผล p99 ไว้ที่ 1.4ms โดยไม่มีการหยุดเลย พื้นที่เผื่อนี้หมายความว่าเราจะไม่ต้องกลับมาทบทวนประสิทธิภาพของการประมวลผลสัญญาณไปอีกนาน การลดการใช้ CPU และหน่วยความจำยังแปลตรง ๆ เป็นต้นทุนโครงสร้างพื้นฐานที่ต่ำลง — เราปลดระวางเซิร์ฟเวอร์ประมวลผลสัญญาณไป 8 จาก 12 ตัว
บทเรียนที่ได้
การเขียนใหม่เป็น Rust นั้นคุ้มค่าสำหรับกรณีเฉพาะของเรา — workload ที่เป็น CPU-bound, จองหน่วยความจำหนัก และไวต่อ latency เราจะไม่เขียน HTTP ingestion layer หรือบริการ query ของ ClickHouse ขึ้นใหม่เป็น Rust เพราะคอมโพเนนต์เหล่านั้นเป็นแบบ I/O-bound และ Go จัดการมันได้อย่างมีประสิทธิภาพ บทเรียนไม่ใช่ "เขียนทุกอย่างใหม่เป็น Rust" แต่คือ "ใช้ Rust ในที่ที่ zero-cost abstractions และประสิทธิภาพที่คาดเดาได้ของมันมีความสำคัญที่สุด"
เรื่องที่น่าประหลาดใจที่สุดคือ compiler ของ Rust จับปัญหาได้มากแค่ไหนระหว่างการเขียนใหม่ บั๊กที่ซ่อนอยู่หลายจุดใน implementation แบบ Go ของเรา — race condition บน buffer ที่ใช้ร่วมกัน, integer overflow ในการคำนวณ hash และการเข้าถึงข้อมูลนอกขอบเขต (out-of-bounds) บน input ที่ผิดรูปแบบ — ถูกจับได้เป็น compile-time error ใน Rust compiler นั้นเรื่องมาก แต่มันคุ้มค่าตัวเองด้วยความถูกต้อง
พูดตามตรง สัปดาห์แรกนั้นเจ็บปวด Sarah จดยอด "การต่อสู้กับ borrow checker" ไว้บนไวท์บอร์ด — เราแตะ 47 ครั้งก่อนที่ทีมจะเลิกนับ แต่พอถึงสัปดาห์ที่สาม โค้ดที่ compile ผ่านก็ทำงานได้เลย ไม่มี production panic ลึกลับ ไม่มี data race ภายใต้โหลด การแลกเปลี่ยนนั้นคุ้มค่าสำหรับอะไรก็ตามที่อยู่บน hot path