今年前半年一直在做系统性能优化,历经3个月,总结了一些慢接口性能优化的方案。比如,超时问题,因为接口耗时过长,超过nginx
配置的10
秒。最后接口从11.3s
降为170ms
。本文将跟小伙伴们分享总结的18种接口优化方案,总体如下。
1. 批量思想:批量操作数据库
优化前:
//for循环单笔入库
for(TransDetail detail:transDetailList){
insert(detail);
}
优化后:
batchInsert(transDetailList);
有些人可能觉得,循环单个操作跟批量操作都在一个事务里,而且线程池化技术,应该性能差不多,其实不然,每次操作都有io传输,db对sql的解析分析,以及db的多种log(binlog/redolog/undolog)等,打个比喻:
假如你需要搬一万块砖到楼顶,你有一个电梯,电梯一次可以放适量的砖(最多放
500
), 你可以选择一次运送一块砖,也可以一次运送500
,你觉得哪种方式更方便,时间消耗更少?
2. 异步
耗时操作,考虑用异步处理,这样可以降低接口耗时。
假设一个转账接口,匹配关联行号,是同步执行的,但是它的操作耗时有点长,优化前的流程:
为了降低接口耗时,更快返回,你可以把匹配关联行号移到异步处理,优化后:
-
至于异步的实现方式,你可以用消息队列实现,也可以用线程池。
-
注意:异步操作后,要注意可能产生的事务问题,同步在一个事务执行的逻辑,异步后,可能就在不同的事务了,一般的解决方案:重试,重试失败回滚
3. 缓存(空间换时间)
在适当的业务场景,恰当地使用缓存,是可以大大提高接口性能的。缓存其实就是一种空间换时间的思想,就是你把要查的数据,提前放好到缓存里面,需要时,直接查缓存,而避免去查数据库或者计算的过程。
这里的缓存包括:Redis
缓存,JVM
本地缓存,memcached
,或者Map
等等。我举个我工作中,一次使用缓存优化的设计吧,比较简单,但是思路很有借鉴的意义。
那是一次转账接口的优化,老代码,每次转账,都会根据客户账号,查询数据库,计算匹配联行号。
因为每次都查数据库,都计算匹配,比较耗时,所以使用缓存,优化后流程如下:
缓存淘汰算法
FIFO/LFU/LRU/过期时间/随机
-
FIFO:最先进入缓存的数据,在缓存空间不足时被清除,为了保证最新数据可用,保证实时性
-
LFU(Least Frequently Used):最近最不常用,基于访问次数,去除命中次数最少的元素,保证高频数据有效性
-
LRU(Least Recently Used):最近最少使用,基于访问时间,在被访问过的元素中去除最久未使用的元素,保证热点数据的有效性
4. 预取思想(缓存预热):提前初始化到缓存
预取思想很容易理解,就是提前把要计算查询的数据,初始化到缓存。如果你在未来某个时间需要用到某个经过复杂计算的数据,才实时去计算的话,可能耗时比较大。这时候,我们可以采取预处理,提前把将来可能需要的数据计算好,放到缓存中,等需要的时候,去缓存取就行。这将大幅度提高接口性能。
比如:电商在大促开始前,一般都要把大促商品提前预热到缓存,以应对大促开始的瞬时流量冲击。
我记得以前在第一个公司做视频直播的时候,看到我们的直播列表就是用到这种优化方案。就是启动个任务,提前把直播用户、积分等相关信息,初始化到缓存。
5. 池化思想:预分配与循环使用
池化思想是一种通过预分配、复用资源来减少系统开销、提升性能的设计模式,广泛应用于高并发、高频资源申请的场景(如线程、数据库连接、网络连接、内存分配等)。以下是池化技术的核心原理、最佳实践及代码示例:
场景 |
无池化 |
有池化 |
---|---|---|
资源创建/销毁 |
频繁申请、释放资源(如TCP握手、线程创建) |
资源预分配,重复使用,减少系统调用开销 |
内存碎片 |
频繁分配小对象导致内存碎片 |
批量分配、复用对象,内存利用率高 |
响应延迟 |
资源初始化耗时(如数据库连接) |
直接获取已初始化的资源,延迟低 |
系统稳定性 |
资源耗尽导致OOM或拒绝服务 |
通过池容量限制保护系统,避免过载 |
6. 事件回调思想:拒绝阻塞等待。
如果你调用一个系统B
的接口,但是它处理业务逻辑,耗时需要10s
甚至更多。然后你是一直阻塞等待,直到系统B的下游接口返回,再继续你的下一步操作吗?这样显然不合理。
我们参考IO多路复用模型。即我们不用阻塞等待系统B
的接口,而是先去做别的操作。等系统B
的接口处理完,通过事件回调通知,我们接口收到通知再进行对应的业务操作即可。
现在分布式事务一般的解决方案,就是回调思想。
比如上架新的商品,一般都是商品保存成功,商品的状态为待上架,异步调用库存初始化,成功后,库存回调商品,把状态改为已上架,回调可以是mq也可以是接口
7. 远程调用由串行改为并行
假设我们设计一个接口,入参为用户id,它需要查用户信息、需要查banner信息、需要查弹窗信息等等。如果是串行查,比如查用户信息200ms
,查banner信息100ms
、查弹窗信息50ms
,那一共就耗时350ms
了,如果还查其他信息,那耗时就更大了。
这些查询没有前后依赖关系,所以如果改为并行调用,即查用户信息、查banner信息、查弹窗信息,可以同时并行发起。
最后接口耗时将大大降低。有些小伙伴说,不知道如何使用并行优化接口?
8. 锁粒度避免过粗
在高并发场景,为了防止超卖等情况,我们经常需要加锁来保护共享资源。但是,如果加锁的粒度过粗,是很影响接口性能的。
什么是加锁粒度呢?
其实就是就是你要锁住的范围是多大。比如你在家上卫生间,你只要锁住卫生间就可以了吧,不需要将整个家都锁起来不让家人进门吧,卫生间就是你的加锁粒度。
不管你是synchronized
加锁还是redis
分布式锁,只需要在共享临界资源加锁即可,不涉及共享资源的,就不必要加锁。这就好像你上卫生间,不用把整个家都锁住,锁住卫生间门就可以了。
比如,在业务代码中,有一个ArrayList
因为涉及到多线程操作,所以需要加锁操作,假设刚好又有一段比较耗时的操作,不涉及线程安全问题。反例加锁,就是一锅端,全锁住,如下:
//不涉及共享资源的慢方法
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();
}
最佳实践总结
-
最小化锁作用域:仅保护共享资源,无关操作移至锁外。
-
选择专用锁对象:避免使用
this
或公共对象,增强可控性。 -
优先使用并发工具类:如
ConcurrentHashMap
、CopyOnWriteArrayList
等减少手动同步。 -
明确资源所有权:通过命名和封装确保共享资源访问路径可控。
9. 切换存储方式:文件中转暂存数据
我的一个真实的优化案例:
之前开发了一个转账接口。如果是并发开启,10个并发度,每个批次
1000
笔转账明细数据,数据库插入会特别耗时,大概6秒左右;这个跟我们公司的数据库同步机制有关,并发情况下,因为优先保证同步,所以并行的插入变成串行啦,就很耗时。
优化前,1000
笔明细转账数据,先落地DB
数据库,返回处理中给用户,再异步转账。如图:
高并发情况,这1000
笔明细入库,耗时都比较大。所以转换了一下思路,把批量的明细转账记录保存的文件服务器,然后记录一笔转账总记录到数据库即可。接着异步再把明细下载下来,进行转账和明细入库。最后优化后,性能提升了十几倍。
10. 索引
提到接口优化,很多小伙伴都会想到添加索引。没错,添加索引是成本最小的优化,而且一般优化效果都很不错。
索引优化这块的话,一般从这几个维度去思考:
-
表是否添加索引?
-
索引是否生效?
-
索引是否合理?
10.1 未加索引
我们开发的时候,容易忘记给SQL添加索引。查看 explain
执行计划。
explain select*from user_info where userId like '%123';
你也可以通过命令show create table
,整张表的索引情况。
show create table user_info;
一般就是:SQL
的where
条件的字段,或者是order by 、group by
后面的字段需需要添加索引。
10.2 索引不生效
索引失效的常见原因:
10.3 索引设计不合理
我们的索引不是越多越好,需要合理设计。比如:
-
索引一般不能超过
5
个 -
删除冗余和重复索引。
-
索引不适合建在有大量重复数据的字段上、如性别字段
-
如果需要使用
force index
强制走某个索引,那就需要思考你的索引设计是否真的合理了
11. 优化SQL
处了索引优化,其实SQL还有很多其他有优化的空间。比如这些:
12.避免大事务问题
为了保证数据库数据的一致性,在涉及到多个数据库修改操作时,我们经常需要用到事务。而使用spring
声明式事务,又非常简单,只需要用一个注解就行@Transactional
,如下面的例子:
@Transactional
public int createUser(User user){
userDao.save(user);
passCertDao.updateFlag(user.getPassId());
return user.getUserId();
}
这块代码主要逻辑就是创建个用户,然后更新一个通行证pass
的标记。如果现在新增一个需求,创建完用户,调用远程接口发送一个email
消息通知,很多小伙伴会这么写:
@Transactional
public int createUser(User user){
userDao.save(user);
passCertDao.updateFlag(user.getPassId());
sendEmailRpc(user.getEmail());
return user.getUserId();
}
这样实现可能会有坑,事务中嵌套RPC
远程调用,即事务嵌套了一些非DB
操作。如果这些非DB
操作耗时比较大的话,可能会出现大事务问题。
所谓大事务问题就是,就是运行时间长的事务。由于事务一致不提交,就会导致数据库连接被占用,即并发场景下,数据库连接池被占满,影响到别的请求访问数据库,影响别的接口性能。
大事务引发的问题
-
系统资源过度占用
长事务或大事务会持续占用数据库连接、内存及CPU资源,导致其他操作因资源竞争而延迟或阻塞,甚至引发系统整体性能下降或服务不可用26。 -
数据一致性与可靠性风险
-
锁竞争:大事务中长时间持有锁(如行锁、表锁),可能引发死锁或阻塞其他事务的正常执行,破坏事务隔离性26;
-
回滚成本高:若事务执行失败,涉及大量数据的回滚操作会消耗更多时间和资源,可能造成业务中断23。
-
-
网络与存储压力激增
-
大事务涉及海量数据传输(如批量更新或插入),导致网络带宽压力陡增,影响分布式系统的稳定性27;
-
高频写入操作可能触发存储系统的IO瓶颈,加剧磁盘负载并缩短硬件寿命36。
-
-
事务日志膨胀与恢复困难
-
大事务生成的日志量庞大(如MySQL的Binlog或Redo Log),可能导致日志文件快速膨胀,增加存储成本和管理复杂度36;
-
数据库崩溃后,大事务的恢复过程耗时较长,影响系统可用性26。
-
-
业务逻辑与扩展性限制
-
大事务通常耦合复杂业务逻辑,难以拆分和维护,增加代码腐化风险23;
-
在分布式系统中,大事务需依赖强一致性协议(如两阶段提交),牺牲系统扩展性并引入协调节点单点故障风险67。
-
我们可以通过这些方案来规避大事务:
-
RPC远程调用不要放到事务里面
-
一些查询相关的操作,尽量放到事务之外
-
事务中避免处理太多数据
13. 深分页问题
在以前公司分析过几个接口耗时长的问题,最终结论都是因为深分页问题。
深分页问题,为什么会慢?我们看下这个SQL
select id,name,balance from account where create_time>'2020-09-19' limit 100000,10;
limit 100000,10
意味着会扫描100010
行,丢弃掉前100000
行,最后返回10
行。即使create_time
,也会回表很多次。
我们可以通过标签记录法和延迟关联法来优化深分页问题。
13.1 延迟关联法
延迟关联法,就是把条件转移到主键索引树,然后减少回表。优化后的SQL如下:
select acct1.id,acct1.name,acct1.balance FROM account acct1 INNER JOIN (SELECT a.id FROM account a WHERE a.create_time >'2020-09-19' limit 100000,10)AS acct2 on acct1.id= acct2.id;
优化思路就是,先通过idx_create_time
二级索引树查询到满足条件的主键ID,再与原表通过主键ID内连接,这样后面直接走了主键索引了,同时也减少了回表。
13.2 游标法
就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。就好像看书一样,上次看到哪里了,你就折叠一下或者夹个书签,下次来看的时候,直接就翻到啦。
假设上一次记录到100000
,则SQL可以修改为:
select id,name,balance FROM account where id > 100000 limit 10;
这样的话,后面无论翻多少页,性能都会不错的,因为命中了id
主键索引。但是这种方式有局限性:需要一种类似连续自增的字段。
14. 优化程序结构
优化程序逻辑、程序代码,是可以节省耗时的。比如,你的程序创建多不必要的对象、或者程序逻辑混乱,多次重复查数据库、又或者你的实现逻辑算法不是最高效的等等。
我举个简单的例子:复杂的逻辑条件,有时候调整一下顺序,就能让你的程序更加高效。
假设业务需求是这样:如果用户是会员,第一次登录时,需要发一条感谢短信。如果没有经过思考,代码直接这样写了
if(isUserVip && isFirstLogin){
sendSmsMsg();
}
假设有5
个请求过来,isUserVip
判断通过的有3
个请求,isFirstLogin
通过的只有1
个请求。那么以上代码,isUserVip
执行的次数为5
次,isFirstLogin
执行的次数也是3
次,
如果调整一下isUserVip
和isFirstLogin
的顺序:
if(isFirstLogin && isUserVip ){
sendMsg();
}
isFirstLogin
执行的次数是5
次,isUserVip
执行的次数是1
次:
酱紫程序是不是变得更高效了呢?
15. 压缩传输内容
打个比喻,一匹千里马,它驮着100斤的货跑得快,还是驮着10斤的货物跑得快呢?
压缩传输内容,传输报文变得更小,因此传输会更快啦。10M
带宽,传输10k
的报文,一般比传输1M
的会快呀。
再举个视频网站的例子:
如果不对视频做任何压缩编码,因为带宽又是有限的。巨大的数据量在网络传输的耗时会比编码压缩后,慢好多倍。
16. 海量数据处理,考虑NoSQL
16.1、问题核心分析
在海量数据场景下,关系型数据库(如MySQL)处理深分页和模糊搜索时存在明显瓶颈:
-
深分页性能问题:
LIMIT offset, size
方式会导致大量无效数据扫描(如LIMIT 100000,30
需先扫描100030行再丢弃前10万条),造成资源浪费和响应延迟68; -
模糊搜索效率低:关系型数据库的B+树索引对
LIKE '%keyword%'
类模糊查询支持不足,易引发全表扫描23; -
统计聚合压力大:复杂统计操作(如COUNT、SUM)在单表亿级数据量时性能急剧下降,分库分表后跨节点聚合复杂度高26。
16.2、NoSQL方案优势对比
特性 |
关系型数据库 |
NoSQL数据库 |
---|---|---|
扩展性 |
依赖分库分表,运维复杂 |
原生分布式架构,线性扩展更简单25 |
数据模型 |
固定表结构,修改成本高 |
支持动态Schema(如JSON文档、列存储)45 |
查询效率 |
深分页、模糊搜索性能差 |
倒排索引(Elasticsearch)、列式存储(HBase)优化特定场景28 |
适用场景 |
ACID事务、强一致性 |
高吞吐、低延迟、灵活查询需求45 |
典型选型建议:
-
Elasticsearch
-
优势:倒排索引支持全文检索和模糊匹配,聚合分析性能优异,适合日志分析、商品搜索等场景28;
-
案例:将MySQL数据同步到Elasticsearch后,模糊查询耗时从秒级降至毫秒级28。
-
-
HBase
-
优势:基于LSM树的列式存储,适合实时读写和海量存储(如用户行为记录)45;
-
案例:单表千亿级数据场景下,HBase的随机读写性能远超传统分库分表方案5。
-
16.3、NoSQL与分库分表的互补性
-
分库分表局限性
-
需处理跨节点JOIN、分布式事务等问题,开发复杂度高26;
-
无法解决深分页、模糊搜索等原生性能缺陷68。
-
-
混合架构实践
-
核心事务数据:MySQL分库分表保障ACID特性;
-
查询与分析:通过CDC工具(如Canal)将数据同步到Elasticsearch/HBase,实现读写分离28。
-
16.4、实施注意事项
-
数据同步延迟:需监控并优化同步链路(如采用Logstash或Flink实时同步)2;
-
索引设计优化:Elasticsearch需合理设置分片、副本和分词器,避免写入性能瓶颈8;
-
资源成本平衡:NoSQL集群资源消耗较高,需根据业务优先级选择存储介质(如冷热数据分离)5。
16.5、总结
在海量数据场景下,NoSQL并非完全替代关系型数据库,而是通过互补架构解决特定性能瓶颈。对于深分页、模糊搜索等需求,Elasticsearch等NoSQL数据库通过倒排索引和分布式计算显著提升查询效率28,而分库分表更适用于保障核心事务的ACID特性6。实际选型需综合查询模式、数据规模和运维成本进行权衡45。
17. 线程池设计要合理
一、核心参数配置
-
核心线程数(Core Pool Size)
-
CPU密集型任务(如加密/压缩):设为CPU核心数或+1,避免过多线程导致上下文切换损耗38
-
IO密集型任务(如数据库操作/网络请求):按公式
线程数 = CPU核心数 * (1 + 平均等待时间/平均CPU时间)
计算,通常设为2*CPU核心数以上36 -
混合型任务:优先拆分任务类型,若无法拆分需根据耗时比例动态调整35
-
-
最大线程数(Maximum Pool Size)
-
一般设置为核心线程数的2-3倍,防止突发流量导致任务堆积56
-
需结合队列容量和系统资源上限,避免内存溢出5
-
-
队列容量(Queue Capacity)
-
短任务/高吞吐场景:使用有界队列(如
ArrayBlockingQueue
),避免无界队列导致内存溢出45 -
长任务/低延迟场景:使用无界队列(如
LinkedBlockingQueue
),但需监控队列增长45
-
二、拒绝策略选择
策略类型 |
适用场景 |
---|---|
AbortPolicy |
需要快速失败且允许任务丢弃的场景 |
CallerRunsPolicy |
需要降低任务提交速度的流量控制场景 |
DiscardOldestPolicy |
优先处理新任务的实时系统 |
自定义策略 |
需要记录日志或异步重试的特殊业务需求 |
三、高级优化策略
-
线程工厂定制
-
使用命名线程(如
ThreadFactoryBuilder
),便于日志排查和监控1
-
-
动态参数调整
-
通过
setCorePoolSize()
和setMaximumPoolSize()
实现运行时扩容/缩容25
-
-
线程存活时间(Keep-Alive)
-
短任务设为30-60秒,长任务适当延长,平衡资源复用与内存占用5
-
四、创建规范与避坑指南
-
禁止使用Executors快捷方法
-
直接通过
ThreadPoolExecutor
构造函数创建,规避无界队列风险17
-
-
混合型任务隔离
-
为不同任务类型(如CPU/IO密集型)创建独立线程池,避免相互干扰36
-
-
监控指标
-
关键指标:活跃线程数、队列堆积量、任务拒绝率、线程执行耗时5
-
18.机器问题 (fullGC、线程打满、太多IO资源没关闭等等)。
有时候,我们的接口慢,就是机器处理问题。主要有fullGC
、线程打满、太多IO资源没关闭等等。
-
之前排查过一个
fullGC
问题:运营小姐姐导出60多万
的excel
的时候,说卡死了,接着我们就收到监控告警。后面排查得出,我们老代码是Apache POI
生成的excel
,导出excel
数据量很大时,当时JVM内存吃紧会直接Full GC
了。 -
如果线程打满了,也会导致接口都在等待了。所以。如果是高并发场景,我们需要接入限流,把多余的请求拒绝掉。
-
如果IO资源没关闭,也会导致耗时增加。这个大家可以看下,平时你的电脑一直打开很多很多文件,是不是会觉得很卡。