业务流程
前面的几篇文章已经陆续讲到了黑箱内部的一些设计,包括核心的软件结构、数据结构、目录结构等。而从本小节开始,我们将会更加深入,来解密黑箱内部的更多设计和实现细节。
解密黑箱的第一步就是要清楚其内部对数据的处理流程是怎样的。当我们要设计一个新系统的时候,也是一样的,第一步要梳理清楚业务流程和数据流向。对撮合引擎来说,就是要了解:从输入到输出,中间都经过了哪些处理流程。
前面的文章已经讲过,本撮合引擎定义了三种输入:开启撮合、处理订单、关闭撮合。后面就分别来看看这三种输入背后的流程。
开启撮合
开启撮合即是开启某个交易标的(交易对)的撮合引擎,未开启撮合的交易标的是无法处理订单的,而已经开启了撮合的交易标的也无法再次开启,不然就会出现同时有两个引擎处理同个交易标的的订单,这是不合理的,同个交易标的的订单只能由一个引擎串行来处理。
为什么不能并行呢?如果同一交易标的的订单可以用多个引擎并行处理的话,那至少会产生几个问题:
- **成交价以哪个为准?**理论上,每一时刻只能有一个成交价,那并行之后,就会产生多个成交价,那成交价就难以确定了。
- **如何维护统一的委托账本?**理论上,每个交易标的有一本保存了所有委托单的委托账本,那并行之后,如何在多个引擎之间维护这个统一的账本呢?如果用数据库统一维护,那无疑会减低撮合性能;如果分为多个子账本,那就很难保证价格优先、时间优先的原则。
以上这两个问题都不好解决,因此,只能先对所有订单进行定序,然后丢入引擎进行串行处理。
说到定序,自然就需要一个定序队列,因此开启撮合时需要初始化对应交易标的的订单定序队列。初始化好定序队列后,就可以真正启动对应交易标的的引擎了。在 Go 程序中,每个交易标的的引擎是以独立 goroutine 运行的;而在其他语言,比如 Java,则是以独立线程来运行。
引擎启动之后,需要先初始化交易委托账本,用来保存委托单。之后就等待定序队列有订单的时候逐个取出来处理了。
另外,再考虑一个场景,撮合程序重启时会发生什么?对于开启了撮合的交易标的,重启后是否需要恢复呢?需要的话,那如何恢复呢?最简单的方案当然是使用缓存,用 Redis 将开启了撮合的交易标的缓存起来,重启时从 Redis 加载并重新开启这些交易标的即可。
因此,触发开启撮合的场景其实有两个,一是接口的主动调用触发的,二是程序重启后从 Redis 缓存自动加载启动的。
最后,开启撮合的结果是同步返回的,因此,它没有异步的输出。
总结下,开启撮合的内部流程大致如下:
处理订单
开启撮合之后,就可以接收处理订单的输入了。撮合程序接收到处理订单的请求时,第一步需要做一些检查,包括每个参数是否有效、订单是否重复或存在、对应交易标的的引擎是否已经开启等。通过了检查之后,就可以将整个订单缓存到 Redis,接着添加到对应交易标的的定序队列中去,等待对应交易标的的引擎消费它进行撮合处理。这个流程如下图:
当订单成功添加到定序队列中后,接口就可以同步返回成功的响应结果了。后续的处理结果则是通过异步的 MQ 进行输出了。交易标的的引擎接收到订单后,根据不同情况会产生不同的输出结果。
我们知道,处理订单有两种 action:下单和撤单。撤单的业务逻辑很简单,就是从交易委托账本中查询该订单是否存在,若存在则从委托账本中删除该订单,然后输出撤单成功的撤单结果;若不存在则输出撤单失败的撤单结果。下单的业务逻辑则比较复杂,还要根据不同的订单类型作不同处理。写作此文时的撮合程序版本支持 6 种不同的 type,包括两种限价类型和四种市价类型。下面就来分别讲解不同订单类型的下单在不同条件下会有怎样的结果。
- limit:普通限价。当委托账本里存在能与该订单匹配成交的委托单时,则可能生成一条或多条成交记录,每条成交记录都将产生异步输出;当委托账本里没有可匹配的委托单时,则将该订单(全部数量或剩余数量)添加到委托账本中,这时不会产生任何输出。
- limit-ioc:IOC限价-即时成交剩余撤销。当委托账本里存在能与该订单匹配成交的委托单时,则可能生成一条或多条成交记录,每条成交记录都将产生异步输出;当委托账本里没有可匹配的委托单时,则将该订单(全部或剩余数量)进行撤单处理,这时会产生一条撤单成功的输出。
- market:默认市价-即时成交剩余撤销。和 IOC 限价一样,当委托账本里与该订单相反方向的订单队列里(也称对手方)存在委托单时,则可能生成一条或多条成交记录,每条成交记录都将产生异步输出;当委托账本里对手方没有委托单时,则将该订单(全部或剩余数量)进行撤单处理,这时会产生一条撤单成功的输出。与 IOC 限价不同的在于:IOC 限价订单是由用户指定了委托价格的,而市价则无需指定委托价格,会直接与对手方的头部委托单成交,直到该订单已全部成交或对手方再无委托单为止。
- market-top5:市价-最优五档即时成交剩余撤销。market 可以与对手方所有价格档位的订单成交,但 market-top5 最多只会和对手方的五个价格档位内的订单成交,超出五档外的订单将不会成交。剩余未成交的都将做撤单处理并产生一条撤单成功的输出。
- market-top10:市价-最优十档即时成交剩余撤销。最多只会和对手方的十个价格档位内的订单成交。
- market-opponent:市价-对手方最优价。如果对手方没有订单,则直接对该订单进行撤单处理并产生一条撤单成功的输出;如果对手方有订单,那最多只会成交一档,如果还剩有未成交的量,那将以对手方一档的价格转为限价单并添加到委托账本中,此时不会产生输出。
用图可表示如下:
另外,每个处理订单的请求——不管是下单还是撤单,也都会缓存到 Redis 里,产生变更时还会更新缓存。这样,程序重启后就可以恢复订单了。
关闭撮合
当某个交易标的准备下架、或取消交易、或暂停交易时,都需要关闭引擎。关闭引擎之前,上游服务最好先停止调用处理订单的接口,不然可能会出现一些非预期的错误,虽然程序已经做了容错处理。
关闭引擎时,同样也有些简单的判断,比如判断该交易标的的引擎是否已经开启,未开启的引擎自然无法关闭。
关闭引擎时,如果定序队列中还存在未处理的订单,那应该等这些订单处理完才真正关闭引擎。
最后,也要清除缓存,将该交易标的的所有订单都从缓存中清除。
关闭引擎的结果也是同步返回的,所有也没有异步的输出。
流程图也比较简答:
小结
本小节讲解了撮合黑箱内部的核心业务流程,包括开启撮合、处理订单、关闭撮合三个输入各自的内部逻辑。理解了这些流程之后,下一篇我们开始来讲代码实现。
惯例留几个思考题:如果关闭撮合的同时还有下单的并发请求,是否容易产生问题?如果有,哪里会产生?什么问题?能如何解决?