探索从5秒到20毫秒的性能优化技巧,提升系统效率的优雅方法与策略

什么是高性能系统

首先,我们需要明确高性能设计的概念。官方定义为:高可用性(High Availability, HA)的核心目标是确保业务的连续性,从用户的角度来看,服务始终稳定正常。业界通常使用几个9(如99.9%)来衡量系统的可用性,通常通过一系列专门的设计,如冗余、消除单点故障等,减少业务停工时间,从而保持其核心服务的高度可用性。

高并发(High Concurrency)指的是系统能够并行处理大量请求。通常通过响应时间、每秒事务数(TPS)、并发用户数量等指标来评估。

**高性能则指的是程序处理速度极快,内存占用低,CPU利用率也较低。**高性能指标通常与高并发指标密切相关,因此提高性能的关键在于增强系统的并发能力。

本文将重点介绍和分享如何设计“高性能、高并发、高可用”的服务。

从哪些方面提升性能

每当谈到高性能设计,常常会涉及一些名词:IO多路复用、零拷贝、线程池、冗余等等。关于这些内容的文章很多,但实际上这是一个系统性的问题。系统优化离不开计算能力(CPU)和存储性能(IO)两个维度,我们可以总结如下:

1. 如何设计高性能计算(CPU)?

  • 降低计算成本:通过优化代码降低时间复杂度,例如将O(N^2)优化为O(N),并合理使用同步/异步、限流等方式减少请求次数;
  • 让更多核心参与计算:通过多线程替代单线程、集群替代单机等方式。

2. 如何提升系统的IO?

  • 加快IO速度:使用顺序读写替代随机读写、使用SSD等硬件加速;
  • 减少IO次数:通过索引/分布式计算替代全表扫描、使用零拷贝减少IO复制次数、批量读写数据库、分库分表以增加连接数等;
  • 减少IO存储:实施数据过期策略、合理使用内存、缓存和数据库等中间件,做好消息压缩等。

高性能优化策略

计算性能优化策略

减少程序计算复杂度

让我们简单分析一下以下的伪代码(业务代码 facade 已去除敏感信息):

boolean result = true;  
// 循环遍历请求的requests, 判断如果是A业务且A业务未达到终态返回false, 否则返回true  
for(Request 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行都去查询数据库,而在第8行对请求进行了判断和筛选,导致第6行的计算资源浪费,且访问DAO的数据是一个耗时的操作,应该先判断业务是否属于A再去查询数据库;
  2. 当前需求只需判断是否有业务A未达到终态即可返回false,因此在11行可以在获得false后直接break,减少计算次数。

优化后的代码如下:

boolean result = true;  
// 循环遍历请求的requests, 判断如果是A业务且A业务未达到终态返回false, 否则返回true  
for(Request 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系统而超时失败,因此应将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左右,但某一天因为代码bug和定时任务触发逻辑的变更,导致调用频率骤升至500TPS,并且由于ID传错,绕过了缓存直接查询数据库和Hbase,造成Hbase读热点,拖垮了集群,影响了存储和查询。

图片

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

多线程代替单线程

在应急定位场景中,A系统调用B系统获取诊断结果,超时时间为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思想,减少单机的计算压力。

系统IO性能优化策略

常见的FullGC解决方案

系统常见的FullGC问题较多,我们首先了解JVM的垃圾回收机制和内存分配策略。

JVM的垃圾回收机制

  1. 堆区在设计上采用分代设计,划分为Eden、Survivor和Tenured/Old区,其中Eden区和Survivor(存活)属于年轻代,Tenured/Old区属于老年代或持久代。
  2. 一般将年轻代的GC称为Minor GC,老年代的GC称为Major GC,Full GC即针对整个堆的垃圾回收。

内存分配策略

  1. 对象优先在Eden区分配;
  2. 大对象直接进入老年代;
  3. 长期存活的对象将进入老年代;
  4. 动态对象年龄判定(虚拟机不会永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄的所有对象的大小总和超过Survivor的一半,年龄达到该年龄的对象就可以直接进入老年代);
  5. 只要老年代的连续空间大于(新生代所有对象的总大小或历次晋升的平均大小)就会进行Minor GC,否则会进行Full GC。

常见的触发FullGC的情况包括:

(1)查询大对象

业务中,历史巡检数据需要定期清理,删除策略为每天删除上个月之前的数据(通过软删除标记),然后等数据库定期清理任务彻底回收;

图片某一天,删除策略由“删除上个月之前的数据”改为“删除上周之前的数据”,因此删除的数据从1000条增加至15万条,数据对象占用了80%以上的内存,直接导致系统FullGC,其他任务受到影响。

图片许多系统代码在查询数据时没有数量限制,随着业务增长,系统容量在不升级的情况下,频繁查询出大量大对象List,导致出现大对象频繁GC的情况。

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

A系统设置了static的List对象,本用于DRM配置读取,但某逻辑对配置信息查询后还进行了Put操作,导致随着业务增长,static对象不断增大且属于类对象,无法回收,造成系统频繁GC。

图片使用Object作为Map的Key本身存在不合理性,同时Key中的对象不可回收,导致出现GC。

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

顺序读写替代随机读写

对于普通机械硬盘,随机写入的性能较差,随着时间推移还会出现碎片,顺序写入显著节省磁盘寻址及盘片旋转时间,从而极大提升性能;许多中间件已实现此功能,例如Kafka的日志文件通过顺序写入消息和不可变性,消息添加到文件末尾,从而保证高性能读写。

数据库索引设计

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

图片(1)尽量避免选择键值较少的列进行索引,因为这些列的区分度不明显,重复数据较少;例如,若将is_delete列做索引,查询10万条数据时,where is_delete=0可能有9万条数据块,加上访问索引块带来的开销,不如进行全表扫描。

(2)避免使用前导like "%***"以及like "%***%",因为前面的模糊匹配难以利用索引顺序访问数据块,导致全表扫描;而使用like "A**%"则不受影响,因为遇到以"B"开头的数据时就可以停止查找。

(3)其他可能导致索引失效的情况包括or查询、多列索引未使用第一部分查询、查询条件中含有计算操作或全表扫描的情况。

分库分表设计

图片随着业务增长,若集群节点数量过多,最终可能达到数据库的连接限制,导致集群节点无法持续增加和扩容,从而无法应对业务流量的增长;这也是蚂蚁实施LDC架构的原因之一,通过业务层的水平拆分和扩展,使每个单元的节点只访问当前对应数据库。

避免大量的表JOIN

阿里编码规约中规定,超过三个表的JOIN应被禁止,因为三个表的笛卡尔积计算会导致操作复杂度几何增长。在进行多个表JOIN时,确保被关联字段具备索引。

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

图片

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

有时业务记录会进行count操作,若对时效性要求不高的统计和计算,建议在业务低峰期定时任务做好计算,并将结果保存至缓存中。

图片对于涉及多个表JOIN的情况,建议采用离线表进行Map-Reduce计算,然后将结果回流至线上表用于展示。

图片

数据过期策略

一张表的数据量如果过大,若未根据索引和日期进行部分扫描而导致全表扫描,将极大影响DB的查询性能。建议合理设计数据过期策略,定期将历史数据放入history表,或备份至离线表中,以减少线上数据存储。

图片

合理使用内存

众所周知,关系型数据库的底层存储在磁盘上,计算速度低于内存缓存。虽然缓存DB与业务系统连接存在一定的调用耗时,但其速度仍低于本地内存;从存储量来看,内存存储数据容量低于缓存。因此,长期持久化的数据应存储在数据库中,设计过程中需平衡成本与查询性能。

图片内存使用还存在数据一致性问题,如何保证DB数据与内存数据的一致性,是强一致性还是弱一致性,数据存储顺序及事务控制等都需要考虑,因此尽量做到用户无感知。

做好数据压缩

许多中间件采用压缩和解压操作来减少数据传输中的带宽成本。这里对数据压缩不再展开说明,但需要强调的是,在高并发的业务运行状态下,合理控制日志打印非常重要,避免为了方便排查而过度打印JSON.toJSONString(Object),从而导致磁盘被迅速占满,同时,按照日志容量过期策略也要方便问题排查。建议合理使用日志,错误码尽量简化,核心业务逻辑应记录摘要日志,结构化数据有利于后续监控和数据分析。

在打印日志时,考虑以下问题:

  1. 这个日志是否可能被查看?查看后可以做什么?
  2. 每个字段是否都是必需打印的?
  3. 出现问题时,能否提高排查效率?

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

要确保整体服务的高可用性,需从全链路视角来看待高可用系统的设计。以下分享一个上游多个系统调用异常处理的业务场景,分析其中的性能优化改造。

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

图片

发送阶段高可用设计

  • 生产消息阶段:本地队列缓存异常明细数据,守护线程定时拉取并批量发送(优化方案1中单条上报的性能问题)。

图片

  • 消息压缩发送:异常规则复用一份组装的模型,按照规则聚合压缩上报(优化业务层数据压缩复用能力)。

图片

  • 中间件提供高效序列化机制及发送的零拷贝技术。

存储阶段

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

消费阶段

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

图片

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

图片

  • 为了提升数据库的count性能,先用Hbase对异常数量进行累加,然后定时线程获取数据批量更新。

图片

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

图片

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

图片

  1. 在对系统进行压力测试和容量评估时,演练数据应设为异常数据的3-5倍以做好流量隔离,同时对管道进行拆分,消费链路的线程池应做好隔离。

图片

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

限流能力通过开关控制实现,并与熔断机制结合。

图片

故障转移能力的设计。

图片

  1. 针对系统内部可提升之处,建议参考高可用性能优化策略逐一突破。

高性能设计总结

架构设计

冗余能力

确保集群的三副本甚至五副本的主动复制,保证所有数据冗余成功后,任务才能继续执行。如果对可用性要求极高,可以适当降低副本数及任务的提交与执行约束。

冗余能力易于理解。例如,一个系统的可用性为90%,若有两台机器,可用性为1-0.1*0.1=99%。机器数量越多,可用性越高;对于对连接数有瓶颈的数据库,需要在业务层做好分库分表的冗余水平扩展能力。

故障转移能力

某些业务场景对数据库的依赖性极高,在数据库不可用的情况下,能否转移到备用库或先中断现场,保存上下文,将当前业务场景上下文写入延迟队列,待故障恢复后再对数据消费和计算。

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

系统资源隔离性

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

若A系统可用性为90%,B系统可用性为40%,则A系统某服务强依赖B系统,A系统可用性将为P(A|B),可用性大幅降低。

事前防御

做好监控

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

做好限流/熔断/降级等

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

瞬间流量问题极易引发故障,务必做好压测与熔断能力。对核心系统的强依赖应减少,提前做好预案管控,并对缓存的雪崩等情况做好预热和保护机制。

同时,一些业务开放不合理的接口,采用爬虫等大量请求web接口的情况,也应具备识别和熔断能力。

提升代码质量

核心业务在大促期间,应提前部署资金安全验证代码的可靠性,确保编码规范,避免线上问题的发生。

代码的FullGC和内存泄漏会引发系统不可用,尤其在业务低峰期可能不明显,但在业务流量高峰时性能将恶化,需提前做好压测和代码审查。

事后防御与恢复

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

其他防御能力方面包括:部署过程中如何做好代码的平滑发布,问题代码的机器如何快速解除流量限制;上下游系统调用的发布顺序如何保证;发布过程中,正常的业务是否在已发布的代码中执行,逆向操作是否在未发布的机器中执行,如何保证业务一致性等,都需充分考虑。