接口性能优化思路总结
对后端开发来说,接口性能优化多少都是会碰到的事,没主动找上你也要思考整理优化的思路和可能的方案。
做技术的,一定要梳理思路,勤于总结,才是快速提升之道。
接口性能监控
接口性能监控,使用 AOP,拦截器,过滤器 都可实现,AOP 还可以对不同层的接口进行监控。
异步处理监控数据,通常会统计接口响应时间,接口成功次数,失败次数,计算出接口的稳定性。
数据库层可以开启慢查询日志,以备需要时可快速定位SQL执行慢的问题。
接口响应时间通常应控制在 500ms 以内,超过就需要考虑优化了。
分布式微服务架构,可集成日志链路跟踪组件,例如,Zipin,SkyWalking
性能慢原因汇总
接口性能慢原因主要有以下几种。
数据库慢查询
- 未加索引
- 索引失效
- 深度分页问题
- 联表过多问题
- 子查询过多问题
- IN中的值过多问题
- 数据量过大问题
业务逻辑复杂
- 存在循环调用
- 业务逻辑冗长
线程池设计不合理
- 线程池参数设置不合理
- 线程池未做隔离,任务过多导致堆积。
锁设计不合理
- 锁类型使用不合理
- 锁粒度较粗
服务器资源问题
- 内存泄露,Full GC 导致内存和CPU打满
- 带宽,CPU,内存等资源不满足业务处理
性能优化方案思路
慢查询优化
基于MySQL,开启数据库慢查询日志,找到慢查询SQL。
养成良好习惯,不要用 SELECT *
的查询,只查询需要用到的字段,要用到的字段尽可能为索引字段。
未加索引
有的开发在库表和编码设计阶段,没有及时考虑使用索引,在发布生产时就极容易忽略或忘掉需要补索引这回事。在系统运行初期数据量小时问题没有表现出来,当运行一段时间后产生大数量时问题就暴露出来了,这本质是人的问题。
所以在库表及编码阶段,一个对自己有要求的开发者,理应及时考虑SQL的索引使用。
1 | # 查看表信息,包含了索引信息 |
注意:如果是在生产环境大数据量情况下增加或更新索引,可能会引起锁表导致系统中断,一定要在低峰期执行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.* FROM 表 1 a, (select id from 表 1 where 条件 LIMIT 100000,20 ) b where a.id=b.id |
或计算出要查询的起始位置和结束位置,直接范围查询。如下:
1 | SELECT film_id, description FROM sakila.film WHERE film_id BETWEEN 50 AND 54 ORDER BY film_id; |
多表关联问题
多表联查的根本问题还是索引使用不当或索引不满足,产生巨量数据的迪卡尔积,严重影响性能。
JOIN 连接是在内存处理的,当数据量较少或 join_buffer 设置较大的,速度也不会很慢。当 join 的数量很大且缓存不够时,会在磁盘上创建临时表进行关联匹配,效率就极低了。
优化:遵循小表驱动大表原因。超过三个表禁止 join
。需要 join 的字段,数据类型必须绝对一致;多表关联查询 时,保证被关联的字段需要有索引。 即使双表 join 也要注意表索引、SQL 性能。
通常在代码层先查询一张表锁定有效的数据,然后以关联字段作为条件查询关联表形成Map,在代码层进行数据拼装。
子查询过多问题
在 SQL 语句中使用子查询进行嵌套查询,先执行子查询,再执行外层查询,子查询的结果作为外层查询的比较条件。子查询灵活,在数据量少时可以使用,但执行效率不高。
优化:数据量大时,建议使用连接查询。
一般来讲,连接查询的效率更高,因为子查询会有创建和销毁临时表的过程,而连接查询不需要建立临时表。
IN中的值过多问题
如果查询SQL语句有 IN
的条件且加了合适的索引,SQL 还比较慢,就需要注意是否 IN 元素过多。
如果 IN 中的值是外部传进来的,不涉及排序,分组,分页的,可以分批查询,或在代码层限制每次查询的最大个数。
或把 IN 转换为 UNION 查询,如下:
1 | SELECT * FROM actor WHERE actor_id IN(21,22,23,24,25,26,27,28); |
或把 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 | 顶级---1 |
分类表和内容表建个字符串字段,称为 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 | A a = doA(); |
可以用 CompletableFuture 解决。
1 | CompletableFuture<A> futureA = CompletableFuture.supplyAsync(() -> doA()); |
这样 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、缓存,再异步落库。