基于Golang的高性能撮合引擎:从思路到MVP

2021年2月26日 331点热度 0人点赞 0条评论

前言

自从有人在微信群里开价5万求购Golang版的撮合引擎之后,我就想自己开发一款,毕竟,以我的经验来说,开发个高性能的撮合引擎并没什么难度。

说干就干,于是,利用业余时间慢慢开发出了一款Golang版的高性能撮合引擎,前前后后花了大概一个月的时间。再想想自己好久没更新文章了,我的个人IP都已经生锈了,也应该发大招磨一磨了。因此决定,干脆就以连载的方式,分享下我是如何设计与实现这款价值超5万的撮合引擎的。

本来,想发成掘金小册,收点稿费,毕竟这是个具有很大商业价值的软件,但问了掘金的人员,他们目前不接收这类主题。最终决定免费发布,还可以多发几个渠道,说不定还能给我多带来些关注量。

好了,下面开始进入撮合引擎系列的正题。


撮合引擎简介

撮合引擎是所有撮合交易系统的核心组件,不管是股票交易系统——包括现货交易、期货交易、期权交易等,还是数字货币交易系统——包括币币交易、合约交易、杠杆交易等,以及各种不同的贵金属交易系统、大宗商品交易系统等,虽然各种不同交易系统的交易标的不同,但只要都是采用撮合交易模式,都离不开撮合引擎。

撮合引擎是可以具有通用性的,一套具有通用性的撮合引擎实现理论上可以应用到任何撮合交易系统中,而无需做任何代码上的调整。即是说,同一套撮合引擎实现,既可以应用在股票交易系统,也可以应用在数字货币交易系统,可以用于现货交易,也可以用于合约交易等。

那么,一套具有通用性的撮合引擎应该具备哪些功能呢?确定该问题的答案之前,我们先简单梳理一下一个完整的交易流程是怎样的?一般会包括以下步骤:

1.系统开放某个交易标的的交易功能。2.用户提交该交易标的的买卖申报,即委托单3.系统验证委托单是否有效,包括交易标的是否处于可交易的状态、订单的价格和数量是否符合要求等。4.确定该委托单的挂单(Maker)费率和吃单(Taker)费率。5.检查用户的资产账户情况,包括账户状态是否交易受限,是否有足够资金用于下单等。6.将详细的委托单数据持久化到数据库,并冻结用户账户中相应数量的资金。7.将委托单进行撮合处理,即在交易委托账本(OrderBook)中寻找能与该委托单匹配成交的订单,匹配的结果可能是:全部成交、部分成交或无匹配。全部成交或部分成交时,可能在交易委托账本中存在一个或多个匹配的订单,即会产生一条或多条成交记录。当无匹配或部分成交时,委托单的部分数据包括剩余未成交的数量会暂时保存到交易委托账本中,等待与后续的委托单匹配撮合。8.将撮合产生的成交记录持久化到数据库,并根据历史成交记录生成市场数据,如K线数据、今日涨跌幅等。9.更新数据库中所有成交订单的委托单数据,以及更新订单用户的资产账户余额。10.将更新的订单数据、市场数据等发送给到前台。

整个交易流程中涉及到多个服务,包括用户服务、账户服务、订单服务、撮合服务、市场数据服务等。其中,只有第7步是撮合引擎处理的。从单一职责原则来说,撮合引擎就应该只做一件事,那就是负责撮合订单。撮合之前的委托单持久化、冻结资金等,以及撮合之后生成K线数据等,都不应该属于撮合引擎的职责。

撮合竞价方式

撮合竞价方式一般有两种,一是集合竞价,二是连续竞价。股票交易系统一般会在不同交易时间段采用不同的竞价方式,比如在开盘或收盘时采用集合竞价,从而产生开盘价收盘价,其余时间采用连续竞价。而大多数字货币交易系统则没有集合竞价,只有连续竞价,开盘价一般是在开始交易之前就设定好的。

集合竞价

所谓集合竞价,是指对一段时间内接收的买卖委托单一次性集中撮合的竞价方式。以深沪的股票交易系统为例,在每个交易日的 9:15~9:25 期间是集合竞价时间。在该时间段内,系统陆续接收到的委托单不会即时成交,而是先将所有委托单按照价格优先、时间优先的原则排序,并在此基础上,找出一个基准价格,使它能同时满足以下三个条件:

1.可实现最大成交量的价格;2.高于该价格的买单与低于该价格的卖单能全部成交的价格;3.与该价格相同的买方或卖方至少有一方全部成交的价格。

在 9:25 分结束的时候,该基准价格就被确定为成交价格,所有高于该价格的买单与低于该价格的卖单都将以该价格成交。未能成交的委托单,则自动转入连续竞价。

不过,如果满足以上三个条件的价格存在两个或两个以上呢?对此,深交所和上交所的处理方案有所不同,深交所会取距前收盘价最近的价格为成交价,而上交所则取使未成交量最小的价格为成交价,如果未成交量最小的价格仍不止一个,则取中间价为成交价。

集合竞价的主要目的就是为了确定开盘价或收盘价。

连续竞价

所谓连续竞价,也是我们所熟悉的竞价方式,是指对买卖委托单逐笔连续撮合的竞价方式。用户的挂单,只要满足成交条件,就能即时成交。而集合竞价,则要等到最后一刻才会成交。

连续竞价时,依然要满足价格优先、时间优先的成交原则:

1.价格优先:买单则价格较高者能优先成交,卖单则是价格较低者能优先成交。2.时间优先:买卖方向和价格相同的委托单,先申报的委托单会比后申报的委托单优先成交。

另外,买入价必须大于或等于卖出价才能撮合成交。当买入价等于卖出价时,成交价就是买入价或卖出价。当买入价大于卖出价时,则还要参考前一笔成交价来确定最新成交价。假设买入价为 B,卖出价为 S,前一笔成交价为 P,最新成交价为 N,那么:

如果 P >= B,则 N = B如果 P <= S,则 N = S如果 B > P > S,则 N = P 

一套通用的撮合引擎应该两种竞价方式都支持,但对于同一交易标的来说,两种竞价方式不能同时进行,因此设计上需要考虑如何在两种竞价方式之间切换,具体的实现思路在后续章节我们再展开来讲。

质量需求

我们的撮合引擎除了要满足以上所说的功能需求,还应该满足一些质量需求,尤其对可用性可伸缩性性能的要求较高。另外,为了达到通用,也要满足可复用性的需求。

先说下可复用性,我们期望的是该撮合引擎既能用于股票交易系统,也能用于数字货币交易系统,既能用于币币交易,也能用于合约交易。因此,该撮合引擎要避免引入与具体系统强相关的业务逻辑,以加强它的可复用性。

再看看性能,要衡量一个撮合引擎的性能,就看它处理每个交易对的 TPS 有多高,即每秒钟能处理多少笔相同交易对的委托单。以前,基于数据库的撮合技术,TPS 一般只有10笔/秒。而现在基本都是采用内存撮合技术,TPS 很容易就能达到1000笔/秒,如果使用独占的高性能服务器,1万笔/秒甚至更高的 TPS 都不难达到。

接着谈谈可伸缩性,我们的每一个撮合引擎既可以同时处理多个交易标的,也可以只处理单个交易标的。当交易标的和并发量增多的时候,可以增加服务器,部署成撮合引擎集群,分别用来处理不同的交易标的,从而能够实现负载均衡。

最后聊聊可用性,高可用主要体现在两点,一是故障率要低,二是对故障维修的时间要短。要降低故障率,那撮合引擎就需要有较高的健壮性,对于可能导致引擎出故障的各种异常情况要考虑好并设计好解决方案。另外,还可以采用多机热备份技术来提高可用性,而且要保证互备服务器之间的数据一致,那就需要引入内存状态机复制方案,实现上会复杂很多。

不过,我们并非一下子就要达到很高的质量要求,因为要求越高,其架构和实现会越复杂。我们可以先从简单的版本开始,然后不断升级迭代。

小结

我们目的是实现一套通用的撮合引擎,要支持集合竞价和连续竞价,还要实现一些质量需求,提高系统的可复用性、性能、可伸缩性、可用性等。后续章节会对这些需求不断深入探讨其设计与实现。另外,我们将采用不断升级迭代的方式来设计和实现多个版本的撮合引擎。

留两个思考题:

1.集合竞价结束的时候,如果不存在符合那三个条件的基准价格,那开盘价又将如何确定?2.对于单个交易对,是否可通过横向增加服务器的方式提高其性能?开篇文章发出去之后,我的撮合引擎被一位超级大佬(曾担任上交所的首席架构师)定位为玩具,直接将我的撮合引擎和国家级撮合引擎作对比了。如果我的撮合引擎达到上交所级别,那就不止值5万了,估计至少值500万了。不过,我的撮合引擎随着不断升级迭代,以后能达到国家级别也说不定。

为了避免再次出现这种尴尬,我还是先说明清楚对此撮合引擎的定位。

MVP版本需求

《精益创业》有个核心概念叫 MVP(Minimum Viable Product),即最小可行性产品。我的撮合引擎第一个版本也是一个 MVP,只实现最基础的功能。所谓最基础的功能,即是说,如果移除了该功能,整个系统都无法完成工作。当然,我们还要加上应用场景,应用于一个初创的小交易平台和应用于像火币、币安甚至深交所、上交所这样庞大的交易平台,对基础功能的定义范围是有很大区别的。我所要做的 MVP 版本,只要适用于小交易平台即可。

这里我要稍微展开聊下产品设计的问题,很多团队——尤其是初创团队,做第一版产品的时候,总觉得这个功能很重要、那个功能很重要,都往第一版的产品里面加。其实,做第一版的时候,更多的应该是做减法,而不是做加法。很多看起来很重要的功能,大部分都是属于那种有了它更好,但没有它也不是整个产品就运行不下去了。

下面我们就来讨论下 MVP 版本的撮合引擎具体要实现哪些功能。

我们知道,撮合有集合竞价连续竞价两种方式,但对于我们的 MVP 版本来说,是否有必要两种撮合方式都支持呢?其实,在币圈,不管是小交易所还是大交易所,基本只采用连续竞价的方式。我以前从事的贵金属交易平台,也同样没有集合竞价这一步。这也说明,集合竞价对一个交易所来说,其实并不是必需的。既然如此,那第一版的撮合引擎其实就可以先把集合竞价功能砍掉。

支持下单和撤单则是必需的,这是一个交易所最最基础的功能,没有这两个功能,交易所就没意义了。下委托单一般还分有几种不同的类型,包括限价市价止盈止损等,最简单的就是限价,这也是所有交易所都必须支持的交易类型,初创交易所一般也只先支持限价交易,所以我们的 MVP 版本也只先支持限价交易即可。

下单和撤单的结果还需要通过事件的方式发送出去,其他服务会监听这些事件并做相应的后续处理。

维护交易委托账本(OrderBook)也是必需的,撮合就是和 OrderBook 里的订单进行匹配成交,暂时没成交的就会保存在 OrderBook 里。

另外,我们也要采用内存撮合技术,因此,OrderBook 其实是直接保存在程序的内存中的。那么,如果程序异常退出的话,那保存的数据也被清空了。所以,我们还需要引入缓存用来备份数据。当程序重启时,可以从缓存中重新加载数据。

MVP 版本还要支持多个交易标的的撮合,因为我们的 MVP 版本撮合引擎只是个单机版的程序,总不能只支持一个交易标的吧。

还要支持开启和关闭指定交易标的撮合的功能,开启撮合时需要做一些初始化的操作,包括初始化开盘价,而关闭撮合后则会删除数据、释放资源等。

汇总一下,我们的 MVP 版本要实现以下这些功能:

1.支持连续竞价的撮合方式;2.支持限价交易、支持撤单;3.支持下单和撤单结果的下发;4.采用内存撮合技术,在内存里维护交易委托账本;5.需要缓存数据,当程序重启时,可以恢复数据;6.支持多个交易标的的撮合;7.支持开启和关闭指定交易标的的撮合功能。

技术选型

需求确定了,接下来就要确定技术方案了,先聊下一些技术选型吧。

首先是开发语言,我的选择是 Golang,原因很简单,Golang 有着接近 C/C++ 的执行性能,但比 C/C++ 有着更高的开发效率,既能满足撮合引擎对性能的要求,也能满足我们快速实现产品的需求。当然,用其他语言也能实现,毕竟,设计思路是通用的。

下单和撤单,开启和关闭撮合,以及结果的下发,都涉及到与其他服务的通信。服务间的通信主要就两种可选方案:同步调用的 RPC 和异步调用的 MQ。同步调用能使请求得到即时的响应,通信相对高效且可靠性较高,但只适用于一对一的通信,且如果并发请求出现超负荷时可能会引发大量的请求超时甚至服务宕机。而 MQ 支持一对多的通信,也因为有缓存队列,能避免并发请求达到峰值时出现服务不可用的情况,但也因为多了个消息中间件,传输有延迟,且请求无法得到即时的应答,还存在丢消息的可能,因此可靠性就比不上同步的 RPC 方式。

对于我们的应用场景来说,结果的下发只能使用 MQ,因为我们并不清楚有多少个下游服务会消费我们的结果消息,也无法要求下游服务提供统一的 RPC 接口供我们调用。下单和撤单请求,则最好采用 RPC 同步方式调用,一是可以对一些无效的请求即时返回响应,二是能减少 MQ 的传输延迟,三是能保证可靠性。对于并发请求超负荷的问题,应该在更上层的网关服务就做好负载均衡,而不应该丢给撮合引擎来处理。

不过,RPC 和 MQ 也有多种具体的实现方案。RPC 方案有 RESTgRPCThriftrpcx 等,MQ 方案有 KafkaRocketMQRabbitMQRedis *等。这些不同的具体方案之间的差异性我就不展开了,感兴趣的读者们可以自己去百度或 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 时,有序队列可以如何实现?

往期推荐:

技术琐话 

以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。

图片

45590基于Golang的高性能撮合引擎:从思路到MVP

这个人很懒,什么都没留下

文章评论