เราสร้าง Pipeline ต่ำกว่า 30ms ของ tracio.ai ได้อย่างไร
จากการเก็บสัญญาณสู่ visitor ID ภายในเวลาต่ำกว่า 30ms: สถาปัตยกรรมของเราที่ใช้ Go, ClickHouse, Redis และการประมวลผลแบบกระจาย
เมื่อเราตั้งใจสร้างเอนจินระบุตัวตนอุปกรณ์ของ tracio.ai เรามีข้อกำหนดหนึ่งข้อที่ต่อรองไม่ได้เลย นั่นคือ pipeline ทั้งหมด ตั้งแต่การรับสัญญาณที่เข้ารหัสไปจนถึงการคืนค่า visitor ID ต้องทำงานเสร็จภายในเวลาต่ำกว่า 30 มิลลิวินาที ที่ percentile ที่ 95 บทความนี้เป็นการอธิบายอย่างละเอียดถึงสถาปัตยกรรมที่เราสร้างขึ้นเพื่อให้บรรลุเป้าหมายนั้น
ภาพรวมของ Pipeline
Pipeline การระบุตัวตนมีทั้งหมดห้าขั้นตอน ได้แก่ การถอดรหัสสัญญาณ, การทำให้สัญญาณเป็นมาตรฐาน (normalization), การคำนวณ hash, การรีโซลฟ์อัตลักษณ์ และการทำ serialization ของการตอบกลับ แต่ละขั้นตอนได้รับการปรับให้เหมาะสมอย่างเป็นอิสระ และขั้นตอนที่สามารถทำงานแบบขนานได้ก็จะทำงานพร้อมกัน งบเวลารวมคือ 30ms จัดสรรคร่าว ๆ ดังนี้ การถอดรหัส 2ms, normalization 3ms, hashing 2ms, การรีโซลฟ์อัตลักษณ์ 20ms, serialization 1ms ส่วนอีก 2ms ที่เหลือเป็นบัฟเฟอร์
การถอดรหัสสัญญาณจะย้อนกระบวนการขนส่งข้อมูลที่เข้ารหัสฝั่งไคลเอนต์ เราใช้แพ็กเกจ crypto ของ Go พร้อมการเร่งความเร็วด้วยฮาร์ดแวร์ ซึ่งถอดรหัส payload ขนาดทั่วไป 4KB ได้เสร็จภายในเวลาต่ำกว่า 1ms ส่วน normalization จะแยกวิเคราะห์ JSON ของสัญญาณ ตรวจสอบชนิดข้อมูล และประยุกต์การแปลงเฉพาะแพลตฟอร์ม เช่น การทำ user agent string ให้เป็นมาตรฐานเพื่อกำจัดสัญญาณรบกวนที่ขึ้นกับเวอร์ชัน
การรีโซลฟ์อัตลักษณ์แบบกระจาย
การรีโซลฟ์อัตลักษณ์ ซึ่งคือการพิจารณาว่าอุปกรณ์นี้เคยพบมาก่อนหรือไม่ เป็นขั้นตอนที่ไวต่อ latency มากที่สุด เราจัดเก็บโปรไฟล์อุปกรณ์ไว้ใน Redis โดยแบ่ง shard กระจายไปทั่วคลัสเตอร์ด้วยเลเยอร์การกำหนดเส้นทาง key แบบกระจาย การกำหนดเส้นทางนี้กระจาย key ตามลายนิ้วมือระดับชั้นฮาร์ดแวร์ (hardware-tier fingerprint) ซึ่งรับประกันว่าการค้นหาสำหรับอุปกรณ์เดียวกันจะไปถึง Redis node เดิมเสมอ
การทำ sharding ของเราใช้ virtual node (150 ตัวต่อ physical node หนึ่งตัว) เพื่อรับประกันการกระจายที่สม่ำเสมอ เมื่อมีการเพิ่มหรือลบ node จะมีเพียง 1/N ของ key ที่ต้องถูกรีแมป โดย N คือจำนวน node เราพัฒนาเลเยอร์การกำหนดเส้นทางนี้ด้วย Go ที่มีเวลาค้นหาแบบ O(log n) และไม่มีการจัดสรรหน่วยความจำ (zero allocations)
Redis ในฐานะที่เก็บอัตลักษณ์
เราเลือก Redis แทนทางเลือกอื่น (Memcached, ScyllaDB, DynamoDB) เพราะเวลาตอบสนองที่คงที่ในระดับต่ำกว่ามิลลิวินาที และการรองรับโครงสร้างข้อมูลที่ซับซ้อน โปรไฟล์อุปกรณ์แต่ละตัวถูกจัดเก็บเป็น Redis hash ที่มีฟิลด์สำหรับ hash ของแต่ละชั้นสัญญาณ, visitor ID, timestamp ที่พบล่าสุด และเมทาดาทาความเชื่อมั่น (confidence metadata)
คิวรีการรีโซลฟ์อัตลักษณ์คือการเรียก HGETALL เพียงครั้งเดียว ตามด้วยการเปรียบเทียบ hash ของสัญญาณขาเข้ากับ hash ที่จัดเก็บไว้ หากชั้นฮาร์ดแวร์ตรงกัน เราจะคืนค่า visitor ID เดิมพร้อมความเชื่อมั่นสูง หากมีเพียงชั้นซอฟต์แวร์ที่ตรงกัน เราจะทำการเปรียบเทียบความคล้ายคลึงของข้อมูลระดับสัญญาณเพื่อพิจารณาว่านี่คืออุปกรณ์เดิมที่มีการอัปเดตเบราว์เซอร์หรือไม่ หากไม่มีอะไรตรงกันเลย เราจะสร้าง visitor ID ใหม่ขึ้นมา
ClickHouse สำหรับการจัดเก็บ Event
ทุก event ของการระบุตัวตนจะถูกเขียนลง ClickHouse แบบ asynchronous เราใช้ตัวเขียนแบบบัฟเฟอร์ที่จัดกลุ่ม (batch) การ insert โดยรวบรวม event เป็นเวลา 100ms หรือจนกว่าจะสะสมได้ 1,000 events แล้วแต่ว่าอย่างใดถึงก่อน การจัดกลุ่มนี้สำคัญมาก เพราะ ClickHouse ทำงานได้ดีที่สุดกับการ insert ขนาดใหญ่ (หลายพันแถวต่อครั้ง) มากกว่าการ insert ทีละแถว
Schema ของ ClickHouse ของเราถูกปรับให้เหมาะกับรูปแบบคิวรีที่พบบ่อยที่สุดสองแบบ ได้แก่ การค้นหา event ทั้งหมดของ visitor ID ที่ระบุ และการรวมข้อมูล event (aggregate) ตามช่วงเวลา เราใช้เอนจิน MergeTree ที่มี primary key เป็น (visitor_id, timestamp) ซึ่งให้การค้นหาแบบจุด (point lookup) ที่รวดเร็วและการสแกนช่วง (range scan) ที่มีประสิทธิภาพ ส่วน materialized view จะดูแลเมทริกรายวันและรายชั่วโมงที่รวมข้อมูลไว้ล่วงหน้า
การบรรลุระดับต่ำกว่า 30ms ในสเกลใหญ่
การตัดสินใจด้านสถาปัตยกรรมสามข้อมีความสำคัญต่อการบรรลุเป้าหมาย latency ของเรา ข้อแรก pipeline เป็นแบบ streaming เต็มรูปแบบ เราเริ่มประมวลผลสัญญาณก่อนที่จะได้รับ body ของ HTTP request ทั้งหมด ข้อสอง การค้นหาบน Redis ใช้ connection pooling ด้วยการเชื่อมต่อแบบถาวร กำจัด overhead ของ TCP handshake ข้อสาม การเขียนลง ClickHouse เป็นแบบ asynchronous เต็มรูปแบบและไม่เคยบล็อกเส้นทางการตอบกลับ
ภายใต้การทดสอบโหลดที่ 50K requests/วินาที latency p50 ของเราคือ 12ms, p95 คือ 24ms และ p99 คือ 38ms ค่า p99 บางครั้งอาจเกินเป้าหมาย 30ms ของเราในระหว่างการปรับสมดุล (rebalancing) คลัสเตอร์ Redis แต่ p95 ยังคงต่ำกว่า 30ms อย่างสม่ำเสมอ สำหรับลูกค้าที่มีข้อกำหนด latency เข้มงวดกว่านี้ เรามีบริการคลัสเตอร์ Redis เฉพาะที่กำจัดการแย่งชิงทรัพยากรแบบ multi-tenant