如何优化提升接口的性能?

Posted IT6889

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何优化提升接口的性能?相关的知识,希望对你有一定的参考价值。

  如何优化提升接口的性能问题?这个问题虽然问的很广,没有一个标准答案,但回答者需要根据以往的工作经验或学习经验来进行回答,根据作答的深浅情况可以反映一个程序员的大致水平。

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

本文我总结了一些行之有效的,优化接口性能的办法:

一、优化索引

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

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

 

从几个纬度去验证索引的问题

1.1、没加索引

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

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

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

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

 可以通过上面的语句可以看出表的索引情况,通常若没有加索引,需要建立索引。

//alter table添加索引
ALTER TABLE `tb_order` ADD INDEX idx_name (name);
//create index 添加索引
CREATE INDEX idx_name ON `tb_order` (name);

 通过上面方式可以添加索引,值得注意的是:想通过命令修改索引是不行的,在mysql中需要删除索引,在重新添加新索引。

//删除索引方式1
ALTER TABLE `tb_order` DROP INDEX idx_name;
//删除索引方式2
DROP INDEX idx_name ON `tb_order`;

 

1.2、索引没生效

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

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

 

 

表字段代表的含义:

 

 

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

灵魂拷问:那索引失效的原因有哪些呢?

 

 

1.2、选错索引

        

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

 

二、优化sql语句

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

 

 

三、远程调用

 

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

调用过程如下图:

 

 

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

 

3.1、并行调用

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

 

 

在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;

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

3.2、数据缓存

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

 

 

如果使用了数据缓存方案,就可能会出现数据一致性问题。

大部分情况下,会先更新到数据库,然后同步到redis。但这种跨库的操作,可能会导致两边数据不一致的情况产生。

四、重复调用

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

4.1 循环查数据库

       在循环中调用查询数据库是不可取的,每查询一次数据库,就是一次远程调用

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以内。

 

4.2 死循环

    有时候我们不注意就会造成代码的死循环。

while(true) 
    if(condition) 
        break;
    
    System.out.println("do samething");

 

这里使用了while(true)的循环调用,这种写法在CAS自旋锁中使用比较多。当满足condition等于true的时候,则自动退出该循环。但如果condition条件比较复杂,一旦出现判断不正确,或者少写了一些逻辑判断,就可能在某些场景下出现死循环的问题。

4.3 无限递归

public void printCategory(Category category) 
  if(category == null 
      || category.getParentId() == null) 
     return;
   
  System.out.println("父分类名称:"+ category.getName());
  Category parent = categoryMapper.getCategoryById(category.getParentId());
  printCategory(parent);

 

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

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

五、异步处理

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

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

 

 

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

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

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

5.1 线程池

 

 

        发站内通知和用户操作日志功能,被提交到了两个单独的线程池中去执行,接口中重点关注的是业务操作,把其他的逻辑交给线程异步执行,让接口性能瞬间提升。

        使用线程池有个问题是:如果服务器重启了,或者是需要被执行的功能出现异常了,无法重试,会丢数据。那怎么处理呢?可以使用中间件mq。

5.1 mq

 

 

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

 

六、避免大事务

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

 

 

 

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

该如何优化大事务呢?
少用@Transactional注解
将查询(select)方法放到事务外
事务中避免远程调用
事务中避免一次性处理太多数据
有些功能可以非事务执行
有些功能可以异步处理

 

七、锁粒度

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

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;

 

7.3 数据库分布式锁

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

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

并发度越高,意味着接口性能越好。所以数据库锁的优化方向是:优先使用行锁,其次使用间隙锁,再其次使用表锁。

 

八、分页处理

       有时候调用某个接口批量查询数据,比如:通过用户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();

先从redis中根据某个key查询是否有菜单数据,如果有则转换成对象,直接返回。如果redis中没有查到菜单数据,则再从数据库中查询菜单数据,有则返回。

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

 

9.2 二级缓存

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

 

这里以caffeine为例,它是spring官方推荐的。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.6.0</version>
</dependency>

第二步,配置CacheManager,开启EnableCaching。

@Configuration
@EnableCaching
public class CacheConfig 
    @Bean
    public CacheManager cacheManager()
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        //Caffeine配置
        Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                //最后一次写入后经过固定时间过期
                .expireAfterWrite(10, TimeUnit.SECONDS)
                //缓存的最大条数
                .maximumSize(1000);
        cacheManager.setCaffeine(caffeine);
        return cacheManager;
    

 第三步,使用Cacheable注解获取数据

@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缓存中。

 

 

该方案的性能更好,但有个缺点就是,如果数据更新了,不能及时刷新缓存。此外,如果有多台服务器节点,可能存在各个节点上数据不一样的情况。

        二级缓存给我们带来性能提升的同时,也带来了数据不一致的问题。使用二级缓存一定要结合实际的业务场景,并非所有的业务场景都适用。

十、分库分表

        有时候,接口性能受限的不是别的,而是数据库。当系统发展到一定的阶段,用户并发量大,会有大量的数据库请求,需要占用大量的数据库连接,同时会带来磁盘IO的性能瓶颈问题。

        此外,随着用户数量越来越多,产生的数据也越来越多,一张表有可能存不下。由于数据量太大,sql语句查询数据时,即使走了索引也会非常耗时。

这时候该怎么办?就需要分库分表处理了。

 

图中将用户库拆分成了三个库,每个库都包含了四张用户表。 如果有用户请求过来的时候,先根据用户id路由到其中一个用户库,然后再定位到某张表。

 

路由的算法挺多的:

  • 根据id取模,比如:id=7,有4张表,则7%4=3,模为3,路由到用户表3。
  • 给id指定一个区间范围,比如:id的值是0-10万,则数据存在用户表0,id的值是10-20万,则数据存在用户表1。
  • 一致性hash算法

分库分表主要有两个方向:垂直水平

说实话垂直方向(即业务方向)更简单。

在水平方向(即数据方向)上,分库和分表的作用,其实是有区别的,不能混为一谈。

  • 分库:是为了解决数据库连接资源不足问题,和磁盘IO的性能瓶颈问题。
  • 分表:是为了解决单表数据量太大,sql语句查询数据时,即使走了索引也非常耗时问题。此外还可以解决消耗cpu资源问题。
  • 分库分表:可以解决 数据库连接资源不足、磁盘IO的性能瓶颈、检索数据耗时 和 消耗cpu资源等问题。

如果在有些业务场景中,用户并发量很大,但是需要保存的数据量很少,这时可以只分库,不分表。

如果在有些业务场景中,用户并发量不大,但是需要保存的数量很多,这时可以只分表,不分库。如果在有些业务场景中,用户并发量大,并且需要保存的数量也很多时,可以分库分表。

 

以上是关于如何优化提升接口的性能?的主要内容,如果未能解决你的问题,请参考以下文章

怎么优化电脑性能

UNITY性能优化☀️二如何优化GC,达到提升流畅度的目的

Linux上MySQL优化提升性能 哪些可以优化的关闭NUMA特性

如何使用多线程优化多查询复杂业务接口,并保证线程安全

如何使用多线程优化多查询复杂业务接口,并保证线程安全

我只改五行代码,接口性能提升了 10 倍!