浅谈系统性能提升的经验和方法
一、背景
资金核对的数据组装-执行-应急链路,有着千万级TPS并发量,同时由于资金业务特性,对系统可用性和准确性要求非常高;日常开发过程中会遇到各种各样的高可用问题,也在不断地尝试做一些系统设计以及性能优化,在此期间总结了部分性能优化的经验和方法,跟大家一起分享和交流。二、什么是高性能系统
先理解一下什么是高性能设计,官方定义: 高可用(High Availability,HA)核心目标是保障业务的连续性,从用户视角来看,业务永远是正常稳定的对外提供服务,业界一般用几个9来衡量系统的可用性。通常采用一系列专门的设计(冗余、去单点等),减少业务的停工时间,从而保持其核心服务的高度可用性。 高并发(High Concurrency)通常是指系统能够同时并行处理很多请求。一般用响应时间、并发吞吐量TPS, 并发用户数等指标来衡量。三、从哪几个方面做好性能提升
每次谈到高性能设计,经常会面临几个名词:IO多路复用、零拷贝、线程池、冗余等等,关于这部分的文章非常的多,其实本质上是一个系统性的问题,可以从计算机体系结构的底层原来去思考,系统优化离不开计算性能(CPU)和存储性能(IO)两个维度,总结如下方法:- 如何设计高性能计算(CPU)
- 减少计算成本: 代码优化计算的时间复杂度O(N^2)->O(N),合理使用同步/异步、限流减少请求次数等;
- 让更多的核参与计算: 多线程代替单线程、集群代替单机等等;
- 如何提升系统IO
- 加快IO速度: 顺序读写代替随机读写、硬件上SSD提升等;
- 减少IO次数: 索引/分布式计算代替全表扫描、零拷贝减少IO复制次数、DB批量读写、分库分表增加连接数等;
- 减少IO存储: 数据过期策略、合理使用内存、缓存、DB等中间件,做好消息压缩等;
四、高性能优化策略
1.1 减少程序计算复杂度
简单来看这段伪代码(业务代码facade做了脱敏)boolean result = true;// 循环遍历请求的requests, 判断如果是A业务且A业务未达到终态返回false, 否则返回truefor(Requet request: requests){// 1. query DB 获取TestDOString id = request.getId();TestDO testDO = queryDOById(id);// 2. 如果是A业务且testDO未到达中态记录为falseif(StringUtils.equals("A", request.getBizType())){// check是否到达终态if(!StringUtils.equals("FINISHED", testDO.getStatus)){result = result && false;}}}return result;
1. 每次请求过来在第6行都去 查询DB,但是在第8行对请求做了判断和筛选,导致第6行的 代码计算资源浪费,而且第6行访问DAO数据,是一个比较耗时的操作,可以先判断业务是否属于A再去查询DB;
2. 当前的需求是只要有一个A业务未到达终态即可返回false, 11行可以在拿到false之后,直接break,减少计算次数;
优化后的代码:boolean result = true;// 循环遍历请求的requests, 判断如果是A业务且A业务未达到终态返回false, 否则返回truefor(Requet request: requests){// 1. 不是A业务的不走查询DB的逻辑if(!StringUtils.equals("A", request.getBizType())){continue;}// 2. query DB 获取TestDOString id = request.getId();TestDO testDO = queryDOById(id);// check是否到达终态if(!StringUtils.equals("FINISHED", testDO.getStatus)){result = false;break;}}return result;
1.2 合理使用同步异步
分析业务链路中,哪些需要同步等待结果,哪些不需要,核心依赖的调度可以同步,非核心依赖尽量异步。 场景:从链路上看A系统调用B系统,B系统调用C系统完成计算再把结论返回给A,A系统超时时间400ms,通常A系统调用B系统300ms,B系统调用C系统200ms。
此时A系统- B系统- C系统已有的调用链路可能会超时失败,因为引入D系统之后,耗时增加了150ms,整个过程是同步调用的,因此需要C系统将调用D系统更新结论的非强依赖改成异步调用。
// C系统调用D系统更新结果featureThreadPool.execute(()->{try{dSystemClient.updateResult(resultDTO);}catch (Exception exception){LogUtil.error(exception, logger, "dSystemClient.updateResult failed! resultDTO = {0}", JSON.toJSONString(resultDTO));}});
1.3 做好限流保护
故障场景:A系统调用B系统查询异常数据,日常10TPS左右甚至更少,某一天A系统改了定时任务触发逻辑,加上代码bug,调用频率达到了500TPS,并且由于ID传错,绕过了缓存直接查询了DB和Hbase, 造成了Hbase读热点,拖垮集群,存储和查询都受到了影响。
1.4 多线程代替单线程
场景:应急定位场景下,A系统调用B系统获取诊断结论,TR超时时间是500ms,对于一个异常ID事件,需要执行多个诊断项服务,并记录诊断流水;每个诊断的耗时大概在100ms以内,随着业务的增长,超过5个诊断项,计算耗时累加到500ms+,这时候服务会出现高峰期短暂不可用。
将这段代码改成异步执行,这样执行诊断的时间是耗时最大的诊断服务
// 提交future任务并发执行futures = executor.invokeAll(tasks, timeout, timeUnit);// 遍历读取结果for (Future<Res> future : futures) {try {// 获取结果Res singleResult = future.get();if (singleResult != null) {result.add(singleResult);}} catch (Exception e) {LogUtil.error(e, logger, "并发执行发生异常!,poolName={0}.", threadPoolName);}}
1.5 集群计算代替单机
这里可以使用三层分发,将计算任务分片后执行,Map-Reduce思想,减少单机的计算压力。
2.1 常见的FullGC解决
系统常见的FullGC问题有很多,先讲一下JVM的垃圾回收机制: Heap区在设计上是分代设计的, 划分为了Eden、Survivor 和 Tenured/Old ,其中Eden区、Survivor(存活)属于年轻代,Tenured/Old区属于老年代或者持久代。一般我们将年轻代发生的GC称为Minor GC,对老年代进行GC称为Major GC,FullGC是对整个堆来说。 内存分配策略:1. 对象优先在Eden区分配 2. 大对象直接进入老年代 3. 长期存活的对象将进入老年代4. 动态对象年龄判定(虚拟机并不会永远地要求对象的年龄都必须达到MaxTenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄的所有对象的大小总和大于Survivor的一半,年龄大于或等于该年龄的对象就可以直接进入老年代)5. 只要老年代的连续空间大于(新生代所有对象的总大小或者历次晋升的平均大小)就会进行minor GC,否则会进行full GC。 系统常见触发FullGC的case: (1)查询大对象:业务上历史巡检数据需要定期清理,删除策略是每天删除上个月之前的数据(业务上打上软删除标记),等数据库定时清理任务彻底回收;
某一天修改了删除策略,从“删除上个月之前的数据”改成了“删除上周之前的数据”,因此删除的数据从1000条膨胀到了15万条,数据对象占用了80%以上的内存,直接导致系统的FullGC, 其他任务都有影响;
A系统设置了static的List对象,本身是用来做DRM配置读取的,但是有个逻辑对配置信息做了查询之后,还进行了Put操作,导致随着业务的增长,static对象越来越大且属于类对象,无法回收,最终使得系统频繁GC 。
2.2 顺序读写代替随机读写
对于普通的机械硬盘而言,随机写入的性能会很差,时间久了还会出现碎片,顺序的写入会极大节省磁盘寻址及磁盘盘片旋转的时间,极大提升性能;这层其实本身中间件帮我们实现了,比如Kafka的日志文件存储消息,就是通过有序写入消息和不可变性,消息追加到文件的末尾,来保证高性能读写。2.3 DB索引设计
设计表结构时,我们要考虑后期对表数据的查询操作,设计合理的索引结构,一旦表索引建立好了之后,也要注意后续的查询操作,避免索引失效。
2.4 分库分表设计
2.5 避免大量的表JOIN
阿里编码规约中超过三个表禁止JOIN,因为三个表进行笛卡尔积计算会出现操作复杂度呈几何数增长,多个表JOIN时要确保被关联的字段有索引。
如果为了业务上某些数据的级联,可以适当根据主键在内存中做嵌套的查询和计算,操作非常频繁的流水表建议对部分字段做冗余,以空间复杂度换取时间复杂度。
2.6 减少业务流水表大量耗时计算
业务记录有时候会做一些count操作,如果对时效性要求不高的统计和计算,建议定时任务在业务低峰期做好计算,然后将计算结果保存在缓存。
2.7 数据过期策略
一张表的数据量太大的情况下,如果不按照索引和日期进行部分扫描而出现全表扫描的情况,对DB的查询性能是非常有影响的,建议合理的设计数据过期策略,历史数据定期放入history表,或者备份到离线表中,减少线上大量数据的存储。
2.8 合理使用内存
众所周知,关系型数据库DB查询底层是磁盘存储,计算速度低于内存缓存,缓存DB与业务系统连接有一定的调用耗时,速度低于本地内存;但是从存储量来看,内存存储数据容量低于缓存,长期持久化的数据建议放DB存在磁盘中,设计过程中考虑好成本和查询性能的平衡。
说到内存,就会有数据一致性问题,DB数据和内存数据如何保证一致性,是强一致性还是弱一致性,数据存储顺序和事务如何控制都需要去考虑,尽量做到用户无感知。
2.9 做好数据压缩
很多中间件对数据的存储和传输采用了压缩和解压操作,减少数据传输中的带宽成本,这里对数据压缩不再做过多的介绍,想提的一点是高并发的运行态业务,要合理的控制日志的打印,不能够为了便于排查,打印过多的JSON.toJSONString(Object),磁盘很容易被打满,按照日志的容量过期策略也很容易被回收,更不方便排查问题;因此建议合理的使用日志,错误码仅可能精简,核心业务逻辑打印好摘要日志,结构化的数据也便于后续做监控和数据分析。 打印日志的时候思考几个问题:这个日志有没有可能会有人看,看了这个日志能做什么,每个字段都是必须打印的吗,出现问题能不能提高排查效率。2.10 Hbase热点key问题
HBase是一个高可靠、高性能、面向列、可伸缩的分布式存储系统,是一种非关系数据库,Hbase存储特点如下:5. 暂时不能支持Master server的故障切换,当Master宕机后,整个存储系统就会挂掉。
Habse的存储结构如下:Table在行的方向上分割为多个HRegion,HRegion是HBase中分布式存储和负载均衡的最小单元,即不同的HRegion可以分别在不同的HRegionServer上,但同一个HRegion是不会拆分到多个HRegionServer上的。HRegion按大小分割,每个表一般只有一个HRegion,随着数据不断插入表,HRegion不断增大,当HRegion的某个列簇达到一个阈值(默认256M)时就会分成两个新的HRegion。
-
反转: 比如用户ID2088这种前缀,以及BBCRL开头的这种相同前缀,都可以适当的反转往后移动。 -
加盐: RowKey 的前面增加一些前缀,比如时间戳Hash,加盐的前缀种类越多,才会根据随机生成的前缀分散到各个 region 中,避免了热点现象,但是也要考虑scan方便 -
哈希: 为了在业务上能够完整地重构 RowKey,前缀不可以是随机的。 所以一般会拿原 RowKey 或其一部分计算 Hash 值,然后再对 Hash 值做运算作为前缀。
五、实战-应急链路系统设计方案
要保证整体服务的高可用,需要从全链路视角去看待高可用系统的设计,这里简单的分享一个上游多个系统调用异常处理系统执行应急的业务场景,分析其中的性能优化改造。以资金应急系统为例分析系统设计过程中的性能优化。如下图所示,异常处理系统涉及到多个上游App(1-N),这些App发“差异日志数据”给到消息队列, 异常处理系统订阅并消费消息队列中的“错误日志数据”,然后对这部分数据进行解析、加工聚合等操作,完成异常的发送及应急处理。
- 发送阶段高可用设计
-
生产消息阶段: 本地队列缓存异常明细数据,守护线程定时拉取并批量发送(优化方案1中单条上报的性能问题)
-
消息压缩发送: 异常规则复用用一份组装的模型,按照规则则Code聚合压缩上报(优化业务层数据压缩复用能力)
- 中间件帮你做好了消息的高效序列化机制以及发送的零拷贝技术
- 存储阶段
- 目前Kafka等中间件,采用IO多路复用+磁盘顺序写数据的机制,保证IO性能
- 同时采用分区分段存储机制,提升存储性能
- 消费阶段
- 定时拉取一段数据批量处理,处理之后上报消费位点,继续计算
- 内部好做数据的幂等控制,发布过程中的抖动或者单机故障保证数据的不重复计算
- 为了提升DB的count性能,先用Hbase对异常数量做好累加,然后定时线程获取数据批量update
- 为了提升DB的配置查询性能,首次查询配置放入本地内存存储20分钟,数据更新之后内存失效
- 对于统计类的计算采用explorer存储,对于非结构化的异常明细采用Hbase存储,对于结构化且可靠性要求高的异常数据采用OB存储
1. 然后对系统的性能做好压测和容量评估,演练数据是异常数据的3-5倍做好流量隔离,对管道进行拆分,消费链路的线程池做好隔离
2. 对于单点的计算模块做好冗余和故障转移, 采取限流等措施
限流能力,上报端采用开关控制限流和熔断
3. 对于系统内部可以提升的地方,可以参考高可用性能优化策略去逐个突破。
六、高性能设计总结