浅谈几个提高后端接口性能的技巧

545 阅读15分钟

O、前言

接口对于我们后端开发同学来说再熟悉不过了,我们日常开发工作大部分都是围绕接口开发进行的。

虽然我们平时在开发接口的过程中,由于业务急、排期紧等原因,更多的是让接口快速实现业务功能,不会特别对接口性能做优化,但是,如果在开发过程中适当地运用一些开发技巧,培养一些好的开发习惯,无形中也会帮助我们提高接口性能,避免当接口性能出现问题时再花费大量的时间和精力去排查和优化。

下面,我结合自身的一些开发经验,总结了几个常用的提高接口性能的技巧。由于本人工作经验还不多,文中如果有不足之处还望各位大佬批评指正~

一、优化远程调用

很多时候,我们需要在接口中通过 RPC(Remote Procedure Call)的方式来远程调用其他服务的接口。

比如有这样一个业务场景:在商品查询接口中需要返回商品名称、商品类目、商品价格、商家名称、商家头像、优惠信息等。其中,商品名称、商品类别、商品价格在商品服务中,商家名称、商家头像在商家服务中,优惠信息在优惠服务中。

商品查询接口需要汇总这些数据统一返回给前端,因此,在商品查询接口中需要调用商品服务接口、商家服务接口和优惠服务接口。如果是串行调用这些接口,调用过程如下所示:

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

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

1.1 并行调用

既然串行调用多个远程接口的性能很差,那改成并行不就好了嘛。并行调用的调用过程如下所示:

采用并行调用后,接口总耗时降低到了 200ms,即耗时最长的那个接口的耗时。

在 Java 中实现并行调用也很简单,Java8 之前可以通过 Future 获取线程执行任务的返回结果,Java8 之后可以通过 CompleteFuture 实现类似的功能,并且它对 Future 做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。

关于 CompleteFuture 的更多介绍见 使用CompletableFuture

1.2 数据异构

我们再来分析一下,上面的商品查询接口之所以要调用商品服务接口、商家服务接口、优惠服务接口三个接口,是因为商品查询接口需要的数据需要通过这些接口来获得。那能不能将这些数据存储在一个地方,这样接口只需要从一个地方获取数据就行了呢?

我们可以把商品数据、商家数据、优惠数据统一存放到 Redis 中,通过查询 Redis 就可以一次拿到所有的数据,如下所示:

这种数据异构方案在高并发场景下可以替代远程调用,提高接口性能。但是需要注意的是,由于数据更新是先根据到数据库,然后再同步到 Redis,因此可能会导致两边数据不一致,需要业务特别考虑。

二、警惕重复调用

重复调用主要出现在循环查询数据库、死循环、无限递归这几个场景中。

2.1 循环查询数据库

假设我们现在有一批商品 ID,需要通过这批商品 ID 从数据库中查询出这批商品,代码可以这么写:

public List<Item> getItemsByIds(List<Long> ids) {
    if (CollectionUtils.isEmpty(ids)) {
        return new ArrayList<>();
    }
    List<Item> result = new ArrayList<>();
    ids.forEach(id -> result.add(itemMapper.getItemById(id)));
    return result;
}

如果这里有 1000 个 ID,那就需要循环 1000 次,查 1000 次数据库,产生 1000 次远程调用,每一次远程调用都会带来相应的网络开销,可见这是非常耗时的。

优化的方式也很简单,我们只需要提供一个根据商品 ID 列表批量查询商品的接口,这样只需要执行一次远程调用,就可以查询出所有的数据,代码如下所示:

public List<Item> getItemByIds(List<Long> ids) {
    if (CollectionUtils.isEmpty(ids)) {
        return new ArrayList<>();
    }
    return itemMapper.getItemByIds(ids);
}

不过这里有一个地方需要注意,ids 的大小需要做限制,不能一次请求太多的的数据,那样网卡很容易被打满,建议控制每次请求的记录数在 500 条以内。

2.2 死循环

看到这有些小伙伴可能会有疑问:死循环也算?代码中不是应该避免死循环吗?

代码中确实应该避免死循环,但有时候死循环是我们自己写的,如下所示:

while (true) {
    if (condition) {
        break;
    }
    // do something
}

当 condition 为 true 时,执行循环体的内容,当 condition 为 false 时,退出循环。

乍一看好像没什么问题,但是重点就在这里的 condition。如果 condition 这个判断条件非常复杂的话,一旦出现判断不准,或者少写了一些判断条件,就会在某些场景下出现死循环的问题。

业务代码中出现死循环多半是因为程序 Bug 导致的,不过这种情况确实很难被测出来,因此代码中遇到 while (true) 的地方还是需要多多留意。

2.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,如果是则终止递归,这样就能避免无限递归的情况。

三、非核心逻辑异步处理

大部分时候我们在编写接口的业务逻辑时,为了实现方便,通常所有的业务逻辑都是同步执行的。

比如有个商品更新接口,这个接口的操作包括更新商品信息、记录操作日志、发送站内信,接口的执行流程如下所示:

表面上看这个接口貌似没什么问题,但是仔细梳理一下这里的业务逻辑后,你会发现,只有更新商品信息是核心逻辑,而记录操作日志和发送站内信都是非核心逻辑,这里核心逻辑和非核心逻辑放在一个线程里同步执行,势必为影响接口的性能。

其实,核心逻辑和非核心逻辑没必要放在一个线程里同步执行,核心逻辑可以同步执行、同步写库,非核心逻辑可以异步执行、异步写库。在这个例子中,记录操作日志和发送站内信这两个操作对实时性的要求并不高,即使晚点执行,无非是运营晚点看到操作日志,或者用户晚点收到站内信,对业务的影响不大,所以可以异步处理。

异步处理的方式通常有两种:多线程和消息队列。

3.1 多线程

使用多线程方式对上述接口进行改造后,接口的执行流程如下所示:

主线程只负责更新商品信息,记录操作日志和发送站内信被提交到了两个单独的线程池中分别进行处理。这样一来,接口只需要重点关注核心逻辑的处理,其他的非核心逻辑交给其他线程异步执行,不仅使得代码更加清晰,也提高了接口的性能。

但是,使用多线程也会带来一些额外的问题:如果服务器遇到问题重启或者宕机了,或者线程池在执行任务的过程中出现异常,那么可能会造成数据丢失,这种情况应该怎么办呢?

可以引入消息队列来解决这个问题。

3.2 消息队列

使用消息队列再次对上述接口进行改造后,接口的执行流程如下所示:

记录操作日志和发送站内信这两个操作在接口中并未实现,而是发送消息到消息队列,然后由对应的消费者对消息进行消费来执行具体的操作。

经过这样改造之后,一方面提高了接口的性能,因为发送消息的操作是很快的,另一方面也提高了接口的可用性和稳定性,因为消息队列中间件会提供相应的机制解决消息重试、丢消息等问题。

四、大查询分页处理

有时候我们会调用某个外部接口批量查询数据。

比如我们需要根据一批商品 ID 调用商品服务提供的接口批量查询这批商品的信息,代码如下所示:

List<Item> items = itemService.getItemsByIds(ids);

如果我们需要一次查询 1000 个商品的信息,那么就要一次传入 1000 个商品的商品 ID。由于调用外部接口需要经过网络传输,如果一次查询的数据量太大,受限于机器带宽等因素,会导致接口的耗时增加。

可以通过分页的方式来优化这种批量查询的场景。将一次获取所有数据的请求,改成分批多次获取,每次获取一部分数据,最后对结果进行汇总。

在实现方式上可以有两种:同步调用和异步调用。

4.1 同步调用

如果是在一个批处理任务中查询 1000 个商品的信息,由于对实时性的要求不高,可以采用同步调用的方式,代码如下所示:

List<List<Long>> allIds = Lists.partition(ids, 200);
for (List<Long> batchIds : allIds) {
    List<Item> items = itemService.getItemsByIds(batchIds);
}

使用 Guava 的 Lists.partition() 方法可以方便地对一个 List 进行分页。

4.2 异步调用

如果是在某个对实时性要求比较高的接口中查询 1000 个商品的信息,此时同步调用可能满足不了接口的实时性要求,那么可以采用异步调用的方式,代码如下所示:

Vector<Item> items = new Vector<>();
List<List<Long>> allIds = Lists.partition(ids, 200);
allIds.stream().forEach((batchIds) -> {
    CompletableFuture.supplyAsync(() -> {
        items.addAll(itemService.getItemsByIds(batchIds));
        return Boolean.TRUE;
    }, executor);
})

启动多个线程分批异步查询 1000 个商品的信息,最后对结果进行汇总。

这里为了简单,用了 Vector 汇总查询结果,感兴趣的同学可以调研一下其他实现线程安全的 List 的方式。

五、引入缓存

缓存是提高接口性能的一个强有力的手段,与此同时,缓存也是一把双刃剑,不仅会提高接口实现的复杂度,还会带来数据不一致等问题。因此,不能为了缓存而缓存,而是要根据具体的业务场景来具体分析。

一般来说,我们可以对一些数据不经常变化但是对实时性要求比较高的场景做缓存。

比如对于查询商品类目这个场景,一方面,商品类目不会经常更新,另一方面,通过查询数据库的方式查询商品类目效率不高,这是因为,商品类目是一个树形结构,有一级类目、二级类目、三级类目等,如果想查询这个商品的所有类目,不管是通过表连接的方式直接查询数据库,还是在代码中通过递归的方式查询,都是比较耗时的。

我们可以将商品类目树做一个缓存,大部分查询直接查询缓存,这样可以降低接口的耗时,提高接口的性能。

根据缓存存放的位置不同,缓存在实际使用中可以进一步分为一级缓存和二级缓存。

5.1 一级缓存

一级缓存是指外部缓存,常用的缓存组件有 Redis、Memcached 等。

对于查询商品类目树这个场景,我们可以将商品类目树缓存到 Redis 中,先尝试从 Redis 获取数据,如果能获取到数据,直接返回。如果不能获取到数据,查询数据库获取数据,同时将数据缓存到 Redis 中,最后将数据返回。代码如下所示:

public CategoryTree getCategoryTree(String categoryKey) {
    CategoryTree categoryTree;
    String json = jedis.get(categoryKey);
    if (StringUtils.isNotBlank(json)) {
        categoryTree = JsonUtil.toObject(json);
        return categoryTree;
    }
    categoryTree = queryCategoryTreeFromDb();
    jedis.put(categoryKey, JsonUtil.toJson(categoryTree));
    return categoryTree;
}

为了保证缓存和数据库的数据一致性,也可以启动一个定时任务定时将数据从数据库更新到 Redis 中。

5.2 二级缓存

上面基于 Redis 的缓存是一种外部缓存,虽然 Redis 很快,但是查询 Redis 的过程也是一次远程调用,如果查询的数据量很大或者查询的次数很多的话,也会增加接口的耗时。

可以在一级缓存的基础上再增加一个二级缓存,即本地缓存。

本地缓存的组件有很多,如 Guava、Caffine 等。如果我们的应用是基于 Spring 开发的话,可以结合 Spring Cache 方便地使用这些本地缓存组件。

以 Caffine 为例,首先引入相应的依赖:

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

然后配置 CacheManager:

@EnableCaching
@Configuration
public class CacheConfig {
    @Beanpublic CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.SECONDS) // 配置过期时间
                .maximumSize(1000); // 配置缓存的最大容量
        cacheManager.setCaffeine(caffeine);
        return cacheManager;
    }
}

最后通过 @Cacheable 注解使用缓存:

@Cacheable(value = "category", key = "#categoryKey")
public CategoryTree getCategoryTree(String categoryKey) {
    CategoryTree categoryTree;
    String json = jedis.get(categoryKey);
    if (StringUtils.isNotBlank(json)) {
        categoryTree = JsonUtil.toObject(json);
        return categoryTree;
    }
    categoryTree = queryCategoryTreeFromDb();
    jedis.put(categoryKey, JsonUtil.toJson(categoryTree));
    return categoryTree;
}

getCategoryTree() 方法的执行流程是:

  1. 查询 Caffine,如果能够获取到数据,返回数据;
  2. 如果 Caffine 获取不到数据,查询 Redis。如果能够获取到数据,返回数据,同时将数据写入到 Caffine;
  3. 如果 Redis 获取不到数据,查询数据库,返回数据,同时将数据写入到 Redis 和 Caffine

采用二级缓存可以进一步提高接口的性能,但是相较于一级缓存而言,带来的数据不一致问题也会更加严重,不光要保证一级缓存和数据库的数据一致性,还要保证二级缓存和数据库的数据一致性,以及二级缓存和一级缓存的数据一致性。因此,我们在使用二级缓存的时候一定要结合具体的业务场景来使用。

额外提一点,上面实现二级缓存的方式并不是最优雅的,因为在方法体中还是要操作 Redis,对业务代码的侵入性较强。一个更好的做法是利用 Spring Cache 的能力扩展 CacheManger,实现一个二级缓存管理器,这样可以完全消除业务代码中对缓存的操作。笔者计划在下篇文章中介绍这种二级缓存管理器的实现(可能是下篇,也可能是下下篇...)。

六、减小锁的粒度

在一些并发场景下,为了防止多个线程同时对一个共享数据做修改导致数据异常,我们通常会做一些并发控制,最常见的方式就是加锁。

比如有一个保存文件的方法,这个方法首先根据 path 创建目录,然后通过 fileUrl 读取并保存文件到 path 下,代码如下所示:

public synchronized void saveFile(String path, String fileUrl) {
   mkdir(path); // 创建目录
   loadAndSaveFile(fileUrl, path); // 读取并保存文件
}

由于目录是共享资源,为了防止多个线程在并发情况下创建相同的目录导致出错,我们需要加锁。这里通过对 saveFile() 方法添加 synchronized 关键字来实现加锁。

表面上看这么做没什么问题,但是仔细观察一下我们会发现,这种加锁方式会同时对 mkdir 和 loadAndSaveFile 两个操作加锁。然而实际执行中,loadAndSaveFile 操作要比 mkdir 操作慢得多,也就是说,当前线程必须要等待前一个获取锁的线程读取并保存文件结束后才能尝试获取锁并执行方法。显然这种方式的性能很差。

实际上,只有 mkdir 操作需要加锁,loadAndSaveFile 操作不需要加锁。我们可以改为在代码块上加锁。改造后的代码如下所示:

public void saveFile(String path,String fileUrl) {
    synchronized(this) {
        if (!exists(path)) { // 判断当前目录是否存在
            mkdir(path); // 如果不存在,创建目录
        }
    }
    loadAndSaveFile(fileUrl, path); // 读取并保存文件
}

改造后的代码减小了锁的粒度,变为只对 mkdir() 操作加锁,而创建目录操作是一个非常快的操作,即使加锁对接口的性能影响也不是很大。

当然,这么做在单机服务中是没有问题的,但是现在我们的服务一般都是部署在分布式环境中,如果想在分布式环境下通过加锁保证线程安全,那么需要使用分布式锁。常见的分布式锁的实现方式有数据库唯一索引、Redis 分布式锁和 ZooKeeper 分布式锁等。出于篇幅原因,这里就不展开讲了,网上的资料也比较多,感兴趣的同学可以自行查阅。

此外,有些并发场景也不是一定要用加锁来解决,加锁毕竟是一个比较耗时的操作。可以考虑用其他方式来保证线程安全,比如不可变类、堆栈封闭等。

还有些场景表面上看没有加锁,实际上是加了锁的,比如 StringBuffer 的 append() 方法,而 StringBuilder 的 append() 的方法就没有加锁,需要在写代码的时候注意一下。

七、SQL 和数据库优化

有时候接口性能差不是因为别的,而是因为数据库查询慢,这时候就要对 SQL 和数据库做优化了,比如看看查询是否走了索引、索引是否可以优化、SQL 是否可以优化等。当用户量和数据量进一步增大时,可能还需要对数据库做优化,比如分库分表等。

索引优化

下面简述几条索引优化的建议:

  • 遵循索引的最左匹配原则
  • 不要在索引列上使用函数或者进行运算
  • 注意索引列的顺序,让选择性最强的索引列放在前面。索引的选择性是指:不重复的索引值和记录总数的比值
  • 避免在 WHERE 子句中使用 != 操作符,因为这样会导致索引失效
  • 索引不会包含有 NULL 值的列,只要列有 NULL 值,或者复合索引中有一个列包含 NULL 值,那么该列上的索引是无效的
  • 前导的模糊查询不能使用索引,如 LIKE '%xxx',而非前导的模糊查询可以,如 LIKE 'xxx%'
  • 尝试多使用覆盖索引。覆盖索引是指索引包含所有需要查询的字段的值。例如这条 SQL 语句:SELECT name FROM user WHERE name = 张三,如果我们在 name 列上建立了一个索引,那么这条 SQL 语句实际上只要查询索引就可以了,这就是覆盖索引。如果是 innodb 存储引擎的话,相当于少了一次查询主索引的过程,提高了查询效率

SQL 优化

下面简述几条 SQL 优化的建议(以 MySQL 为例):

  • 避免使用 SELECT *
  • 如果明确知道只有一条结果返回,LIMIT 1 能够提高效率
  • 用连接查询代替子查询。因为子查询需要创建临时表,查询完成后再删除这些临时表,会产生额外的性能消耗
  • 切分大查询。因为一个大查询如果一次性执行的话,可能会一次锁住很多数据,阻塞很多小的但重要的查询
  • 分解大连接查询。将一个大连接查询分解成对每一个表进行一次单表查询,然后在应用程序中进行关联,可以更好地利用缓存,因为对于连接查询,如果其中一个表的数据发生变化,那么整个查询缓存就无法使用。而分解后的多个查询,即使其中一个表的数据变化,其它表的查询缓存依然可以使用。此外,分解后的单表查询的缓存结果更可能被其它查询使用到,减少冗余记录的查询,也可以减少锁的竞争。

分库分表

当系统发展到一定阶段后,随着用户数量的增大,数据库会逐渐成为接口性能的瓶颈。

一方面,用户数量增大,会有大量的数据库请求,占用大量的数据库连接,很快就会把连接池打满,同时带来一些磁盘 IO 的性能问题。另一方面,随着用户数量越来越多,产生的数据也越来越多,一张表有可能存不下,即便能存下,查询也会非常耗时,即便走了索引也很耗时。

这时候,我们就需要对数据库做分库分表。

网上关于分库分表的文章挺多的,这里就不具体展开了,简单说一下我对分库分表的理解。分库分表在方向上一般有两个,垂直和水平。

垂直,即从业务方向上进行划分,这个比较简单,比如商品库、商家库、订单库等。

水平,即从数据方向上进行划分,这个分库和分表还是有所区别的,简单总结如下:

  • 分库:解决数据库连接资源不足和磁盘 IO 性能问题
  • 分表:解决单表数据量太大问题

如果有些业务场景,用户并发量很大,但是产生的数据量不大,可以只分库,不分表;如果有些业务场景,用户并发量不大,但是产生的数据量很大,可以只分表,不分库;如果有些业务场景,用户并发量很大,产生的数据量也很大,那可以同时进行分库和分表。

参考