如何优化提升接口的性能

如何优化提升接口的性能


如何优化提升接口的性能问题?这个问题虽然问的很广,没有一个标准答案,导致接口性能问题的原因千奇百怪,不同的项目不同的接口,原因可能也不一样。
本文我总结了一些行之有效的,优化接口性能的办法,给有需要的朋友一个参考。

一、优化索引

首先大家可能第一想到就是优化索引,没错,优化索引的成本是最小的。可以通过查看日志或监控平台报告,查看某只接口用到的sql语句耗时比较长的,这是你可能会有以下疑问:

1、这条sql加了索引没?
2、加的索引生效没?
3、mysql选错索引没?

1.1、没加索引
sql语句中 where 条件的关键字段,或者 order by 后面的排序字段,忘了加索引,这个问题在项目中很常见。
项目刚开始的时候,由于表中的数据量小,加不加索引sql查询性能差别不大。
后来,随着业务的发展,表中数据量越来越多,就不得不加索引了。

//查看表的索引
show index from `tb_order`;
//查看整张表的建表语句,也可以查看索引情况
show create table `tb_order`;

1.2、索引没生效
通过上面的方式可以查询出是否建立了索引,但它生效了没?如何判定索引是否生效呢?可以使用 explain 命令,查看mysql的执行计划,它会显示索引的使用情况。

//explain检查索引使用情况
explain select * from `tb_order` where code='002';

attachmentId-376

attachmentId-377

经验总结:sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效。

那索引失效的原因有哪些呢?

attachmentId-378
1.2、选错索引
有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b?这就是mysql会选错索引,必要时可以使用 force index 来强制查询sql走某个索引。

二、优化sql语句

优化索引后没啥效果,那我们咋办呢?接下来可以优化sql语句,相对于改造代码,优化sql的成本是最小的。

attachmentId-379

三、串行改并行
举个例子,有这样的业务场景:在用户信息查询接口中需要返回用户名称、性别、等级、头像、积分、成长值等信息。而用户名称、性别、等级、头像在用户服务中,积分在积分服务中,成长值在成长值服务中。为了汇总这些数据统一返回,需要另外提供一个对外接口服务。于是,用户信息查询接口需要调用用户查询接口、积分查询接口 和 成长值查询接口,然后汇总数据统一返回。

attachmentId-380
这种串行调用远程接口性能是非常不好的,调用远程接口总的耗时为所有的远程接口耗时之和。那如何优化远程接口性能呢?
3.1、并行调用

attachmentId-381
在java8之前可以通过实现 Callable 接口,获取线程返回结果。java8以后通过 CompleteFuture 类实现该功能。这里以CompleteFuture为例:

public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
final UserInfo userInfo = new UserInfo();
CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
getRemoteUserAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
    getRemoteBonusAndFill(id, userInfo);
    return Boolean.TRUE;
}, executor);

CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
    getRemoteGrowthAndFill(id, userInfo);
    return Boolean.TRUE;
}, executor);
CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
userFuture.get();
bonusFuture.get();
growthFuture.get();
return userInfo;

3.2、数据缓存
上面说到的用户信息查询接口需要调用用户查询接口、积分查询接口和成长值查询接口,然后汇总数据统一返回。 那么,可以把用户信息、积分和成长值的数据统一存储到一个地方,比如:redis,存的数据结构就是用户信息查询接口所需要的内容。然后通过用户id,直接从redis中查询数据出来,不就OK。

attachmentId-382
四、重复调用
重复调用在代码中随处可见,但如果没有控制好,会非常影响接口的性能。
在循环中调用查询数据库是不可取的,每查询一次数据库,就是一次远程调用。

public List `<User>` queryUser(List `<User>` searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List<User> result = Lists.newArrayList();
searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
return result;

}

上面的案例就是在forEach循环中每次去调用查询数据库,将用户信息添加到集合中。这是不合理的,那怎么优化呢?将用户id集合批量查询用户的接口,只远程调用一次,就能查询出所有的数据。

public List `<User>`  queryUser(List `<User>`  searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List `<Long>`  ids = searchList.stream().map(User::getId).collect(Collectors.toList());
return userMapper.getUserByIds(ids);
}

id集合的大小要做限制,最好一次不要请求太多的数据。要根据实际情况而定,建议控制每次请求的记录条数在500以内。

五、异步处理

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

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

attachmentId-383

遵循一个原则:核心逻辑可以同步执行,同步写库。非核心逻辑,可以异步执行,异步写库。

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

5.1 线程池

attachmentId-384

发站内通知和用户操作日志功能,被提交到了两个单独的线程池中去执行,接口中重点关注的是业务操作,把其他的逻辑交给线程异步执行,让接口性能瞬间提升。
使用线程池有个问题是:如果服务器重启了,或者是需要被执行的功能出现异常了,无法重试,会丢数据。那怎么处理呢?可以使用中间件mq。

5.2 mq

attachmentId-385

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

六、避免大事务

在使用spring框架开发项目时,为了方便,喜欢使用 @Transactional 注解提供事务功能。虽然这种方式能少写很多代码,提升开发效率,但也容易造成大事务,引发其他的问题。0

attachmentId-386

大事务问题可能会造成接口超时,对接口的性能有直接的影响。

该如何优化大事务呢?

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

七、锁粒度

某些业务场景中,为了防止多个线程并发修改某个共享数据,造成数据异常,通常情况下选择加锁处理,但如果锁加得不好,导致锁的粒度太粗,也会非常影响接口性能。

7.1 synchronized

public synchronized doSave(String fileUrl) {
    mkdir();
    uploadFile(fileUrl);
    sendMessage(fileUrl);
}

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

文件上传操作是非常耗时的,如果将整个方法加锁,那么需要等到整个方法执行完之后才能释放锁。显然,这会导致该方法的性能很差。
public void doSave(String path,String fileUrl) {
    synchronized(this) {
      if(!exists(path)) {
          mkdir(path);
       }
    }
    uploadFile(fileUrl);
    sendMessage(fileUrl);
}

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

当然,这种做在单机版的服务中,是没有问题的。但现在部署的生产环境,为了保证服务的稳定性,一般情况下,同一个服务会被部署在多个节点。多节点部署避免了因为某个节点挂了,导致服务不可用的情况。同时也能分摊整个系统的流量,避免系统压力过大。但带来了个新问题:synchronized只能保证一个节点加锁是有效的,但如果有多个节点如何加锁呢?
 使用**分布式锁**。目前主流的分布式锁包括:redis分布式锁、zookeeper分布式锁 和 数据库分布式锁。

7.2 redis分布式锁

public void doSave(String path,String fileUrl) {
   if(this.tryLock()) {
      mkdir(path);
   }
   uploadFile(fileUrl);
   sendMessage(fileUrl);
}

private boolean tryLock() {
    try {
    String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
    if ("OK".equals(result)) {
      return true;
    }
  } finally{
      unlock(lockKey,requestId);
  }  
  return false;
}

八、分页处理

有时候调用某个接口批量查询数据,比如:通过用户id批量查询出用户信息。若一次查询的用户数量太多,远程调用接口,会发现该用户查询接口经常超时。
List<User> users = remoteCallUser(ids);

那这种情况下如何优化呢?**分页处理,将一次获取所有的数据的请求,改成分多次获取,每次只获取一部分用户的数据,最后进行合并和汇总。

8.1 同步调用

List<List`<Long>`> allIds = Lists.partition(ids,200);

for(List `<Long>` batchIds:allIds) {
List `<User>` users = remoteCallUser(batchIds);
}

8.2 异步调用

List<List`<Long>`> allIds = Lists.partition(ids,200);

final List `<User>` result = Lists.newArrayList();
allIds.stream().forEach((batchIds) -> {
CompletableFuture.supplyAsync(() -> {
result.addAll(remoteCallUser(batchIds));
return Boolean.TRUE;
}, executor);
})

九、加缓存
解决接口性能问题,加缓存 是一个非常高效的方法。但不能为了缓存而缓存,还是要看具体的业务场景。毕竟加了缓存,会导致接口的复杂度增加,它会带来数据不一致问题。

在有些并发量比较低的场景中,比如用户下单,可以不用加缓存。还有些场景,比如在商城首页显示商品分类的地方,假设这里的分类是调用接口获取到的数据,但页面暂时没有做静态化。如果查询分类树的接口没有使用缓存,而直接从数据库查询数据,性能会非常差。

9.1 redis缓存

在关系型数据库,比如:mysql中,级联菜单的查询是一个非常耗时的操作。这时候想要用缓存,可以用jedis和redisson框架直接从缓存中获取数据。

String json = jedis.get(key);
if(StringUtils.isNotEmpty(json)) {
CategoryTree categoryTree = JsonUtil.toObject(json);
return categoryTree;
}
return queryCategoryTreeFromDb();

此外,我们还需要有个job每隔一段时间,从数据库中查询菜单数据,更新到redis当中,这样以后每次都能直接从redis中获取菜单的数据,而无需访问数据库了。

9.2 二级缓存

上面的方案是基于redis缓存的,虽说redis访问速度很快。但毕竟是一个远程调用,而且菜单树的数据很多,在网络传输的过程中,是有些耗时的。有没有办法,不经过请求远程,就能直接获取到数据呢? 使用 二级缓存 ,即基于内存的缓存。除了自己手写的内存缓存之后,目前使用比较多的内存缓存框架有:guava、Ehcache、caffine等。


@Service
public class CategoryService {
   
   @Cacheable(value = "category", key = "#categoryKey")
   public CategoryModel getCategory(String categoryKey) {
      String json = jedis.get(categoryKey);
      if(StringUtils.isNotEmpty(json)) {
         CategoryTree categoryTree = JsonUtil.toObject(json);
         return categoryTree;
      }
      return queryCategoryTreeFromDb();
   }
}

调用categoryService.getCategory()方法时,先从caffine缓存中获取数据,如果能够获取到数据,则直接返回该数据,不进入方法体。如果不能获取到数据,则再从redis中查一次数据。如果查询到了,则返回数据,并且放入caffine中。如果还是没有查到数据,则直接从数据库中获取到数据,然后放到caffine缓存中。

attachmentId-387

该方案的性能更好,但有个缺点就是,如果数据更新了,不能及时刷新缓存。此外,如果有多台服务器节点,可能存在各个节点上数据不一样的情况。
二级缓存给我们带来性能提升的同时,也带来了数据不一致的问题。使用二级缓存一定要结合实际的业务场景,并非所有的业务场景都适用。

十、接口按需返回
在定义接口的返回数据结构时,根据需求按需返回,比如只需要用户的姓名、工号、职务,部门数据,不要直接返回一个实体对象包含所有字段,只返回所需要的数据才是最佳选择。前端在传递参数的时候,尽量根据需求传递参数,不要为了方便将所有参数封装后传递到后端。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值