一、为什么要研究这个问题?
在实际开发中,我们经常会遇到需要并发调用多个服务的场景。网上关于 CompletableFuture 的文章铺天盖地,但大多停留在 API 介绍层面,真正讲清楚"生产环境怎么用"的很少。
我的困惑是:
- 线程池该怎么配置?博客里写的
corePoolSize=10, maxPoolSize=20是怎么来的? - 真实的业务系统是怎么用的?
- 有没有权威的开源项目可以参考?
带着这些问题,我开始了一段探索之旅。
二、在业务系统中找不到答案
2.1 翻遍开源电商项目
我首先想到的是去看电商项目,因为商品详情页是典型的并发场景。
ruoyi-vue-pro(28k+ stars)
- 结果:所有业务逻辑都是同步的
- 用的是 MyBatis 直接查数据库
- 异步场景都用 RabbitMQ 解耦了
mall 项目(微服务版本)
- 结果:微服务间调用用 Feign + Hystrix
- 没有在单个接口里做复杂的并发编排
- 异步任务也是走 MQ
为什么业务系统里找不到?
我后来想明白了:
- CRUD 系统的核心是事务一致性,不是性能优化
- 真正需要并发的场景,架构上会用 MQ 解耦
- 如果一个 API 需要调 10 个依赖,那可能是设计有问题
2.2 转变思路:去中间件里找
既然业务系统里用得少,那中间件肯定有!中间件的核心诉求就是性能。
于是我锁定了 Elasticsearch:
- 跨多个分片/索引并行搜索
- 结果需要合并排序
- 有官方 Java Client 实现
三、Elasticsearch 的两种并发场景
3.1 场景一:单索引多分片并行查询
即使是同一个索引,ES 内部也会分成多个分片(shard)存储。
graph TB
A[搜索请求: logs-2024] --> B[协调节点]
B --> C[Shard 0]
B --> D[Shard 1]
B --> E[Shard 2]
C --> F[10条结果]
D --> G[10条结果]
E --> H[10条结果]
F --> I[合并排序]
G --> I
H --> I
I --> J[Top 10 结果]
关键点:
- ES 自动并行查询所有分片
- 每个分片返回局部 Top 10
- 协调节点合并后重新排序,取全局 Top 10
这是 ES 内部的实现,对用户透明。
3.2 场景二:跨多个索引并行查询
用户按时间分片创建了多个索引:
logs-2024-01
logs-2024-02
logs-2024-03
当搜索时,ES 会并行查询这 3 个索引:
graph LR
A[搜索请求] --> B[logs-2024-01]
A --> C[logs-2024-02]
A --> D[logs-2024-03]
B --> E[10条结果, 分数: 0.9, 0.85...]
C --> F[10条结果, 分数: 0.95, 0.88...]
D --> G[10条结果, 分数: 0.92, 0.87...]
E --> H[按分数重新排序]
F --> H
G --> H
H --> I[全局 Top 10]
为什么需要重新排序?
每个索引独立计算相关性分数,但全局来看,logs-2024-02 中分数 0.95 的文档应该排在 logs-2024-01 中分数 0.9 的文档前面。
这就是典型的"分而治之"模式:
- 并行查询(分)
- 合并排序(治)
四、我们的真实业务场景:商品详情页
电商场景更容易理解,当用户打开商品详情页时:
graph TB
A[用户请求: /product/123] --> B[ProductController]
B --> C[查询商品基本信息]
B --> D[查询评论列表]
B --> E[查询库存状态]
B --> F[查询优惠活动]
B --> G[查询推荐商品]
C --> H[MySQL: product表]
D --> I[MySQL: review表]
E --> J[Redis: inventory缓存]
F --> K[活动服务API]
G --> L[推荐服务API]
H --> M[等待所有完成]
I --> M
J --> M
K --> M
L --> M
M --> N[组装返回]
串行 vs 并行的性能对比:
| 操作 | 耗时 |
|---|---|
| 查商品信息 | 50ms |
| 查评论 | 80ms |
| 查库存 | 30ms |
| 查优惠 | 100ms |
| 查推荐 | 120ms |
- 串行总耗时: 50 + 80 + 30 + 100 + 120 = 380ms
- 并行总耗时: max(50, 80, 30, 100, 120) = 120ms
性能提升 3 倍!
为什么这是真实场景?
- 每个查询都是独立的,没有依赖关系
- 数据来源不同(MySQL、Redis、外部 API)
- 对用户体验影响直接(页面加载时间)
五、从 ES 源码学到的核心思想
5.1 线程池设计哲学
我翻到了 ES 的核心代码:
位置: elasticsearch/server/src/main/java/org/elasticsearch/threadpool/ThreadPool.java
ES 的关键决策(来自官方 PR 和 Issue):
| 决策 | 来源 | 原因 |
|---|---|---|
| 移除无界队列 | PR #18491 | 防止 OOM |
| 队列大小 = 256 × 最大线程数 | Issue #14448 | 既能缓冲又不会无限增长 |
| 线程数基于 CPU 核心数 | Issue #18613 | 避免过度上下文切换 |
| 使用快速失败的拒绝策略 | 源码 | 让问题尽早暴露,不隐藏 |
核心原则:
// 不是这样配置
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // 为什么是10?
20, // 为什么是20?
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500) // 为什么是500?
);
// 而是这样
int processors = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
processors, // 核心线程数 = CPU核心数
processors * 2, // 最大线程数(IO场景)
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(256 * processors * 2), // 有理论依据的队列大小
new ThreadFactory(), // 自定义线程工厂(带名字)
new AbortPolicy() // 快速失败策略
);
5.2 为什么这么设计?
1. 核心线程数 = CPU 核心数
线程的核心作用是利用 CPU。如果线程数远大于核心数,就会频繁上下文切换,反而降低性能。
假设 CPU 有 8 核:
- 配置 8 个线程:充分利用,切换少
- 配置 100 个线程:大量时间花在切换上,实际执行时间变少
2. 最大线程数 = CPU 核心数 × 2(IO 密集型)
当线程在等待 IO(查数据库、调接口)时,CPU 是空闲的。此时可以有更多线程,让 CPU 不闲着。
8 核 CPU:
- 纯计算任务:8 个线程最佳
- IO 密集任务:8 × 2 = 16 个线程,因为有一半时间在等 IO
3. 队列大小 = 256 × 最大线程数
这是 ES 团队测试出来的经验值:
- 太小:来不及缓冲突发流量
- 太大:堆积任务过多,响应时间变长,还可能 OOM
最大 16 个线程:
队列容量 = 256 × 16 = 4096
含义:最多可以缓冲 4096 个待处理任务
4. 线程必须有名字
ES 的所有线程都有明确的名字前缀:search-, write-, listener-
// 错误:线程名是 pool-1-thread-3,看不出在干什么
new ThreadPoolExecutor(...)
// 正确:线程名是 ProductQuery-1,一眼看出是商品查询
executor.setThreadNamePrefix("ProductQuery-");
在生产环境排查问题时,线程 dump 能清晰看到每个线程的用途。
5.3 拒绝策略的选择
ES 用的是 AbortPolicy(直接抛异常),而不是:
DiscardPolicy(静默丢弃)DiscardOldestPolicy(丢弃最老的)CallerRunsPolicy(调用者执行)
为什么?
中间件的核心诉求是稳定性:
- 宁可快速失败,也不要静默丢数据
- 宁可让调用方感知到压力,也不要自己硬扛
但业务系统不同:
业务系统更希望降级而不是失败,所以可以用 CallerRunsPolicy:
- 线程池满了,就在当前线程执行
- 虽然变成同步了,但总比报错好
六、提炼出的设计原则
6.1 配置线程池的思考路径
6.2 不同场景的配置对比
| 场景 | 核心线程数 | 最大线程数 | 队列容量 | 拒绝策略 |
|---|---|---|---|---|
| ES 搜索 | CPU 核心数 | CPU 核心数 | 1000(固定) | AbortPolicy |
| ES 写入 | CPU 核心数 | CPU 核心数 | 10000(大队列) | AbortPolicy |
| 商品详情查询 | CPU 核心数 | CPU 核心数 × 2 | 256 × 最大线程数 | CallerRunsPolicy |
| 数据导入 | CPU 核心数 | CPU 核心数 × 2 | 较小(避免堆积) | AbortPolicy |
6.3 监控指标设计
ES 内部对线程池有完善的监控,我们也应该关注:
核心指标:
- 活跃线程数:当前正在执行任务的线程数
- 队列大小:等待执行的任务数
- 拒绝次数:线程池满了被拒绝的任务数
- 完成任务数:历史累计完成的任务数
- 平均执行时间:任务的平均耗时
告警阈值:
- 队列堆积 > 最大线程数 × 100:可能有慢查询
- 拒绝次数突增:流量超过处理能力
- 活跃线程数长期 = 最大线程数:需要扩容
七、实践中的关键细节
7.1 避开 Spring @Async 的坑
Spring 的 @Async 基于代理,同一个类内部调用不会生效:
// 错误示范
@Service
public class ProductService {
@Async
public CompletableFuture<Product> getProduct(Long id) {
// ...
}
public ProductDetailVO getDetail(Long id) {
// 这样调用不会异步执行!
CompletableFuture<Product> future = this.getProduct(id);
}
}
原因: this.getProduct() 直接调用了原始方法,绕过了代理。
正确做法: 把异步方法抽到独立的 Service 类。
7.2 异常处理的艺术
CompletableFuture 的异常不会直接抛出,需要显式处理:
CompletableFuture<Product> productFuture = service.getProduct(id)
.exceptionally(ex -> {
log.error("查询商品失败,使用缓存", ex);
return getCachedProduct(id); // 降级
});
ES 的做法: 快速失败,让调用方感知异常 业务系统: 优雅降级,返回缓存或默认值
7.3 超时控制
某个查询过慢会拖累整体性能:
CompletableFuture<Reviews> reviewsFuture = service.getReviews(id)
.orTimeout(2, TimeUnit.SECONDS) // Java 9+
.exceptionally(ex -> {
log.warn("评论查询超时,返回空列表");
return Collections.emptyList();
});
7.4 链路追踪的传递
在分布式系统中,traceId 需要传递到异步线程:
String traceId = MDC.get("traceId");
CompletableFuture.supplyAsync(() -> {
MDC.put("traceId", traceId); // 恢复 traceId
return doQuery();
}, executor);
八、何时应该用并发,何时不该用
8.1 适合并发的场景
-
多个独立的 IO 操作
- 查询多个数据表
- 调用多个外部 API
- 读取多个缓存 key
-
操作之间无依赖
- 查商品和查评论没有先后顺序
- ES 查多个索引独立进行
-
对响应时间敏感
- 用户等待的接口(商品详情页)
- 实时查询场景
8.2 不适合并发的场景
-
有依赖关系的操作
// 错误:第二步依赖第一步的结果 CompletableFuture<User> user = getUser(); CompletableFuture<Orders> orders = getOrders(user.get().getId());应该用
thenCompose串联。 -
需要事务一致性
// 错误:扣库存和创建订单必须在同一个事务 CompletableFuture<Void> f1 = deductInventory(); CompletableFuture<Void> f2 = createOrder(); -
纯 CPU 密集计算 应该用
ParallelStream或ForkJoinPool,而不是自定义线程池。 -
需要消息可靠性保证 用 MQ(如 RabbitMQ、RocketMQ),而不是 CompletableFuture。
九、生产环境的调优思路
9.1 压测验证
配置不是拍脑袋出来的,要用真实流量压测:
1. 初始配置:核心线程数 = CPU核心数
2. 压测工具:JMeter / Gatling
3. 监控指标:TPS、响应时间、线程池状态
4. 调整参数:根据监控数据优化
5. 重复压测:验证效果
9.2 分业务隔离
不同业务用不同的线程池,避免互相影响:
@Bean("productQueryExecutor")
public Executor productQueryExecutor() { ... }
@Bean("orderProcessExecutor")
public Executor orderProcessExecutor() { ... }
@Bean("reportGenerationExecutor")
public Executor reportGenerationExecutor() { ... }
好处:
- 报表生成慢,不会影响商品查询
- 可以针对不同业务特点配置参数
- 监控更精细
9.3 优雅降级策略
当系统压力大时,要有降级方案:
1. 降级非核心功能:推荐商品查询失败,返回空列表
2. 使用缓存数据:评论查询超时,返回缓存的热门评论
3. 简化返回结果:只返回必要字段
4. 限流保护:对单个用户限制并发数
十、总结
这次探索让我明白了几个道理:
10.1 权威项目的价值
博客文章可能会"YY"一些场景,但 Elasticsearch 这样的顶级项目,每一行代码、每一个配置都有深思熟虑的原因:
- 为什么移除无界队列?(防止 OOM)
- 为什么线程数等于 CPU 核心数?(避免上下文切换)
- 为什么队列大小是 256 倍?(经验值)
10.2 理解比代码更重要
最终的代码可能差不多,但理解了原理后:
- 知道为什么这么配
- 知道怎么针对自己的场景调整
- 知道出问题怎么排查
- 知道如何监控和优化
10.3 真实场景的两面性
ES 的场景: 同类数据的并行查询 + 合并排序
- 多个索引/分片
- 结果需要重新排序
- 强调正确性
业务场景: 不同数据源的并行查询 + 简单组装
- 商品、评论、库存、优惠
- 结果直接组装
- 强调响应时间
两者的共性是"并行 + 合并",但具体实现和侧重点不同。
10.4 配置线程池的核心逻辑
- 先判断任务类型(CPU 密集 / IO 密集)
- 基于 CPU 核心数计算线程数
- 用 ES 的经验公式计算队列大小
- 选择合适的拒绝策略
- 加上监控和告警
- 压测验证和调优
记住: 没有万能的配置,只有适合自己场景的配置。
参考资料:
- Elasticsearch 官方源码:
elasticsearch/server/src/main/java/org/elasticsearch/threadpool/ - ES Issue #18613: 线程池改进
- ES PR #18491: 移除无界队列
- ES Issue #14448: 队列大小限制
全文完