交易系统架构演进之路(一):1.0版

2020-12-11 15:24:34 浏览数 (1)

前言

近几年,我在资产证券类交易系统领域做得比较多,从2016年开始,在贵金属交易领域深耕了两年,负责的交易平台用户量曾达到几百万,日活也有几十万,日流水更是千万级别。2018年之后,在数字资产交易行业又沉淀了两年,虽然用户量级没达到之前在贵金属交易平台的级别,但因为交易标的明显比在贵金属时多得多,所以整体的并发量和交易量却大得多。

基于我这几年的经验总结,我将以数字资产交易平台为案例,聊聊从 0 到 1 再到 N 的交易系统的架构应该如何不断演进。第一篇文章先从起步阶段开始。

需求分析

任何架构的演进都是由场景驱动的,离开场景谈架构就是耍流氓。因此,做架构设计之前,我们要先了解当下的场景。场景就是需求,一般可以将需求分为三类:商业需求、功能需求和质量需求

商业需求是最高层次的需求,它关注从客户群、企业现状、未来发展、预算、立项、开发、运营、维护在内的整个软件生命周期涉及的商业因素,包括了商业层面的目标、期望和限制等。商业需求一般对架构的影响比较大,对架构产生限制的商业因素也比较多,比较常见的包括:上市时间、成本预算、人力现状、目标市场、阶段性计划、国际化等等。

功能需求描述的就是系统应该提供的服务,包括为用户提供的服务,也包括为其他系统提供的服务,比如开放 API。

质量需求则是技术上的需求了,在三类需求中,一般其需求层次也是最低的,但却是大部分架构师最关注的。质量需求涉及到的属性也比较多,关注最多的比如可用性、性能、安全性、扩展性等等。

对于一个从 0 到 1 的产品来说,大部分情况最受限制的就是成本预算,因此,能投入的人力也比较少,而且上市时间拖得越久成本就越高,所以上市时间也是越快越好。总的来说,产品第一个阶段的核心需求就是,要用最低的成本、最快的速度,设计研发完成产品,并推向市场。要满足此需求,我们只需要开发出一个 MVP(最小化可行产品) 即可。所谓 MVP,就是只要能让用户完成最简化的核心流程即可,不需要太多考虑优化流程、更好的用户体验等。

那么,对于一个数字资产交易平台来说,其 MVP 最核心的功能就是可以让用户完成资产交易,要能让用户完成交易,最简化的流程就是:

注册 ——> 登录 ——> 入金 ——> 交易 ——> 出金

注册现在主流的有两种方式,一是手机号注册,二是邮箱注册。现在最大的三大数字资产交易所——币安、火币、OKEx,两种注册方式都提供了。不过币安在之前很长一段时间内都只支持邮箱注册。国外的大交易所,也好多都只提供邮箱注册。因此,我们这个 MVP 就先提供邮箱注册即可。

数字资产的入金出金一般有两种途径,一是提供链上的充币提币功能,二是提供场外的法币交易功能。优先提供哪种途径,主要取决于目标用户群体,如果目标用户群体完全是区块链小白,根本不懂怎么进行链上的转账,那就应该优先提供场外的法币交易功能,否则就优先提供链上的充提币功能。主流做法更多是优先提供链上出入金的功能,我们这个案例也如此。那链上出入金,就需要对接不同的区块链,这又需要时间成本,为了最简化,我们可以就只先做一个交易对,ETH/USDT,只先对接以太坊链。

交易其实还有内部流程,用户可以进行下单和撤单。如果下单后没撮合成交,那用户就可以撤单。如果撮合成交了,交易也完成了。

另外,虽然图中只有 5 个流程,但其实还有其他相关的重要功能也需要提供,包括查看行情、查看订单、查看个人资产等。至于其他的周边功能,如重置密码、修改密码、实名认证、邀请注册等,甚至管理后台,都统统先不做。

质量需求方面,也不需要满足太多特性,只要保证服务基本可用,用户的资产相对安全即可。

至此,总结一下,我们的数字资产交易平台,第一个版本的需求分析结果,需要完成的功能需求包括:

  • 邮箱注册:需要用到邮箱验证码,所以还需要有发邮件的功能
  • 登录:简单的邮箱和密码登录即可
  • 充币:需要生成钱包地址和调用区块链的转账接口,第一版只接以太坊链的 ETH 和 USDT
  • 提币:提币需要审核,但第一版不做管理后台,先直接数据库层面进行审核
  • 下单:第一版只提供一种下单方式,限价委托单
  • 撤单:未完全成交的委托单可以撤单
  • 查看行情:行情包括买卖盘口、成交记录、最新成交价、K线图等
  • 查看订单:用户的委托单、成交单都是需要可以查询的
  • 查看个人资产:查看用户资产的 ETH 和 USDT 分别有多少可用、冻结

架构设计

需求分析完成之后,就可以进入架构设计了。对于 MVP 版本来说,最重要的就是简单快速实现需求并上线,那就没必要考虑 SOA、微服务等分布式架构,就直接用单体架构最合适。但并不是说用单体架构就可以不用做架构设计了,单体也只是服务端用单体而已,但整个交易系统并不只有服务端,还包括客户端和数据库,而且单体内部又如何组织,这也是需要设计的。而且,架构师还应该具备一定的前瞻性,要考虑到后续的业务发展,要有适度超前的设计思维,从而设计出能满足当前场景需求的系统,并具备扩展性,能快速实现满足下一阶段的业务需求,以及能方便快捷地进行架构演进。

接着,就来说说我对当前版本的设计思路。先从整体来考虑,首先,是否要前后端分离?分离的话,API 要怎么设计?其次,数据库的选型,是用传统的关系型数据库(也称为 OldSQL),还是 NoSQl,抑或 NewSQL?数据库的表又应该如何设计?最后,服务端的代码应该如何组织,单体内部采用什么架构模式?等等。

先说第一个问题,是否要前后端分离?答案是肯定要分离的,虽然第一版只需要支持 Web 端,但后续肯定还要支持移动端 App,甚至支持桌面客户端,不做前后端分离的话就很难做到多端支持。既然前后端是分离的,那就需要对客户端与服务端之间交互的 API 进行设计,包括使用什么通讯协议、数据传输协议、安全机制等。

API 设计

通讯协议主要就是 HTTPWebSocket 了,HTTP 只能由客户端发起通信,但它是无状态的,HTTP 服务就容易通过横向扩展实现负载均衡;WebSocket 则支持全双工通信,但它是有状态的,扩展就比较麻烦,安全性也较差。对于交易平台这样的系统,大部分情况下用 HTTP 是比较合适的,安全性较高,且无状态的,高并发情况下也容易扩展 HTTP 服务器。不过,行情数据比较特殊,因为更新频率比较高,数据量也较大,对安全性的要求也不高,用 WebSocket 建立连接,由服务端不断向客户端推送数据,这种方式是比较合适的。但从另一方面来说,如果两种协议都要支持,无疑会增加开发成本。这时候应该怎么选型呢?

其实,做架构设计,很多时候就是需要在这样相互矛盾的场景下做选择、做平衡。架构师要做的并不是设计最好的完美架构,而是满足当下的合适架构。对于我们当前的场景来说,减低开发成本更重要,因此,我们只用 HTTP 一种协议就行了,至于行情数据,就先用轮询请求的方式去获取。

数据传输协议则比较多,有 JSON、ProtoBuf、FlatBuffers、MessagePack、Thrift 等等,各有各的优缺点,这又该如何选型呢?如果论性能,MessagePack 是最快的,但扩展性较差,维护成本也较高。ProtoBuf 性能次于 MessagePack,大部分成熟项目选用它,缺点就是可读性差、库比较大、维护成本较高。JSON 是最简单易用的,开发成本也最低,可读性也好,缺点就是体积大、性能低。对于我们当前的场景,其实也不需要考虑太多,开发成本最重要,所以选择 JSON 是最合适的。

安全方面,TLS 肯定是要强制使用的,毕竟是金融系统,最基本的安全措施不能省。用户鉴权也是重要的一块,现在主流的做法基本都是采用 Token 方式做鉴权,常用的具体方案就是采用 JWT。用 JWT 还可以实现鉴权服务的无状态化,容易横向扩展。至于其他的安全机制,比如限流、时间戳超时、URL签名、防重放等,则留到后续版本再去补充。

接着,再来聊聊 API 接口层面的设计。API 接口的参数一般可以分为两类:公共参数业务参数。公共参数一般包括请求ID、时间戳、客户端IP、版本号、签名值等与具体业务无关的通用参数,也是每个接口都需要传的固定参数。业务参数则是具体每个接口的业务需要的参数了,比如登录接口要求的登录账号和密码。一开始就做好这种划分,后面就容易扩展,第一个版本的公共参数可以先只传个时间戳。

最后,对应于我们上面整理出来的需求列表,需要设计的业务接口包括:发送邮箱验证码、邮箱注册、邮箱登录、获取充币地址、查询充币记录、查询资产余额、设置提币地址、设置资金密码、申请提币、查询提币记录、取消提币、下单、撤单、查询委托单、查询成交单、获取Ticker信息、获取深度数据、获取成交数据、获取K线数据,总共 19 个接口。这些接口,如果按业务领域来划分,其实可以分为用户、账户、交易、行情 4 个领域,那这些接口就可以如下归类了:

  • 用户:发送邮箱验证码、邮箱注册、邮箱登录
  • 账户:获取充币地址、查询充币记录、查询资产余额、设置提币地址、设置资金密码、申请提币、查询提币记录、取消提币
  • 交易:下单、撤单、查询委托单、查询成交单
  • 行情:获取Ticker信息、获取深度数据、获取成交数据、获取K线数据

不同领域的接口,在 URL 上就可以区分开,比如,用户域的接口可以统一用 domain.com/user/ 为前缀,账户域可以设置为 domain.com/account/,交易域为 domain.com/trade/,行情域为 domain.com/market/,这样区分之后,后续拆分了微服务之后,网关层就容易对接口进行服务路由了。

另外,查询类的读请求可以统一用 GET 方法,非查询类请求可以统一用 POST 方法,以方便后续可以对请求进行读写分离。

其实,这种业务领域的划分,可以用 DDD(领域驱动设计) 的思想去分析和设计,不只是用于 API 设计,也用于数据库设计,以及整个应用的领域建模设计。在一开始就做好这种领域建模,后续将单体应用拆分为多个微服务的时候就会顺畅很多。

至此,API 设计部分就先讲这么多了,更细节的设计就不再去深入了。

关键流程分析

API 接口确定之后,很多人下一步做的是直接进行数据库设计,其实,在这之前,应该先梳理分析关键的实现流程,梳理之后,才会清楚,哪些数据需要持久化?是否需要用到 MQ 中间件?是否需要用到缓存?需要接入哪些第三方平台?等等。

首先,我们有发送邮箱验证码的接口,这就需要用到邮箱服务,那第一个需要考虑的问题就是:是自己搭建邮箱服务器,还是选择第三方邮件发送平台?自己搭建的成本比较高,因此普遍都是采用第三方。那接下来的问题就是:选择哪个第三方邮件发送平台呢?这平台比较多,我比较推荐用阿里云的邮件推送。

邮箱验证码需要在注册时进行检查验证的,因此,这个验证码就需要在服务器存储起来,有两种存储方案,一是持久化到数据库,二是用缓存。其实,验证码本来没必要做持久化存储,因为验证码用完就可以丢弃的,而且其有效时间也比较短,所以用缓存是比较合适的,现在普遍也是用 Redis 作为缓存系统。但如果我们只有这一个场景才需要用到缓存,那为了这一个小场景引入一套缓存系统,就有点大材小用了。

充提币的流程,就需要对接区块链系统了。这也有两种方案,一是自己搭建区块链节点,二是接入第三方提供的 API。第一种方案的实现成本比较高,所以尽量是选择第二种方案。前面我们说过,第一版只先接以太坊链,而以太坊链目前最常用的第三方 API 就是 infura,其免费版本就已经足够使用。

下单和撤单,内部就涉及到撮合流程了,这个流程就稍微复杂一些,但从整体实现来说,有数据库撮合内存撮合两种方案。数据库撮合实现起来相对简单,但性能较差。内存撮合性能高,但实现就比较复杂了,比较考验架构师的设计能力。我们第一版其实对性能要求不高,所以可以先用简单的数据库撮合方案。

撮合成功后就会产生成交价和成交记录,这也是需要持久化保存的,程序还需要将每一次成交记录累加起来计算出 K 线数据,以及更新 Ticker 信息。这些数据都应该持久化到数据库保存起来。接口获取行情数据时,就从数据库读取即可,这是最简单的方案。

关键流程就这些了,接下来,就进入数据库设计阶段了。

数据库设计

数据库选型方面其实也很简单,虽然现在 NewSQL 很火,但其主要应用于分布式的大数据场景,我们当前版本根本用不上,所以暂时不用考虑。OldSQL 应用最广泛的当属 MySQL,这是无可厚非的,这也是我们的优先选择。NoSQL 方面,应用最广泛的就是内存数据库 Redis,主要被用来替代 Memcached 作缓存系统;其次是 MongoDB,在很多方面并不比 MySQL 差,不足的就是无法支持复杂事务和复杂查询。刚好,交易系统对复杂事务和复杂查询的场景还是比较常见的,所以 MongoDB 并不太合适,因此我们只能选择 MySQL。

不过,如果不考虑开发成本,其实,行情数据是比较适合用 MongoDB 存储的,因为数据量大、查询频率高,也没有复杂事务和复杂查询。我们可以在后续版本考虑此方案。

设计关系数据库时,可以尽量遵循一些设计规范,这些规范也称为范式,从而设计出结构合理、冗余较小的数据库。总共有六大范式,因为篇幅原因,我就不展开细讲了。不过,在实际设计的时候,其实也不是必须完全遵循这些范式,只是尽量遵循,但对于一些特殊情况,是可以有一些妥协的,比如,在一张表中增加冗余字段可能不符合范式,但可以提高查询效率。不过,有一条原则需要遵循:选择作为冗余的字段应不需要额外的工作来保持数据一致性。比如用户昵称,这是用户可以随时修改的,就不适合作为冗余字段。

另外,在这些范式之外,还有其他一些设计原则也很重要,我挑几点重要但却容易被忽略的点说说:

  1. 优先用逻辑主键,而非业务主键。逻辑主键最简单的就是自增ID,业务主键比如用户的邮箱,业务主键虽然变动的可能性低,但并非真的一成不变的,而且业务主键的查询效率一般也比较低。
  2. 尽量不要建立外键。外键会产生强耦合,不利于以后表的扩展和重构,尽量保证每个表的独立性,表之间的关系最好通过 ID 进行关联。
  3. 不要有 NULL 值。如果是数字,可以设置默认值为 0,如果是字符,那就设置默认为空字符串,空值不占用空间,NULL 还需要占用空间。有 NULL 值的话,索引效率也会下降很多。

数据表的设计,前面也有提到,可以用 DDD 的思想作为设计的方法论,而根据我们前面确定的业务需求,最终我分为了以下几张表:

  • 用户表:保存用户信息,主要包括用户的邮箱、登录密码、资金密码、注册时间等
  • 验证码表:保存邮箱验证码,主要包括接收的邮箱、验证码、使用状态等
  • 账户表:保存用户的账户信息,主要包括用户每种数字资产的余额、充币地址、提币地址等
  • 委托订单表:所有委托订单都会存放在这张表
  • 成交记录表:每笔撮合成交的记录都存放在此表
  • 充币记录表:用户往充币地址转账成功后,就可以从区块链上读取到记录,读取到之后就记入该表
  • 提币记录表:所有提币记录都存放在此表
  • 1分钟K线数据表:第一版我们只先提供 1 分钟 K 线图,所以也只有 1 分钟的 K 线数据表
  • Ticker信息表:Ticker 信息主要包括每天的开盘价、最新价、最高价、最低价、成交量、成交额等

限于篇幅,数据库设计方面我也不再继续深入了,后面就聊聊最后一块,服务端的设计。

服务端设计

服务端是个单体应用,对其的设计,主要是对内部模块的划分,以及代码结构的组织。

首先,最简单的划分就是采用三层架构,分为:API 层、Service 层、DAO 层。API 层,也有的称为 Controller 层或 Web 层,主要负责用户请求的处理;Service 层也是业务逻辑层,实现核心的业务逻辑,包括事务控制;DAO 层则是数据访问层,负责访问数据库,实现数据的增删改查操作。三层之间的调用关系是从上到下的,API 层依赖于 Service 层,Service 层依赖于 DAO 层,不能倒过来,也不能跨层依赖,即 API 层不能直接调用 DAO 层。而且,层与层之间的依赖应该依赖于接口,而不是依赖于具体实现,遵循依赖倒置原则,从而减低不同层级间的耦合性。

另外,期间涉及到一些不同的对象,缺乏经验的人很容易搞混,有必要明确区分一下,尤其是 PO、DAO、DTO。

PO = Persistant Object,持久对象。对应于数据库的数据模型,一个简单的 PO 对应于数据库中某个表中的一条记录,多条记录则用 PO 集合。在概念上,PO 不包含对数据库的任何操作。PO 还是 Service 层和 DAO 层之间传输数据的对象。

DAO = Data Access Object,数据访问对象。也是数据访问层最核心的对象,其封装了对数据库进行 CRUD 操作的各种方法,为 Service 层提供调用接口,通常和 PO 结合使用。

DTO = Data Transfer Object,数据传输对象。和 PO 很类似,不过是在 API 层和 Service 层之间传递数据的对象,一般也是返回给到前端的对象。

在具体实现上,很多人没对 PO 和 DTO 进行区分,就只用一种 PO 对象贯穿所有层级,这其实是不对的。比如,我们的用户对象,在 PO 层面,会包括用户密码、创建时间、修改时间等,但在 DTO 层面的用户对象,是不应该包含这些字段的,所以应该将两种对象区分开来。

三层架构只是在水平方向功能维度的一个划分,其实,还可以在垂直方向业务维度再进行划分。我们前面就将接口划分为了用户、账户、交易、行情 4 个模块,那在 API 层和 Service 层其实也是一样的,也可以再划分为这 4 个业务模块。不过,目前的 DAO 层可能不太适合这么去划分,就直接按不同数据表的维度去组织代码即可。

然后,关于编程语言方面的选型,主要也是看团队所熟悉的技术栈了,如果是从 0 到 1 搭建团队来做,我推荐尽量用 Golang,因为交易系统的特性就是高并发,而 Golang 的语言特性就非常适合开发高并发的应用。

更深入的代码实现层面,我就不再深入探讨了。根据以上所描述的架构设计结果来看,在代码实现层面,其实也没什么难点。

总结

1.0 版本的交易系统架构设计,我就聊这么多了,因为篇幅原因,也无法面面俱到,很多细节也没有讲,但大体的设计思路应该是讲到位的了。另外,虽然我是在聊交易平台的架构设计,但背后的本质,更多其实是想传达更普适的架构思想。比如:

  • 我们应该由场景驱动架构,做架构之前要先充分理解需求
  • 不要过度设计,但可以适度超前
  • 能用简单方案满足当前需求,就不要考虑复杂方案
  • 架构就是在各种选择中做平衡

1.0 版本的交易系统还是很简陋,性能也很低,很多设计其实也还不怎么优雅,这些在后续的版本中我会慢慢讲怎么优化、怎么不断演进。

如果有时间,我也可能会自己编写代码实现一套交易系统,并可能开源,但不能确定。

0 人点赞