Go 在证券行情系统中的应用

Posted GoCN

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go 在证券行情系统中的应用相关的知识,希望对你有一定的参考价值。

    本文内容包含三个部分:证券行业系统背景介绍,证券行情业务特点,行情系统开发遇到的挑战。


一 证券行情系统背景介绍

    以行情云和交易云为核心,广发证券构建了 Open Trading 交易平台、GF Quant量化分析平台、各类交易终端、开发者社区等FinTech生态系统,从理念到技术水平均走在业内前沿。以交易系统和高频行情为核心,我们在外面构建了广发交易云和 Open Trading交易平台,这个交易平台对外提供API接口,还有 FIX(金融信息交换)协议。

    下方的DMA是直接市场访问,我们通过API接口对外可以支持手机证券客户端,手机证券客户端主要给个人投资者使用,还有上面的操盘手,操盘手是我们正在研发中的专业操盘软件,用于PC客户端。机构投资客户端和第三方终端都可以接入到我们这个平台。

    外面橙色这一圈,就是比较新的概念,如开发者社区,开发者可以开发一些插件,发布到插件市场。这些插件可以放到操盘手上面,插件可以自己定制一些交易的算法和功能,可以用到我们软件上面做交易。投资者社区可以讨论一些投资的想法或者交易算法,设计一些好的交易算法可以发布到算法市场。最外层就是其他延伸类的服务。


 二 证券行情业务特点


Go 在证券行情系统中的应用

    第一个特点超低延迟。延迟过大会导致投资决策失误,客户流失。例如投资人通过客户端看到的行情不是最新行情,看到的现价和实际情况不一致。比如实际已经涨到10块1毛,他看到是10块钱,下一个10块钱的买单,这时候订单就没办法成交,如果是牛市可能就错过了买入机会

    第二超高并发牛市时全民炒股刷新行情数据,行情刷新的请求量平时很多倍,其中带宽和并发量都是海量级别。我国现有1.2亿的股民,平均每十人里面就有一位股民,用户量是非常大的,访问时间也容易集中到开盘或收盘的几分钟

    第三个特点是超高可靠性。数据出错可导致真金白银的损失,因为这些数据也是我们拿到交易所原始数据之后计算出来的,计算出错都可能导致用户投资决策失误。

    第四个特点是超严格监管这点金融行业之外的开发人员可能理解不深刻。特别是股灾之后,现在整个行业都进入全面监管、从严监管的时代,股灾之后证监会还有交易所都是我们的监管方,经常证券公司检查,检查系统各方面是不是合规,服务器放在哪里,数据怎么存储,都是有合规约束。其他行业的互联网产品开发,后端服务可以放到公有云上面,但是券商很多都不能放在外面。都是用自有机房私有云,自己要造一些轮子,运维也会麻烦一些。

    举个例子,2015年5月29日上证指数冲击五千点,某些券商信息系统发生了中断或者缓慢,引起各方广泛关注,也受到证监会的处罚(引用自证券日报《证监部门处罚部分信息系统瘫痪券商》)。当时交易量创下了世界纪录,一天的交易量超过过去几。多券商行情系统因为访问量太大出现了崩式瘫痪,当同行们的行情系统卡的不更新时,广发的情况好一点,虽然卡一点但还是在更新,在业内赢得了不少好评和口碑。


三 行情系统开发遇到的挑战

    任何事物都是有两面性的。我们选择技术栈的时候,有我们看中的优点还有缺点需要弥补,行情系统开发的过程中我们先后遇到以下问题:

Go 在证券行情系统中的应用

1 开发语言的选择问题

Go 在证券行情系统中的应用

     我们有三项选择,首先是C/C++,这两种语言是历史悠久的高性能系统级语言,类似于左上角这辆丰田AE86,上世纪70年代设计理念,80年代投入使用,这辆车非常轻,车重不足1吨,很多爱好者都会改装它,最可以改装到800多公斤,非常适合爱改装和造轮子的老司机。虽然历史悠久但是还有人在玩,选这辆车的人经常会改装整车一半的零件,他们说丰田只造了半辆车,剩下一半都要自己改装。很多C/C++做项目开发人说,每次做项目如果选C/C++,项目启动的第一件事就是自己写一个网络库,这是项目造的第一个轮子(最近业内流行的是造协程的轮子)

    第二个选择就是Java,这是金融机构广泛使用的安全可靠的系统级语言,类似于解放军装备的T99主战坦克,诞生于上世纪90年代,车重50吨,它的特点是火力猛装甲厚行动迟缓安全性高。T99坦克时速最高可以到50〜60公里,目前世界最快的坦克只能开到100公里。大家选这种坦克型语言主要看重是它们的安全性非常好。

    最后是我们的Golang语言,为并发而生,集成现代设计理念的系统级语言,类似于特斯拉Model S,诞生于近几年,集成了AutoPilot等高级驾驶辅助功能,代表了业界发展方向。选择特斯拉的人群,相信它是未来的发展方向,比如自动辅助驾驶,可以减轻司机的负担。Golang也是如此,Golang集成的新工具都省掉很多轮子,可以直接拿来用,开发效率很高,解放了程序员的生产力

    昨天晚饭的时候有阿里的工程师说,他们想在公司内部推广Golang,但是阿里的技术基于Java,运维系统和发布系统都只支持Java,换一种语言运维人员不会支持。这个问题只能等Golang更加流行,企业内部系统的支持慢慢跟上来。创业型团队完全没有这种历史负担可以直接用Golang,我们开发更快性能更高。广发证券现在行情后端的团队所有的代码都是用Golang开发的。


 2 GC问题的困扰


Go 在证券行情系统中的应用

    再说国内的情况,虽然做高频交易可能性不大,但是国内有涨停板和跌停板的限制。比如顺丰上市连续五个涨停板,一般人想买顺丰的股票买不到,一开盘就是涨停价,封死在那里。如果想买到涨停板怎么办呢?这个时候就要在开市一瞬间,用极速交易系统发一个买单过去冲到所有买单最前面。如果订单在开市时间点之前到达是作废的,进不了交易系统,必须在开市的第一时间进去。据说现在同行研究用原子钟与交易所对准时间,在交易所开盘时间点到达把订单发过去,这样就能抢先买到涨停板里的卖单时间就是金钱在这里得到充分的体现。


2.1 Go在GC性能上的改进

Go 在证券行情系统中的应用

    讨论一下Go在GC上的性能问题。Go1.8版本是当前最新版本,相比于1.7版本GC暂停大幅减少,通常低于100微秒甚至10微秒。我们实际测试了一下,右侧图表是一个负载不是很高的服务器,GC普通情况下暂停确实已经影响不是太大。我们关心毫秒延迟,所以100微秒对我们没有影响。Go使用CMS,它的优点是不中断业务的情况下并行执行,将停顿时间降低到最小,缺点是GC并行执行需要更多的状态同步开销,降低了GC吞吐量以及堆空间增长难以预测。为什么这么说?很多支持协程的语言,发现堆里面剩余空间不足的时候,会把业务协程给暂停下来,所有的业务全部暂停,这个时候去做批量化的GC处理,堆空间就不会难以预测。比如算法限定堆对象上限为300MB达到此上限时把所有业务暂停做一次清理,堆对象自然不会超过300MB。如果做并行GC,发现快到300MB时启动GC,如果业务线程在快速申请释放对象,GC的线程回收效率可能跟不上,堆对象就会超过300MB到很高。上面的毛刺就是GC捡垃圾的速度跟不上业务线丢垃圾的速度,导致我们的堆空间暴涨。


2.2 GC算法考量的因素

    第一点是并发回收器利用多核处理器并行执行。一个核心在跑业务的时候,另外一个核心能不能去把它产生的垃圾收回来

    第二点是停顿时间,回收器会造成多长时间的停顿。比如Go使用的并行的GC就可以把停顿时间降低到最小,暂停业务线程只是为了同步状态,然后业务线可以继续跑,GC继续扫描垃圾并回收掉。

    第三点是停顿频率。回收器造成的停顿频率分布我们希望它越均匀越好,或者说在业务线程空闲的时候,可以多停顿一下,把所有的垃圾回收回来。

    第四点压缩,移动内存对象整理内存碎片发频繁申请释放大量内存对象,如果内存对象不能移动,回收后的空闲内存可能是一小块一小块零散的碎片。这时候如果要分配大对象,小碎片用不上,只能分配新空间才能把大对象放上去,小碎片就造成内存空间的浪费。一个好的GC算法,可以移动内存对象,通过移动整理来把小碎片合并成一块大的空闲区域,这就是内存碎片的整理。

    第五点堆内存的开销,回收器算法需要消耗多少额外的内存开销来做GC扫描以及统计。

    第六点GC吞吐量,在给定的CPU时间内,回收器可以回收多少内存垃圾。GC吞吐量不够的时候,回收垃圾需要更长的处理时间。

Go 在证券行情系统中的应用

    上图是线上跑的一个系统,这个系统的压力不是太大,每秒处理1000多条数据,跑了一段时间之后,我们就发现内存堆空间占用了1个多GB,我们预测他的内存几百MB就够用了。但是跑太久就会出现堆空间不断增大,可能的原因a是无压缩造成。目前Go GC算法不支持压缩,其实不支持压缩也是在考虑很多情况下的权衡。比如说Go要跟Cgo线程互操作,一个对象要跟Cgo线程之间共享,压缩可能导致Cgo没法访问。Go考虑到这种场景,就选择不移动对象,内存垃圾压缩实现不是太好,比Java要差一点。然后原因b吞吐量不足,为了暂停时间尽可能短,牺牲的就是吞吐量。GC Stop The World 时间和吞吐量我们只能二选一,Go选择停顿时间短所以吞吐量会差一点。原因c是无停顿,处理不及时,因为Go在跑的时候,其他GC也会同时在处理,没有把业务线程停下来。比如右边绿色升上去了,这个就是申请的对象速度非常快,黄色的GC没有跟上来,这个也会导致堆空间增大一些。原因d并发执行不可预测,在并发时就无法预测堆空间会涨到哪里如果申请的速度非常快,这个会有可能涨到天上,最后内存爆掉。


2.3 避免Goroutine的频繁创建销毁

    前面讲了很多GC问题,这种情况下就要避免出现GC问题。我们要避免Goroutine的频繁创建销毁,并发量小于1000时,每个请求分配一个Goroutine,并发模型简单易于开发,类似于Apache而并发模型。Apache每新建一个连接时就从进程池中分配一个处理,这种并发模型非常简单,代码也是同步的。并发量大于1000时,频繁创建的Goroutine在销毁时产会生大量的内存垃圾,比如每秒创建或销毁1000个Goroutine时,垃圾就非常多,GC线程就会非常繁忙。CPU 30%-50%的时间用来处理GC。整个系统的响应速度就会很慢。这时就不能每个请求创建一个Goroutine那么奢侈,最好用采用nginx并发模型。


2.4 对象缓存池的使用

    为了避免GC问题,减轻GC的压力还可以使用对象缓存池。不创建新对象才能避免GC,没有生就没有死,不创建新的就不会有回收问题。业务正常状态下对象的创建速度和销毁速度近似平衡,所以一个缓存池可以完美的解决问题。Go的标准库里面有一个sync.pool的缓存池实现,缺点是没有办法控制缓存对象数量和销毁时机。sync.pool的对象缓存在下一次做GC的时候,会全部回收。

Go 在证券行情系统中的应用

    介绍一个对象缓存池的简单实。首先创建一个Channel,长度设置为一万,也就是缓存池的容量。然后写一个分配的函数AllocSetU64,分配方法通过Select语句实现,第一个case取出一个缓存对象,如果这个Channel为空说明缓存池空,第一个取缓存的case被跳过直接进入default,只能创建新的对象释放函数FreeSetU64的回收也是利用这个Channel如果没有满就丢到Channel如果满就直接执行default的空操作,意思是解除引用把对象留给GC回收。况右边图表就是这段代码运行时的统计情可以看到加了这个缓存池之后,实际上线跑的时候,第一行申请新对象的统计为0,表示没有创建新对象,而分配时复用旧对象是6.25K,当前这个时间点把对象释放的数量也是6.25K,业务在跑的时候,对象创建速度和释放速度是差不多的对象的创建和释放全部循环使用了缓存池对象这样就不会有对象的销毁,所以GC的压力就会小很多。


2.5 栈对象和堆对象

  • 栈对象在函数返回时释放,堆对象由GC释放

  • Go编译器的做法:不逃逸的对象放栈上,可能逃逸的放堆上

  • 尽量使用栈对象,特别是在快速调用和返回的函数中,栈对象的分配速度比堆对象快一倍

  • 长时间不返回的函数中,过多的栈对象可能增加Goroutine栈空间维护的开销

  • go tool compile -m 辅助分析对象的分配情况

    关于栈对象和堆对象C/C++程序员会有明确的概念,Golang程序员可能极少关注Go的栈对象在函数返回时释放,堆对象由GC释放。

    如果想减轻堆里GC压力,自然尽可能把对象放到栈里面,特别在快速调用和返回函数中,栈对象分配速度比堆对象快一倍。原因是在栈里面分配对象非常容易,只需要把栈指针往后挪一下,挪出来的空间就可以放新对象;如果堆里面分配,堆里面有分配算法问题要执行另外当堆里面空间用完了,还需要分配新的堆空间。所以堆里面分配速度会慢很多,而且会产生垃圾。

    最后长时间不返回的函数中,过多的栈对象可能会增加Goroutine栈空间维护的开销,Goroutine的栈是分配在进程堆空间默认每个栈分配4K内存,当函数生成对象非常多栈不断的增加4K的时候怎么办呢?Golang有两种办法,一种不分段的栈,分配一个更大的栈空间再把小的栈空间里的数据拷贝过来就能继续增长。Go默认使用分段的栈,会在另外一个地方再分配4K作为一个新栈节点这个4K节点和旧的4K节点用链表连接起来栈对象太多栈空间不够用如果分段就会分配新的段在某种情况性能会非常差:比如调用一个函数,这个函数消耗的栈空间超过4K之前的段就满了,就要分配一块新区域,这个函数一返回新分配的那块区域就需要被释放掉,如果再调用就会又增长,也就是栈间会不断增长、收缩,这个过程性能开销会很大。如果遇到这种情况,最好不要把非常大的数组放在栈里要放在堆里面。有时候如果不知道对象编译器放到堆里还是栈里,可以用一些工具如go tool compile -m来辅助分析对象的分配情况,编译器会把所有对象分析出来,告诉你这个变量是放到堆里面还是栈里面。


3 面向并发的数据结构

    在多线程时代并发访问临界区资源时往往要加锁,锁的存在使得并行任务互相干扰影响性能。在多处理器多核时代并发问题会更加复杂,同块内存单元的读写也会互相干扰影响性能。

Go 在证券行情系统中的应用

    先介绍Cache Miss的代价,内存延迟往往很高,从10到100纳秒不等,一个3.0GHz的CPU在100ns可以处理多达1200条指令。一次缓存失效就会失去执行500条指令的机会。参考右图,个CPU插槽里是多核处理器,从C1到Cn多个核心,多个核心的一级缓存、二级缓存独立,三级缓存共享。一级缓存二级缓存每一次读写使用的时间非常短是纳秒级别,如果到三级就是12纳秒,如果三级缓存还没有命中,访问内存就是56纳秒。在开发高性能程序时,就要考虑访问的内存空间,是不是尽可能用CPU缓存。

   先介绍一下原理再讨论我们遇到的问题,访问同一块内存区域一个Goroutine在第一个CPU上,另一个Goroutine在另一个CPU上。他们都会访问同一个内存区域,这个内存区域在左右两边的缓存里都有。两边修改之后如何保持一致呢?CPU内部有个Cache一致性协议。缓存段处于独占或修改状态的时候才可以修改我们的缓存里面分为一段一段,每64字节一段,一段缓存影射一块内存)。每个缓存段都是有状态的,比如左边的Socket1在三级缓存里个段设成独占状态,这种状态下左边的CPU就可以修改这个段。申请独占的时候就会出现一个问题,比如Socket2CPU要申请为独占,就会通过QPI总线告诉Socket1的CPU这一段已经失效了,如果Socket1再访问内存的话,就不能使用缓存,必须从内存里面重新加载,这时候性能损耗就会非常处理延时也更高

Go 在证券行情系统中的应用

    介绍完CPU缓存技术背景知识,我们再看一下Per-CPU的存储。如两个协程在工作时都要做一个统计状态上报,比如协程一收到一条消息我们有一个统计的API去把统计变量加1,另外一个协议也收到一条数据,也去把统计变量加1。假如两个协程在两个CPU上时,第一个协程加1的时候,就会在三级缓存里面独占内存缓存段,导致第二个CPU对应的缓存段失效,之后第二个协程又申请独占缓存加1导致左边失效。两个协程跑的时候导致对方的缓存不断失效,就需要不断透过缓存直接访问内存,缓存作用丧失性能会下降几十倍。

    对于这种情况可以使用Per-CPU storage,每个CPU给一个统计变量。比如CPU1有一个状态统计,CPU2有一个状态统计,最后汇总就是最终的统计量。比如这个计数问题,要计数的时候就把所有CPU统计情况加起来得到最终结果。Go要怎么实现这个问题呢?Go在做这种高性能计算的时候,两个协程之间要不互相干扰,需把协程绑定在各自操作系统线程上,这样协程就不会跑到其他协程的操作系统线程上。操作系统线程又可以做一个CPU亲和性绑定,两次绑定之后,Goroutine就只会在一个CPU核心上跑且每次操作对应CPU的storage,有一个另外的Goroutine来读(定时每5秒、10秒),把CPU统计量汇总加起来,这样就可以解决上面的问题。

    再看一下另外一个并行读锁之间的干扰,多线程时代为了避免锁的开销,有些情况数据库读的非常多,写的非常少,就有读写锁的概念。通常认为两个读锁之间不会互相干扰,所以很多时候我们都是大量使用读写锁。但是分析读锁的代码,读锁需要统计当前有多少读锁加在上面,有计数变量每次做+1计算。如果一个Goroutine在循环里面频繁使用读锁,加读锁,释放读锁,另外一个Goroutine也有这样一个循环加读锁,释放读锁,这时会同样出现上文的问题,两个协程之间操作同一个内存区域,导致各自CPU缓存失效,性能大的降低。

Go 在证券行情系统中的应用


    4、融合替代方案

    通过我们的内存分析发现很多Go的第三库设计的时候没有考虑性能问题,比如一些服务发现,还有一些编码、解码的库,频繁的创建大量堆对象留下很多内存垃圾。为避免使用这种库有时要用Cgo重写,比如Protobuffer解包交给Cgo,就不会有内存垃圾。

    Goroutine难以管理10万级以上的连接。我们有一个手机客户端的推送服务,服务器单机支持30万并发,因为手机客户端网络非常不稳定,走到电梯或者墙角网络就断了,通常移动网络里面可能会1%的重连,所以每秒有3K的Goroutine创建销毁,这种情况下GC压力很大,我们要用Cgo的方案解决。

    从Go中调用Cgo函数的开销非常大,主要是因为二者的栈结构不同。解决方案是创建一个Cgo线程常驻在内存,如果有任务需要交给它就通过内存通信,Go把这个对象丢到内存队列里,Cgo处理完之后再丢回内存队列,Go再从里面拿回结果。

Go 在证券行情系统中的应用

    做网络服务的时候,希望网络IO性能越高越好,吐吞量越大越好。我们使用Docker等虚拟网络的时候可以把MTU调大,使用巨型帧传输数据。默认局域网里的MTU是1500字节,这个长度适合互联网传输,但如果传输是在内网之间1500就太小可以调到9000以上,协议栈一次可以传输更多数据。我们用UDP发包的时候都是用UDP的大包,每次调用可以一次性把几千的数据拷到内核里,减少了系统调用的数量,这些数据一次性穿透协议中发送出去。如上图我们的Docker网络,两个通信的容器就在同一个Docker Host里面,这个数据包不会传到外网,我们可以把MTU设的越大越好,它不经过物理网卡,直接在协议站里从一个容器拷贝到另外一个容器。

    有时候会存在两个Docker不在同一台主机上面,会存在跨主机的通信问题。这时网卡在硬件上有分片offload和校验offload功能。offload在这里可以理解为减负,这个事情本来协议栈可以干,但是网卡可以帮忙干,帮协议中做一个加速,减轻CPU的负担。左图解释了在开启分片offload之前协议栈的数据是怎么传输的,应用层有一大块数据要传输交给TCP的协议栈,就会在IP层切成一片片,切完片交给网卡发出去。这时候如果用TCP抓包,抓到的都是小于1500字节,已经分好片的。右图把网卡分片功能打开之后,应用程序发一个大包到TCP层,到了IP层还是不会做分片,协议栈就不管分片这事情,所以CPU资源就省下来了。网卡收到大包之后,网卡自主分成一片一片的发出去。所以开启后数据在协议栈里面处理会非常快,CPU的负荷会降低很多,分片和校验都交给网卡。

    最后分享一下我们实际使用中遇到的问题,大包无法正常接收。我们有一个服务可以把UDP包给另外一个服务器,另外一个服务器做汇总统计。发现UDP包长度超过1493的时候,另外一端收到的UDP校验就会出错,协议栈就会把这个包丢掉。UDP协议里面有一个长度字段是两个字节,所以一个UDP长度最多可以达到65535,为什么这里到1493就出错了?第一个包是从Docker虚拟网卡发出来时我们抓到的,这个时候看UDP校验出错,这个没有关系,因为它支持分片,这个时候协议栈没有做分片也没有做校验。第二条抓到的时候,是在Docker上面,第三条和第四条已经到了我们的物理网卡,最后通过虚拟网卡到物理网卡上面,虽然做了分片,但是分片后还是错的。

    最后终于查到原因:左边是Docker里面看到的虚拟网卡Veth,Veth是虚拟网卡并非真实硬件设备,它并不真实处理分片但是总是默认报告它支持分片。所以左图Veth的UDP分片显示已经打开了,而右边Host物理网卡不支持UDP分片。Veth给内核报告它支持分片,协议栈的传输层和IP层遇到大包就不会拆分,如果大包要发到另外一台主机,就会从Veth转发到Host的物理网卡,这个网卡不支持分片,Linux的补救措施是CPU计算IP分片后再交给网卡,虽然照顾到了IP层但是忽略了更上一层的传输层如这里UDP的分片,所以UDP的校验字段没有重新计算是错的。这个问题查了之后发现,很多基于docker的技术社区都有提出类似问题但是没有好的解决方案,我们只能在需要跨主机通信而Host网卡不支持UDP分片时关闭Dokcer容器内Veth的UDP分片功能。


以上是关于Go 在证券行情系统中的应用的主要内容,如果未能解决你的问题,请参考以下文章

Go语言的应用

北斗对时设备(GPS校时产品)在数字城市系统中的应用

哪些知名公司在使用Golang语言

应用运维的红蓝演练:全链路压测在券商系统的落地实践

兴业证券受邀参加华为开发者大会并获最佳应用体验合作伙伴奖项

“智能报表系统”的建设经验--东北证券