Xây dựng hệ thống khớp lệnh crypto đáng tin cậy
Độ tin cậy khi khớp lệnh crypto đến từ máy trạng thái, khóa idempotency và đối soát không ngừng nghỉ. Bài học từ việc xây dựng MadaiOps.
By Vitor Lima
Hầu hết các sản phẩm giao dịch crypto bán cho bạn tốc độ và một biểu đồ. Phần khó không phải là vẽ những cây nến. Phần khó là có thể trả lời, tại bất kỳ mili-giây nào, một câu hỏi tưởng chừng đơn giản: trạng thái thật sự của lệnh của tôi ngay lúc này là gì? Khi chúng tôi bắt đầu xây dựng MadaiOps, ứng dụng vận hành lệnh và giao dịch nội bộ của mình, câu hỏi đó chi phối mọi thứ. Tốc độ là một tính năng. Sự thật mới là sản phẩm. Độ tin cậy khi khớp lệnh crypto là thứ bạn có khi ứng dụng có thể trả lời câu hỏi đó một cách trung thực ngay cả khi sàn giao dịch đang xuống cấp, và một ứng dụng hiển thị một lệnh đã khớp mà thực ra chưa xảy ra — hoặc che giấu một lệnh đã khớp thật — sẽ hủy hoại niềm tin theo cách mà không chiến thắng nào về độ trễ có thể mua lại được.
Bài viết này nói về phần kỹ thuật khiến một ứng dụng quản lý lệnh trở nên đáng tin cậy: máy trạng thái, tính idempotency, đối soát qua WebSocket, và những chế độ hỏng hóc chỉ lộ ra khi hệ thống chịu tải. Chúng tôi có quan điểm rõ ràng về tất cả những điều này, bởi những lựa chọn nhàm chán mới là thứ giữ cho bạn không mất khả năng thanh toán.
Mô hình hóa lệnh như một máy trạng thái tường minh
Quyết định đúng đắn nhất mà chúng tôi từng đưa ra là ngừng xem một lệnh như một dòng dữ liệu mà bạn UPDATE và bắt đầu xem nó như một máy trạng thái hữu hạn với các phép chuyển đổi được đặt tên và liệt kê rõ ràng. Một lệnh tồn tại trong các trạng thái như PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED, và EXPIRED. Mỗi phép chuyển đổi là một hàm của trạng thái hiện tại cộng với một sự kiện đầu vào, và các phép chuyển đổi không hợp lệ bị từ chối một cách rõ ràng thay vì âm thầm nuốt vào.
Phần thưởng: sự mơ hồ trở thành mối lo lúc kiểm tra tính hợp lệ thay vì một sự cố lúc 2 giờ sáng. Một lệnh FILLED không bao giờ có thể quay lại WORKING. Một sự kiện khớp lệnh đến cho một lệnh CANCELED không phải là điều "bất khả thi" — đó là một tình huống tranh chấp có thật, và máy trạng thái buộc bạn phải quyết định nó có nghĩa là gì. Thường thì nó có nghĩa là lệnh hủy đã thua trong cuộc đua, lệnh thực sự đã khớp, và trạng thái CANCELED cục bộ của bạn đã sai. Nếu mô hình hóa lệnh như những dòng dữ liệu có thể thay đổi thì sự kiện đó chỉ đơn giản ghi đè lên thứ gì đó; bạn không bao giờ nhận ra cho đến khi việc đối soát — hoặc một người dùng — phát hiện ra.
Điều rút ra
Hãy liệt kê các trạng thái của bạn trước khi viết dù chỉ một lời gọi API. Nếu bạn không thể vẽ được sơ đồ trên bảng trắng, thì bạn chưa hiểu lĩnh vực của mình, và sàn giao dịch sẽ dạy bạn theo cách khó khăn. Hãy lưu trữ các phép chuyển đổi, không chỉ trạng thái hiện tại. Một nhật ký sự kiện chỉ-thêm về những gì đã xảy ra sẽ tốt hơn một cột trạng thái đơn có thể thay đổi, bởi vì nhật ký có thể phát lại được còn cột thì chỉ là phỏng đoán.
Idempotency không phải là tùy chọn
Mạng lưới thử lại. Logic thử lại của chính bạn cũng thử lại. Một người dùng nhấn đúp vào "Mua." Bất kỳ tình huống nào trong số này đều có thể gửi cùng một lệnh hai lần, và trên một sàn giao dịch, một lệnh trùng lặp là tiền thật. Biện pháp phòng vệ là một khóa idempotency do phía client tạo ra — hầu hết các sàn gọi nó là clientOrderId hoặc newClientOrderId — được gắn vào mọi lệnh ngay khoảnh khắc người dùng xác nhận, trước khi yêu cầu rời khỏi thiết bị hoặc máy chủ.
Quy tắc chúng tôi áp dụng: khóa được tạo ra một lần, được lưu trữ cục bộ trước lời gọi mạng, và được tái sử dụng nguyên văn trong mọi lần thử lại. Nếu lần thử đầu tiên hết thời gian chờ mà không có phản hồi, lần thử lại mang theo cùng một khóa, và sàn giao dịch hoặc tạo lệnh một lần hoặc trả về lệnh đã tồn tại. Dù thế nào, bạn cũng hội tụ về một lệnh duy nhất. Tạo khóa sau khi thất bại nghĩa là bạn vừa xây một cỗ máy tạo lệnh trùng lặp.
Điều này áp dụng cả cho việc hủy và sửa lệnh. Một yêu cầu hủy cũng cần phải có tính idempotent: hủy một lệnh đã bị hủy là một thao tác không làm gì cả và trả về thành công, chứ không phải một lỗi kích hoạt thêm một vòng thử lại. Chúng tôi xem "trạng thái cuối mong muốn đã là đúng" là thành công ở mọi nơi. Nghe có vẻ tầm thường. Nhưng nó loại bỏ cả một loại "bão thử lại" (retry storm).
Điều rút ra
Hãy lưu khóa idempotency trước khi hành động, không phải sau khi thành công. Khoảng cách giữa "tôi đã gửi nó" và "tôi biết nó đã thành công" chính là nơi tiền biến mất, và khóa là thứ duy nhất khép lại khoảng cách đó.
Đối soát không ngừng nghỉ: WebSocket là một gợi ý, không phải sự thật
Các sàn giao dịch đẩy các cập nhật lệnh qua WebSocket, và chúng thật tuyệt vời khi hoạt động: khớp lệnh dưới một giây, số lượng khớp một phần theo thời gian thực, xác nhận hủy. Nhưng một WebSocket là một luồng mà bạn có thể bỏ lỡ. Bạn sẽ bị mất kết nối. Các tin nhắn đến không đúng thứ tự, bị rớt trong lúc kết nối lại, hoặc đơn giản là không bao giờ đến vì sàn đang xuống cấp. Nếu trạng thái lệnh của bạn chỉ là bất cứ điều gì socket nói với bạn lần cuối, thì ứng dụng của bạn sai một cách tự tin ngay khoảnh khắc socket trục trặc.
Vì vậy chúng tôi chạy hai vòng lặp. Vòng nhanh tiêu thụ WebSocket và áp dụng các sự kiện một cách lạc quan. Vòng chậm gọi REST API theo chu kỳ — GET /order và các endpoint lệnh đang mở — theo một khoảng thời gian nhất định và sau mỗi lần kết nối lại, rồi đối soát ảnh chụp đó với trạng thái cục bộ. REST là nguồn sự thật; socket là một gợi ý có độ trễ thấp. Khi chúng bất đồng, REST thắng và chúng tôi ghi lại sự sai lệch, bởi vì một sự sai lệch là một báo cáo lỗi mà hệ thống tự viết cho bạn.
Thứ tự rất quan trọng. Hầu hết các sàn gắn dấu cho các cập nhật bằng một số thứ tự đơn điệu tăng dần hoặc một updateTime. Chúng tôi loại bỏ bất kỳ sự kiện nào cũ hơn sự kiện áp dụng gần nhất cho lệnh đó, điều này khiến việc chuyển giao không đúng thứ tự trở nên vô hại. Khi kết nối lại, hành động đúng là: đăng ký lại, ngay lập tức lấy một ảnh chụp REST đầy đủ để lấp đầy khoảng trống, rồi mới tin tưởng luồng trực tiếp trở lại. Bỏ qua ảnh chụp và bạn sẽ bỏ lỡ lệnh đã khớp trong hai giây bạn ngoại tuyến.
Điều rút ra
Đừng bao giờ để một luồng dữ liệu thời gian thực là thứ duy nhất ghi trạng thái của bạn. Socket khiến bạn nhanh; vòng lặp đối soát khiến bạn đúng. Bạn cần cả hai, và khi chúng xung đột, đúng luôn thắng nhanh.
Khớp một phần và mất kết nối: hai thứ phơi bày bạn
Khớp một phần là nơi những ứng dụng ngây thơ âm thầm nói dối. Một lệnh mua 1.0 BTC khớp 0.3, rồi 0.4, rồi treo lại không phải là "đang mở" và cũng không phải "đã xong." Nó là PARTIALLY_FILLED với 0.7 đã thực hiện và 0.3 còn lại, và từng con số trong đó đều phải được theo dõi từ các sự kiện khớp riêng lẻ, chứ không suy ra từ một trường trạng thái đơn. Chúng tôi cộng dồn số lượng đã thực hiện và giá trung bình có trọng số theo khối lượng từ các lần khớp rời rạc, để vị thế luôn có thể tái dựng lại từ những thành phần nguyên thủy. Một người dùng nhìn thấy "đã khớp" cần biết đã khớp bao nhiêu, ở mức giá trung bình nào. Bất cứ điều gì kém hơn đều là một nửa sự thật.
Mất kết nối làm điều này tồi tệ hơn. Bị rớt mạng giữa lúc đang khớp và WebSocket chưa bao giờ báo cho bạn về 0.4, thế là trạng thái cục bộ của bạn đã lỗi thời. Đây chính xác là lý do tồn tại của kỷ luật kết-nối-lại-rồi-chụp-ảnh: ảnh chụp REST trả về số lượng tích lũy đã thực hiện, và vòng lặp đối soát khép lại khoảng trống mà người dùng không bao giờ thấy một con số sai. Chế độ hỏng hóc mà chúng tôi thiết kế để chống lại không phải là "ứng dụng bị sập" — sập là trung thực. Đó là "ứng dụng vẫn chạy và hiển thị một thứ sai."
Điều rút ra
Hãy theo dõi số lượng tích lũy đã thực hiện như sự thật chính và suy ra mọi thứ khác từ đó. Đừng bao giờ lưu "số lượng còn lại" như một trường độc lập mà bạn thay đổi. Hãy suy ra nó (đã đặt − đã thực hiện) để nó không thể trôi lệch khỏi thực tế.
Trung thực dưới áp lực thắng thông minh
Luôn có một cám dỗ dai dẳng để tỏ ra thông minh: dự đoán khớp lệnh trước khi xác nhận, giấu đi những lỗi "thoáng qua", làm mượt một lần kết nối lại để giao diện không bao giờ nhấp nháy. Chúng tôi đã học cách chống lại hầu hết những điều đó. Một người vận hành giao dịch không muốn một lời nói dối mượt mà; họ muốn biết, một cách trung thực, rằng kết nối vừa bị rớt và trạng thái đang được đối soát. Một trạng thái "đang kết nối lại, đang xác minh các lệnh" hiển thị rõ ràng có giá trị hơn một giao diện liền mạch mà hóa ra đang hiển thị sự thật của ngày hôm qua.
Cụ thể, chúng tôi phơi bày sự bất định thay vì che đậy nó. Khi trạng thái cục bộ và sàn giao dịch chưa được đối soát gần đây, ứng dụng nói ra điều đó. Khi một lệnh đang ở CANCEL_REQUESTED nhưng chưa được xác nhận là đã hủy, nó hiển thị đúng như vậy, không phải một CANCELED sớm. Giao diện lạc quan thì ổn cho một ứng dụng danh sách việc cần làm. Với tiền bạc, sự lạc quan là một trách nhiệm pháp lý mà bạn phải trả bằng các ticket hỗ trợ và niềm tin đã mất.
Điều rút ra
Khi nghi ngờ, hãy hiển thị sự nghi ngờ. Điều đáng an tâm nhất mà một ứng dụng tài chính có thể làm dưới áp lực là từ chối phỏng đoán.
Suy nghĩ cuối
Không điều nào trong số này là hào nhoáng. Không có buổi demo nào mà "các khóa idempotency của chúng tôi được lưu trước lời gọi mạng" nhận được tràng pháo tay. Nhưng độ tin cậy không phải là một tính năng bạn thêm vào sau. Nó là tập hợp các quyết định bạn đưa ra từ sớm và từ chối thỏa hiệp, rồi bảo vệ mỗi khi một lối tắt trông có vẻ hấp dẫn. Hãy xây dựng hệ thống nói lên sự thật khi mọi thứ đang bốc cháy, và những phần nhanh, đẹp sẽ tự lo được cho chính chúng.