接口性能优化思路总结

对后端开发来说,接口性能优化多少都是会碰到的事,没主动找上你也要思考整理优化的思路和可能的方案。

做技术的,一定要梳理思路,勤于总结,才是快速提升之道。

接口性能监控

接口性能监控,使用 AOP,拦截器,过滤器 都可实现,AOP 还可以对不同层的接口进行监控。

异步处理监控数据,通常会统计接口响应时间,接口成功次数,失败次数,计算出接口的稳定性。

数据库层可以开启慢查询日志,以备需要时可快速定位SQL执行慢的问题。

接口响应时间通常应控制在 500ms 以内,超过就需要考虑优化了。

分布式微服务架构,可集成日志链路跟踪组件,例如,Zipin,SkyWalking

性能慢原因汇总

接口性能慢原因主要有以下几种。

数据库慢查询

  • 未加索引
  • 索引失效
  • 深度分页问题
  • 联表过多问题
  • 子查询过多问题
  • IN中的值过多问题
  • 数据量过大问题

业务逻辑复杂

  • 存在循环调用
  • 业务逻辑冗长

线程池设计不合理

  • 线程池参数设置不合理
  • 线程池未做隔离,任务过多导致堆积。

锁设计不合理

  • 锁类型使用不合理
  • 锁粒度较粗

服务器资源问题

  • 内存泄露,Full GC 导致内存和CPU打满
  • 带宽,CPU,内存等资源不满足业务处理

性能优化方案思路

慢查询优化

基于MySQL,开启数据库慢查询日志,找到慢查询SQL。

养成良好习惯,不要用 SELECT * 的查询,只查询需要用到的字段,要用到的字段尽可能为索引字段。

未加索引

有的开发在库表和编码设计阶段,没有及时考虑使用索引,在发布生产时就极容易忽略或忘掉需要补索引这回事。在系统运行初期数据量小时问题没有表现出来,当运行一段时间后产生大数量时问题就暴露出来了,这本质是人的问题。

所以在库表及编码阶段,一个对自己有要求的开发者,理应及时考虑SQL的索引使用。

1
2
3
4
5
6
7
8
# 查看表信息,包含了索引信息
SHOW CREATE TABLE table_name;
# 查看表索引
SHOW INDEX FROM table_name;
# 增加索引
ALTER TABLE table_name ADD INDEX index_name (column_list)
ALTER TABLE table_name ADD UNIQUE (column_list)
ALTER TABLE table_name ADD PRIMARY KEY (column_list)

注意:如果是在生产环境大数据量情况下增加或更新索引,可能会引起锁表导致系统中断,一定要在低峰期执行SQL。

索引失效

找到慢的SQL,使用MySQL提供的 EXPLAIN 查看SQL执行计划中是否有使用索引。如果预期是使用的而未使用,则索引失效了。

索引失效原通常有以下:

  • 复合索引的使用未遵循最左前缀原则。
  • WHERE 条件索列有计算、函数、转换。
  • 模糊查询使用左侧通配符 %xxxx
  • 索引列区分度不够,优化器认为全表扫描比使用索引效率更好。
  • 使用 OR 连接了非索引列。
  • IS NOT NULL 包含了非索引列。
  • 使用不等于(<>,!=) 查询。
  • 大表大字段使用了 SELECT *查询。

找到索引失效原因针对性解决就是。

索引列区分度不够问题,比如:

  • 字段值只有固定的几个
  • 字段值非常集中在某几个值,其它值占比极少
  • 字段大量为空,只有少量的值

区分度优化,在设计索引时,使用短索引,长字符串索引可以使用前缀索引,指定前缀长度,节省空间,查询更快,长度为 20 的索引,区分度高达 90% 以上,可以使用以下语句计算出区分度。

1
SELECT COUNT(DISTINCT LEFT(column, index_length))/count(1) FROM table_name;

如果优化器选择的索引不是预期的或放弃了索强,可以使用 FORCE INDEX(index_name)关键字强制使用索引(使用前有必要使用 EXPLAIN 分析下)。

1
SELECT * FROM tb_name FORCE INDEX(index_name) WHERE ctime......

超大分页问题

MySQL 的 Limit 分页查询,并不会跳过 offset 行,而是取 offset+N 行,然后返回放弃前 offset 行,返回 N 行,那当 offset 特别大的时候,效率就非常的低下。

优化:要么控制返回的总页数,要么对超过特定阈值的页数进行 SQL 改写。

MySQL 正例:先快速定位需要获取的 id 段,然后再关联。

即先在覆盖索引上进行查询操作而不是整行数据,再将结果与完整行联合查询。

1
SELECT a.* FROM1 a, (select id from1 where 条件 LIMIT 100000,20 ) b where a.id=b.id

或计算出要查询的起始位置和结束位置,直接范围查询。如下:

1
2
3
4
5
6
SELECT film_id, description FROM sakila.film WHERE film_id BETWEEN 50 AND 54 ORDER BY film_id;

# 返回 1604916030之间的数据
SELECT * FROM sakila.rental ORDER BY rental_id DESC LIMIT 20;
# 下一页查询可从指定位置开始
SELECT * FROM sakila.rental WHERE rental_id < 16030 ORDER BY rental_id DESC LIMIT 20;

多表关联问题

多表联查的根本问题还是索引使用不当或索引不满足,产生巨量数据的迪卡尔积,严重影响性能。

JOIN 连接是在内存处理的,当数据量较少或 join_buffer 设置较大的,速度也不会很慢。当 join 的数量很大且缓存不够时,会在磁盘上创建临时表进行关联匹配,效率就极低了。

优化:遵循小表驱动大表原因。超过三个表禁止 join。需要 join 的字段,数据类型必须绝对一致;多表关联查询 时,保证被关联的字段需要有索引。 即使双表 join 也要注意表索引、SQL 性能。

通常在代码层先查询一张表锁定有效的数据,然后以关联字段作为条件查询关联表形成Map,在代码层进行数据拼装。

子查询过多问题

在 SQL 语句中使用子查询进行嵌套查询,先执行子查询,再执行外层查询,子查询的结果作为外层查询的比较条件。子查询灵活,在数据量少时可以使用,但执行效率不高。

优化:数据量大时,建议使用连接查询。

一般来讲,连接查询的效率更高,因为子查询会有创建和销毁临时表的过程,而连接查询不需要建立临时表。

IN中的值过多问题

如果查询SQL语句有 IN的条件且加了合适的索引,SQL 还比较慢,就需要注意是否 IN 元素过多。

如果 IN 中的值是外部传进来的,不涉及排序,分组,分页的,可以分批查询,或在代码层限制每次查询的最大个数。

或把 IN 转换为 UNION 查询,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SELECT * FROM actor WHERE actor_id IN(21,22,23,24,25,26,27,28);
# 转 UNION ALL 方式一:
(SELECT * FROM actor WHERE actor_id = 21) UNION ALL
(SELECT * FROM actor WHERE actor_id = 22) UNION ALL
(SELECT * FROM actor WHERE actor_id = 23);

# 转 UNION ALL 方式二:
SELECT *
FROM (
SELECT 1 cid UNION ALL
SELECT 2 UNION ALL
SELECT 3 UNION ALL
SELECT 4 UNION ALL
SELECT 5) AS tmp, table_ t
WHERE tmp.cid = t.id;

或把 IN 转换为 OR 查询(此方式和用 IN 没看出有多大性能差异),如下:

1
select * from table_name where id in(1,2,3) or id in(4,5,6);

或把 IN 值转为一个临时表,关联主表查询,如下:

1
SELECT t1.* FROM table_ t1, (SELECT id FROM table_ t WHERE t.id IN(1,2,3,4,5)) t2 WHERE t1.id = t2.id;

有一种场景是查出父类及下面的所有子类,有一种处理是在代码层递归查询出父 id 和所有子 id,再执 IN 查询,且带分页。子类没有限制可能很多(现实有碰到,例如医学知识库,IN 值有几千个)。

针对这种情况,可以增加一个字符串字段,用于子类拼接所有父类的 id,对这个字段建立前缀索引,在查询时直接利用该字段进行查询。如果是二级及以下子类查询,则反查出所有父类的 id,组装出当前类的所有父类id前缀,利用该字段查询。

例如,有个深层的子类,id 为 100,父子结构如下:

1
2
3
4
5
顶级---1
二级 |--6
三级 |--10
四级 |--30
五级 |--100

分类表和内容表建个字符串字段,称为 id索引字段,建个长度为20的前缀索引,在插入值时存放拼接所有父类及自己 id 值,示例字段值为 1:6:10:30:100,假如查询传入的 id = 30,先到分类表查出索引值为 1:6:10:30,拿这索引值到内容表通过前缀所引查出所有分类的内容。

数据量过大问题

如果是数量过大超出的最初始设计预期,单纯的代码或SQL优化一般是解决不了了。就需要考虑调整数据存储架构了,或者对MySQL 分表,或分库分表;或对数据进行拆分,例如增加 ES 存储;或替换使用大数据存储的数据库,例如 Hadoop,HBase 等。

这种涉及到底层数据存储架构调整的,需要经过严谨的调研,方案设计,方案评审,性能评估,开发、测试,联调。同时需要设计严密的数据迁移方案、回滚方案、降级措施,故障处理预案。

以上除了团队内部工作,还可能涉及到跨部门,业务逻辑和接口调用及出入参可能都发生变化。

业务逻辑复杂

循环调用

存在循环调用,每次循环逻辑一致,没有前后依赖。可以使用并发编程,或多线程处理。

  • 使用 Stream.parallelStream() 并发处理。
  • 使用线程池进行多线程处理,Future<?> future = ExecutorService.submit();

顺序调用

不是循环调用,而是一次次的顺序调用,但调用之间没有结果上的依赖,也可以用多线程方式。

例如,在报表时,需要对原始数据做多个纬度的细统计,然后还要做总的统计,相互之间没有代码层的结果依赖(依赖存到数据库的结果数据),大致逻辑如下。

1
2
3
4
A a = doA();
B b = doB();

C c = doC(a,b);

可以用 CompletableFuture 解决。

1
2
3
4
5
CompletableFuture<A> futureA = CompletableFuture.supplyAsync(() -> doA());
CompletableFuture<B> futureB = CompletableFuture.supplyAsync(() -> doB());
CompletableFuture.allOf(futureA,futureB) // 等a b 两个任务都执行完成

C c = doC(futureA.join(), futureB.join());

这样 A 和 B 两个逻辑可以并行执行,最大执行时间取决于最慢的那一个。

线程池设计不合理

如果使用了线程池来并行处理任务,但实际执行效率仍不够理想,就需要重新评估线程池设计的合理性。

在复杂业务中使用线程池,要注意线程池的隔离。

线程池的几个核心参数:核心线程数,最大线程数,空闲时间,等待队列,线程工厂,拒绝策略

线程池创建时,如果没有预热处理,则池中线程数为 0 ,当任务提交到线程池,则开始创建核心线程。

当核心线程被占满了,还有新的任务到达,则任务进入等待队列。当等待队列也被占满,则开始创建非核心线程运行。如果达到最大线程数,还有新任务到达,则开始执行线程池的拒绝策略。

线程池设计不合理因素:

  • 核心线程数设置过小,没有达到并行的效果。
  • 线程池公用,未做隔离,别的业务的任务占用了核心线程池,且执行时间较长;导致本业务直接进入等待队列。
  • 线程池公共,未做隔离,大量任务同一个线程池处理占满线程池,大量任务在队列中等待。

优化思路:一、调整线程池参数;二、按业务拆分线程池,使线程池隔离。

锁设计不合理

主要有两种:锁类型使用不当 或 锁粒度过粗

  • 锁类型使用不当

    例如,读不改写共享变量,是可以共享的;读写不能同时进行。可以加读写锁时,而加成了互斥锁,那么在高频读场景时,效率会大大降低。

  • 锁粒度过粗

    锁包裹的范围过大,下个任务进来等待锁时间过长,无法提前做准备工作。

    需要尽可能缩小锁的粒度范围,通常控制在是修改共享变量的代码块范围。

Full GC或资源不够

Full GC:代码存在内存泄露导致内存占用过高,CPU 和 内存使用率超高且不降低。根据监控数据和日志进行具体分析。

资源不够:带宽不够,内存不够,CPU不够,磁盘 I/O 响应慢。花钱提高配置,其它无解。

适时引入缓存

一些高频访问的数据可以引入缓存来提高 I/O 处理速度,进而提高响应速度。可根据性能瓶颈和实际的业务需要评估选择合适的缓存。

缓存架构设计总体可把缓存分三层五级:

应用层缓存

主要指客户端应用层的缓存。通常包括浏览器缓存,APP 缓存。通常缓存图片,CSS,JS等静态资源文件。

代理层缓存

企业级网络架构通常会有个 代理层,可在代理层增加缓存处理,例如 CDN 服务,Nginx 缓存。

  • CDN 缓存:负载均衡,内容存储,就近分发。需购买 CDN 服务
  • Nginx 缓存:负载均衡,静态资源缓存,数据压缩传输

服务端缓存

Java 应用通常包括:Web 容器缓存,应用本地缓存,分布式缓存。

  • Web 容器缓存:Tomcat 等 Servlet 容器的缓存,例如 Session ,Cookie,JSP 页面缓存。

  • 应用内缓存:又称本地缓存,直接在应用内存取数据,没有磁盘和网络I/O。

    需保持分布式环境下应用之间缓存一致性,应用内缓存与分布式缓存一致性,复杂度较高。

    实现方案:简单的 Map,Guava Cache,Ehcache等。

  • 分布式缓存:常指可独立部署,数据存储在内存中的缓存中间件,主要利用内存的高速 I/O 特性。

    实现方案:Redis,MemCached,Tair

同步改异步

异步回调处理

业务复杂、逻辑处理冗长、依赖外部系统响应的业务,可提供异步回调处理。

在接口层校验和存储成功后,快速返回成功 或 失败,业务状态是:处理中。

处理结束后更新业务状态,并回调给业务方,或把处理结果发布到消息广播队列,多业务方监听处理业务。

通常也需要提供业务状态查询接口,以供业务方主动查询业务。

典型的应用场景:支付系统的退款,先是下退款单完成后直接返回,最终的退款是否成功是异步回调通知。还有如,批量任务调度系统等。

补充:支付系统的支付逻辑,通常是先下支付单,再拉起支付进行支付,这是两个操作步骤,且是异步,不同的调用方,天然就需要异步回调来通知支付结果。

异步落库处理

如果耗时瓶颈在数据库操作,可以考虑将数据暂存到文件、MQ、缓存,再异步落库。

相关参考

  1. MySQL 分页查询的优化技巧
  2. MySql中当in或or参数过多时导致索引失效
  3. MySQL中eq_range_index_dive_limit参数学习
作者

光星

发布于

2022-05-14

更新于

2023-03-06

许可协议

评论