กับดักของผลทดสอบ (Benchmark)

คนที่โพสต์ผล Benchmark มักไม่เคยแสดงภาพรวมทั้งหมดให้คุณเห็น พวกเขาวัดผลจาก handler ที่แทบไม่ได้ทำอะไรเลย เช่น JSON Hello World หรือข้อความ static string ในสภาวะสุญญากาศแบบนั้น Fiber ชนะขาดลอย เพราะมันรองรับ requests ต่อวินาทีได้มากกว่าและใช้หน่วยความจำน้อยกว่า Gin นั่นคือวิศวกรรมที่แท้จริง แต่แอปพลิเคชันของคุณไม่ใช่สภาวะสุญญากาศ

ผมกำลังสร้างแอปพลิเคชันด้านความปลอดภัยแบบเรียลไทม์ในเคนยา แอปนี้รับพิกัด GPS จากผู้ใช้ทั่ว Nairobi และ Mombasa แล้วส่งการแจ้งเตือนเกี่ยวกับจุดเสี่ยงอาชญากรรมหรืออุบัติเหตุทางถนน นั่นหมายถึงการเขียนข้อมูลลง PostgreSQL, การเรียกใช้ Firebase Cloud Messaging และการทำ reverse geocoding lookup เมื่อ stack ของคุณต้องมีการส่งข้อมูลผ่านเครือข่าย (network hops) และการรับส่งข้อมูลกับฐานข้อมูล (database round-trips) การที่ framework ประหยัดเวลาไปได้เพียงไม่กี่นาโนวินาทีในการทำ routing จึงแทบไม่มีความหมาย คอขวด (bottleneck) ไม่ใช่ตัว router แต่มันคือการที่ SMS gateway ทำงานล่าช้าจน timeout หรือเป็น query ของ PostGIS ที่กำลังสแกนตารางรายงานเหตุการณ์ต่างหาก ความเร็วในการประมวลผลดิบๆ (raw throughput) อาจดูน่าดึงดูด จนกว่ามันจะถูกนำไปใช้งานจริงในระดับ production

ความภักดีต่อ Standard Library

Gin สร้างขึ้นบน net/http โดยตรง ซึ่งเรื่องนี้สำคัญกว่าที่คิด

นักพัฒนา Go ทุกคนรู้จัก net/http การ debug ก็ทำตาม stack traces ที่คุ้นเคย Middleware ที่เขียนไว้เมื่อห้าปีที่แล้วยังสามารถนำมาใช้งานได้ทันทีโดยไม่ต้องปรับแต่งอะไรมากมาย วัตถุ (objects) ของ request และ response ทำงานตรงตามที่เอกสารของ Go ระบุไว้ทุกประการ Gin จึงมีความคาดเดาได้ (predictable) เพราะมันทำงานใกล้ชิดกับตัวภาษาเอง

Fiber เปลี่ยนรากฐานนั้นด้วย fasthttp ซึ่งเป็น HTTP engine ที่ปรับแต่งมาเพื่อประสิทธิภาพแบบ zero-allocation เพื่อให้บรรลุเป้าหมายนั้น มันจึงใช้ Request/Response Pooling แทนที่จะปล่อยให้ garbage collector ทำการคืนหน่วยความจำหลังจากแต่ละ request, Fiber จะนำ memory blocks กลับมาใช้ใหม่ (recycle) โดยการล้างข้อมูลเดิมออกแล้วส่งต่อให้กับการเชื่อมต่อถัดไปที่เข้ามา เทคนิคนี้คือที่มาของความเร็ว แต่มันก็คือที่มาของอันตรายเช่นกัน

อันตรายที่ซ่อนอยู่ของการใช้หน่วยความจำซ้ำ

ในทางทฤษฎี การทำ Pooling ฟังดูปลอดภัย แต่ในทางปฏิบัติ มันมาพร้อมกับข้อตกลงที่ละเอียดอ่อน นั่นคือ คุณต้องไม่ถือ reference ของข้อมูล request ไว้หลังจากที่ handler ทำงานเสร็จสิ้น หาก goroutine ทำงานนานกว่า request หรือหากคุณเก็บ slice ของ request body ไว้เพื่อประมวลผลในภายหลัง Fiber จะดึงหน่วยความจำนั้นกลับคืนและส่งต่อให้ผู้ใช้คนถัดไป

ผลลัพธ์ที่ได้คือการปนเปื้อนของข้อมูล (contamination) บั๊กนี้จะไม่ปรากฏในการทำ unit test แต่มันจะปรากฏในรูปแบบของพิกัด GPS ของคนขับรถใน Nairobi ที่จู่ๆ ก็ไปโผล่บนแผนที่ใน Kisumu หรือปรากฏในรูปแบบของ JWT token จากผู้ใช้คนหนึ่งที่รั่วไหลเข้าไปใน context ของผู้ใช้อีกคนหนึ่ง สิ่งเหล่านี้คือความล้มเหลวเชิงเวลา (temporal failures) ซึ่งจะหายไปเมื่อคุณเพิ่มการทำ logging เพราะการทำ logging จะมีการจองหน่วยความจำใหม่และทำให้จังหวะเวลา (timing) เปลี่ยนไป สุดท้ายคุณจะจบลงด้วยการไล่ตาม "ผี" ที่หาตัวไม่เจอ

สำหรับ Gin ตัว standard library จะจอง request object ใหม่เสมอ จึงไม่มี pool ให้เกิดการปนเปื้อน ความปลอดภัยนี้มีความสำคัญอย่างยิ่งเมื่อแอปพลิเคชันของคุณต้องจัดการกับตำแหน่งที่ตั้งจริงซึ่งเชื่อมโยงกับความปลอดภัยในชีวิตจริง

แรงดึงดูดของ Ecosystem

นอกจากนี้ยังมี "ภาษีเงียบ" ของความเข้ากันได้ (compatibility)

Middleware ส่วนใหญ่ใน Go ตั้งอยู่บนพื้นฐานของ net/http ไม่ว่าจะเป็น JWT validators, OpenTelemetry exporters, CORS handlers หรือ rate limiters ต่างก็ใช้ standard interface ทั้งสิ้น Gin เข้าใจ interface นั้นโดยธรรมชาติ เพียงแค่ใส่ middleware เข้าไปก็ใช้งานได้ทันที

Fiber มี context type และ handler signature เป็นของตัวเอง คุณจึงต้องใช้ adapter บางครั้ง adapter ก็เป็นตัวทางการ บางครั้งก็ล้าหลัง library ต้นทางไปเป็นปี และบางครั้งมันก็ทำงานต่างออกไปเล็กน้อยเมื่ออยู่ภายใต้โหลดหนักๆ เมื่อคุณต้องบริหารทีม