方案背景
观察近期保证金服务lark群聊天记录,保证金服务jraft频繁报错问题,特别是高吞吐量场景更加明显,并且出现异常时定位问题和解决问题都非常吃力,jraft成为整个保证金服务的可用性和性能瓶颈。
问题根源
集群环境中,采用jraft目的是在程序崩溃时能“自动”,“快速”,“准确”的恢复,让其他节点对外提供服务,外部系统无感知。但jraft集群是有缺陷的。以撮合服务撮合订单为例,具体表现至少如下几方面:
-
监控报警:jraft缺乏监控和自动报警自动恢复,异常时需要人为重启,人为分析日志,效率低下。
-
性能:jraft处理数据同步时需要获得半数以上节点认可,假若存在5个jraft节点并且委托单的tps为10万,在这过程中每秒钟需要处理40万次网络传输(同步到其余4个节点),并且按照半数以上原则至少20万次网络传输需要同步等待(同步到其余至少2个节点),否则无法处理下一秒钟的委托单,如果发生一些异常,整个系统还会阻塞重新选举,难于对付高吞吐场景。
-
伸缩性:节点越多时需要网络资源越多,处理每个委托单性能越来越差,缺乏伸缩性。
-
服务职责:整体架构上看主流程职责不单一,发送至其他jraft节点的非主流程的功能可能阻塞主流程,每个委托单处理需要耗费大量时间进行数据同步等待。
-
使用成本:raft协议不易理解,编写的业务代码容易出错,难于针对性编写单元测试,无法把开发测试环境的数据备份到本地,很难保护错误现场,很难把错误现场迁移到其他地方回放。可以说从开发,运维,后续升级成本都非常大。
解决方案
本文探索一种解决方案,必须达到如下效果:
-
监控方便,确保服务崩溃时能够自动快速准确的恢复
-
确保单个交易对10万tps能轻松应对
-
撮合服务节点增多不会影响每个委托单的处理能力,或者避免撮合服务的网络流量
-
撮合服务jvm只处理核心业务,不需要等待非主流程功能执行完毕
-
恢复时间足够短,如果自动恢复失败可以人工轻易恢复
-
程序编写足够简单,易于理解,出问题时方便回放
基于以上特点,可以考虑釆用大数据方案,使用kafka,flink,redis进行实时同步和持久化,和多机热备份相结合
数据架构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HyuHVTDu-1656268331644)(3.drawio.png)]
- kafka保存日志
- redis充当镜像服务器,目的是备份jvm内存,
- flink充当消费者的容器,管理消费者的存活
- 撮合服务主节点将tradingResult推送到kafka中
- 消费者订阅kafka中的日志恢复到redis内存
- 正常情况下redis内存,撮合服务热备节点jvm内存和撮合服务主节点内存必定一致
- 撮合服务主节点宕机时,自动切换程序会尝试把多机热备节点切换为主节点,
- 极端情况热备也无能为力,需要人工处理
- 使用人工方式,重启一个撮合服务节点作为主节点,从redis拉取镜像内存
整体流程
以交易委托账本orderBook为例(假设通过tradingResult消息能恢复orderBook)
原有逻辑
- 交易等服务通过rpc调用,或者监听新委托单事件触发,进入撮合服务业务处理,处理完毕后推送tradingResult消息到kafka
全量备份流程
-
初始化redis时,flink为暂停状态,
-
初始化redis时,撮合引擎也为暂停状态,所以没有tradingResult推送kafka,可以知道kafka的最新offset
实时备份流程
-
新建一个kafka消费者组,消费者组的所有节点跟撮合服务不同jvm,消费者组第一次启动前要确定从kafka的哪个位置消费。然后订阅tradingResult,每隔10秒钟批量拉取tradingResult,解释所有tradingResult消息,变成更新orderBook的redis命令集
-
向redis批量更新orderBook和更新offset,整个事务成功后再告诉kafka消费完毕
恢复流程
自动恢复
- 主节点宕机,多机热备程序把热备节点当做新的主节点
人工恢复
- 多机热备程序失效,人工启动一个新节点即可
方案优势
-
redis充当备份机器,其数据来自消费者组,不会占用撮合服务本身的资源
-
撮合服务还是内存计算,宕机时保存了宕机前的内存,可以人工手动恢复
-
重启撮合服务后加载orderBook数据是从局域网redis加载,每秒钟10万以上,能够快速恢复orderBook
-
利用了原有kafka的tradingResult,避免了jraft写入和等待同步,处理委托单职责更加单一,速度更快
-
flink消费者组读取tradingResult和写入都是批量操作。写入目标为redis时,整体处理速度理论上要比上游推送tradingResult快的多,比订阅tradingResult的其他消费者(比如资产)更快,因为它们要持久化到关系型数据库,或者说速度瓶颈不太可能在flink消费者组
-
撮合服务应用启动时比较redis中offset和kafka的offset是否一致,确保redis中的orderBook跟撮合服务宕机前一致,数据不丢失
-
kafka,redis,flink都有相对完善的监控体系,解决问题便捷
-
此方案的撮合服务代码简单,基本上无状态,可以随时重启
-
假若一种场景,测试人员发现一个问题,但根据日志文件看不出来,可以把redis数据同步到本地redis,启动本地程序进行debug
维护升级
-
怎么确保redis的数据和kafka是一致的?
- 提交到redis时,把offset和更新orderbook命令集同时提交,成功后对kafka进行commit
-
flink消费者组宕机了怎么办?
- 状态都保存在redis,flink消费者组为无状态,可以随时重启
-
可以不使用flink吗?
- 当然可以,flink只是充当消费者组的容器,我们可以直接建立消费者组,做好必要监控即可。
-
redis宕机后,持久化的数据不见了,但撮合服务还活着,怎么办?
- 此问题跟第一次启动redis要怎么初始化redis是同一个问题,也就是执行全量同步操作
- 关闭flink消费者组
- 重新建立redis集群,数据为空
- 触发撮合服务的把内存镜像写入redis的方法:暂停撮合,同步至redis,重启撮合
- 重启flink消费者组
-
升级维护时,比如增加了或删除了字段或修改了字段名,怎么办?
- 对于需要全内存运行的数据结构,一般都是稳定的,不会经常修改
- 如果缺失修改了,则需要针对性设计转换逻辑
- 新增字段名时,此时redis并没有对应的数据,产品要设置一个默认数据或者干脆为null,然后扫描redis,增加每个订单的数据
- 删除字段名时,此时redis虽然存在对应的数据,但撮合服务应用不需要,扫描redis,把redis中的多余字段删除即可
- 修改字段名时,扫描redis,增加新的字段,值跟原来的一样,然后删除旧的字段
- jraft如何处理?
-
当内存只有几百M时,撮合服务启动加载可能很快,但当内存有几G时,怎么办?
-
整个交易系统除了撮合服务比较特殊,需要全内存执行,其他都可以做到横向增加机器
-
撮合服务还可以按照每个交易对拆分,orderBook需要保证状态的只有买盘卖盘两个队列
-
每个队列元素的核心字段只有委托单id,价格,委托时间,委托数量,未成交数量
-
只传输这5个关键字段即可,加上字段名也不到100字节,传输过程甚至可以把字段名也省去
-
1K流量可以传输10个订单,1M流量可以传输1万个订单,1G流量可以传输1000万个订单
-
从现实来看,单个交易对1000万未成交订单几乎不可能,因此每次启动都会几十秒内完成
-
-
假定数据实在太大,比如100G内存,这样的话哪怕一秒钟传输100M,撮合服务应用也要几十分钟才能启动,怎么办?
-
这种情况下,可以对方案进行增强
-
flink消费者组每次启动时,创建一个有序列表(如果已存在,则先删除或清空),用于保存flink消费者组本次启动后解释的所有groovy命令
-
flink消费者组每次提交redis命令集和offset时,把带数据的groovy命令也提交到redis的一个有序列表
-
撮合服务应用内部编写一个接口,执行groovy脚本,目的为了更新内存中的orderbook
-
每天隔一段时间,比如8个小时,暂停flink消费者组(此时撮合服务应用,kafka集群,redis都还正常运行)
-
启动新的撮合服务节点,会从redis加载全量数据,100G数据比较大,可能要1个钟头才能启动完后,启动完后新撮合服务节点jvm内存跟redis的内存一样,但新撮合服务节点和redis都落后kafka和主节点(因为撮合服务主节点和kafka都在持续运行)。
-
确保新的撮合服务jvm成功后,启动flink消费者组,此时redis的有序列表刚刚被清空,开始保存flink消费者组启动以来的groovy
-
撮合服务应用宕机时,如果多机热备节点无法使用,读入redis中的groovy命令集文件,并调用grovvy命令的方法执行
-
因为这些groovy脚本都是近期8个小时内产生的,数据量不大很快就能执行完,然后把这个新的撮合服务节点当做主节点
-
-
除了kafka,撮合服务不想引入redis依赖怎么办?
- 引入redis是方便把内存镜像写入redis和从redis读内存镜像,调用方法的时候开启redis连接,调用完关闭连接即可,不会浪费内存
- 如果实在不想引入redis依赖或跟redis网络不通,则要把内存镜像同步至redis和从redis恢复为内存镜像两部分通过中间文件处理
- 内存镜像同步至redis,可以把内存镜像替换成写本地文件,文件内容为redis命令集,把文件拷贝到redis所在机器执行
- 从redis恢复内存镜像,又有两种方式
- 对于1G以下数据量的,可采用全量方式,直接编写另外一个java程序读取redis全量数据生成groovy命令集文件,拷贝到本地,调用执行groovy命令的方法
- 对于1G以上数据量的,可采用8小时全量+增量的方式,由于新的撮合服务节点已经有8小时以前的数据,只需编写另外一个java程序读取redis的有序列表生成groovy命令集文件,拷贝到新的撮合服务节点,调用执行groovy命令的方法