Oluwa.io
All writing
·2 min readวิศวกรรมคริปโตความน่าเชื่อถือการเทรด

การส่งมอบระบบดำเนินคำสั่งซื้อขายคริปโตที่เชื่อถือได้

ความน่าเชื่อถือของการดำเนินคำสั่งซื้อขายคริปโตเกิดจาก state machine, idempotency key และการกระทบยอดอย่างไม่ลดละ บทเรียนจากการสร้าง MadaiOps

By Vitor Lima

ผลิตภัณฑ์เทรดคริปโตส่วนใหญ่ขายเรื่องความเร็วและกราฟ ส่วนที่ยากไม่ใช่การวาดแท่งเทียน แต่คือการที่จะสามารถตอบได้ในทุกมิลลิวินาที ต่อคำถามที่ดูเหมือนเรียบง่ายอย่างน่าหลอกลวงว่า สถานะที่แท้จริงของคำสั่งซื้อของฉันตอนนี้เป็นอย่างไร? ตอนที่เราเริ่มสร้าง MadaiOps ซึ่งเป็นแอปคำสั่งซื้อและการดำเนินงานเทรดภายในของเรา คำถามนั้นเป็นตัวจัดระเบียบทุกอย่าง ความเร็วคือฟีเจอร์ ความจริงคือผลิตภัณฑ์ ความน่าเชื่อถือของการดำเนินคำสั่งซื้อขายคริปโตคือสิ่งที่คุณมีเมื่อแอปสามารถตอบคำถามนั้นได้อย่างซื่อสัตย์แม้ในขณะที่ตลาดกำลังทำงานได้ไม่ดี และแอปที่แสดงการจับคู่คำสั่งที่ไม่ได้เกิดขึ้นจริง — หรือซ่อนการจับคู่ที่เกิดขึ้นแล้ว — จะทำลายความไว้วางใจในแบบที่ชัยชนะด้านความหน่วงใด ๆ ก็ซื้อคืนกลับมาไม่ได้

โพสต์นี้ว่าด้วยงานวิศวกรรมที่ทำให้แอปคำสั่งซื้อน่าเชื่อถือ ได้แก่ state machine, idempotency, การกระทบยอดผ่าน WebSocket และรูปแบบความล้มเหลวที่จะปรากฏก็ต่อเมื่ออยู่ภายใต้ภาระงานหนักเท่านั้น เรามีจุดยืนที่ชัดเจนกับทั้งหมดนี้ เพราะทางเลือกที่น่าเบื่อคือทางเลือกที่ทำให้คุณอยู่รอดทางการเงินได้

จำลองคำสั่งซื้อให้เป็น state machine ที่ชัดเจน

การตัดสินใจที่ดีที่สุดเพียงข้อเดียวที่เราทำ คือการเลิกมองคำสั่งซื้อว่าเป็นแถว (row) ที่คุณ UPDATE และเริ่มมองมันว่าเป็น finite state machine ที่มีการเปลี่ยนสถานะ (transition) ซึ่งถูกตั้งชื่อและระบุไว้อย่างชัดเจน คำสั่งซื้ออยู่ในสถานะต่าง ๆ เช่น PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED และ EXPIRED ทุกการเปลี่ยนสถานะเป็นฟังก์ชันของสถานะปัจจุบันบวกกับเหตุการณ์ที่เข้ามา และการเปลี่ยนสถานะที่ผิดกฎจะถูกปฏิเสธอย่างเสียงดัง แทนที่จะถูกดูดกลืนอย่างเงียบ ๆ

ผลตอบแทน คือความคลุมเครือกลายเป็นเรื่องที่ต้องจัดการในเวลาตรวจสอบความถูกต้อง (validation) แทนที่จะเป็นเหตุการณ์ฉุกเฉินตอนตีสอง คำสั่งซื้อที่เป็น FILLED จะไม่มีทางกลับไปเป็น WORKING ได้เลย เหตุการณ์การจับคู่ (fill) ที่มาถึงสำหรับคำสั่งซื้อที่เป็น CANCELED ไม่ใช่เรื่อง "เป็นไปไม่ได้" — มันคือ race condition ที่เกิดขึ้นจริง และ state machine บังคับให้คุณตัดสินใจว่ามันหมายความว่าอย่างไร โดยปกติมันหมายความว่าการยกเลิกแพ้ในการแข่งขัน คำสั่งซื้อถูกจับคู่จริง และสถานะ CANCELED ในเครื่องของคุณผิดพลาด หากจำลองคำสั่งซื้อเป็นแถวที่เปลี่ยนแปลงได้ เหตุการณ์นั้นก็แค่เขียนทับบางอย่าง คุณจะไม่มีวันสังเกตเห็นจนกว่าการกระทบยอด — หรือผู้ใช้ — จะจับได้

บทสรุปที่ได้

จงระบุสถานะทั้งหมดของคุณก่อนที่คุณจะเขียน API call แม้แต่ครั้งเดียว ถ้าคุณวาดไดอะแกรมบนไวท์บอร์ดไม่ได้ แสดงว่าคุณยังไม่เข้าใจโดเมนของคุณ และตลาดจะสอนคุณด้วยวิธีที่เจ็บปวด จงจัดเก็บ การเปลี่ยนสถานะ ไม่ใช่แค่สถานะปัจจุบัน บันทึกเหตุการณ์แบบต่อท้ายอย่างเดียว (append-only event log) ของสิ่งที่เกิดขึ้น ดีกว่าคอลัมน์สถานะที่เปลี่ยนแปลงได้เพียงคอลัมน์เดียว เพราะบันทึกนั้นเล่นซ้ำได้ ส่วนคอลัมน์นั้นเป็นเพียงการเดา

Idempotency ไม่ใช่ทางเลือก

เครือข่ายลองใหม่ ตรรกะการลองใหม่ของคุณเองก็ลองใหม่ ผู้ใช้กด "ซื้อ" สองครั้งติดกัน สิ่งเหล่านี้ล้วนส่งคำสั่งซื้อเดียวกันสองครั้งได้ และบนตลาดแลกเปลี่ยน คำสั่งซื้อที่ซ้ำกันคือเงินจริง ๆ เกราะป้องกันคือ idempotency key ที่สร้างขึ้นฝั่งไคลเอนต์ — ตลาดส่วนใหญ่เรียกมันว่า clientOrderId หรือ newClientOrderId — ที่แนบไปกับทุกคำสั่งซื้อในทันทีที่ผู้ใช้ยืนยัน ก่อนที่คำขอจะออกจากอุปกรณ์หรือเซิร์ฟเวอร์เสียด้วยซ้ำ

กฎที่เราบังคับใช้ คือ key ถูกสร้างขึ้นเพียงครั้งเดียว ถูกบันทึกไว้ในเครื่อง ก่อน ที่จะเรียกเครือข่าย และถูกนำกลับมาใช้ซ้ำแบบเหมือนเดิมทุกประการในทุกการลองใหม่ ถ้าความพยายามครั้งแรกหมดเวลา (timeout) โดยไม่มีการตอบกลับ การลองใหม่จะพก key เดิมไปด้วย และตลาดจะสร้างคำสั่งซื้อขึ้นเพียงครั้งเดียวหรือส่งคำสั่งที่มีอยู่แล้วกลับมา ไม่ว่าทางใดคุณก็จะบรรจบกันที่คำสั่งซื้อเดียว หากสร้าง key หลังจาก ความล้มเหลว คุณก็ได้สร้างเครื่องผลิตคำสั่งซื้อซ้ำขึ้นมาแล้ว

สิ่งนี้ขยายไปถึงการยกเลิกและการแก้ไขด้วย คำขอยกเลิกก็ควรเป็น idempotent เช่นกัน การยกเลิกคำสั่งซื้อที่ถูกยกเลิกไปแล้วคือ no-op ที่คืนค่าสำเร็จ ไม่ใช่ข้อผิดพลาดที่จะไปกระตุ้นลูปการลองใหม่อีกรอบ เราถือว่า "สถานะปลายทางที่ต้องการเป็นจริงอยู่แล้ว" คือความสำเร็จในทุกที่ ฟังดูเหมือนเรื่องเล็กน้อย แต่มันขจัดปัญหาพายุการลองใหม่ (retry storm) ทั้งหมวดหมู่

บทสรุปที่ได้

จงบันทึก idempotency key ก่อนที่คุณจะลงมือทำ ไม่ใช่หลังจากที่คุณสำเร็จ ช่องว่างระหว่าง "ฉันส่งมันไปแล้ว" กับ "ฉันรู้ว่ามันสำเร็จ" คือจุดที่เงินหายไปพอดี และ key คือสิ่งเดียวที่ปิดช่องว่างนั้นได้

จงกระทบยอดอย่างไม่ลดละ: WebSocket เป็นเพียงคำใบ้ ไม่ใช่ความจริง

ตลาดแลกเปลี่ยนส่งการอัปเดตคำสั่งซื้อผ่าน WebSocket และมันยอดเยี่ยมเมื่อมันทำงาน คือ การจับคู่ในเสี้ยววินาที ปริมาณการจับคู่บางส่วนแบบสด และการยืนยันการยกเลิก แต่ WebSocket คือสตรีมที่คุณพลาดได้ คุณจะถูกตัดการเชื่อมต่อ ข้อความมาถึงไม่เป็นลำดับ ถูกทิ้งระหว่างการเชื่อมต่อใหม่ หรือไม่มาถึงเลยเพราะตลาดทำงานได้ไม่ดี ถ้าสถานะคำสั่งซื้อของคุณคือ เพียงแค่ สิ่งที่ซ็อกเก็ตบอกล่าสุดเท่านั้น แอปของคุณก็จะผิดพลาดอย่างมั่นใจในทันทีที่ซ็อกเก็ตสะดุด

ดังนั้นเราจึงรันสองลูป ลูปเร็วบริโภค WebSocket และประยุกต์เหตุการณ์อย่างมองโลกในแง่ดี (optimistically) ลูปช้าจะ poll REST API — GET /order และ endpoint คำสั่งซื้อที่เปิดอยู่ — เป็นช่วง ๆ และหลังการเชื่อมต่อใหม่ทุกครั้ง แล้วกระทบยอดสแนปช็อตนั้นกับสถานะในเครื่อง REST คือแหล่งความจริง ซ็อกเก็ตเป็นเพียงคำใบ้ที่มีความหน่วงต่ำ เมื่อทั้งสองไม่ตรงกัน REST ชนะ และเราบันทึกความแตกต่างนั้นไว้ เพราะความแตกต่างคือรายงานบั๊กที่ระบบเขียนขึ้นให้คุณ

ลำดับสำคัญ ตลาดส่วนใหญ่ประทับตราการอัปเดตด้วยหมายเลขลำดับที่เพิ่มขึ้นเรื่อย ๆ (monotonic sequence number) หรือ updateTime เราทิ้งเหตุการณ์ใด ๆ ที่เก่ากว่าเหตุการณ์ล่าสุดที่ประยุกต์ไปแล้วสำหรับคำสั่งซื้อนั้น ซึ่งทำให้การส่งข้อมูลแบบไม่เรียงลำดับไม่เป็นอันตราย เมื่อเชื่อมต่อใหม่ การเคลื่อนไหวที่ถูกต้องคือ subscribe ใหม่ ดึงสแนปช็อต REST ฉบับเต็มทันทีเพื่อเติมช่องว่าง จากนั้น จึงกลับมาเชื่อถือสตรีมสดอีกครั้ง ถ้าข้ามการดึงสแนปช็อต คุณจะพลาดการจับคู่ที่เกิดขึ้นในช่วงสองวินาทีที่คุณออฟไลน์

บทสรุปที่ได้

อย่าปล่อยให้ฟีดแบบเรียลไทม์เป็นตัวเขียนสถานะเพียงตัวเดียวของคุณ ซ็อกเก็ตทำให้คุณเร็ว ลูปกระทบยอดทำให้คุณถูกต้อง คุณต้องการทั้งสองอย่าง และเมื่อมันขัดแย้งกัน ความถูกต้องเอาชนะความเร็วทุกครั้ง

การจับคู่บางส่วนและการตัดการเชื่อมต่อ: สองสิ่งที่เปิดโปงคุณ

การจับคู่บางส่วน (partial fill) คือจุดที่แอปแบบไร้เดียงสาโกหกอย่างเงียบ ๆ คำสั่งซื้อ 1.0 BTC ที่จับคู่ได้ 0.3 แล้ว 0.4 แล้วค้าง ไม่ใช่ "เปิดอยู่" และไม่ใช่ "เสร็จแล้ว" มันคือ PARTIALLY_FILLED ที่ดำเนินการไปแล้ว 0.7 และเหลืออีก 0.3 และตัวเลขทุกตัวเหล่านั้นต้องถูกติดตามจากเหตุการณ์การจับคู่แต่ละครั้ง ไม่ใช่อนุมานจากฟิลด์สถานะเดียว เราสะสมปริมาณที่ดำเนินการไปและราคาเฉลี่ยถ่วงน้ำหนักด้วยปริมาณ (volume-weighted average price) จากการจับคู่ย่อย ๆ ดังนั้นสถานะจึงสามารถสร้างขึ้นใหม่ได้จากค่าพื้นฐานเสมอ ผู้ใช้ที่เห็นคำว่า "จับคู่แล้ว" จำเป็นต้องรู้ว่าจับคู่ ไปเท่าไร ที่ราคาเฉลี่ยเท่าใด น้อยกว่านั้นคือความจริงเพียงครึ่งเดียว

การตัดการเชื่อมต่อยิ่งทำให้เรื่องนี้ซับซ้อนขึ้น ถ้าหลุดออฟไลน์กลางการจับคู่ WebSocket ก็ไม่เคยบอกคุณเรื่อง 0.4 นั้น สถานะในเครื่องของคุณจึงล้าสมัย นี่คือเหตุผลที่วินัยการเชื่อมต่อใหม่แล้วดึงสแนปช็อตมีอยู่ สแนปช็อต REST คืนค่าปริมาณสะสมที่ดำเนินการไปแล้ว และลูปกระทบยอดปิดช่องว่างโดยที่ผู้ใช้ไม่เคยเห็นตัวเลขที่ผิด รูปแบบความล้มเหลวที่เราออกแบบเพื่อรับมือไม่ใช่ "แอปแครช" — การแครชนั้นซื่อสัตย์ แต่มันคือ "แอปยังคงทำงานอยู่และแสดงบางอย่างที่ไม่จริง"

บทสรุปที่ได้

จงติดตามปริมาณสะสมที่ดำเนินการไปแล้วเป็นข้อเท็จจริงหลัก และหาค่าอื่น ๆ ทั้งหมดจากมัน อย่าจัดเก็บ "ปริมาณคงเหลือ" เป็นฟิลด์อิสระที่คุณเปลี่ยนแปลง จงคำนวณมัน (สั่ง − ดำเนินการแล้ว) เพื่อให้มันไม่หลุดออกจากความเป็นจริง

ซื่อสัตย์ภายใต้แรงกดดัน ดีกว่าฉลาดแกมโกง

มีสิ่งล่อใจที่คงอยู่เสมอที่จะทำตัวฉลาดแกมโกง คือ ทำนายการจับคู่ก่อนการยืนยัน ซ่อนข้อผิดพลาด "ชั่วคราว" กลบเกลื่อนการเชื่อมต่อใหม่เพื่อไม่ให้ UI กะพริบ เราเรียนรู้ที่จะต้านทานสิ่งเหล่านี้ส่วนใหญ่ ผู้ปฏิบัติการเทรดไม่ต้องการคำโกหกที่ราบรื่น เขาต้องการรู้อย่างซื่อสัตย์ว่าการเชื่อมต่อเพิ่งหลุด และสถานะกำลังกระทบยอด สถานะ "กำลังเชื่อมต่อใหม่ กำลังตรวจสอบคำสั่งซื้อ" ที่มองเห็นได้ มีค่ามากกว่า UI ที่ไร้รอยต่อแต่กลับกลายเป็นว่ากำลังแสดงความจริงของเมื่อวาน

พูดให้เป็นรูปธรรม เราเปิดเผยความไม่แน่นอนแทนที่จะปิดบังมัน เมื่อสถานะในเครื่องกับตลาดยังไม่ได้กระทบยอดกันเมื่อเร็ว ๆ นี้ แอปจะบอกเช่นนั้น เมื่อคำสั่งซื้อเป็น CANCEL_REQUESTED แต่ยังไม่ได้รับการยืนยันว่ายกเลิกแล้ว มันจะแสดงสิ่งนั้นพอดี ไม่ใช่ CANCELED ก่อนเวลาอันควร UI แบบมองโลกในแง่ดีนั้นใช้ได้กับแอปรายการสิ่งที่ต้องทำ แต่สำหรับเรื่องเงิน การมองโลกในแง่ดีคือหนี้สินที่คุณต้องจ่ายด้วยตั๋วซัพพอร์ตและความไว้วางใจที่สูญเสียไป

บทสรุปที่ได้

เมื่อสงสัย จงแสดงความสงสัยนั้น สิ่งที่ให้ความอุ่นใจที่สุดที่แอปการเงินทำได้ภายใต้ความกดดัน คือการปฏิเสธที่จะเดา

ความคิดปิดท้าย

ไม่มีสิ่งใดในนี้ที่หรูหรา ไม่มีการสาธิตไหนที่ประโยค "idempotency key ของเราถูกบันทึกก่อนเรียกเครือข่าย" จะได้รับเสียงปรบมือ แต่ความน่าเชื่อถือไม่ใช่ฟีเจอร์ที่คุณเพิ่มทีหลัง มันคือชุดการตัดสินใจที่คุณทำแต่เนิ่น ๆ และปฏิเสธที่จะประนีประนอม แล้วปกป้องมันทุกครั้งที่ทางลัดดูน่าดึงดูด จงสร้างระบบที่บอกความจริงเมื่อทุกอย่างกำลังลุกเป็นไฟ แล้วส่วนที่เร็วและสวยงามจะดูแลตัวมันเอง