找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1978

积分

0

好友

274

主题
发表于 昨天 14:35 | 查看: 8| 回复: 0

接口性能优化是后端开发者持续面临的挑战。最近,我对一个线上的批量评分查询接口进行了优化,成功将其响应时间从最初的20秒降低到500毫秒以内。整个过程主要围绕三个关键步骤展开,下面将分享这次优化的具体思路与实施细节。

1. 问题定位:慢查询告警

日常监控中,一封慢查询汇总邮件引起了我的注意。其中一个批量评分查询接口的最大耗时达到了20秒,平均耗时也有2秒。通过链路追踪工具(如 SkyWalking)进一步分析,发现该接口在大多数情况下响应很快(约500毫秒),但偶尔会出现耗时极高的请求。

这引发了疑问:是查询的数据量不同导致的吗?例如,查询某个子组织很快,但查询根组织平台时数据量巨大。然而,排查后否定了这个猜测。最终,问题根源被锁定在调用方:他们在结算单列表页面批量调用此接口时,传入的参数列表(List)数据量异常庞大,可能包含数百甚至数千条记录。

2. 现状分析:复杂的串行处理

理想情况下,批量查询大量ID可以通过主键索引快速完成。但该接口的内部逻辑较为复杂,其简化后的伪代码如下:

public List<ScoreEntity> query(List<SearchEntity> list) {
    //结果
    List<ScoreEntity> result = Lists.newArrayList();
    //获取组织id
    List<Long> orgIds = list.stream().map(SearchEntity::getOrgId).collect(Collectors.toList());
    //通过fegin调用远程接口获取组织信息
    List<OrgEntity> orgList = feginClient.getOrgByIds(orgIds);

    for(SearchEntity entity : list) {
        //通过组织id找组织code
        String orgCode = findOrgCode(orgList, entity.getOrgId());

        //通过组合条件查询评价
        ScoreSearchEntity scoreSearchEntity = new ScoreSearchEntity();
        scoreSearchEntity.setOrgCode(orgCode);
        scoreSearchEntity.setCategoryId(entity.getCategoryId());
        scoreSearchEntity.setBusinessId(entity.getBusinessId());
        scoreSearchEntity.setBusinessType(entity.getBusinessType());
        List<ScoreEntity> resultList = scoreMapper.queryScore(scoreSearchEntity);

        if(CollectionUtils.isNotEmpty(resultList)) {
            ScoreEntity scoreEntity = resultList.get(0);
            result.add(scoreEntity);
        }
    }
    return result;
}

核心问题有两点:

  1. 需要远程调用(如通过Fegin)获取组织信息,这是必要逻辑,无法轻易去除。
  2. for 循环中,每条记录都需要根据不同的组合条件(org_code, category_id, business_id, business_type)查询数据库。由于调用方未传入ID,无法使用 WHERE id IN (...) 进行批量查询。若使用超长的 OR 条件拼接或 (a,b) IN ((1,2),(1,3)...) 写法,在数据量大时性能也堪忧。

因此,优化重点首先落在了循环内的单次查询效率上。

3. 第一招:优化数据库索引

最直接且成本较低的优化手段是数据库索引。原表仅在 business_id 字段上建有普通索引,对于新的组合查询条件效果不佳。

我为其添加了一个联合索引:

alter table user_score add index  `un_org_category_business` (`org_code`,`category_id`,`business_id`,`business_type`) USING BTREE;

该索引覆盖了查询条件中的所有字段,能够极大提升单次查询的效率。此招立竿见影,接口最大耗时从 20秒 降至 5秒 左右。

4. 第二招:利用多线程并行查询

第一招优化后,瓶颈转移到了串行的 for 循环。既然每次查询相对独立,为何不并行执行?

我们将单线程查询改为多线程并行。由于需要收集所有查询结果,Java 8 的 CompletableFuture 非常适合此场景。代码调整如下:

CompletableFuture[] futureArray = dataList.stream()
     .map(data -> CompletableFuture
          .supplyAsync(() -> query(data), asyncExecutor)
          .whenComplete((result, th) -> {
       })).toArray(CompletableFuture[]::new);
CompletableFuture.allOf(futureArray).join();

关键点:使用自定义线程池。为避免无限制创建线程,必须使用线程池。以下是两种常见的创建方式:

1. 使用 ThreadPoolExecutor

ExecutorService threadPool = new ThreadPoolExecutor(
    8, //corePoolSize线程池中核心线程数
    10, //maximumPoolSize 线程池中最大线程数
    60, //线程池中线程的最大空闲时间,超过这个时间空闲线程将被回收
    TimeUnit.SECONDS,//时间单位
    new ArrayBlockingQueue(500), //队列
    new ThreadPoolExecutor.CallerRunsPolicy()); //拒绝策略

2. 使用 ThreadPoolTaskExecutor (Spring常用):

@Configuration
public class ThreadPoolConfig {

    /**
     * 核心线程数量,默认1
     */
    private int corePoolSize = 8;

    /**
     * 最大线程数量,默认Integer.MAX_VALUE;
     */
    private int maxPoolSize = 10;

    /**
     * 空闲线程存活时间
     */
    private int keepAliveSeconds = 60;

    /**
     * 线程阻塞队列容量,默认Integer.MAX_VALUE
     */
    private int queueCapacity = 1;

    /**
     * 是否允许核心线程超时
     */
    private boolean allowCoreThreadTimeOut = false;

    @Bean("asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(corePoolSize);
        executor.setMaxPoolSize(maxPoolSize);
        executor.setQueueCapacity(queueCapacity);
        executor.setKeepAliveSeconds(keepAliveSeconds);
        executor.setAllowCoreThreadTimeOut(allowCoreThreadTimeOut);
        // 设置拒绝策略,直接在execute方法的调用线程中运行被拒绝的任务
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 执行初始化
        executor.initialize();
        return executor;
    }
}

通过引入多线程并行查询,接口性能再次提升约5倍,从 5秒 缩短至 1秒 左右。

5. 第三招:限制单次查询数据量并分批调用

经过前两轮优化,耗时仍在1秒以上,根本原因在于单次请求查询的数据量过大。之前的限制(2000条)仍显宽松。

于是,我们决定将单次接口调用允许的最大记录数降至 200条。但这直接限制接口会导致上游业务系统报错,因此需要协同业务方共同优化。我们探讨了两种方案:

方案一:前端分页
让结算单列表页前端实现分页加载订单数据,从根本上减少单次请求的数据量。但这需要前端开发资源,短期内无法实施。

方案二:业务端分批调用,并行聚合
这是最终采用的折中方案。业务系统后端将原先的一次性大查询,拆分成多个小批次(如每批100条),然后使用多线程同时调用评分查询接口,最后聚合所有结果。

为什么不在评分接口服务端无限增大线程池?

  1. 单服务节点资源有限,线程数不能无限增加。
  2. 线上服务通常部署多个节点。由业务端发起多线程调用,可以将负载自然地分散到不同的服务节点上,实现“伪”负载均衡,提高系统整体吞吐能力和抗压性。

业务系统采用8个线程进行分批调用后,整体耗时从 1秒 左右降至 500毫秒 以内。

6. 总结与思考

本次优化通过 “优化联合索引 → 引入多线程并行 → 限制批量并分批调用” 这三招,阶梯式地将接口性能从20s提升到500ms以内。这三招的改造成本由低到高,见效速度由快到慢,形成了有效的组合策略。

需要指出的是,使用多线程和分批调用是应对当前架构的临时优化方案。根本解决之道是重新设计数据模型(如适度冗余组织编码)或业务流程,但这往往涉及多方系统,改造周期长。临时的性能优化方案为我们赢得了宝贵的排期时间。

委屈的熊猫人表情包

云栈社区,你可以发现更多关于数据库与中间件性能调优及高并发处理的实战经验与深度讨论。性能优化是一条永无止境的路,不断分析、实验与总结,才能守护好系统的平稳高效运行。




上一篇:Trilium Notes部署指南:在NAS上用Docker搭建开源个人知识库
下一篇:Spring Boot参数校验:避免@NotEmpty误用,详解与@NotBlank区别
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-11 21:51 , Processed in 0.201815 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表