Oluwa.io
全部文章
·1 分钟阅读工程加密货币可靠性交易

打造可靠的加密货币订单执行系统

加密货币订单执行的可靠性源自状态机、幂等键以及持续不懈的对账。这是我们构建 MadaiOps 过程中总结出的经验。

作者 Vitor Lima

大多数加密货币交易产品卖的是速度和一张图表。真正困难的部分并不是把 K 线画出来,而是能够在任意一毫秒回答一个看似简单的问题:我的订单此刻的真实状态到底是什么? 当我们开始构建内部订单与交易运营应用 MadaiOps 时,这个问题统领了一切。速度是一项功能,真相才是产品。所谓加密货币订单执行的可靠性,就是当交易场所正在劣化时,应用依然能够诚实地回答这个问题;而一个显示了从未发生的成交,或隐藏了确实发生的成交的应用,会以任何延迟优势都无法挽回的方式摧毁信任。

这篇文章讲的是让一款订单应用值得信赖的工程实践:状态机、幂等性、基于 WebSocket 的对账,以及那些只有在高负载下才会暴露的故障模式。我们对这一切都有鲜明的立场,因为那些看似枯燥的选择,恰恰是让你保持偿付能力的关键。

把订单建模成一个显式的状态机

我们做出的最正确的一个决定,就是不再把订单当作一行你去 UPDATE 的记录,而是把它当作一个带有命名、可枚举状态转移的有限状态机。一个订单存在于诸如 PENDING_NEWWORKINGPARTIALLY_FILLEDFILLEDCANCEL_REQUESTEDCANCELEDREJECTEDEXPIRED 这样的状态之中。每一次转移都是当前状态加上一个入站事件的函数,非法的转移会被大声拒绝,而不是被悄悄吞掉。

回报是:歧义变成了一个在校验阶段就要处理的问题,而不是凌晨两点的一次事故。一个 FILLED 的订单永远不可能退回 WORKING。一个成交事件到达一个 CANCELED 的订单,并非"不可能"——它是一个真实的竞态,状态机会强迫你决定它意味着什么。通常它意味着撤单在竞速中输了,订单实际上已经成交,而你本地的 CANCELED 是错的。如果你把订单建模成可变的行,那个事件就只是覆盖了某个值;你根本不会察觉,直到对账——或者某个用户——发现它为止。

关键要点

在你写下第一个 API 调用之前,先把你的状态枚举出来。如果你没法在白板上画出这张图,那你还没有理解自己的领域,交易所会用惨痛的方式教会你。存储转移,而不仅仅是当前状态。一份只追加的、记录发生过什么的事件日志,胜过一个单一可变的状态列,因为日志是可重放的,而那个列只是一次猜测。

幂等性不是可选项

网络会重试。你自己的重试逻辑会重试。用户会双击"买入"。这其中任何一种情况都可能把同一个订单发送两次,而在交易所上,一个重复的订单就是真金白银。防御手段是一个客户端生成的幂等键——大多数交易所称之为 clientOrderIdnewClientOrderId——它在用户提交的那一刻就被附加到每一个订单上,早在请求离开设备或服务器之前。

我们强制执行的规则是:这个键只生成一次,在网络调用之前就本地持久化,并在每一次重试时被原封不动地复用。如果第一次尝试超时且没有任何响应,那么重试会携带同一个键,交易所要么只创建一次订单,要么返回已存在的那个。无论哪种情况,你都会收敛到唯一的一个订单。如果你在失败之后才生成键,那你就造出了一台重复下单机器。

这一点同样适用于撤单和修改。一个撤单请求也应该是幂等的:撤销一个已经被撤销的订单是一个返回成功的空操作,而不是一个触发另一轮重试循环的错误。我们在任何地方都把"期望的最终状态已经成立"视为成功。这听起来微不足道,但它消除了一整类重试风暴。

关键要点

在你行动之前持久化幂等键,而不是在你成功之后。"我发出去了"与"我知道它成功了"之间的那道缝隙,正是钱消失的地方,而幂等键是唯一能弥合这道缝隙的东西。

持续不懈地对账:WebSocket 是提示,不是真相

交易所通过 WebSocket 推送订单更新,当它正常工作时非常美妙:亚秒级成交、实时的部分成交数量、撤单确认。但 WebSocket 是一条你可能会错过的数据流。你一定会遇到断连。消息会乱序到达、在重连期间被丢弃,或者仅仅因为交易场所劣化而根本不来。如果你的订单状态取决于套接字最后告诉你的内容,那么在套接字打嗝的那一刻,你的应用就会自信地出错。

所以我们跑两个循环。快循环消费 WebSocket 并乐观地应用事件。慢循环按一定间隔、并在每次重连之后轮询 REST API——GET /order 和挂单端点——然后把这个快照与本地状态对账。REST 是真相之源;套接字是一个低延迟的提示。当二者不一致时,REST 获胜,我们记录下这个分歧,因为一个分歧就是系统替你写好的一份缺陷报告。

顺序很重要。大多数交易所会给更新打上一个单调递增的序列号或一个 updateTime。我们丢弃任何比该订单上一次已应用事件更旧的事件,这让乱序投递变得无害。重连时正确的做法是:重新订阅,立即拉取一份完整的 REST 快照来回填空缺,然后再重新信任实时流。如果你跳过快照,就会错过你离线那两秒里发生的成交。

关键要点

永远不要让一个实时数据源成为你状态的唯一写入方。套接字让你快;对账循环让你正确。两者你都需要,而当它们冲突时,正确每一次都胜过快。

部分成交与断连:暴露你的那两件事

部分成交正是幼稚应用悄悄撒谎的地方。一个 1.0 BTC 的订单,先成交 0.3,再成交 0.4,然后卡住,它既不是"未成交",也不是"已完成"。它是 PARTIALLY_FILLED,已执行 0.7,剩余 0.3,而这里的每一个数字都必须从各个独立的成交事件中追踪出来,而不是从单一的状态字段推断出来。我们从这些离散的成交中累加已执行数量和成交量加权平均价,这样仓位总是可以从原子事实中重建出来。一个看到"已成交"的用户,需要知道成交了多少、以什么平均价。任何低于这个标准的都是半真半假。

断连会让这一切雪上加霜。在成交进行到一半时掉线,WebSocket 从未告诉过你那 0.4,所以你的本地状态是过时的。这正是"重连后即取快照"这条纪律存在的原因:REST 快照返回累计已执行数量,对账循环在用户从未看到一个错误数字的情况下弥合空缺。我们所防范的故障模式并不是"应用崩溃了"——崩溃是诚实的。而是"应用继续运行,却显示了某些虚假的东西"。

关键要点

把累计已执行数量作为首要事实来追踪,其余一切都由它派生。永远不要把"剩余"存成一个你去修改的独立字段。用派生的方式(已下单 − 已执行)算出它,这样它就不可能与现实脱节漂移。

高压下的诚实胜过聪明

有一种持续存在的诱惑,让你想耍聪明:在确认之前就预测成交、隐藏"瞬时"错误、把一次重连平滑掉让 UI 永不闪烁。我们已经学会抵制其中的大部分。一个交易操作员不想要一个平滑的谎言;他们想要诚实地知道,连接刚刚断了,状态正在对账中。一个可见的"正在重连,正在核验订单"状态,比一个到头来显示的是昨天真相的无缝 UI 更有价值。

具体来说,我们把不确定性暴露出来,而不是把它糊过去。当本地状态与交易所最近没有对上账时,应用会如实说明。当一个订单处于 CANCEL_REQUESTED 但尚未确认撤销时,它会精确地显示这一点,而不是一个过早的 CANCELED。乐观式 UI 对一款待办事项应用没问题。但对于钱,乐观是一种负债,你要用工单和失去的信任来偿还。

关键要点

拿不准时,就把这份不确定性显示出来。一款金融应用在承压时能做的最令人安心的事,就是拒绝去猜。

结语

这一切都不光鲜。没有哪个演示会因为"我们的幂等键在网络调用之前就被持久化了"而赢得掌声。但可靠性不是一项你事后再加的功能。它是你在早期做出、并拒绝妥协的一整套决策,然后在每一次捷径看起来诱人时都去捍卫它。构建一个在一切都着火时仍然说真话的系统,那些快速、漂亮的部分自然会照顾好它们自己。