简介: 资金核对的数据组装-执行-应急链路,有着千万级TPS并发量,同时由于资金业务特性,对系统可用性和准确性要求非常高;日常开发过程中会遇到各种各样的高可用问题,也在不断地尝试做一些系统设计以及性能优化,在此期间总结了部分性能优化的经验和方法,跟大家一起分享和交流,后续遇到一些新的问题也会持续总结和补充。
资金核对的数据组装-执行-应急链路,有着千万级TPS并发量,同时由于资金业务特性,对系统可用性和准确性要求非常高;日常开发过程中会遇到各种各样的高可用问题,也在不断地尝试做一些系统设计以及性能优化,在此期间总结了部分性能优化的经验和方法,跟大家一起分享和交流,后续遇到一些新的问题也会持续总结和补充。
先理解一下什么是高性能设计,官方定义: 高可用(High Availability,HA)核心目标是保障业务的连续性,从用户视角来看,业务永远是正常稳定的对外提供服务,业界一般用几个9来衡量系统的可用性。通常采用一系列专门的设计(冗余、去单点等),减少业务的停工时间,从而保持其核心服务的高度可用性。
高并发(High Concurrency)通常是指系统能够同时并行处理很多请求。一般用响应时间、并发吞吐量TPS, 并发用户数等指标来衡量。
高性能是指程序处理速度非常快,所占内存少,CPU占用率低。高性能的指标经常和高并发的指标紧密相关,想要提高性能,那么就要提高系统发并发能力。
本文主要对做“高性能、高并发、高可用”服务的设计进行介绍和分享。
每次谈到高性能设计,经常会面临几个名词:IO多路复用、零拷贝、线程池、冗余等等,关于这部分的文章非常的多,其实本质上是一个系统性的问题,可以从计算机体系结构的底层原来去思考,系统优化离不开计算性能(CPU)和存储性能(IO)两个维度,总结如下方法:
如何设计高性能计算(CPU)减少计算成本: 代码优化计算的时间复杂度O(N^2)->O(N),合理使用同步/异步、限流减少请求次数等让更多的核参与计算: 多线程代替单线程、集群代替单机等等如何提升系统IO加快IO速度: 顺序读写代替随机读写、硬件上SSD提升等减少IO次数: 索引/分布式计算代替全表扫描、零拷贝减少IO复制次数、DB批量读写、分库分表增加连接数等减少IO存储: 数据过期策略、合理使用内存、缓存、DB等中间件,做好消息压缩等简单来看这段伪代码(业务代码facade做了脱敏)
boolean result = true;// 循环遍历请求的requests, 判断如果是A业务且A业务未达到终态返回false, 否则返回truefor(Requet request: requests){ // 1. query DB 获取TestDO String id = request.getId(); TestDO testDO = queryDOById(id); // 2. 如果是A业务且testDO未到达中态记录为false if(StringUtils.equals("A", request.getBizType())){ // check是否到达终态 if(!StringUtils.equals("FINISHED", testDO.getStatus)){ result = result && false; } }}return result;
代码中存在很明显的几个问题:
每次请求过来在第6行都去查询DB,但是在第8行对请求做了判断和筛选,导致第6行的代码计算资源浪费,而且第6行访问DAO数据,是一个比较耗时的操作,可以先判断业务是否属于A再去查询DB当前的需求是只要有一个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 获取TestDO String id = request.getId(); TestDO testDO = queryDOById(id); // check是否到达终态 if(!StringUtils.equals("FINISHED", testDO.getStatus)){ result = false; break; }}return result;
优化之后的计算耗时从平均270.75ms-->40.5ms
日常优化代码可以用ARTHAS工具分析下程序的调用耗时,耗时大的任务尽可能做好过滤,减少不必要的系统调用。
分析业务链路中,哪些需要同步等待结果,哪些不需要,核心依赖的调度可以同步,非核心依赖尽量异步
场景:从链路上看A系统调用B系统,B系统调用C系统完成计算再把结论返回给A,A系统超时时间400ms,通常A系统调用B系统300ms,B系统调用C系统200ms
现在C系统需要将调用结论返回给D系统,耗时150ms
此时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)); }});
故障场景:A系统调用B系统查询异常数据,日常10TPS左右甚至更少,某一天A系统改了定时任务触发逻辑,加上代码bug,调用频率达到了500TPS,并且由于ID传错,绕过了缓存直接查询了DB和Hbase, 造成了Hbase读热点,拖垮集群,存储和查询都受到了影响
后续对A系统做了查询限流,保证并发量在15TPS以内,核心业务服务需要做好查询限流保护,同时也要做好缓存设计。
场景: 应急定位场景下,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); }}
这里可以使用三层分发,将计算任务分片后执行,Map-Reduce思想,减少单机的计算压力。
系统常见的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, 其他任务都有影响;
很多系统代码对于查询数据没有数量限制,随着业务的不断增长,系统容量在不升级的情况下,经常会查询出来很多大的对象List,出现大对象频繁GC的情况
(2)设置了用不回收的static方法
A系统设置了static的List对象,本身是用来做DRM配置读取的,但是有个逻辑对配置信息做了查询之后,还进行了Put操作,导致随着业务的增长,static对象越来越大且属于类对象,无法回收,最终使得系统频繁GC
电脑本身用Object做Map的Key有一定的不合理性,同时key中的对象是不可回收的,导致出现了GC。
当执行Full GC后空间仍然不足,则抛出如下错误【java.lang.OutOfMemoryError: Java heap space】,而为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
对于普通的机械硬盘而言,随机写入的性能会很差,时间久了还会出现碎片,顺序的写入会极大节省磁盘寻址及磁盘盘片旋转的时间,极大提升性能;这层其实本身中间件帮我们实现了,比如Kafka的日志文件存储消息,就是通过有序写入消息和不可变性,消息追加到文件的末尾,来保证高性能读写。
设计表结构时,我们要考虑后期对表数据的查询操作,设计合理的索引结构,一旦表索引建立好了之后,也要注意后续的查询操作,避免索引失效。
(1) 尽量不选择键值较少的列即区分度不明显,重复数据很少的做索引;比如我们用is_delete这种列做了索引,查询10万条数据,where is_delete=0,有9万条数据块,加上访问索引块带来的开销,不如全表扫描全部的数据块了;
(2)避免使用前导like "%***"以及like "%***%", 因为前面的匹配是模糊的,电脑很难利用索引的顺序去访问数据块,导致全表扫描;但是使用like "A**%"不影响,因为遇到"B"开头的数据就可以停止查找列,我们在做根据用户信息模糊查询数据时,遇到了索引失效的情况
(3) 其他可能的场景比如,or查询,多列索引不使用第一部分查询,查询条件中有计算操作,或者全表扫描比索引查询更快的情况下也会出现索引失效
目前AntMonitor以及Tars等工具已经帮我们扫描出来耗时和耗CPU很大的SQL,可以根据执行计划调整查询逻辑,频繁的少量数据查询利用好索引,当然建立过多的索引也有存储开销,对于插入和删除很频繁的业务,也要考虑减少不必要的索引设计。
随着业务的增长,如果集群中的节点数量过多,最终会达到数据库的连接限制,导致集群中的节点数量受限于数据库连接数,集群节点无法持续增加和扩容,无法应对业务流量的持续增长;这也是蚂蚁做LDC架构的其中原因之一,在业务层做水平拆分和扩展,使得每个单元的节点只访问当前节点对应的数据库。
阿里编码规约中超过三个表禁止JOIN,因为三个表进行笛卡尔积计算会出现操作复杂度呈几何数增长,多个表JOIN时要确保被关联的字段有索引。
如果为了业务上某些数据的级联,可以适当根据主键在内存中做嵌套的查询和计算,操作非常频繁的流水表建议对部分字段做冗余,以空间复杂度换取时间复杂度。
业务记录有时候会做一些count操作,如果对时效性要求不高的统计和计算,建议定时任务在业务低峰期做好计算,然后将计算结果保存在缓存。
点击查看原文,获取更多福利!
https://developer.aliyun.com/article/1110418?utm_content=g_1000365996
版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。
电脑 电脑