慢接口分析与优化总结

本文参考:

  1. 从11s到170ms!看看人家的接口优化技巧
  2. MySQL进阶-----order by优化和group by优化

1. 慢接口优化的意义

在工作的过程中,很多时候不只是完成产品的业务需求,技术需求也是需要关注到的点。那其实在系统发展的过程中,业务复杂度增加、业务数据量提升,经常就会出现一些请求响应时间变长的接口,这都会给用户带来不好的体验。“慢接口优化”应运而生,能够显著提升页面响应速度,提高系统的并发能力。

在这里插入图片描述

2. 接口耗时构成

首先画一张架构图,看下接口从 Client 到 Server 经历了哪些步骤,我们又可以针对各个步骤做哪些方面的优化:

在这里插入图片描述

下面的优化技巧就针对步骤涉及到的不同方面进行优化。

3. 优化技巧

3.1. 内部代码逻辑

异步执行[异步思想]

对于一些非核心业务逻辑,如果这部分逻辑执行时间长且不影响主流程,可以让其异步执行,主流程先返回,从而降低接口耗时。或者是主流程线程继续执行,不关心异步执行的逻辑。

比方说:用户领取红包以后写入不同运营人群是发的人群异步消息,数据库插入完成以后同步至 ES 是发的异步消息

具体来说,有如下几种实现异步的方式:

  • 使用消息队列,将非核心业务逻辑作为消息放入消息队列执行,主业务逻辑发完消息就继续执行;甚至可以直接将原先定时任务的刷数接口,通过 DTS 数据传输工具,将其从 hive 底表的单条记录转换为 MQ 消息,完成异步化改造
  • 使用异步线程或者线程池,执行主流程以外的逻辑,比如 Kafka/Zookeeper/Tomcat 等组件中有专门负责网络 IO 的异步线程,异步地从发送队列中拉取请求发送;或者是程序内部需要用到飞书导出的 API,启动时起一个异步定时线程池,定期地拉取飞书 token
  • 使用事件监听机制(观察者模式),主流程触发事件,监听者监听对应的事件异步执行自定义逻辑,比如 Spring 中的 EventListener 监听器、Zookeeper 中的 watcher 监听者
  • 部署定时任务或者微服务,在主流程以外的实例中部署,异步地执行非核心业务逻辑,部署资源消耗较大

并行优化

在设计接口的时候,如果使用串行逻辑,一个程序块执行完再执行另一个程序块,任务只能顺序执行,接口耗时较大,整体吞吐量收到限制。如果观察到程序块之间没有严格的时序关系,比方说多次 RPC 远程调用下游,则可以将远程调用改为并行执行。常用的做法就是通过线程池提交多次 RPC 任务返回 Future 函数,然后在主程序中 get 这多个并行任务。n 次 RPC 调用理论上就可以提高 n 倍的吞吐量。

如果并行处理过程中还需要访问共享资源,需要进行并发控制,比方说多次并行的结果放入同一个 List 中,可以加锁,或者通过 CAS 算法,或者直接使用线程安全的容器,比如 SynchronizedList、ConcurrentHashMap 等。

拒绝阻塞等待

当接口需要调用调用下游的 RPC 接口(或者是前端调用后端接口),但是下游接口需要 10s 甚至更多才能返回,我们的主程序又通常都是同步阻塞等待下游 RPC返回,这会极大拖慢接口的耗时。甚至在某些有接口调用时间阈值限制的场景下会被动断开连接,给用户造成不好的体验。之前我们的项目中就存在前端组件调用后端接口,如果后端接口 15s 内没返回就会被组件强制断开连接,最终导致前端超时获取不到数据的情况。这里可以后端接口先返回,等后端逻辑处理完以后,再通过消息触发前端查询(前端架构可以感知消息),这样就可以将前端阻塞等待的时间缩小。

还可以参考 CompletableFuture 中的 whenComplete 方法也有回调机制,不需要主流程在这里阻塞等待,等前面的操作执行完会自动执行回调操作;还有网络 IO 中的 NIO 模型,read()/write() 在非阻塞的模式下,IO 没有数据时接口直接返回,主线程可以继续去遍历其他已经有就绪事件的 IO。

预分配与循环使用[池化思想]

「线程池」就是池化思想的集大成者,它可以帮助我们管理线程,避免增加线程和销毁线程的资源消耗。每次需要用到线程的时候都去创建一个新的线程,会增加耗时,也会增大资源开销,那么线程池就是很好的复用了已经创建的线程(其中核心线程被创建出来一般不会被回收,当然想回收也可以通过参数 allowCoreThreadTimeOut 配置)。

使用到线程池的场景也很多,包括 MySQL 的 HikariCP 数据库连接池、 HttpClient 的连接池等。

这体现的是分配完以后可以循环使用的思想,也出现在 Http 网络连接中,通过配置 Keep-Alive 长连接,每次 Http 请求可以复用前面三次握手后建立的 TCP 连接,避免资源的频繁销毁创建。

线程池合理设计

在前面池化思想中就提到过「线程池」,线程池可以帮忙并行处理任务,提高接口响应速度;帮我们管理线程,避免创建和销毁线程的资源开销;甚至可以起到削峰的作用,因为其本身就自带有一个阻塞队列。

合理的设计线程池,可以让我们更高效地处理任务,但是如果线程池设计的不合理,会极大影响我们的接口效率,甚至拖垮接口的响应时间。我们可以重点关注线程池七个参数中的这几个参数:核心线程数、最大线程数、阻塞队列

线程池设计可以注意下面几点:

  • 如果核心线程数过小,不能够达到很好的并行效果。如果核心线程数太大,需要考虑机器资源是否足够,避免占用太多的 CPU/IO 资源劣化系统稳定性;此外,还需要考虑下游的承载能力,核心线程的大量请求是否会打挂下游
  • 阻塞队列不宜过小,因为这样子起不到缓冲削峰的作用,线程池也会更容易创建大量的非核心线程,增大线程池创建开销。阻塞队列也不可过大,如果核心线程处理速度不足,大量的任务阻塞在队列中如果不能及时处理,前端的请求就会发生超时,并且阻塞队列过大还容易导致 OOM
  • 线程池还需要注意「隔离」,不同线程池各司其职,不然上述的问题就会互相扰乱,核心业务的稳定性也会被边缘业务拖垮

锁粒度避免过粗

锁本质是为了让代码块“串行化”,那么在串行化的代码块中加入定制的逻辑,就可以实现效率上的“做一次”或者是正确性上的”避免并发问题“。但是如果加锁的力度过粗,是很影响接口性能的。

不管你是synchronized加锁还是redis分布式锁,只需要在共享临界资源加锁即可,不涉及共享资源的,就不必要加锁。这就好像你上卫生间,不用把整个家都锁住,锁住卫生间门就可以了。

比如,在业务代码中,有一个ArrayList因为涉及到多线程操作,所以需要加锁操作,假设刚好又有一段比较耗时的操作(代码中的slowNotShare方法)不涉及线程安全问题。反例加锁,就是一锅端,全锁住:

//不涉及共享资源的慢方法
private void slowNotShare() {
    try {
        TimeUnit.MILLISECONDS.sleep(100);
    } catch (InterruptedException e) {
    }
}

//错误的加锁方法
public int wrong() {
    long beginTime = System.currentTimeMillis();
    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
        //加锁粒度太粗了,slowNotShare其实不涉及共享资源
        synchronized (this) {
            slowNotShare();
            data.add(i);
        }
    });
    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
    return data.size();
}

正例:

public int right() {
    long beginTime = System.currentTimeMillis();
    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
        slowNotShare();//可以不加锁
        //只对List这部分加锁
        synchronized (data) {
            data.add(i);
        }
    });
    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
    return data.size();
}

优化程序结构

优化程序逻辑、程序代码,是可以节省耗时的。比如,你的程序创建过多不必要的对象、或者程序逻辑混乱,多次重复查数据库、又或者你的实现逻辑算法不是最高效的,等等。

我举个简单的例子:复杂的逻辑条件,有时候调整一下顺序,就能让你的程序更加高效。

假设业务需求是这样:如果用户是会员,并且第一次登陆时,需要发一条感谢短信。如果没有经过思考,代码直接这样写了

if(isUserVip && isFirstLogin){
    sendSmsMsg();
}

假设有5个请求过来,isUserVip判断通过的有3个请求,isFirstLogin通过的只有1个请求。那么以上代码,isUserVip执行的次数为5次,isFirstLogin执行的次数也是3次,如下:

在这里插入图片描述

如果调整一下isUserVipisFirstLogin的顺序:

if(isFirstLogin && isUserVip){
    sendMsg();
}

isFirstLogin执行的次数是5次,isUserVip执行的次数是1次:

在这里插入图片描述

本质上就是漏斗结构,往下钻满足条件的请求不断变小的过程。

【注意】不过有时候程序结构的调整也需要考虑逻辑判断背后服务的负载能力:

假设业务需求是这样的:用户在具有开卡资格以后(当前状态未开卡为普通用户),普通用户和会员用户会展示不同的文案

考虑漏斗模型和接口的时效性要求,我原先的判断流程图如下,Redis 判断是否具有开卡资格,进入分支以后才会使用 RPC,Redis 中拥有开卡资格的人是较少的。但是这里 Redis 判断用的是我们业务的集群,承接的却是进入业务的全站流量,这部分量级是很夸张的,Redis 使用率直线飙升到 70% 以上,会影响业务的稳定性,在大促场景下甚至会直接打挂业务集群。

在这里插入图片描述

最终调整程序流程图如下,由 RPC 人群服务承接全站的流量,该服务具有上千台机器,服务的稳定性不会有问题。

这其实也是“性能”和“稳定性”的权衡,在追求一味时效性的同时,也需要合理评估服务集群的承载能力。

在这里插入图片描述

3.2. 缓存

恰当引入缓存[空间换时间思想]

对于一些非实时更新的数据(更新频率可能是小时级、天级甚至月级),我们往往可以将它们放入缓存,避免频繁读取数据库;有时候也可以避免调用外部服务的时间过长,提高接口响应速度;分布式缓存如 Redis 的承载能力也远大于 Mysql 等关系型数据库,也常用来做大流量的提前分流治理。

前面的缓存包括分布式缓存、本地缓存:

  • 分布式缓存,如 Redis、Memcached

    省钱卡包红包领取数据、日级更新数据接口的结果都可以使用分布式缓存,通过缓存过期、job定时任务刷新、监听 binlog/领域事件、主动更新等方式,保证缓存的时效性,以及缓存与数据库的一致性。

  • 本地缓存,如 JVM 本地缓存、Map 缓存,甚至可以延伸到类中的一个字段变量

    相比于分布式缓存,本地缓存建议存放数据量极小且不经常更新甚至不更新的数据,比如销售行业信息、销售部门信息等

缓存延迟优化

以 Redis 为例,当我们本身使用不当或者运维不合理的时候,可能导致 Redis 访问延迟变大。可能存在如下原因:

  1. 使用了复杂度过高的命令,如 sort、sunion 等

  2. 操作、存储大的 bigkey

    bigKey 的危害有如下五个方面:

    • 内存空间分配不平衡:在 Redis Cluster 中,bigKey 所在的分片会占用更多内存,数据和查询的倾斜会导致该分片的稳定性下降
    • 客户端超时阻塞:由于 Redis 执行命令是单线程,在操作 bigKey 时会比较耗时,那么客户端就会超时
    • 网络阻塞:每次获取 bigKey 产生的网络流量较大,在固定的带宽下,分配给别的服务的网络资源就会更少
    • 阻塞工作线程:主动删除 del bigKey,被动过期删除、数据迁移时,都会由于处理大 key 时间较长,会阻塞后续命令的执行
  3. 大量数据集中过期,集中过期导致 Redis 响应慢的原因与 Redis 的过期策略有关。

  4. Redis 实例内存使用达到了上限

  5. Fork 子进程导致响应变慢,Redis 生成 RDB 和 AOF 都需要父进程 fork 出一个子进程进行数据的持久化,父进程需要拷贝内存页表给子进程,因此 Redis 内存不宜设置太大,一般不超过 10G,实例越大,Fork 耗时越长,在 Fork 完成之前,整个实例都会被阻塞住。

提前初始化缓存[预取思想]

把未来可能需要的数据获取好,提前放到本地缓存中,需要的时候,直接去取就可以,而不需要实时计算,这样子就可以大幅度减少接口耗时。这样子可能在启动的时候会耗费一定的时间去预取数据,即使后面程序中并没有用到对应的数据。

比如在项目代码中实现 Spring 的 ApplicationListener 接口,监听 ContextRefreshEvent 事件,重写其中的 onApplicationEvent 方法,可以在 Spring 容器初始化以后提前读取销售的行业信息,并缓存到本地 Map 中,需要的时候直接从本地缓存中取就可以。

还比如在配置中心比如 Nacos、TCC(字节内部配置组件)、Diamond(阿里内部配置组件),会在 Spring 启动时由 BeanPostProcessor 去解析注解,根据注解中的定位信息,从远程服务器拉取对应的配置,放入本地实例的变量中,需要的时候直接用就可以。

3.3. 数据库 DB

批量读写[批量思想]

循环内的 insert、查询可能会因为数据量的增长导致接口性能急剧下降。将循环内的查询、insert 改成批量操作,可以大大减少数据库的访问时间,缩减系统与DB之间的连接、关闭等消耗。此外,也可以根据实际的业务场景,将大批次的数据操作,改成小批次的数据操作。

Demo:

// 优化前:循环入库性能低下
for(Employee employee: employeeList) {
  insert(employ);
}

// 优化后: 批量入库
batchInsert(employeeList);

索引

提到接口优化,我们第一时间都会想到 DB 的索引优化,索引优化确实是成本最小的优化,而且一般效果都不错。我们可以从下面几个维度去思考:

SQL 没加索引

开发的时候,容易疏忽忘记给 SQL 添加索引,所以写完 SQL 的时候,我们就可以顺便 explain 看下执行计划。

一般加索引就看下面的字段:

SQL 的 where 字段 + order by/group by 后面的字段

其中 order by/group by 字段也符合最左匹配原则,参考 MySQL进阶-----order by优化和group by优化

索引没生效

即使加了索引,有些索引也会失效,下面就是索引失效的十大场景:

在这里插入图片描述

索引设计不合理

索引不是越多越好,需要合理设计

  • 删除冗余和重复的索引,减少索引占用空间
  • 索引一般不超过 5 个
  • 索引不适合建立在区分度不高的字段上,比如性别字段
  • 适当使用覆盖索引,避免回表查询
  • 如果需要使用force index强制走某个索引,那就需要思考你的索引设计是否真的合理了

慢 SQL 优化

SQL 语句优化

在这里插入图片描述

其他优化思路

当 SQL 语句本身无法提供更好的优化效果,可以考虑优化整体查询架构,复杂/大表场景可以

  • 区分冷热数据分档
  • 分库分表
  • 迁移 ES/Hive 等。

避免大事务问题

为保证数据库多个操作的原子性,在涉及到数据库修改的时候,我们常会用 Java Spring 中的 @Transactional 声明式事务,

@Transactional
public int createUser(User user){
    // insert
    userDao.save(user);
  	// update
    passCertDao.updateFlag(user.getPassId());
    return user.getUserId();
}

但是如果事务中穿插着其他 RPC/MQ 等非 DB 操作,耗时会比较大,从而出现大事务问题

@Transactional
public int createUser(User user){
    // insert
    userDao.save(user);
  	// 远程调用
  	RPC/MQ
  	// update
    passCertDao.updateFlag(user.getPassId());
    return user.getUserId();
}

大事务的危害:

  1. 并发情况下,数据库连接资源容易耗尽
  2. 锁定数据较多,容易造成大量阻塞和锁超时,进而接口超时
  3. 执行时间长,容易造成主从延迟
  4. 回滚所需要的时间变长

那么大事务又是如何产生的呢?

  1. 单个事务操作数据库操作较多
  2. 事务中存在 RPC/MQ 等非 DB 耗时操作
  3. 大量的锁竞争

我们可以通过如下方案规避大事务:

  • 查询操作,尽可能放到事务外
  • RPC/MQ 等非 DB 操作放到事务外面,我有一篇文章也专门讲了 @Transactional 声明式事务回调编程,可以尽可能使代码结构改动缩小
  • 事务中避免处理太多的数据

深分页问题

一个典型的深分页例子如下:

select id, name from account where create_time > '2024-10-07' limit 100000, 10;

假设基于 create_time 建立了二级索引 idx_create_time。

limit 100000, 10 offset = 100000 足够深了,MySQL 执行计划会扫描整个二级索引树,拿出满足 create_time > ‘2024-10-07’ 条件的 100000 + 10 行,每行逐个回表拿出主键索引树上的所有字段,即 100000 + 10 条记录的字段并选出 id, name,然后丢弃前 100000 条记录。这么多次回表显然是无法接受的。

可以通过“游标记录法”和“延迟关联法”来优化深分页问题。

游标记录法

记录上次查询满足条件的记录最大 id,比方说就是等于 100000,则 SQL 语句可以修改为:

select id, name from account where id > 100000 limit 10

这样子可以直接在主键索引树上扫描,where 条件直接过滤了,只会取出来 10 条记录,减少了回表次数。但前提是:需要有一种自增的字段(这里是 id,或者已经 order by 的字段)

延迟关联法

可以理解成把二级索引树上的条件转移到主键索引树上来,减少回表次数。优化 SQL 语句如下:

select acct1.id, acct1.name from account acct1 INNER JOIN (select a.id from account a where a.create_time > '2024-10-07' limit 100000, 10) AS acct2 on acct1.id = acct2.id;

先通过二级索引树 idx_create_time 上的覆盖索引查询到满足条件的主键 ID(不需要回表),然后与原表内连接,这样相当于也是直接走了主键索引,减少了回表次数。

3.4. 调用下游服务

批量调用

这个思想同 DB 的批量读写,将多次 RPC 调用转换为批量查询,能有效减少网络建连、通信的耗时。

拒绝无效等待

当下游服务接口发生抖动的时候,若当前主程序一直等待,可能会一直拿不到接口返回从而无法响应。超时控制可以让程序在一定时间内必须返回,当下游服务劣化的时候不会影响当前业务的稳定性。

3.5. 网络

流量控制

通过限流算法、降级措施,避免不必要的流量进入,保障接口的正常响应。

压缩传输内容

Http 通常采用 gzip 压缩算法压缩 HTML、JavaScript、CSS 等文件,但是当传输内容仍然较大的时候,前后端可以联合协商传输的 JSON 结构,减少不必要字段的传输。甚至在调用中间件的时候,也可以将操作的内容压缩,比如 Redis 存储 value 的时候,可以提前使用 GZIP 将 value 压缩,然后设置 key - value, get 的时候解压缩对应的内容。

3.6. 服务器资源

  • FullGC 问题:大批量导出 Excel,当 Excel 过大时,就会把 JVM 卡死
  • 线程池打满也会让多余的请求处于等待状态,因此高并发场景需要限流,把多余的请求直接拒绝掉
  • 程序打开的 IO 连接也要记得及时关闭,否则也会占用 IO 资源,影响进程的切换速度
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

互联网民工蒋大钊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值