股票行情数据是一种典型的时序数据(Time-series Data),在一般的IT系统中,日志数据其实也是一种时序数据,在大数据的世界里,也有大量应用是基于时序数据处理的,可以说时间序列的数据无处不在。所以,哪怕是不炒股、不熟悉金融世界的工程师,从本文也可以了解一些具有普遍性的技术思考,例如在大规模、高密度的数据处理中,是把数据快照搬到计算节点作运算还是把计算能力放到数据节点中“就地计算”?协程(co-routine)在这类计算密集型系统中又有何作用?
实时行情服务是券商的基础服务,给普通投资者描绘出风云变幻的动态市场画面,也给量化投资者提供最重要的建模基础数据和下单信号,时间就是金钱,行情服务必须要快。证券交易系统行情指标很丰富,最基础常见的包括:行情报价、分笔数据、分时数据、分钟K线、日周月年K线、各类财务技术指标、多维度排序、多维度统计等等,这些指标需要由交易所的行情数据流结合时间流进行统计计算。本文分享广发证券行情服务并行化计算演进的过程,包括五个部分:
-
股票行情是怎么回事
-
日均10亿次的行情指标计算
-
基于Redis+Lua的方案:“就地计算”
-
引入Goroutine的方案:“海量算子”
-
孰优孰劣
一、不炒股没关系,股票行情科普在这里
任何交易在达成之前,通常都有一个讨价还价的僵持过程,或者买方让价,或者卖方让价,两不相让的时候需要第三方介入撮合取个“平均价”,这个过程就是定价。证券股票交易也不例外,不同的是买卖双方互不可见,双方通过券商渠道把自己的价量报给交易所,交易所通常按照达成最大成交量的原则撮合定价。撮合涉及order book和tick data两个概念。交易所维护两个“账本”分别记录买方和卖方申报的价量,实时或按照一定频率进行撮合定价成交,申报、成交时对“账本”进行增、改、删操作。
这两个“账本”就称作order book,对order book增改删操作引起数据变化,每个变化的快照就称作tick data。国内股民数超 1.2 亿户,A股上市公司近3000家,可以想见的到order book是一个大“账本”,tick data瞬息万变,一个交易日产生的tick data量更是惊人。国内交易所目前采用抓取快照的方式,抓取order book的前五/十档价量,剔除”无用的”其他档价量数据,统计交易当日开市时点到目前时点的最高、最低、成交量、成交额数据,我们将这种数据称为原始行情数据(如图1)。
交易所将原始行情数据近实时的发送给券商,券商行情系统对这份原始数据进一步处理,比如按时间区间统计出5分钟、10分钟、15分钟、30分钟、1小时、1天、1周、1月、1季度、1年这十个周期时间段K线蜡烛图的高、开、低、收价格及成交量、成交额数据。
券商将处理好的行情数据揭示给投资者做再报价参考。可见这类数据量大、变化频繁、中间统计计算耗时,一旦延迟,以后就再也跟不上“实时”的节奏了。又快又准,是股票行情服务的基本要求,任何误差、延迟,都可以引起交易者的经济损失,导致投诉甚至社会事件。系统该如何设计才能高速的处理和展示这类实时行情数据,是个很大的技术挑战。
图1
二、你们股炒的爽,我们指标算的酸爽
券商行情系统可以分为行情原始数据接收解析、中间指标统计计算处理、行情数据请求响应三个模块。原始数据接收解析模块只需要做好与不同交易所的行情消息格式适配即可,行情数据请求响应模块要达到原始行情报价及各类行情统计指标的快速展现需要减少中间处理步骤,在行情原始数据接收解析、统计指标计算完成后立即推送给终端用户,同时存一份到内存数据库中以便终端用户再次查询读取,推送与查询两路相结合,达到在数据落地之前即已展示给用户的效果。
剩下要做的就是要尽量减少中间指标统计计算的处理时延。A股上市公司3000多家,基金加债券数更是上万,但它们之间是无关联的,在统计计算时完全可以分片、并行处理,存储上采用内存数据库,redis内置多种数据结构的支持满足多统计指标存储的需求、容易分片部署便于并行计算,如图2。
图 2
以最简单的十个周期时间段的K线统计指标,证券数保守算1w只,10*1w=10w,也就是说分分秒秒就有近10w的计算量,国内沪深交易所一个交易日开市4个小时,按照交易所行情数据每3秒更新一次,可得出日计算量就有4.8亿,加上其他指标计算如市盈率、涨跌幅、换手率、委比委差、多板块多指标排序,日计算量已突破10亿。
三、“就地计算”的方案 – 把计算能力放在数据里
好在不同的证券可以分片并行、同样各周期段的K线指标也可以并行统计计算,实时处理这么大的计算量,我们显然需要高度并行处理。
我们最早的方案基于Redis,因为Redis是一个非常好的承载时间序列数据的高性能内存存储技术。Redis除了内置多种数据结构,还内置了Lua语言解释器,这意味着Redis除了具备数据存储能力也拥有了数据计算的能力。一个Redis存储集群中,每个节点加载Lua脚本,这基本上就是一个分布式并行计算集群了。
我们剩下要做的是事情是实现一个行情收集器,接收来自不同交易所的行情原始数据(node.js善于处理io型事务,很适合用来实现收集器,细节不是本文焦点,不在此详述),发送eval lua指令到Redis集群。 Redis收到指令后执行我们用Lua实现的指标计算逻辑完成指标计算,之后通过Redis pub将行情数据推送出去。如图3通过将计算挪到Redis存储节点,我们避免了复杂易出错的多进程管理问题,也大大简化了开发的工作量。
图 3
这个方案的一个优点,是技术架构比较简单,从数据存储到运算处理,都在Redis上,我们仅专注于Redis集群的性能优化、高可用方案实现、容灾备份、数据复制。对于运维来说,运维一套相对单一的技术系统,“零部件”(moving parts)越少越好,出现故障、单点失败的环节也少了很多。
四、“海量算子”- 把数据快照挪到技术节点
上述Redis+Lua的方案,早期也服务了我们的市场与客户,但是当指标计算量不断增大后,其不足也日益显著,主要体现在高度密集的计算导致影响存储集群的性能而产生延迟,并且在交易期间对存储集群作动态扩容是非常困难的。
有鉴于此,我们很自然的只能把指标计算任务从数据存储中回收,放弃一个数据与计算一体化的相对简单的架构,把计算的职责交给存储之外的专用运算节点。此时Redis变成单纯的内存存储。这种实现将计算与存储分离,计算所需的数据不再能从”本地”获取到了,对于Redis而言,运算服务算是“out of process”(进程外),所以计算指标所需的数据,必须以一份数据快照的方式从Redis传递到运算节点,用以计算和更新,计算结果最终回写到Redis,如图4。
这个对于Redis而言“out of process”的计算能力,我们称之为运算节点(相对于Redis的数据节点),是一系列非常容易水平扩容的、高度并发的程序,我们采用了Golang来实现。Golang这个语言,天生支持协程(Goroutine) – 在一个运行的Golang程序中,可轻易启动上万的协程,所消耗的资源远小于线程,天生适合并行计算。
当接收到交易所tick数据时, 在一个运算节点中对每只证券解析及每类指标计算启动单独的goroutine,每个goroutine在它的生命周期中只做一件事就结束(存活时间毫秒级),这很像数学上的函数执行过程完全没有副作用,goroutine就是一个闭包算子,相互之间毫无影响。
Golang的内存回收是并行的,数万个goroutine启动到销毁对性能的影响很小,另外tick data本身是有间隔的从交易所发过来的,在goroutine销毁那一刻往往是tick data空闲的间隔时间,这个空闲时机点用来做goroutine回收是合适的。当然也有一些常驻goroutine用来将指标结果数据落地到Redis。程序语言本身是有各自不同的设计哲学的,Golang正是这样一种语言,其协程机制让我们能够更细粒度的处理可分而治之的系统,同时免去了复杂易出错的多进程、多线程问题。
图 4
五、方案比较 - 孰优孰劣
两种方案的对比:
1、Redis既负责存储也负责计算
-
我们通过搭建Redis集群来进行分布式并行计算,Redis集群本身有主备节点,这就涉及到主备同步的问题,虽然Redis自己解决了同步问题并且也支持增量同步,但通过eval lua指令在存储节点上计算时,同步的不是计算结果而是计算本身,也就是说同一个指标计算在主节点和备节点都需要各自计算一次。Redis又是单线程处理,对于复杂的指标计算通过查看SLOWLOG会有上百毫秒的延迟,这会造成阻塞,对于要求快速的行情服务是不可接受的,为了减少阻塞,就需要尽可能多的进行分片,这意味着需要起更多的redis节点。
-
Redis集群一旦搭建,节点缩扩容需要人工干预。对于证券行情服务这种目前9:30-15:00业务高峰,其他时段空闲的系统来说,高峰时无法弹性扩容,低峰时Redis节点进程无法回收造成资源浪费。
2、Redis负责存储,海量Goroutine做算子
-
将指标计算从Redis拿出来交给goroutine,Redis仅作存储节点,主备节点同步的是指标计算结果,这样降低了Redis进程的cpu使用率,也不再有SLOWLOG记录,同时仅需要很少的分片,大大减少了Redis节点数。
-
Redis集群规模的大小仅需要根据业务数据量确定。指标计算量的变化可以轻易通过启动更多的goroutine来进行方便的弹性扩容,goroutine数也随投资者活跃程度变化,对交易频繁的股票只需要启动更多的goroutine,不会像方案1那样造成部分Redis节点成为热点。在休市时段goroutine完全回收释放。
图 5
从Redis+Lua的数据存储与运算一体化,过度到数据存储与并行运算分离,也大大减少了系统对硬件资源的要求,Redis集群规模减少十倍,如图表1。采用Redis+Lua方案实现上比较简单,开发工作量较小,收集器与Redis数据交换量小,这种方案更适合简单的逻辑计算比如计数器;goroutine的实现方案,虽然开发上复杂,与Redis数据交换量大了一些,但更适合像行情指标计算这类复杂的应用场景。
方案 |
机器数(1个部署单元) |
Redis集群 |
总CPU核数 |
总内存 |
计算最大延迟 |
In-process:数据“就地计算”(利用Lua) |
5台高配 |
90个节点 |
128核 |
128G |
几十毫秒 |
Out of process:数据快照挪到运算节点(基于goroutine) |
4台中配 |
9个节点 |
32核 |
64G |
几毫秒 |
总结
计算机科学发展到今日,基本的逻辑计算单元从进程到线程,再演进到比线程轻量的协程(co-routine),给开发人员更多的“工具”和更简单的方法去“榨取”硬件资源的利用率,同时又带来业务系统性能的提升。在面对诸多工具时,需要我们结合实际业务的场景特点对不同工具进行对比做出合适的设计选择。
作者介绍
陶瑞甫,中山大学信科院硕士毕业至今六年多一直从事软件研发工作,曾在华为参与底层进程管理、上层云管理系统研发,在腾讯参与多个互联网社交增值服务产品研发,2013年初加入广发证券负责证券行情云服务建设与研发工作,目前参与证券交易系统相关研发工作。关注软件层面高性能并行计算技术,并致力于将这些理论技术应用于证券业的实际场景,给投资者带来更优质的服务。
请发表评论