浅谈系统性能提升的经验和方法

提到互联网系统设计,你可能听到最多的词儿就是“三高”,也就是“高并发”“高性能”“高可用”,它们是互联网系统架构设计永恒的主题。今天我们浅谈系统性能提升的经验和方法。

一、背景

资金核对的数据组装-执行-应急链路,有着千万级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等中间件,做好消息压缩等;

四、高性能优化策略

1. 计算性能优化策略

1.1 减少程序计算复杂度

简单来看这段伪代码(业务代码facade做了脱敏)

boolean result = true;
// 循环遍历请求的requests, 判断如果是A业务且A业务未达到终态返回false, 否则返回true
for(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;

代码中存在很明显的几个问题:

1.每次请求过来在第6行都去查询DB,但是在第8行对请求做了判断和筛选,导致第6行的代码计算资源浪费,而且第6行访问DAO数据,是一个比较耗时的操作,可以先判断业务是否属于A再去查询DB;

2.当前的需求是只要有一个A业务未到达终态即可返回false, 11行可以在拿到false之后,直接break,减少计算次数;

优化后的代码:

boolean result = true;
// 循环遍历请求的requests, 判断如果是A业务且A业务未达到终态返回false, 否则返回true
for(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

641

日常优化代码可以用ARTHAS工具分析下程序的调用耗时,耗时大的任务尽可能做好过滤,减少不必要的系统调用。

1.2 合理使用同步异步

分析业务链路中,哪些需要同步等待结果,哪些不需要,核心依赖的调度可以同步,非核心依赖尽量异步。

场景:从链路上看A系统调用B系统,B系统调用C系统完成计算再把结论返回给A,A系统超时时间400ms,通常A系统调用B系统300ms,B系统调用C系统200ms。

format,webp

现在C系统需要将调用结论返回给D系统,耗时150ms

641

此时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读热点,拖垮集群,存储和查询都受到了影响。

641

后续对A系统做了查询限流,保证并发量在15TPS以内,核心业务服务需要做好查询限流保护,同时也要做好缓存设计。

1.4 多线程代替单线程

场景:应急定位场景下,A系统调用B系统获取诊断结论,TR超时时间是500ms,对于一个异常ID事件,需要执行多个诊断项服务,并记录诊断流水;每个诊断的耗时大概在100ms以内,随着业务的增长,超过5个诊断项,计算耗时累加到500ms+,这时候服务会出现高峰期短暂不可用。

641

将这段代码改成异步执行,这样执行诊断的时间是耗时最大的诊断服务

// 提交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 集群计算代替单机

641

这里可以使用三层分发,将计算任务分片后执行,Map-Reduce思想,减少单机的计算压力。

2. 系统IO性能优化策略

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)查询大对象:业务上历史巡检数据需要定期清理,删除策略是每天删除上个月之前的数据(业务上打上软删除标记),等数据库定时清理任务彻底回收;

641

某一天修改了删除策略,从“删除上个月之前的数据”改成了“删除上周之前的数据”,因此删除的数据从1000条膨胀到了15万条,数据对象占用了80%以上的内存,直接导致系统的FullGC, 其他任务都有影响;

641

很多系统代码对于查询数据没有数量限制,随着业务的不断增长,系统容量在不升级的情况下,经常会查询出来很多大的对象List,出现大对象频繁GC的情况。

(2)设置了用不回收的static方法

A系统设置了static的List对象,本身是用来做DRM配置读取的,但是有个逻辑对配置信息做了查询之后,还进行了Put操作,导致随着业务的增长,static对象越来越大且属于类对象,无法回收,最终使得系统频繁GC。

641

本身用Object做Map的Key有一定的不合理性,同时key中的对象是不可回收的,导致出现了GC。

当执行Full GC后空间仍然不足,则抛出如下错误【java.lang.OutOfMemoryError: Java heap space】,而为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

2.2 顺序读写代替随机读写

对于普通的机械硬盘而言,随机写入的性能会很差,时间久了还会出现碎片,顺序的写入会极大节省磁盘寻址及磁盘盘片旋转的时间,极大提升性能;这层其实本身中间件帮我们实现了,比如Kafka的日志文件存储消息,就是通过有序写入消息和不可变性,消息追加到文件的末尾,来保证高性能读写。

2.3 DB索引设计

设计表结构时,我们要考虑后期对表数据的查询操作,设计合理的索引结构,一旦表索引建立好了之后,也要注意后续的查询操作,避免索引失效。

641

(1) 尽量不选择键值较少的列即区分度不明显,重复数据很少的做索引;比如我们用is_delete这种列做了索引,查询10万条数据,where is_delete=0,有9万条数据块,加上访问索引块带来的开销,不如全表扫描全部的数据块了;

(2) 避免使用前导like “%***”以及like “%***%”, 因为前面的匹配是模糊的,很难利用索引的顺序去访问数据块,导致全表扫描;但是使用like “A**%”不影响,因为遇到”B”开头的数据就可以停止查找列,我们在做根据用户信息模糊查询数据时,遇到了索引失效的情况;

(3) 其他可能的场景比如,or查询,多列索引不使用第一部分查询,查询条件中有计算操作,或者全表扫描比索引查询更快的情况下也会出现索引失效;

目前AntMonitor以及Tars等工具已经帮我们扫描出来耗时和耗CPU很大的SQL,可以根据执行计划调整查询逻辑,频繁的少量数据查询利用好索引,当然建立过多的索引也有存储开销,对于插入和删除很频繁的业务,也要考虑减少不必要的索引设计。

2.4 分库分表设计

641

随着业务的增长,如果集群中的节点数量过多,最终会达到数据库的连接限制,导致集群中的节点数量受限于数据库连接数,集群节点无法持续增加和扩容,无法应对业务流量的持续增长;这也是蚂蚁做LDC架构的其中原因之一,在业务层做水平拆分和扩展,使得每个单元的节点只访问当前节点对应的数据库。

2.5 避免大量的表JOIN

阿里编码规约中超过三个表禁止JOIN,因为三个表进行笛卡尔积计算会出现操作复杂度呈几何数增长,多个表JOIN时要确保被关联的字段有索引。

641

如果为了业务上某些数据的级联,可以适当根据主键在内存中做嵌套的查询和计算,操作非常频繁的流水表建议对部分字段做冗余,以空间复杂度换取时间复杂度。

641

2.6 减少业务流水表大量耗时计算

业务记录有时候会做一些count操作,如果对时效性要求不高的统计和计算,建议定时任务在业务低峰期做好计算,然后将计算结果保存在缓存。

641

涉及到多个表JOIN的建议采用离线表进行Map-Reduce计算,然后再将计算结果回流到线上表进行展示。

641

2.7 数据过期策略

一张表的数据量太大的情况下,如果不按照索引和日期进行部分扫描而出现全表扫描的情况,对DB的查询性能是非常有影响的,建议合理的设计数据过期策略,历史数据定期放入history表,或者备份到离线表中,减少线上大量数据的存储。

641

2.8 合理使用内存

众所周知,关系型数据库DB查询底层是磁盘存储,计算速度低于内存缓存,缓存DB与业务系统连接有一定的调用耗时,速度低于本地内存;但是从存储量来看,内存存储数据容量低于缓存,长期持久化的数据建议放DB存在磁盘中,设计过程中考虑好成本和查询性能的平衡。

641

说到内存,就会有数据一致性问题,DB数据和内存数据如何保证一致性,是强一致性还是弱一致性,数据存储顺序和事务如何控制都需要去考虑,尽量做到用户无感知。

2.9 做好数据压缩

很多中间件对数据的存储和传输采用了压缩和解压操作,减少数据传输中的带宽成本,这里对数据压缩不再做过多的介绍,想提的一点是高并发的运行态业务,要合理的控制日志的打印,不能够为了便于排查,打印过多的JSON.toJSONString(Object),磁盘很容易被打满,按照日志的容量过期策略也很容易被回收,更不方便排查问题;因此建议合理的使用日志,错误码仅可能精简,核心业务逻辑打印好摘要日志,结构化的数据也便于后续做监控和数据分析。

打印日志的时候思考几个问题:这个日志有没有可能会有人看,看了这个日志能做什么,每个字段都是必须打印的吗,出现问题能不能提高排查效率。

2.10 Hbase热点key问题

HBase是一个高可靠、高性能、面向列、可伸缩的分布式存储系统,是一种非关系数据库,Hbase存储特点如下:

  1. 列的可以动态增加,并且列为空就不存储数据,节省存储空间。
  2. HBase自动切分数据,使得数据存储自动具有水平scalability。
  3. HBase可以提供高并发读写操作的支持,分布式架构,读写锁等待的概率大大降低。
  4. 不能支持条件查询,只支持按照Rowkey来查询。
  5. 暂时不能支持Master server的故障切换,当Master宕机后,整个存储系统就会挂掉。

Habse的存储结构如下:Table在行的方向上分割为多个HRegion,HRegion是HBase中分布式存储和负载均衡的最小单元,即不同的HRegion可以分别在不同的HRegionServer上,但同一个HRegion是不会拆分到多个HRegionServer上的。HRegion按大小分割,每个表一般只有一个HRegion,随着数据不断插入表,HRegion不断增大,当HRegion的某个列簇达到一个阈值(默认256M)时就会分成两个新的HRegion。

641

HBase 中的行是按照 Rowkey 的字典顺序排序的,这种设计优化了 scan 操作,可以将相关的行以及会被一起读取的行存取在临近位置,便于scan。Rowkey这种固有的设计是热点故障的源头。热点的热是指发生在大量的 client 直接访问集群的一个或极少数个节点(访问可能是读,写或者其他操作)。

大量访问会使热点 Region 所在的单个机器超出自身承受能力,引起性能下降甚至 Region 不可用,这也会影响同一个 RegionServer 上的其他 Region,由于主机无法服务其他 Region 的请求,这样就造成数据热点(数据倾斜)现象。

所以我们在向 HBase 中插入数据的时候,应优化 RowKey 的设计,使数据被写入集群的多个 region,而不是一个,尽量均衡地把记录分散到不同的 Region 中去,平衡每个 Region 的压力。

常见的热点Key避免的方法: 反转,加盐和哈希

  • 反转:比如用户ID2088这种前缀,以及BBCRL开头的这种相同前缀,都可以适当的反转往后移动。
  • 加盐: RowKey 的前面增加一些前缀,比如时间戳Hash,加盐的前缀种类越多,才会根据随机生成的前缀分散到各个 region 中,避免了热点现象,但是也要考虑scan方便
  • 哈希:为了在业务上能够完整地重构 RowKey,前缀不可以是随机的。 所以一般会拿原 RowKey 或其一部分计算 Hash 值,然后再对 Hash 值做运算作为前缀。

总之Rowkey在设计的过程中,尽量保证长度原则、唯一原则、排序原则、散列原则。

五、实战-应急链路系统设计方案

要保证整体服务的高可用,需要从全链路视角去看待高可用系统的设计,这里简单的分享一个上游多个系统调用异常处理系统执行应急的业务场景,分析其中的性能优化改造。

以资金应急系统为例分析系统设计过程中的性能优化。如下图所示,异常处理系统涉及到多个上游App(1-N),这些App发“差异日志数据”给到消息队列, 异常处理系统订阅并消费消息队列中的“错误日志数据”,然后对这部分数据进行解析、加工聚合等操作,完成异常的发送及应急处理。

641

发送阶段高可用设计

  • 生产消息阶段:本地队列缓存异常明细数据,守护线程定时拉取并批量发送(优化方案1中单条上报的性能问题)
  • 消息压缩发送:异常规则复用用一份组装的模型,按照规则则Code聚合压缩上报(优化业务层数据压缩复用能力)
641

中间件帮你做好了消息的高效序列化机制以及发送的零拷贝技术

存储阶段

  • 目前Kafka等中间件,采用IO多路复用+磁盘顺序写数据的机制,保证IO性能
  • 同时采用分区分段存储机制,提升存储性能

消费阶段

定时拉取一段数据批量处理,处理之后上报消费位点,继续计算

641

内部好做数据的幂等控制,发布过程中的抖动或者单机故障保证数据的不重复计算

641

为了提升DB的count性能,先用Hbase对异常数量做好累加,然后定时线程获取数据批量update

641

为了提升DB的配置查询性能,首次查询配置放入本地内存存储20分钟,数据更新之后内存失效

641

对于统计类的计算采用explorer存储,对于非结构化的异常明细采用Hbase存储,对于结构化且可靠性要求高的异常数据采用OB存储

641

1.然后对系统的性能做好压测和容量评估,演练数据是异常数据的3-5倍做好流量隔离,对管道进行拆分,消费链路的线程池做好隔离

641

2.对于单点的计算模块做好冗余和故障转移, 采取限流等措施

限流能力,上报端采用开关控制限流和熔断

641

故障转移能力

641

3.对于系统内部可以提升的地方,可以参考高可用性能优化策略去逐个突破。

六、高性能设计总结

1. 架构设计

1.1 冗余能力

做好集群的三副本甚至五副本的主动复制,保证全部数据冗余成功场景,任务才可以继续执行,如果对可用性要求很高,可以降低副本数以及任务的提交一执行约束。

冗余很容易理解,如果一个系统的可用性为90%,两台机器的可用性为1-0.1*0.1=99%,机器越多,可用性会更高;对于DB这种对连接数有瓶颈的,我们需要在业务上做好分库分表也是一种冗余的水平扩展能力。

1.2 故障转移能力

部分业务场景对于DB的依赖性很高,在DB不可用的情况下,能不能转移到FO库或者先中断现场,保存上下文,对当前的业务场景上下文写入延迟队列,等故障恢复后再对数据进行消费和计算。

有些不可抗力和第三方问题,可能会严重影响整个业务的可用性,因此要做好异地多话,冗余灾备以及定期演练。

1.3 系统资源隔离性

在异常处理的case中,经常会因为上游数据的大量上报导致队列阻塞,影响时效性,因此可以做好核心业务和非核心业务资源隔离,对于秒杀类的场景甚至可以单独部署独立的集群支撑业务。

如果A系统可用性90%,B系统的可用性40%,A系统某服务强依赖B系统,那么A系统的可用性为P(A|B), 可用性大大降低。

2. 事前防御

2.1 做好监控

对系统的CPU,线程CE、IO、服务调用TPS、DB计算耗时等设置合理的监控阈值,发现问题及时应急

2.2 做好限流/熔断/降级等

上游业务流量突增的场景,需要有一定的自我保护和熔断机制,前提是避免业务的强依赖,解决单点问题,在异常消费链路中,对上游做了DRM管控,下游也有一定的快速泄洪能力,防止因为单业务异常拖垮整个集群导致不可用。

瞬间流量问题很容易引发故障,一定要做好压测和熔断能力,秒杀类的业务减少对核心系统的强依赖,提前做好预案管控,对于缓存的雪崩等也要有一定的预热和保护机制。

同时有些业务开放了不合理的接口,采用爬虫等大量请求web接口,也要有识别和熔断的能力

2.3 提升代码质量

核心业务在大促期间做好封网、资金安全提前部署核对主动验证代码的可靠性,编码符合规范等等,都是避免线上问题的防御措施;

代码的FullGC, 内存泄漏都会引发系统的不可用,尤其是业务低峰期可能不明显,业务流量高的情况下性能会恶化,提前做好压测和代码Review。

3. 事后防御和恢复

事前做好可监控和可灰度,事后做好任何场景下的故障可回滚。

其他关于防御能力的还有:部署过程中如何做好代码的平滑发布,问题代码机器如何快速地摘流量;上下游系统调用的发布,如何保证依赖顺序;发布过程中,正常的业务已经在发布过的代码中执行,逆向操作在未发布的机器中执行,如何保证业务一致性,都要有充分的考虑。‍

发表评论

Crypto logo

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus cursus rutrum est nec suscipit. Ut et ultrices nisi. Vivamus id nisl ligula. Nulla sed iaculis ipsum.

Contact

Company Name

Address