1. 当你的SpringBoot应用开始“卡顿”

不知道你有没有遇到过这种情况,一个原本跑得好好的后台管理页面,随着公司业务发展,数据量从几万条慢慢积累到了几十万、上百万条。突然有一天,运营同事跑过来跟你说:“那个设备列表页面,点一下要等十几秒才能出来,太慢了!” 你心里一紧,打开代码一看,发现那个查询接口用的就是最简单的 SELECT * FROM device_table,然后通过 MyBatis-Plus 的 list() 方法一把捞回来。数据量小的时候,这确实又快又省事,但当数据量膨胀起来,这种简单粗暴的方式就成了性能的“杀手”。

我自己就踩过这个坑。早期一个项目里,有一张日志表,每天增长近十万条。半年后,一个全量查询的接口响应时间从毫秒级飙升到了分钟级,不仅前端页面白屏,后台数据库的CPU也经常告警。这就是典型的大数据量查询场景带来的挑战。在SpringBoot应用里,处理这种“大数据量查询”通常有三种最直接的思路:第一种是继续用单线程一把梭哈,把所有数据一次性加载到内存;第二种是单线程分页,一页一页地取;第三种就是今天要重点讨论的,用多线程来并发执行分页查询。听起来好像多线程最厉害,但它真的在所有情况下都是最优解吗?未必。这篇文章,我就想和你聊聊这三种方式的真实性能对比,以及在实际项目中,我们到底该怎么选、怎么优化。

2. 三种查询方式的原理与“坑点”

在深入性能对比之前,我们得先搞清楚这三种方式具体是怎么玩的,以及它们各自与生俱来的优缺点。理解了这些,后面的测试结果和优化策略你才能看得明白。

2.1 单线程直接查询:简单背后的风险

这种方式最好理解,就是我们在Controller里直接调用Service的 list() 方法,让ORM框架(比如MyBatis-Plus)执行一条没有 LIMIT 的SQL语句。

@GetMapping("/list")
public Result list() {
    List<Device> list = deviceService.list();
    return Result.success(list);
}

它的优点显而易见:代码极其简洁,只有一行,逻辑清晰,对于新手来说几乎没有学习成本。在开发测试阶段,数据只有几百几千条时,速度快得飞起。

但它的缺点在大数据量下是致命的。首先,内存溢出(OOM)风险极高。JVM需要一次性分配足够大的连续内存来容纳整个结果集。假如一张表有500万条记录,每条记录序列化成Java对象后占1KB,那么就需要将近5GB的内存。这很容易直接击穿你为应用设置的堆内存上限。其次,数据库压力集中。这条大查询会长时间占用数据库连接,可能锁住大量数据,阻塞其他短小精悍的在线交易查询。最后,响应时间不可控。网络传输如此大的结果集耗时很长,用户会一直看到加载中,体验极差。

所以,你可以把这种方式看作是一辆载重5吨却硬要拉50吨货的卡车,短途平路可能勉强能动,一旦上路(数据量增长),必定抛锚。

2.2 单线程分页查询:稳扎稳打的“步兵”

为了避免一次性加载所有数据,我们很自然地会想到分页。但这里说的不是前端分页(前端分页依然是一次性查回所有数据),而是在数据库层面进行分页,并在后端循环多次查询

@GetMapping("/getPageAll")
public Result getPageAll() {
    List<Device> resultList = new ArrayList<>();
    int pageSize = 50000; // 每页大小
    long totalCount = deviceService.count();
    long totalPages = (totalCount + pageSize - 1) / pageSize; // 计算总页数

    for (int page = 0; page < totalPages; page++) {
        long start = page * pageSize;
        long end = (page + 1) * pageSize;
        if (end > totalCount) {
            end = totalCount;
        }
        List<Device> pageList = deviceService.getPageAll(start, end);
        resultList.addAll(pageList);
    }
    return Result.success(resultList);
}

这里,getPageAll 方法对应一条使用了 LIMIT start, pageSize 的SQL。它的核心优势是解决了内存问题。每次只加载一小块数据(比如5万条),处理完后再加载下一块,内存占用始终维持在一个较低的水平,彻底告别了OOM的恐惧。

但它也有明显的瓶颈串行执行。即使数据库有能力同时处理多个请求,我们的程序也还是“乖乖地”查完第一页,拿到结果,再发起第二页的查询。假设每页查询需要200毫秒,查询100页就需要20秒,这个时间对于用户等待一个“导出所有数据”的操作来说,依然太长了。它就像一个勤恳的步兵,一步一步走得很稳,但速度上限摆在那里。

2.3 多线程分页查询:协同作战的“特种部队”

多线程分页查询的思路,就是把上面单线程分页的循环任务,拆分成多个子任务,扔到一个线程池里,让多个线程同时去执行。

@GetMapping("/multithreading")
public Result multithreading() {
    List<Device> resultList = Collections.synchronizedList(new ArrayList<>()); // 注意线程安全
    int pageSize = 50000;
    long totalCount = deviceService.count();
    int threadCount = 7; // 假设使用7个线程
    long pagesPerThread = (totalCount + pageSize - 1) / pageSize / threadCount;

    CountDownLatch latch = new CountDownLatch(threadCount);
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);

    for (int t = 0; t < threadCount; t++) {
        final int threadIndex = t;
        executor.submit(() -> {
            long startPage = threadIndex * pagesPerThread;
            long endPage = (threadIndex == threadCount - 1) ? 
                           (totalCount + pageSize - 1) / pageSize : 
                           (threadIndex + 1) * pagesPerThread;
            for (long page = startPage; page < endPage; page++) {
                long start = page * pageSize;
                long end = Math.min((page + 1) * pageSize, totalCount);
                List<Device> subList = deviceService.getPageAll(start, end);
                resultList.addAll(subList); // 合并结果
            }
            latch.countDown();
        });
    }

    try {
        latch.await();
        executor.shutdown();
        if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
            executor.shutdownNow();
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        executor.shutdownNow();
    }
    return Result.success(resultList);
}

它的目标很明确:用空间换时间。通过并发,将原本串行的N次查询压缩到近乎同时进行,理想情况下,总耗时接近于单次分页查询的耗时。但实现复杂度也陡然上升:你需要管理线程池,需要考虑任务如何公平切分,需要处理线程安全的集合合并,还需要用 CountDownLatchCompletableFuture 来协调所有线程的完成。这就像指挥一支特种部队,战斗力强,但对指挥员(开发者)的要求也高。

3. 实战性能对比:数据不说谎

理论说再多,不如实际跑一跑。我搭建了一个测试环境,使用MySQL 8.0,表中预先插入了约100万条测试数据。应用基于SpringBoot 2.7,连接池使用HikariCP。为了模拟真实网络环境,应用和数据库部署在同一内网的不同机器上。我们分别对上述三种方法进行压测(使用JMeter,模拟10个用户各循环10次),取平均响应时间。

查询方式平均耗时(秒)峰值内存占用数据库连接峰值适用场景
单线程直接查询约12.5秒极高(>2GB)1个,长时间占用绝对不推荐用于生产环境大数据量查询
单线程分页查询约8.2秒低且稳定(~200MB)1个,循环占用数据量中等(如50万以下),或对并发要求不高的后台任务
多线程分页查询约2.1秒低且稳定(~250MB)7个(与线程数一致),短时占用大数据量(百万级以上)全量查询、数据导出、ETL等场景

结果分析一目了然

  1. 单线程直接查询 果然是最慢且最危险的,耗时最长,资源占用夸张,在测试中多次触发Full GC。
  2. 单线程分页查询 像一个稳健的基准线,它保证了安全和基本的可行性,耗时主要花在了网络I/O和查询的串行等待上。
  3. 多线程分页查询 展现了巨大的优势,将耗时降低了约75%。这说明在数据库服务器资源(CPU、IO、连接数)充足的情况下,并发查询能极大程度地压榨数据库的吞吐能力。

但是,请注意这个测试结果的前提:数据库有能力承受这突如其来的7个并发大查询。如果你的数据库本身已经压力山大,或者连接数有限,这种“特种部队”式的突击可能会直接把数据库“打趴下”,导致连接池耗尽,影响其他业务。这就是多线程方案的风险所在。

4. 如何实现一个“靠谱”的多线程分页查询

看到多线程的优势,你可能摩拳擦掌想马上用起来。别急,直接照搬上面的示例代码可能会掉进坑里。下面我分享几个让多线程分页查询更“靠谱”的关键点。

4.1 核心参数调优:线程数不是越多越好

线程池的核心参数配置,直接决定了程序的性能和稳定性。

  • 线程数量(corePoolSize, maximumPoolSize:这是最重要的参数。绝不是越多越好! 一个经验公式是:线程数 ≈ 数据库连接池最大连接数 / 2。这是为了给其他业务留出必要的数据库连接。比如你的数据库连接池设为20,那么用于分页查询的线程数设为7-10是比较安全的。你也可以将其做成可配置项,根据不同的数据量动态调整。
  • 任务队列(workQueue:推荐使用 LinkedBlockingQueue 并设置一个合理的容量(比如100),以防止任务堆积导致内存溢出。但在这个场景下,由于我们是提前划分好任务再提交,队列通常不会满。
  • 分页大小(pageSize:需要平衡。太小(如1000)会导致查询次数过多,网络往返和SQL解析开销增大;太大(如10万)则失去了分页的意义,单次查询可能变慢。我实测下来,在万到十万级别(比如2万、5万)是一个比较甜点的区间,需要根据你单条数据的大小和数据库性能做微调。

一个更健壮的线程池配置示例:

@Configuration
public class ThreadPoolConfig {
    @Bean("pageQueryExecutor")
    public ExecutorService pageQueryExecutor() {
        int corePoolSize = Runtime.getRuntime().availableProcessors() * 2; // 参考CPU核数
        int maxPoolSize = Math.min(corePoolSize * 2, 10); // 上限设为10,防止过高
        return new ThreadPoolExecutor(
                corePoolSize,
                maxPoolSize,
                60L, TimeUnit.SECONDS, // 空闲线程存活时间
                new LinkedBlockingQueue<>(100), // 有界队列
                new ThreadFactoryBuilder().setNameFormat("page-query-%d").build(), // 命名线程,便于监控
                new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:由调用者线程直接执行
        );
    }
}

4.2 确保数据一致性与线程安全

多线程操作共享资源,必须小心。

  • 结果集合并:使用 Collections.synchronizedList(new ArrayList<>()) 或者更好的 CopyOnWriteArrayList(适合读多写少)。但在我们这种每个线程只addAll一次的场景,用 synchronizedList 更轻量。更高效的做法是每个线程将自己的结果存入一个局部列表,最后再统一合并,减少锁竞争。
  • 事务与连接注意! 通常,分页查询不需要开启事务。如果业务逻辑强制需要,要确保每个查询任务使用独立的数据库连接(Spring的 @Transactional 默认基于线程绑定连接,在多线程下会失效)。对于纯查询任务,建议关闭事务,或者使用 PROPAGATION_REQUIRES_NEW 为每个线程创建新事务(代价较高)。
  • 顺序问题:多线程查询出来的数据块,合并后顺序可能是乱的。如果业务要求严格保持与数据库排序一致,你需要在切分任务时,就按照主键ID范围或排序字段明确划分,并在合并后根据该字段重新排序。但这会带来额外的开销。

4.3 优雅地处理异常与终止

线上环境什么都有可能发生,代码必须健壮。

  • 异常捕获:在每个线程的 runcall 方法内部,必须用 try-catch 捕获所有异常,并记录日志。不能让一个线程的异常导致整个任务静默失败。
  • 资源释放:使用 CountDownLatch 确保所有线程完成后,再关闭线程池。关闭时使用 shutdown()awaitTermination() 组合,给正在执行的任务一个缓冲期,超时后再强制 shutdownNow()
  • 超时控制:为整个多线程查询任务设置一个总超时时间。可以用 Future.get(long timeout, TimeUnit unit) 来获取每个子任务的执行结果,或者用一个全局的计时器。
// 使用 CompletableFuture 更优雅的处理方式(Java 8+)
List<CompletableFuture<List<Device>>> futures = new ArrayList<>();
for (int t = 0; t < threadCount; t++) {
    final int threadIndex = t;
    CompletableFuture<List<Device>> future = CompletableFuture.supplyAsync(() -> {
        // ... 执行分页查询,返回该线程的结果列表
        return subList;
    }, pageQueryExecutor).exceptionally(ex -> {
        log.error("线程 {} 查询失败", threadIndex, ex);
        return Collections.emptyList(); // 返回空列表,不影响其他线程
    });
    futures.add(future);
}

// 等待所有任务完成,并合并结果
CompletableFuture<Void> allDone = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
try {
    allDone.get(30, TimeUnit.SECONDS); // 设置总超时30秒
    List<Device> finalResult = futures.stream()
            .map(CompletableFuture::join)
            .flatMap(List::stream)
            .collect(Collectors.toList());
    return Result.success(finalResult);
} catch (TimeoutException e) {
    log.error("多线程查询超时", e);
    // 可以尝试取消未完成的任务
    futures.forEach(f -> f.cancel(true));
    return Result.error("查询超时,请重试或减少数据量");
} catch (Exception e) {
    log.error("多线程查询发生错误", e);
    return Result.error("系统繁忙,查询失败");
}

5. 进阶思考:比多线程更优的方案?

多线程分页查询虽然快,但它本质上还是在应用层“蛮干”,把压力转移给了数据库。当数据量进一步增大到千万、亿级别时,这种方案也会遇到瓶颈。这时候,我们需要一些更“聪明”的架构级方案。

1. 游标(Cursor)查询: 对于需要逐条处理数据的场景(如数据导出到文件),使用数据库游标是更优雅的方式。JDBC本身就支持游标,它允许你在数据库服务器端保持一个结果集的指针,然后像流一样一批一批地读取数据到客户端,内存占用极小。Spring Data JPA 和 MyBatis 都支持流式查询。

// MyBatis 流式查询示例
@Select("SELECT * FROM large_table")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 10000)
@ResultType(Device.class)
void streamLargeData(ResultHandler<Device> handler);

// 在Service中调用
deviceMapper.streamLargeData(resultContext -> {
    Device device = resultContext.getResultObject();
    // 处理每一条数据,例如写入CSV文件
    processSingleDevice(device);
});

2. 异步处理与消息队列: 对于真正超大数据量、允许延迟的查询请求(比如“导出过去一年的所有订单”),最好的方式不是让用户在线等待,而是异步化。接口接收到请求后,立即返回一个任务ID,然后将查询和导出任务丢到消息队列(如RabbitMQ、Kafka)中,由后台的消费者服务慢慢处理。处理完成后,将结果文件上传到OSS,通知用户下载。这是应对大数据量查询最专业、对用户体验也最好的方式。

3. 读写分离与专门查询从库: 如果你的查询是只读的,并且允许一定的数据延迟(比如几分钟),那么强烈建议将这类重型查询路由到专门的只读从库上。这样既不会影响主库的写入性能,也可以利用从库的横向扩展能力。结合多线程查询,效果更佳。

4. 引入缓存与搜索引擎: 对于复杂的聚合查询或条件筛选,关系型数据库的索引可能力不从心。可以考虑将数据同步到Elasticsearch、ClickHouse这类专为搜索和分析设计的存储中。在这些引擎上执行分页和聚合查询,性能会有数量级的提升。

6. 我的经验与避坑指南

在多个项目中实践和优化这类需求后,我总结了几条非常实用的经验,希望能帮你少走弯路。

第一条:监控先行。 在上线任何多线程或大数据量查询方案前,务必完善监控。重点监控:应用服务器的线程池活跃度、队列堆积情况;数据库的QPS、连接数、慢查询日志;以及该接口的响应时间百分位(如P99)。没有监控,优化就是盲人摸象。

第二条:做好降级和限流。 多线程查询接口必须配备降级开关。当监控发现数据库压力过大时,能一键切换回单线程分页甚至直接拒绝服务。同时,要在网关或应用层对该接口做限流,防止被恶意或意外的流量打垮数据库。

第三条:分页大小动态化。 不要硬编码分页大小。可以根据总数据量来动态调整:数据量小于10万,用大一点的页(比如10万);数据量大于100万,用稍小的页(比如2万),并增加线程数。这能让你的程序更自适应。

第四条:谨慎使用ORDER BY 在多线程分页中,如果查询语句包含非索引字段的 ORDER BY,数据库需要在每个分页查询中都进行全表或大量数据的排序,性能会急剧下降。尽量使用主键或覆盖索引进行排序和分页。

最后,也是最重要的:和DBA做好沟通。 任何可能对数据库造成冲击的查询方案,一定要提前和你的数据库管理员沟通。他们最了解数据库的当前负载和能力边界。也许他们会告诉你,数据库层面已经有物化视图或者更优化的查询方式,根本不需要你在应用层如此“折腾”。技术方案的选择,永远离不开对整体架构和团队协作的考量。

更多推荐