前言
开篇文章发出去之后,我的撮合引擎被一位超级大佬(曾担任上交所的首席架构师)定位为玩具,直接将我的撮合引擎和国家级撮合引擎作对比了。如果我的撮合引擎达到上交所级别,那就不止值5万了,估计至少值500万了。不过,我的撮合引擎随着不断升级迭代,以后能达到国家级别也说不定。
为了避免再次出现这种尴尬,我还是先说明清楚对此撮合引擎的定位。
MVP版本需求
《精益创业》有个核心概念叫 MVP(Minimum Viable Product),即最小可行性产品。我的撮合引擎第一个版本也是一个 MVP,只实现最基础的功能。所谓最基础的功能,即是说,如果移除了该功能,整个系统都无法完成工作。当然,我们还要加上应用场景,应用于一个初创的小交易平台和应用于像火币、币安甚至深交所、上交所这样庞大的交易平台,对基础功能的定义范围是有很大区别的。我所要做的 MVP 版本,只要适用于小交易平台即可。
这里我要稍微展开聊下产品设计的问题,很多团队——尤其是初创团队,做第一版产品的时候,总觉得这个功能很重要、那个功能很重要,都往第一版的产品里面加。其实,做第一版的时候,更多的应该是做减法,而不是做加法。很多看起来很重要的功能,大部分都是属于那种有了它更好,但没有它也不是整个产品就运行不下去了。
下面我们就来讨论下 MVP 版本的撮合引擎具体要实现哪些功能。
我们知道,撮合有集合竞价和连续竞价两种方式,但对于我们的 MVP 版本来说,是否有必要两种撮合方式都支持呢?其实,在币圈,不管是小交易所还是大交易所,基本只采用连续竞价的方式。我以前从事的贵金属交易平台,也同样没有集合竞价这一步。这也说明,集合竞价对一个交易所来说,其实并不是必需的。既然如此,那第一版的撮合引擎其实就可以先把集合竞价功能砍掉。
支持下单和撤单则是必需的,这是一个交易所最最基础的功能,没有这两个功能,交易所就没意义了。下委托单一般还分有几种不同的类型,包括限价、市价、止盈止损等,最简单的就是限价,这也是所有交易所都必需支持的交易类型,初创交易所一般也只先支持限价交易,所以我们的 MVP 版本也只先支持限价交易即可。
下单和撤单的结果还需要通过事件的方式发送出去,其他服务会监听这些事件并做相应的后续处理。
维护**交易委托账本(OrderBook)**也是必需的,撮合就是和 OrderBook 里的订单进行匹配成交,暂时没成交的就会保存在 OrderBook 里。
另外,我们也要采用内存撮合技术,因此,OrderBook 其实是直接保存在程序的内存中的。那么,如果程序异常退出的话,那保存的数据也被清空了。所以,我们还需要引入缓存用来备份数据。当程序重启时,可以从缓存中重新加载数据。
MVP 版本还要支持多个交易标的的撮合,因为我们的 MVP 版本撮合引擎只是个单机版的程序,总不能只支持一个交易标的吧。
还要支持开启和关闭指定交易标的撮合的功能,开启撮合时需要做一些初始化的操作,包括初始化开盘价,而关闭撮合后则会删除数据、释放资源等。
汇总一下,我们的 MVP 版本要实现以下这些功能:
- 支持连续竞价的撮合方式;
- 支持限价交易、支持撤单;
- 支持下单和撤单结果的下发;
- 采用内存撮合技术,在内存里维护交易委托账本;
- 需要缓存数据,当程序重启时,可以恢复数据;
- 支持多个交易标的的撮合;
- 支持开启和关闭指定交易标的的撮合功能。
技术选型
需求确定了,接下来就要确定技术方案了,先聊下一些技术选型吧。
首先是开发语言,我的选择是 Golang,原因很简单,Golang 有着接近 C/C++ 的执行性能,但比 C/C++ 有着更高的开发效率,既能满足撮合引擎对性能的要求,也能满足我们快速实现产品的需求。当然,用其他语言也能实现,毕竟,设计思路是通用的。
下单和撤单,开启和关闭撮合,以及结果的下发,都涉及到与其他服务的通信。服务间的通信主要就两种可选方案:同步调用的 RPC 和异步调用的 MQ。同步调用能使请求得到即时的响应,通信相对高效且可靠性较高,但只适用于一对一的通信,且如果并发请求出现超负荷时可能会引发大量的请求超时甚至服务宕机。而 MQ 支持一对多的通信,也因为有缓存队列,能避免并发请求达到峰值时出现服务不可用的情况,但也因为多了个消息中间件,传输有延迟,且请求无法得到即时的应答,还存在丢消息的可能,因此可靠性就比不上同步的 RPC 方式。
对于我们的应用场景来说,结果的下发只能使用 MQ,因为我们并不清楚有多少个下游服务会消费我们的结果消息,也无法要求下游服务提供统一的 RPC 接口供我们调用。下单和撤单请求,则最好采用 RPC 同步方式调用,一是可以对一些无效的请求即时返回响应,二是能减少 MQ 的传输延迟,三是能保证可靠性。对于并发请求超负荷的问题,应该在更上层的网关服务就做好负载均衡,而不应该丢给撮合引擎来处理。
不过,RPC 和 MQ 也有多种具体的实现方案。RPC 方案有 REST、gRPC、Thrift、rpcx 等,MQ 方案有 Kafka、RocketMQ、RabbitMQ、**Redis **等。这些不同的具体方案之间的差异性我就不展开了,感兴趣的读者们可以自己去百度或 Google。RPC 方案我们选择最简单的 REST 即可,开发、对接和测试都比较方便。MQ 方案则选定 Redis,因为 Redis 从 5.0 版本开始引入了 Stream 数据结构,提供了类似Kafka的消息队列功能,但由于 Redis 的数据是存储在内存中的,其处理速度相比其他 MQ 快很多。另外,我们还要用 Redis 做缓存,用同一个中间件也更方便。
软件结构
上图就是我们 MVP 版本的撮合服务的软件结构设计图,很简单吧。其实,就是按照业务流程进行了分层而已。分层是最简单的一种架构方式,其实任何庞大复杂的系统,第一步拆解都可以按业务流程进行分层。
Handler 接收由上游服务发过来的 HTTP 请求,我们只需定义三个接口:
- OpenMatching:开启撮合,只需接收两个参数:交易标的(交易对)和开盘价。
- CloseMatching:关闭撮合,只需一个参数:交易标的(交易对)。
- HandleOrder:接收委托单,会有一个参数 Action 表示动作是下单还是撤单,其他参数则是委托单的数据了,包括订单 ID、交易对、买卖方向、委托数量、委托价格等。
Handler 对请求做一些常规的校验之后,就会转发给相应的 Process 做处理,我们也定义了对应的三个 Process:
- NewEngine:创建一个新的协程/线程,作为指定交易对的撮合引擎。
- CloseEngine:将指定交易对的撮合引擎关闭。
- Dispatch:将不同交易对的委托单分发到相应的撮合引擎。
Engine 即是每个交易对的撮合引擎协程或线程了。由于每个交易对的撮合引擎对委托单的处理必须是串行的,因此,Dispatch 时需将委托单先扔到不同交易对的有序队列里去,再由 Engine 从各自的队列中消费取出委托单进行撮合处理。
Redis 既用来做数据缓存,也用来做消息队列。缓存的数据主要是当前开启了撮合的交易对,以及撮合引擎里的交易委托账本。为了保证数据的一致性,账本里每个委托单的每一次变化,都需要更新到 Redis 中去。当撮合引擎重启时,就需要从 Redis 中读取缓存的委托单,重新初始化交易委托账本。这样,就能保证程序退出后重启,能恢复到退出前的状态。消息队列则可用 Redis 的新数据结构 Stream 实现,用来发送成交记录和撤单结果。
小结
我们第一版先做个 MVP,做个单体版的撮合服务,支持连续竞价、限价委托、撤单、开启和关闭撮合、支持多交易对等功能,采用内存撮合技术。软件结构上主要分为 Handler、Process、Engine 三个层级,底层用 Redis 做数据缓存和消息队列。下一篇我们来设计数据结构。
留一个思考题:Dispatch 分发委托单到 Engine 时,有序队列可以如何实现?