交易系统,可以分成两个部分:账户管理(Account) 和 撮合引擎(ME)。账户管理,包括用户资金、持仓、挂单。撮合引擎,就是Order撮合。
最简单的方案,是把Account和ME混合在一起。流程是:
用户下单 -> 撮合 -> 成交,直接更新taker和maker的资金、持仓
好处是简单,没有接下来要讲的很多问题。
坏处是只能单线程,有性能瓶颈。如果把网络IO、协议解析、日志等都放到其他线程中,而主线程专门执行上述流程,那么单线程能到20万qps。对于一般交易所应该足够,但对于大型交易所不太够。
ME部分,按照Symbol(币对、股票、合约等市场)拆分,并发执行。对Account的操作,加锁。
多线程并发执行,可以显著提高性能。但也有些问题:
Account加锁,对于现货交易(币对交易)是可行的,因为Account是 User+Currency 的,每个Account只有两个字段:Total和Frozen,对Account的更新也只是加加减减,加锁临界区非常小。 但对于合约(期货、期权交易),Account需要维护所有的持仓,并根据持仓、挂单、当前市场价来实时计算保证金,加锁临界区非常大,加锁不能接受。
多线程并发操作Account的另外一个问题是,在重放Log时,无法保证对Account的更新顺序。不过对Account的更新只是加加减减,是满足交换律的,所以最终结果是一致的。比如对一个账号先+100再-30,和先-30再+100,最终结果是一致的。这个最终一致性有什么具体影响?
为了解决上述方案的两个问题(加锁临界区大;不能保证Account更新顺序),可以在处理下单请求时,全程对Account加锁(而不是仅在有成交时)。
仍然是按照Symbol拆多线程,并发执行。在下单一开始就对Account加锁,并一直持续到下单请求结束。关键点在于,如何处理锁冲突(两个case):
同一个Account在其他线程下单,也需要加锁。解决方案是,直接pending这个请求,并继续执行后面的其他用户的请求。在执行完后,再来重试这个pending的请求。限制pending和重试的次数。如果超过一定次数,比如5次,就拒绝用户。
同一个Account在其他线程中,作为maker撮合,需要更新。解决方案是,maker的更新,通过消息(而非直接函数调用)。这个消息通过channel发给整个状态机的入口,并也记录Log,并定序。除了成交消息外,还需要Order的结束消息,比如maker Order因为STP被撤单。
这就解决了上个方案的两个问题。但maker的成交消息的方案,引入了新的问题,即一个请求不再是原子的了,而是包括很多条Log:一个请求Log,多个成交Log,和多个order结束Log。不是原子的,就可能导致状态不一致。比如记录了请求Log,而成交Log没有完全记录,程序就异常终止。但这种异常并不影响程序的继续执行。可以通过后台的对账来发现异常,并通过API来修复。
Account和ME分层,之间不直接调用,而是通过消息传输。
对于下单请求,先经过Account,冻结资金;然后发给ME做撮合;如果有成交,则发消息给Account做结算。
好处是,Account和ME完全独立,不用加锁,没有上面方案因为加锁带来的问题。不过仍然有非原子的问题。
坏处就是复杂。
有两种分层方案:1. 一个服务,拆线程,Account多个线程(按用户拆),ME多个线程(按Symbo拆);2. 拆服务。
一般来说,拆服务(分布式)的优点是:
缺点(也是相对于拆线程而言):
总结,这个方案优点是无锁,缺点是复杂。两个实现,1拆线程,略复杂;2拆服务,太过复杂。
最终的选择,取决于具体的场景和决策者的口味。我个人喜欢1和3。
打快照有几种方案:
对于数量不大的内容,可以采用上述1或2。对于大数据,这里提供一个新的思路。以HashMap为例。
对于删除操作,再做讨论。
交易系统有两类请求:1下单撤单等;2资金变更,包括外部划转和内部成交。
前者要求低延迟,但可以容忍极个别的不一致,比如程序异常导致的请求回滚;后者不能容忍不一致,但不关心延迟。
Raft的延迟,来源于节点间的消息和本地磁盘同步。能否这样,对于前类消息,在提交Raft Log后,不等Raft协议的确认,就立即执行并返回给用户;对于后类消息,则严格按照Raft协议来。