文章笔记
引言
原文如下:https://mp.weixin.qq.com/s/yXVkHSRdwjXFM7Xv03x3-Q
主要内容是介绍一些常见的调优技巧,有部分根据一些简单例子介绍,虽然都不叫浅但是可以作为一个思路引导。这里简单记录笔记内容。
应急链路设计
个人偏向于先实战后理论,这样读着比较有意思。笔记也同样这样安排。
案例给出的是上游多个系统调用异常处理系统执行应急的业务场景。下游的工作是把“差异日志数据”给到消息队列, 异常处理系统订阅并消费消息队列中的“错误日志数据”,然后对这部分数据进行解析、加工聚合等操作,完成异常的发送及应急处理。
最开始的系统设计如下:
如果是高可用设计,进行拆分如下,构建守护进程,异常数据推送到本地队列,然后守护进程定期批量拉取发送。
接着是消息压缩发送:异常规则复用用一份组装的模型,按照规则 Code 聚合压缩上报(优化业务层数据压缩复用能力)
后续则只需要依赖消息中间件的序列化和零拷贝机制。
存储阶段
本地消息队列使用Kafka存储。
- 依托Kafka 实现 IO 多路复用+磁盘顺序写数据的机制,保证 IO 性能。
- 分区分段存储机制,提升存储性能。
消费阶段
在消息队列的消费段也是分批拉取数据消费。处理之后上报消费位点,然后继续完成计算操作。
消息队列需要做幂等处,同时需要保证节点抖动重复推送消息问题处理
最后是入库的处理,提高DB处理性能,节奏Hbase进行异常数量累加,定期获取线程update操作。提高DB查询,都会把首次查询放到本地缓存存储20分钟,数据更新则立即失效。
在DB的存储上也进行针对性处理。统计类的计算采用 explorer 存储,对于非结构化的异常明细采用 Hbase 存储,对于结构化且可靠性要求高的异常数据采用 OB 存储。
整套架构搭建完成之后,是压力测试,整体架构稳定度测试,测试的内容如下:
- 异常数据量成倍提高测试,对于异常流量拆分,线程隔离等。
- 单点模块计算进行冗余、故障转移和限流
- 可以优化的地方参考高可用性能优化策略(参考上面的优化策略)去逐个突破。
高并发和高性能
高并发是什么?一般用响应时间、并发吞吐量 TPS, 并发用户数等指标来衡量。
高性能是什么?高性能是指程序处理速度非常快,所占内存少,CPU 占用率低
针对高并发和高性能,常见的手段不管是IO 多路复用、零拷贝、线程池、冗余等等都是真是某两个大维度的处理,这两个大维度就是CPU和IO,归根结底目的其实尽可能的缩短磁盘和CPU之间的处理差距。
比如CPU用更短的时间完善任务,就需要从时间复杂度和空间复杂度入手,大部分人即使遇到非常复杂业务,需要自己手写算法的场景也是比较少的。
而针对IO磁盘的场景,在数据库的设计上展现的淋漓尽致,不管操作系统还是算法还是数据结构的设计,基本都要考虑磁盘的存储,所以磁盘IO这一块有非常多的优化空间。
不过从个人来看这篇文章脱离了另一个角度那就是SQL,SQL是集CPU、内存、硬盘计算机三大核心的统领,如果没有SQL现今的业务开发不知道要复杂多少倍。可谓是软件领域最具影响力也是最为难啃的一块。
综上所述,这篇文章的高性能和高并发,都是针对业务场景来讲述的。
高性能策略
案例一:循环查库和业务判断
第一个案例是常见的for循环查库,而查库之后又继续业务判断过滤数据,这时候就可以使用先过滤数据再查库,或者查库提到循环外面,先搜集数据再一次查找。
这里直接对比优化前后的代码:
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;
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;
主要的优化点是:
- 提前业务逻辑判断并且过滤无效的数据。
- 尽可能的减少循环次数,也就是计算次数,让循环尽可能结束。
- 如果允许个人更建议把
queryDOById
改为queryDOByIds
,不建议在for循环中做查库。如果是for循环里面更新,或者需要大批量的更新数据,可以使用下面的套路代码,假设我们下面的代码是分批大批量更新订单的状态。下面这个代码在个人处理业务过程中屡试不爽。
if (CollectionUtils.size(tradeNos) > 0) {
// 获取每次分批操作数量
long batchInsertSize = getBatchInsertSize();
if (size > batchInsertSize) {
// 取整,进行 N - 1次的更新动作
long p = size / batchInsertSize;
for (int i = 1; i <= p; i++) {
long rows = mapper.update(tradeNos.stream().limit(batchInsertSize * i)
.skip((i - 1) * batchInsertSize).collect(Collectors.toList()));
log.info("分批更新:{} 条", rows);
result += rows;
}
// 如果发现还有剩余但是不满一批数据的数量,也进行操作。
if (size % batchInsertSize > 0) {
long rows = mapper.update(tradeNos.stream()
.limit(size).skip(batchInsertSize * p).collect(Collectors.toList()));
log.info("分批更新:{} 条", rows);
result += rows;
}
} else {
// 如果可以一批次完成,直接完成即可
long rows = mapper.update(tradeNos);
log.info("分批更新:{} 条", rows);
result += rows;
}
}
原文第一个案例整个优化过程图如上。日常优化代码可以用 ARTHAS 工具分析下程序的调用耗时,耗时大的任务尽可能做好过滤,减少不必要的系统调用。
评价:这个例子比较入门和基础,但是确实很多时候写代码写入神了会出现这样的代码。
案例二:同步异步处理
第二个案例是合理同步和异步。分析业务链路中,哪些需要同步等待结果,哪些不需要,主要的处理套路是:核心依赖的调度可以同步,非核心依赖尽量异步。或者核心功能异步但是存在兜底机制可以及时处理,比如存库定期执行任务重试,依赖数据库事务完成原子操作等。
这个案例的思路是遇到了多个业务系统依赖调用的情况,需要尽可能找出非核心业务,把同步调用改为异步调用,
最终出现下面的改动代码:
featureThreadPool.execute(()->{
try{
dSystemClient.updateResult(resultDTO);
}catch (Exception exception){
LogUtil.error(exception, logger, "dSystemClient.updateResult failed! resultDTO = {0}", JSON.toJSONString(resultDTO));
}
});
第三个案例:限流保护
这一块过于笼统和简单,这里通过一些资料先给一些总结:
目前主流的限流算法为窗口算法和桶算法,而限流方式又划分为单机限流与分布式限流。
窗口算法实现简单,也就是计时器的方式,和我们寻找通过电磁炉定时一个道理,但是时间窗口算法因为没有缓冲所以存在临界区的问题。
桶算法稍微复杂一些,虽然没窗口算法直观,但是有一些别的优势:
- 消费速率恒定,是保护自身系统的前提。
- 令牌可以面对突发暴增流量,缓慢加速问题也存在多种解决手段。
窗口算法和桶算法限流都适用于单机限流,分布式限流可以结合注册中心、负载均衡计算每个服务的限流阈值,比较经典的限流实现比如Sentinel的限流手段就比较值得学习,Sentinel 以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性,这个组件对付大部分的业务场景是足够使的,当然源码也十分优秀,值得学习和借鉴设计思路。
更为细致的限流手段比如Redis 限流,Redis本身极强的单机性能对付分布式场景十分不错,如果不想重造轮子,可以直接使用开源工具如 redisson,已经封装了基于 Redis 的限流。
其他的限流工具比如耳熟能详的Guava,虽然算法效率很高但是也是只适用单机限流,
最后上面这段总结来自于:https://www.cnblogs.com/niumoo/p/16007224.html 这篇文章。
第四个案例:单线程改多线程
一个诊断任务500内完成,如果一个服务调用需要100毫秒,那么整个调用会随着时间增长
// 提交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 性能优化策略
IO优化策略讲的是JVM的优化,JVM的优化首先需要搞清楚,这部分内容是JVM基础内容,这里不过多扩展新生代、老年代,垃圾回收等等概念,直接给出一个老年代回收的条件条件判断流程。
大List更新/删
有时候因为临时的需求调整或者一些紧急情况需要对于数据库做下面的操作。
如果查库一次性读取所有的数据到List,必然会把JVM撑爆,并且这样的任务会导致大对象频繁的GC情况。
为此可以使用上面提到的分批方法套路进行改善,比如下面的方式先把所有的唯一ID查出来,然后进行分批,在分批的同事把这一批次的数据直接干掉,也就是所谓的大事化小的处理方式。这个改造对于大部分开发来说应该不难想到,这里就不过多介绍了。
if (CollectionUtils.size(tradeNos) > 0) {
// 获取每次分批操作数量
long batchInsertSize = getBatchInsertSize();
if (size > batchInsertSize) {
// 取整,进行 N - 1次的更新动作
long p = size / batchInsertSize;
for (int i = 1; i <= p; i++) {
long rows = mapper.update(tradeNos.stream().limit(batchInsertSize * i)
.skip((i - 1) * batchInsertSize).collect(Collectors.toList()));
log.info("分批更新:{} 条", rows);
result += rows;
}
// 如果发现还有剩余但是不满一批数据的数量,也进行操作。
if (size % batchInsertSize > 0) {
long rows = mapper.update(tradeNos.stream()
.limit(size).skip(batchInsertSize * p).collect(Collectors.toList()));
log.info("分批更新:{} 条", rows);
result += rows;
}
} else {
// 如果可以一批次完成,直接完成即可
long rows = mapper.update(tradeNos);
log.info("分批更新:{} 条", rows);
result += rows;
}
}
无法回收的 static对象
有时候我们考虑的最简单的缓存方式应该是像下面这样:
毫无疑问,这就是一个最简单的缓存,然后这个缓存存在致命缺陷,那就是线程不安全,
private staic final Map<String, Object> cache = new HashMap<>();
而在文章的案例中,也出现了因为查询配置使用静态对象存储的情况,当配置内对象积累,会导致这个不可回收的static对象出现GC,但是GC根据判断又不能被移除的情况!!
当执行 Full GC 后空间仍然不足,则抛出如下错误【java.lang.OutOfMemoryError: Java heap space
】
在这个例子中,首先不应该使用静态对象,而是改为可以被新生代回收的代码块对象,这样方法出栈之后可以被快速回收,也可以使用类似LRU淘汰机制存储这些对象,当队列已满就自动“末位淘汰”。
顺序读写代替随机读写
这里提到两个点:
- 合理表设计和提前规划业务会用到的热字段,在热字段合理使用索引。
- 诀窍是提前编写一些伪业务查询代码,可以很直观的验证索引设计是否合理。
设计和使用索引有下面这些技巧:
- 越是具备唯一性的字段优先考虑,举例来说就是比如男女就不适合作为索引,哪怕来几百万数据,他们的区分度也就是1和0,而订单ID则不同,通常每个用户有自己唯一的流水订单,数据库建立索引也更佳合理,各种查询的覆盖面也会更广。
- 避免
like "%***"
以及like "%***%"
,但是注意前缀索引的like "固定值%"
这种情况是可以走索引的。 - 关注or、group、sort,子查询 in,多列索引查询、函数操作等等情况。
减少业务流水表大量耗时计算
涉及到多个表 JOIN 的建议采用离线表进行 Map-Reduce 计算,之后再进行回流计算。
数据过期策略
数据过期策略是定期把数据存储到历史表进行备份,或者备份到离线表中,减少线上大量数据的存储。通过定期分流数据,可以减少count和一些索引扫描的时间,大大提高查询的执行效率。
合理使用内存
合理使用内存需要引入淘汰策略,划分数据的存储空间,计过程中需要考虑好成本和查询性能的平衡。
数据压缩
目前主流中间件本身对于数据提供了压缩策略,日常最容易接触磁盘IO的场景是打印日志,不能够为了便于排查,打印过多的JSON.toJSONString(Object)
,同时磁盘很容易被打满,按照日志的容量过期策略也很容易被回收。
所以数据压缩这一节更为重要的是打印日志,打印日志的时候思考几个问题:
- 这个日志有没有可能会有人看?看了这个日志能做什么?
- 每个字段都是必须打印的吗?
- 出现问题能不能提高排查效率?
分库分表设计
分库分表是十分考验业务场景的,这部分内容需要展开单独的大长文+业务实战,文章也是说了等于没说。
避免大量的表 JOIN
阿里编码规约中超过三个表禁止 JOIN。这一点主要是对于很多业务核心的查询往往在几个超级大表上周转,导致优化难度成倍上升。
解决这些JOIN手段无非几种:
- 分库分表,冷热字段分离
- 如果条件允许,部分业务代替SQL,减少表关联。
- 冗余字段,代价是增加业务复杂度和各种数据同步兜底。
可以看到核心就是空间复杂度换取时间复杂度。
高性能小结
总结主要针对三点:架构、故障转移、资源隔离性能。防御措施则分为事前防御和事后防御,当然事前防御是核心,事后防御直接作为下下策的兜底手段使用。
架构
架构主要考虑的就是冗余,冗余很好理解机器越多,可用性会更高。水平的分库分表也是一种冗余。
故障转移
对于DB依赖性高的业务,可以考虑把异常输出到FO库或者对于业务场景的上下文写入到消息队列,保存现场,故障恢复之后进行重新推送处理。
不可抗力的第三方因素需要考虑异地多话,冗余灾备以及定期演练。
资源隔离
对于依赖上游同时会因为上游推送数据压力巨大的系统,需要做好核心业务和非核心业务资源隔离。对于高并发的业务需要单独机器部署,比如秒杀的场景。
可用性计算: A 系统可用性 90%,B 系统的可用性 40%,A 系统某服务强依赖 B 系统,那么 A 系统的可用性为 P(A|B), 可用性大大降低。
事前防御手段
- 良好的监控排查机制。比如RocketMq、RabbitMq提供可视化界面,ELK三件套日志监控等手段。
- 限流/熔断/降级:上游业务流量突增,下游必需做好挡板和措施,解决方式是做好做好完备的压力测试和熔断检测能力。同时多准备几套方案,当然大部分下能接触这个难度的业务基本都有成熟的兜底手动,各个公司都有不同思路,这里不过多扩展。
- 代码质量:这个算是最为务实的一项,要对于自己代码进行一定的压力考验,比如前面提到的突然大量的更新删除如何从JVM层面防止大对象进入频繁GC。代码质量的最好方式当然是有专业的审查代码人员,代码质量不过关打回去重写,但是大部分公司基本是没有这东西的,所以只能自己平时多思考和多看优秀案例了。
事后防御
事后防御都是擦屁股,所以优先考虑恢复关键业务,以及故障是否可以兜底回滚或者重试。比如下游调用失败提供手动触发调用机制,上游提供手动回调的操作等等。这些小操作在关键时刻能帮大忙。
最后是原文的一些口水话,比如部署过程中如何做好代码的平滑发布,问题代码机器如何快速地摘流量;上下游系统调用的发布,如何保证依赖顺序;发布过程中,正常的业务已经在发布过的代码中执行,逆向操作在未发布的机器中执行,如何保证业务一致性,都要有充分的考虑。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。