当前位置:首页 > 通信资讯 > 正文

如何优化接口性能(接口性能优化方法)

如何优化接口性能(接口性能优化方法)

前言

接口性能优化对于从事后端开发的同学来说,肯定再熟悉不过了,因为它是一个跟开发语言无关的公共问题。

该问题说简单也简单,说复杂也复杂。

  • 有时候,只需加个索引就能解决问题。
  • 有时候,需要做代码重构。
  • 有时候,需要增加缓存。
  • 有时候,需要引入一些中间件,比如mq。
  • 有时候,需要需要分库分表。
  • 有时候,需要拆分服务。
  • 等等。。。

导致接口性能问题的原因千奇百怪,不同的项目不同的接口,原因可能也不一样。

本文我总结了一些行之有效的,优化接口性能的办法,给有需要的朋友一个参考。

1.索引

接口性能优化大家第一个想到的可能是:优化索引。

没错,优化索引的成本是最小的。

你通过查看线上日志或者监控报告,查到某个接口用到的某条sql语句耗时比较长。

这时你可能会有下面这些疑问:

该sql语句加索引了没?

加的索引生效了没?

mysql选错索引了没?

1.1 没加索引

sql语句中where条件的关键字段,或者order by后面的排序字段,忘了加索引,这个问题在项目中很常见。

项目刚开始的时候,由于表中的数据量小,加不加索引sql查询性能差别不大。

后来,随着业务的发展,表中数据量越来越多,就不得不加索引了。

可以通过命令:

  1. show index from `order`;

能单独查看某张表的索引情况。

也可以通过命令:

  1. show create table `order`;

查看整张表的建表语句,里面同样会显示索引情况。

通过ALTER TABLE命令可以添加索引:

  1. ALTER TABLE `order` ADD INDEX idx_name (name);

也可以通过CREATE INDEX命令添加索引:

  1. CREATE INDEX idx_name ON `order` (name);

不过这里有一个需要注意的地方是:想通过命令修改索引,是不行的。

目前在mysql中如果想要修改索引,只能先删除索引,再重新添加新的。

删除索引可以用DROP INDEX命令:

  1. ALTER TABLE `order` DROP INDEX idx_name;

用DROP INDEX命令也行:

  1. DROP INDEX idx_name ON `order`;

1.2 索引没生效

通过上面的命令我们已经能够确认索引是有的,但它生效了没?此时你内心或许会冒出这样一个疑问。

那么,如何查看索引有没有生效呢?

答:可以使用explain命令,查看mysql的执行计划,它会显示索引的使用情况。

例如:

  1. explain select * from `order` where code='002';

结果:

如何优化接口性能(接口性能优化方法)

通过这几列可以判断索引使用情况,执行计划包含列的含义如下图所示:

如何优化接口性能(接口性能优化方法)

如果你想进一步了解explain的详细用法,可以看看我的另一篇文章《explain | 索引优化的这把绝世好剑,你真的会用吗?》

说实话,sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效了。

下面说说索引失效的常见原因:

如何优化接口性能(接口性能优化方法)

如果不是上面的这些原因,则需要再进一步排查一下其他原因。

1.3 选错索引

此外,你有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b?

没错,有时候mysql会选错索引。

必要时可以使用force index来强制查询sql走某个索引。

至于为什么mysql会选错索引,后面有专门的文章介绍的,这里先留点悬念。

2. sql优化

如果优化了索引之后,也没啥效果。

接下来试着优化一下sql语句,因为它的改造成本相对于java代码来说也要小得多。

下面给大家列举了sql优化的15个小技巧:

如何优化接口性能(接口性能优化方法)

由于这些技巧在我之前的文章中已经详细介绍过了,在这里我就不深入了。

更详细的内容,可以看我的另一篇文章《聊聊sql优化的15个小技巧》,相信看完你会有很多收获。

3. 远程调用

很多时候,我们需要在某个接口中,调用其他服务的接口。

比如有这样的业务场景:

在用户信息查询接口中需要返回:用户名称、性别、等级、头像、积分、成长值等信息。

而用户名称、性别、等级、头像在用户服务中,积分在积分服务中,成长值在成长值服务中。为了汇总这些数据统一返回,需要另外提供一个对外接口服务。

于是,用户信息查询接口需要调用用户查询接口、积分查询接口 和 成长值查询接口,然后汇总数据统一返回。

调用过程如下图所示:

如何优化接口性能(接口性能优化方法)

调用远程接口总耗时 530ms = 200ms + 150ms + 180ms

显然这种串行调用远程接口性能是非常不好的,调用远程接口总的耗时为所有的远程接口耗时之和。

那么如何优化远程接口性能呢?

3.1 并行调用

上面说到,既然串行调用多个远程接口性能很差,为什么不改成并行呢?

如下图所示:

如何优化接口性能(接口性能优化方法)

调用远程接口总耗时 200ms = 200ms(即耗时最长的那次远程接口调用)

在java8之前可以通过实现Callable接口,获取线程返回结果。

java8以后通过CompleteFuture类实现该功能。我们这里以CompleteFuture为例:

  1. public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
  2. final UserInfo userInfo = new UserInfo();
  3. CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
  4. getRemoteUserAndFill(id, userInfo);
  5. return Boolean.TRUE;
  6. }, executor);
  7. CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
  8. getRemoteBonusAndFill(id, userInfo);
  9. return Boolean.TRUE;
  10. }, executor);
  11. CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
  12. getRemoteGrowthAndFill(id, userInfo);
  13. return Boolean.TRUE;
  14. }, executor);
  15. CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
  16. userFuture.get();
  17. bonusFuture.get();
  18. growthFuture.get();
  19. return userInfo;
  20. }

温馨提醒一下,这两种方式别忘了使用线程池。示例中我用到了executor,表示自定义的线程池,为了防止高并发场景下,出现线程过多的问题。

3.2 数据异构

上面说到的用户信息查询接口需要调用用户查询接口、积分查询接口 和 成长值查询接口,然后汇总数据统一返回。

那么,我们能不能把数据冗余一下,把用户信息、积分和成长值的数据统一存储到一个地方,比如:redis,存的数据结构就是用户信息查询接口所需要的内容。然后通过用户id,直接从redis中查询数据出来,不就OK了?

如果在高并发的场景下,为了提升接口性能,远程接口调用大概率会被去掉,而改成保存冗余数据的数据异构方案。

如何优化接口性能(接口性能优化方法)

但需要注意的是,如果使用了数据异构方案,就可能会出现数据一致性问题。

用户信息、积分和成长值有更新的话,大部分情况下,会先更新到数据库,然后同步到redis。但这种跨库的操作,可能会导致两边数据不一致的情况产生。

4. 重复调用

重复调用在我们的日常工作代码中可以说随处可见,但如果没有控制好,会非常影响接口的性能。

不信,我们一起看看。

4.1 循环查数据库

有时候,我们需要从指定的用户集合中,查询出有哪些是在数据库中已经存在的。

实现代码可以这样写:

  1. public List<User> queryUser(List<User> searchList) {
  2. if (CollectionUtils.isEmpty(searchList)) {
  3. return Collections.emptyList();
  4. }
  5. List<User> result = Lists.newArrayList();
  6. searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
  7. return result;
  8. }

这里如果有50个用户,则需要循环50次,去查询数据库。我们都知道,每查询一次数据库,就是一次远程调用。

如果查询50次数据库,就有50次远程调用,这是非常耗时的操作。

那么,我们如何优化呢?

具体代码如下:

  1. public List<User> queryUser(List<User> searchList) {
  2. if (CollectionUtils.isEmpty(searchList)) {
  3. return Collections.emptyList();
  4. }
  5. List ids = searchList.stream().map(User::getId).collect(Collectors.toList());
  6. return userMapper.getUserByIds(ids);
  7. }

提供一个根据用户id集合批量查询用户的接口,只远程调用一次,就能查询出所有的数据。

这里有个需要注意的地方是:id集合的大小要做限制,最好一次不要请求太多的数据。要根据实际情况而定,建议控制每次请求的记录条数在500以内。

4.2 死循环

有些小伙伴看到这个标题,可能会感到有点意外,死循环也算?

代码中不是应该避免死循环吗?为啥还是会产生死循环?

有时候死循环是我们自己写的,例如下面这段代码:

  1. while(true) {
  2. if(condition) {
  3. break;
  4. }
  5. System.out.println("do samething");
  6. }

这里使用了while(true)的循环调用,这种写法在CAS自旋锁中使用比较多。

当满足condition等于true的时候,则自动退出该循环。

如果condition条件非常复杂,一旦出现判断不正确,或者少写了一些逻辑判断,就可能在某些场景下出现死循环的问题。

出现死循环,大概率是开发人员人为的bug导致的,不过这种情况很容易被测出来。

还有一种隐藏的比较深的死循环,是由于代码写的不太严谨导致的。如果用正常数据,可能测不出问题,但一旦出现异常数据,就会立即出现死循环。

4.3 无限递归

如果想要打印某个分类的所有父分类,可以用类似这样的递归方法实现:

  1. public void printCategory(Category category) {
  2. if(category == null
  3. || category.getParentId() == null) {
  4. return;
  5. }
  6. System.out.println("父分类名称:"+ category.getName());
  7. Category parent = categoryMapper.getCategoryById(category.getParentId());
  8. printCategory(parent);
  9. }

正常情况下,这段代码是没有问题的。

但如果某次有人误操作,把某个分类的parentId指向了它自己,这样就会出现无限递归的情况。导致接口一直不能返回数据,最终会发生堆栈溢出。

建议写递归方法时,设定一个递归的深度,比如:分类最大等级有4级,则深度可以设置为4。然后在递归方法中做判断,如果深度大于4时,则自动返回,这样就能避免无限循环的情况。

5. 异步处理

有时候,我们接口性能优化,需要重新梳理一下业务逻辑,看看是否有设计上不太合理的地方。

比如有个用户请求接口中,需要做业务操作,发站内通知,和记录操作日志。为了实现起来比较方便,通常我们会将这些逻辑放在接口中同步执行,势必会对接口性能造成一定的影响。

接口内部流程图如下:

如何优化接口性能(接口性能优化方法)

这个接口表面上看起来没有问题,但如果你仔细梳理一下业务逻辑,会发现只有业务操作才是核心逻辑,其他的功能都是非核心逻辑。

在这里有个原则就是:核心逻辑可以同步执行,同步写库。非核心逻辑,可以异步执行,异步写库。

上面这个例子中,发站内通知和用户操作日志功能,对实时性要求不高,即使晚点写库,用户无非是晚点收到站内通知,或者运营晚点看到用户操作日志,对业务影响不大,所以完全可以异步处理。

通常异步主要有两种:多线程 和 mq。

5.1 线程池

使用线程池改造之后,接口逻辑如下:

如何优化接口性能(接口性能优化方法)

发站内通知和用户操作日志功能,被提交到了两个单独的线程池中。

这样接口中重点关注的是业务操作,把其他的逻辑交给线程异步执行,这样改造之后,让接口性能瞬间提升了。

但使用线程池有个小问题就是:如果服务器重启了,或者是需要被执行的功能出现异常了,无法重试,会丢数据。

那么这个问题该怎么办呢?

5.2 mq

使用mq改造之后,接口逻辑如下:

如何优化接口性能(接口性能优化方法)

对于发站内通知和用户操作日志功能,在接口中并没真正实现,它只发送了mq消息到mq服务器。然后由mq消费者消费消息时,才真正的执行这两个功能。

这样改造之后,接口性能同样提升了,因为发送mq消息速度是很快的,我们只需关注业务操作的代码即可。

6. 避免大事务

很多小伙伴在使用spring框架开发项目时,为了方便,喜欢使用@Transactional注解提供事务功能。

没错,使用@Transactional注解这种声明式事务的方式提供事务功能,确实能少写很多代码,提升开发效率。

但也容易造成大事务,引发其他的问题。

下面用一张图看看大事务引发的问题。

如何优化接口性能(接口性能优化方法)

从图中能够看出,大事务问题可能会造成接口超时,对接口的性能有直接的影响。

我们该如何优化大事务呢?

  1. 少用@Transactional注解
  2. 将查询(select)方法放到事务外
  3. 事务中避免远程调用
  4. 事务中避免一次性处理太多数据
  5. 有些功能可以非事务执行
  6. 有些功能可以异步处理

关于大事务问题我的另一篇文章《让人头痛的大事务问题到底要如何解决?》,它里面做了非常详细的介绍,如果大家感兴趣可以看看。

7. 锁粒度

在某些业务场景中,为了防止多个线程并发修改某个共享数据,造成数据异常。

为了解决并发场景下,多个线程同时修改数据,造成数据不一致的情况。通常情况下,我们会:加锁。

但如果锁加得不好,导致锁的粒度太粗,也会非常影响接口性能。

7.1 synchronized

在java中提供了synchronized关键字给我们的代码加锁。

通常有两种写法:在方法上加锁 和 在代码块上加锁。

先看看如何在方法上加锁:

  1. public synchronized doSave(String fileUrl) {
  2. mkdir();
  3. uploadFile(fileUrl);
  4. sendMessage(fileUrl);
  5. }

这里加锁的目的是为了防止并发的情况下,创建了相同的目录,第二次会创建失败,影响业务功能。

但这种直接在方法上加锁,锁的粒度有点粗。因为doSave方法中的上传文件和发消息方法,是不需要加锁的。只有创建目录方法,才需要加锁。

我们都知道文件上传操作是非常耗时的,如果将整个方法加锁,那么需要等到整个方法执行完之后才能释放锁。显然,这会导致该方法的性能很差,变得得不偿失。

这时,我们可以改成在代码块上加锁了,具体代码如下:

  1. public void doSave(String path,String fileUrl) {
  2. synchronized(this) {
  3. if(!exists(path)) {
  4. mkdir(path);
  5. }
  6. }
  7. uploadFile(fileUrl);
  8. sendMessage(fileUrl);
  9. }

这样改造之后,锁的粒度一下子变小了,只有并发创建目录功能才加了锁。而创建目录是一个非常快的操作,即使加锁对接口的性能影响也不大。

最重要的是,其他的上传文件和发送消息功能,任然可以并发执行。

当然,这种做在单机版的服务中,是没有问题的。但现在部署的生产环境,为了保证服务的稳定性,一般情况下,同一个服务会被部署在多个节点中。如果哪天挂了一个节点,其他的节点服务任然可用。

多节点部署避免了因为某个节点挂了,导致服务不可用的情况。同时也能分摊整个系统的流量,避免系统压力过大。

同时它也带来了新的问题:synchronized只能保证一个节点加锁是有效的,但如果有多个节点如何加锁呢?

答:这就需要使用:分布式锁了。目前主流的分布式锁包括:redis分布式锁、zookeeper分布式锁 和 数据库分布式锁。

由于zookeeper分布式锁的性能不太好,真实业务场景用的不多,这里先不讲。

下面聊一下redis分布式锁。

7.2 redis分布式锁

在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被我们用到了很多实际业务场景当中。

使用redis分布式锁的伪代码如下:

  1. public void doSave(String path,String fileUrl) {
  2. try {
  3. String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  4. if ("OK".equals(result)) {
  5. if(!exists(path)) {
  6. mkdir(path);
  7. uploadFile(fileUrl);
  8. sendMessage(fileUrl);
  9. }
  10. return true;
  11. }
  12. } finally{
  13. unlock(lockKey,requestId);
  14. }
  15. return false;
  16. }

跟之前使用synchronized关键字加锁时一样,这里锁的范围也太大了,换句话说就是锁的粒度太粗,这样会导致整个方法的执行效率很低。

其实只有创建目录的时候,才需要加分布式锁,其余代码根本不用加锁。

于是,我们需要优化一下代码:

  1. public void doSave(String path,String fileUrl) {
  2. if(this.tryLock()) {
  3. mkdir(path);
  4. }
  5. uploadFile(fileUrl);
  6. sendMessage(fileUrl);
  7. }
  8. private boolean tryLock() {
  9. try {
  10. String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  11. if ("OK".equals(result)) {
  12. return true;
  13. }
  14. } finally{
  15. unlock(lockKey,requestId);
  16. }
  17. return false;
  18. }

上面代码将加锁的范围缩小了,只有创建目录时才加了锁。这样看似简单的优化之后,接口性能能提升很多。说不定,会有意外的惊喜喔。哈哈哈。

redis分布式锁虽说好用,但它在使用时,有很多注意的细节,隐藏了很多坑,如果稍不注意很容易踩中。详细内容可以看看我的另一篇文章《聊聊redis分布式锁的8大坑》

7.3 数据库分布式锁

mysql数据库中主要有三种锁:

  • 表锁:加锁快,不会出现死锁。但锁定粒度大,发生锁冲突的概率最高,并发度最低。
  • 行锁:加锁慢,会出现死锁。但锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
  • 间隙锁:开销和加锁时间界于表锁和行锁之间。它会出现死锁,锁定粒度界于表锁和行锁之间,并发度一般。

并发度越高,意味着接口性能越好。

所以数据库锁的优化方向是:

优先使用行锁,其次使用间隙锁,再其次使用表锁。

赶紧看看,你用对了没?

8.分页处理

有时候我会调用某个接口批量查询数据,比如:通过用户id批量查询出用户信息,然后给这些用户送积分。

但如果你一次性查询的用户数量太多了,比如一次查询2000个用户的数据。参数中传入了2000个用户的id,远程调用接口,会发现该用户查询接口经常超时。

调用代码如下:

  1. List<User> users = remoteCallUser(ids);

众所周知,调用接口从数据库获取数据,是需要经过网络传输的。如果数据量太大,无论是获取数据的速度,还是网络传输受限于带宽,都会导致耗时时间比较长。

那么,这种情况要如何优化呢?

答:分页处理。

将一次获取所有的数据的请求,改成分多次获取,每次只获取一部分用户的数据,最后进行合并和汇总。

其实,处理这个问题,要分为两种场景:同步调用 和 异步调用。

8.1 同步调用

如果在job中需要获取2000个用户的信息,它要求只要能正确获取到数据就好,对获取数据的总耗时要求不太高。

但对每一次远程接口调用的耗时有要求,不能大于500ms,不然会有邮件预警。

这时,我们可以同步分页调用批量查询用户信息接口。

具体示例代码如下:

  1. List
如果您对该产品感兴趣,请填写办理(客服微信:xiaoxiongyidong)

为您推荐:

发表评论

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。