本文内容包含三个部分:证券行业系统背景介绍,证券行情业务特点,行情系统开发遇到的挑战。
一 证券行情系统背景介绍
以行情云和交易云为核心,广发证券构建了 Open Trading 交易平台、GF Quant量化分析平台、各类交易终端、开发者社区等FinTech生态系统,从理念到技术水平均走在业内前沿。以交易系统和高频行情为核心,我们在外面构建了广发交易云和 Open Trading交易平台,这个交易平台对外提供API接口,还有 FIX(金融信息交换)协议。
下方的DMA是直接市场访问,我们通过API接口对外可以支持手机证券客户端,手机证券客户端主要给个人投资者使用,还有上面的操盘手,操盘手是我们正在研发中的专业操盘软件,用于PC客户端。机构投资客户端和第三方终端都可以接入到我们这个平台。
外面橙色这一圈,就是比较新的概念,如开发者社区,开发者可以开发一些插件,发布到插件市场。这些插件可以放到操盘手上面,插件可以自己定制一些交易的算法和功能,可以用到我们软件上面做交易。投资者社区可以讨论一些投资的想法或者交易算法,设计一些好的交易算法可以发布到算法市场。最外层就是其他延伸类的服务。
重点说一下高频行情,高频行情信息包括实时报价、逐笔成交、分时成交、周期K线,还有资金流向。这些数据由券商通过专项线路从交易所获得原始数据后计算生成。目前直连交易所有两条线路,一条是互联网专线,还有一条是卫星专线。卫星专线因为延时很大,所以实时性不是很好,我们通常只做灾难备份。
二 证券行情业务特点
第一个特点超低延迟。延迟过大会导致投资决策失误,客户流失。例如投资人通过客户端看到的行情不是最新行情,看到的现价和实际情况不一致。比如实际已经涨到10块1毛,他看到的是10块钱,下一个10块钱的买单,这时候订单就没办法成交,如果是牛市可能就错过了买入机会。
第二超高并发。牛市时全民炒股刷新行情数据,行情刷新的请求量超出平时很多倍,其中带宽和并发量都是海量级别。我国现有1.2亿的股民,平均每十人里面就有一位股民,用户量是非常大的,访问时间也容易集中到开盘或收盘的几分钟。
第三个特点是超高可靠性。数据出错可导致真金白银的损失,因为这些数据也是我们拿到交易所原始数据之后计算出来的,计算出错都可能导致用户投资决策失误。
第四个特点是超严格监管。这点金融行业之外的开发人员可能理解不深刻。特别是股灾之后,现在整个行业都进入全面监管、从严监管的时代,股灾之后证监会还有交易所都是我们的监管方,经常到证券公司检查,检查系统各方面是不是合规,服务器放在哪里,数据怎么存储,都是有合规约束。其他行业的互联网产品开发,后端服务可以放到公有云上面,但是券商很多都不能放在外面。都是用自有机房私有云,自己要造一些轮子,运维也会麻烦一些。
举个例子,2015年5月29日上证指数冲击五千点,某些券商信息系统发生了中断或者缓慢,引起各方广泛关注,也受到证监会的处罚(引用自证券日报《证监部门处罚部分信息系统瘫痪券商》)。当时交易量创下了世界纪录,一天的交易量超过过去几周。多家券商行情系统因为访问量太大出现了雪崩式瘫痪,当同行们的行情系统卡的不更新时,广发的情况好一点,虽然卡一点但还是在更新,在业内赢得了不少好评和口碑。
三 行情系统开发遇到的挑战
任何事物都是有两面性的。我们选择技术栈的时候,有我们看中的优点还有缺点需要弥补,行情系统开发的过程中我们先后遇到以下问题:
1 开发语言的选择问题
我们有三项选择,首先是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问题的困扰
海量并发和海量数据处理系统,GC的内存对象扫描标记不仅消耗大量CPU资源,还会因为GC过程Stop The World造成毫秒级延时,拖慢行情推送速度。右侧图表是美国证券市场订单处理时间的演化图(国内情况也类似),从最初一千毫秒现在到零点几毫秒。局域网内部的延时是零点几毫秒,跨省的光纤延时是几十毫秒,如果是中国到美国这种跨洋的专线延时就在上百个毫秒。因为现在订单执行很快,所以我们考虑网络延时的情况下还要把系统做的更快。行情和交易都是与时间赛跑的实时应用领域。举一个国外的例子,因为光纤在铺设的时候为了避开障碍物,都选择沿着公路马路铺设,为了把芝加哥期货交易所和纽约交易所通讯时间缩短3毫秒,花费数亿美元埋藏一般条遇山开山遇河挖隧道只为了走直线的光纤。虽然光在光纤里面以光速传播,但是光信号在光纤内传播会有衰减,每隔一段距离都需要加一个信号放大器,信号放大器就是一个中继器,每一个中继器都会增加网络延时。所以如果能把光纤铺设更短,网络延时就会缩小。再比如频繁花巨资更新通信设备,只是为了微秒的提速,乃至colocation到把机器并排放到证交所的机器的旁边。
再说国内的情况,虽然做高频交易的可能性不大,但是国内有涨停板和跌停板的限制。比如顺丰上市连续五个涨停板,一般人想买顺丰的股票买不到,一开盘就是涨停价,封死在那里。如果想买到涨停板怎么办呢?这个时候就要在开市一瞬间,用极速交易系统发一个买单过去冲到所有买单最前面。如果订单在开市时间点之前到达是作废的,进不了交易系统,必须在开市后的第一时间进去。据说现在有同行研究用原子钟与交易所对准时间,在交易所开盘时间点到达时把订单发过去,这样就能抢先买到涨停板里的卖单。时间就是金钱在这里得到充分的体现。
2.1 Go在GC性能上的改进
讨论一下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吞吐量不够的时候,回收垃圾需要更长的处理时间。
上图是线上跑的一个系统,这个系统的压力不是太大,每秒处理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的时候,会全部回收。
介绍一个对象缓存池的简单实现。首先创建一个Channel,长度设置为一万,也就是缓存池的容量。然后写一个分配的函数AllocSetU64,分配方法通过Select语句实现,第一个case取出一个缓存对象,如果这个Channel为空说明缓存池空,第一个取缓存的case被跳过直接进入default,只能创建新的对象。释放函数FreeSetU64的回收也是利用这个Channel,如果没有满就丢到Channel,如果满就直接执行default的空操作,意思是解除引用把对象留给GC回收。况右边图表就是这段代码运行时的统计情,可以看到加了这个缓存池之后,实际上线跑的时候,第一行申请新对象的统计为0,表示没有创建新对象,而分配时复用旧对象是6.25K,当前这个时间点把对象释放的数量也是6.25K,业务在跑的时候,对象创建速度和释放速度是差不多的。对象的创建和释放全部循环使用了缓存池对象,这样就不会有对象的销毁,所以GC的压力就会小很多。
栈对象在函数返回时释放,堆对象由GC释放
Go编译器的做法:不逃逸的对象放栈上,可能逃逸的放堆上
尽量使用栈对象,特别是在快速调用和返回的函数中,栈对象的分配速度比堆对象快一倍
长时间不返回的函数中,过多的栈对象可能增加Goroutine栈空间维护的开销
go tool compile -m 辅助分析对象的分配情况
堆里面空间用完了,还需要分配新的堆空间。所以堆里面分配速度会慢很多,而且会产生垃圾。
这个4K节点和旧的4K节点用链表连接起来。当栈对象太多栈空间不够用,如果分段就会分配新的段,在某种情况性能会非常差:比如调用一个函数,这个函数间会不断增长、收缩,这个过程性能开销会很大。如果遇到这种情况,最好不要把非常大的数组放在栈里要放在堆里面。有时候如果不知道对象编译器放到堆里还是栈里,可以用一些工具如go tool compile -m来辅助分析对象的分配情况,编译器会把所有对象分析出来,告诉你这个变量是放到堆里面还是栈里面。
3 面向并发的数据结构
4、融合替代方案
做网络服务的时候,希望网络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的技术社区都
有疑问加站长微信联系(非本文作者)