什么是高性能系统
先理解一下什么是高性能设计,官方定义: 高可用(High Availability,HA)核心目标是保障业务的连续性,从用户视角来看,业务永远是正常稳定的对外提供服务,业界一般用几个 9 来衡量系统的可用性。通常采用一系列专门的设计(冗余、去单点等),减少业务的停工时间,从而保持其核心服务的高度可用性。
高并发(High Concurrency)通常是指系统能够同时并行处理很多请求。一般用响应时间、并发吞吐量 TPS, 并发用户数等指标来衡量。
高性能是指程序处理速度非常快,所占内存少,CPU 占用率低。高性能的指标经常和高并发的指标紧密相关,想要提高性能,那么就要提高系统发并发能力。
本文主要对做“高性能、高并发、高可用”服务的设计进行介绍和分享。
从哪几个方面做好性能提升
每次谈到高性能设计,经常会面临几个名词:IO 多路复用、零拷贝、线程池、冗余等等,关于这部分的文章非常的多,其实本质上是一个系统性的问题,可以从计算机体系结构的底层原来去思考,系统优化离不开计算性能(CPU)和存储性能(IO)两个维度,总结如下方法:
1、如何设计高性能计算(CPU)?
-
减少计算成本: 代码优化计算的时间复杂度 O(N^2)->O(N),合理使用同步/异步、限流减少请求次数等;
-
让更多的核参与计算: 多线程代替单线程、集群代替单机等等;
2、如何提升系统 IO?
-
加快 IO 速度: 顺序读写代替随机读写、硬件上 SSD 提升等;
-
减少 IO 次数: 索引/分布式计算代替全表扫描、零拷贝减少 IO 复制次数、DB 批量读写、分库分表增加连接数等;
-
减少 IO 存储: 数据过期策略、合理使用内存、缓存、DB 等中间件,做好消息压缩等;
高性能优化策略
计算性能优化策略
减少程序计算复杂度
简单来看这段伪代码(业务代码 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;
代码中存在很明显的几个问题:
-
每次请求过来在第 6 行都去查询 DB,但是在第 8 行对请求做了判断和筛选,导致第 6 行的代码计算资源浪费,而且第 6 行访问 DAO 数据,是一个比较耗时的操作,可以先判断业务是否属于 A 再去查询 DB;
-
当前的需求是只要有一个 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
日常优化代码可以用 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 思想,减少单机的计算压力。
系统 IO 性能优化策略
常见的 FullGC 解决
系统常见的 FullGC 问题有很多,先讲一下 JVM 的垃圾回收机制和内存分配策略。
JVM 的垃圾回收机制 :
-
Heap 区在设计上是分代设计的, 划分为了 Eden、Survivor 和 Tenured/Old ,其中 Eden 区、Survivor(存活)属于年轻代,Tenured/Old 区属于老年代或者持久代。
-
一般我们将年轻代发生的 GC 称为 Minor GC,对老年代进行 GC 称为 Major GC,FullGC 是对整个堆来说。
内存分配策略 :
-
对象优先在 Eden 区分配;
-
大对象直接进入老年代;
-
长期存活的对象将进入老年代;
-
动态对象年龄判定(虚拟机并不会永远地要求对象的年龄都必须达到
MaxTenuringThreshold
才能晋升老年代,如果 Survivor 空间中相同年龄的所有对象的大小总和大于 Survivor 的一半,年龄大于或等于该年龄的对象就可以直接进入老年代); -
只要老年代的连续空间大于(新生代所有对象的总大小或者历次晋升的平均大小)就会进行 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 的日志文件存储消息,就是通过有序写入消息和不可变性,消息追加到文件的末尾,来保证高性能读写。
DB 索引设计
设计表结构时,我们要考虑后期对表数据的查询操作,设计合理的索引结构,一旦表索引建立好了之后,也要注意后续的查询操作,避免索引失效。
(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 与业务系统连接有一定的调用耗时,速度低于本地内存;但是从存储量来看,内存存储数据容量低于缓存,长期持久化的数据建议放 DB 存在磁盘中,设计过程中考虑好成本和查询性能的平衡。
说到内存,就会有数据一致性问题,DB 数据和内存数据如何保证一致性,是强一致性还是弱一致性,数据存储顺序和事务如何控制都需要去考虑,尽量做到用户无感知。
做好数据压缩
很多中间件对数据的存储和传输采用了压缩和解压操作,减少数据传输中的带宽成本,这里对数据压缩不再做过多的介绍,想提的一点是高并发的运行态业务,要合理的控制日志的打印,不能够为了便于排查,打印过多的JSON.toJSONString(Object)
,磁盘很容易被打满,按照日志的容量过期策略也很容易被回收,更不方便排查问题;因此建议合理的使用日志,错误码仅可能精简,核心业务逻辑打印好摘要日志,结构化的数据也便于后续做监控和数据分析。
打印日志的时候思考几个问题:
-
这个日志有没有可能会有人看?看了这个日志能做什么?
-
每个字段都是必须打印的吗?
-
出现问题能不能提高排查效率?
实战-应急链路系统设计方案
要保证整体服务的高可用,需要从全链路视角去看待高可用系统的设计,这里简单的分享一个上游多个系统调用异常处理系统执行应急的业务场景,分析其中的性能优化改造。
以资金应急系统为例分析系统设计过程中的性能优化。如下图所示,异常处理系统涉及到多个上游 App(1-N),这些 App 发“差异日志数据”给到消息队列, 异常处理系统订阅并消费消息队列中的“错误日志数据”,然后对这部分数据进行解析、加工聚合等操作,完成异常的发送及应急处理。
发送阶段高可用设计
-
生产消息阶段:本地队列缓存异常明细数据,守护线程定时拉取并批量发送(优化方案 1 中单条上报的性能问题)
-
消息压缩发送:异常规则复用用一份组装的模型,按照规则则 Code 聚合压缩上报(优化业务层数据压缩复用能力)
-
中间件帮你做好了消息的高效序列化机制以及发送的零拷贝技术
存储阶段
-
目前 Kafka 等中间件,采用 IO 多路复用+磁盘顺序写数据的机制,保证 IO 性能
-
同时采用分区分段存储机制,提升存储性能
消费阶段
-
定时拉取一段数据批量处理,处理之后上报消费位点,继续计算
-
内部好做数据的幂等控制,发布过程中的抖动或者单机故障保证数据的不重复计算
-
为了提升 DB 的 count 性能,先用 Hbase 对异常数量做好累加,然后定时线程获取数据批量 update
-
为了提升 DB 的配置查询性能,首次查询配置放入本地内存存储 20 分钟,数据更新之后内存失效
-
对于统计类的计算采用 explorer 存储,对于非结构化的异常明细采用 Hbase 存储,对于结构化且可靠性要求高的异常数据采用 OB 存储
1.然后对系统的性能做好压测和容量评估,演练数据是异常数据的 3-5 倍做好流量隔离,对管道进行拆分,消费链路的线程池做好隔离
图片
2.对于单点的计算模块做好冗余和故障转移, 采取限流等措施
限流能力,上报端采用开关控制限流和熔断
故障转移能力
3.对于系统内部可以提升的地方,可以参考高可用性能优化策略去逐个突破。
高性能设计总结
架构设计
冗余能力
做好集群的三副本甚至五副本的主动复制,保证全部数据冗余成功场景,任务才可以继续执行,如果对可用性要求很高,可以降低副本数以及任务的提交一执行约束。
冗余很容易理解,如果一个系统的可用性为 90%,两台机器的可用性为 1-0.1*0.1=99%,机器越多,可用性会更高;对于 DB 这种对连接数有瓶颈的,我们需要在业务上做好分库分表也是一种冗余的水平扩展能力。
故障转移能力
部分业务场景对于 DB 的依赖性很高,在 DB 不可用的情况下,能不能转移到 FO 库或者先中断现场,保存上下文,对当前的业务场景上下文写入延迟队列,等故障恢复后再对数据进行消费和计算。
有些不可抗力和第三方问题,可能会严重影响整个业务的可用性,因此要做好异地多话,冗余灾备以及定期演练。
系统资源隔离性
在异常处理的 case 中,经常会因为上游数据的大量上报导致队列阻塞,影响时效性,因此可以做好核心业务和非核心业务资源隔离,对于秒杀类的场景甚至可以单独部署独立的集群支撑业务。
如果 A 系统可用性 90%,B 系统的可用性 40%,A 系统某服务强依赖 B 系统,那么 A 系统的可用性为 P(A|B), 可用性大大降低。
事前防御
做好监控
对系统的 CPU,线程 CE、IO、服务调用 TPS、DB 计算耗时等设置合理的监控阈值,发现问题及时应急
做好限流/熔断/降级等
上游业务流量突增的场景,需要有一定的自我保护和熔断机制,前提是避免业务的强依赖,解决单点问题,在异常消费链路中,对上游做了 DRM 管控,下游也有一定的快速泄洪能力,防止因为单业务异常拖垮整个集群导致不可用。
瞬间流量问题很容易引发故障,一定要做好压测和熔断能力,秒杀类的业务减少对核心系统的强依赖,提前做好预案管控,对于缓存的雪崩等也要有一定的预热和保护机制。
同时有些业务开放了不合理的接口,采用爬虫等大量请求 web 接口,也要有识别和熔断的能力
提升代码质量
核心业务在大促期间做好封网、资金安全提前部署核对主动验证代码的可靠性,编码符合规范等等,都是避免线上问题的防御措施;
代码的 FullGC, 内存泄漏都会引发系统的不可用,尤其是业务低峰期可能不明显,业务流量高的情况下性能会恶化,提前做好压测和代码 Review。
事后防御和恢复
事前做好可监控和可灰度,事后做好任何场景下的故障可回滚。
其他关于防御能力的还有:部署过程中如何做好代码的平滑发布,问题代码机器如何快速地摘流量;上下游系统调用的发布,如何保证依赖顺序;发布过程中,正常的业务已经在发布过的代码中执行,逆向操作在未发布的机器中执行,如何保证业务一致性,都要有充分的考虑。