从 Elasticsearch 源码学习生产级并发调用的设计思想

104 阅读10分钟

一、为什么要研究这个问题?

在实际开发中,我们经常会遇到需要并发调用多个服务的场景。网上关于 CompletableFuture 的文章铺天盖地,但大多停留在 API 介绍层面,真正讲清楚"生产环境怎么用"的很少。

我的困惑是:

  • 线程池该怎么配置?博客里写的 corePoolSize=10, maxPoolSize=20 是怎么来的?
  • 真实的业务系统是怎么用的?
  • 有没有权威的开源项目可以参考?

带着这些问题,我开始了一段探索之旅。


二、在业务系统中找不到答案

2.1 翻遍开源电商项目

我首先想到的是去看电商项目,因为商品详情页是典型的并发场景。

ruoyi-vue-pro(28k+ stars)

  • 结果:所有业务逻辑都是同步的
  • 用的是 MyBatis 直接查数据库
  • 异步场景都用 RabbitMQ 解耦了

mall 项目(微服务版本)

  • 结果:微服务间调用用 Feign + Hystrix
  • 没有在单个接口里做复杂的并发编排
  • 异步任务也是走 MQ

为什么业务系统里找不到?

我后来想明白了:

  1. CRUD 系统的核心是事务一致性,不是性能优化
  2. 真正需要并发的场景,架构上会用 MQ 解耦
  3. 如果一个 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 的文档前面。

这就是典型的"分而治之"模式:

  1. 并行查询(分)
  2. 合并排序(治)

四、我们的真实业务场景:商品详情页

电商场景更容易理解,当用户打开商品详情页时:

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 倍!

为什么这是真实场景?

  1. 每个查询都是独立的,没有依赖关系
  2. 数据来源不同(MySQL、Redis、外部 API)
  3. 对用户体验影响直接(页面加载时间)

五、从 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 配置线程池的思考路径

112.jpg

6.2 不同场景的配置对比

场景核心线程数最大线程数队列容量拒绝策略
ES 搜索CPU 核心数CPU 核心数1000(固定)AbortPolicy
ES 写入CPU 核心数CPU 核心数10000(大队列)AbortPolicy
商品详情查询CPU 核心数CPU 核心数 × 2256 × 最大线程数CallerRunsPolicy
数据导入CPU 核心数CPU 核心数 × 2较小(避免堆积)AbortPolicy

6.3 监控指标设计

ES 内部对线程池有完善的监控,我们也应该关注:

核心指标:

  1. 活跃线程数:当前正在执行任务的线程数
  2. 队列大小:等待执行的任务数
  3. 拒绝次数:线程池满了被拒绝的任务数
  4. 完成任务数:历史累计完成的任务数
  5. 平均执行时间:任务的平均耗时

告警阈值:

  • 队列堆积 > 最大线程数 × 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 适合并发的场景

  1. 多个独立的 IO 操作

    • 查询多个数据表
    • 调用多个外部 API
    • 读取多个缓存 key
  2. 操作之间无依赖

    • 查商品和查评论没有先后顺序
    • ES 查多个索引独立进行
  3. 对响应时间敏感

    • 用户等待的接口(商品详情页)
    • 实时查询场景

8.2 不适合并发的场景

  1. 有依赖关系的操作

    // 错误:第二步依赖第一步的结果
    CompletableFuture<User> user = getUser();
    CompletableFuture<Orders> orders = getOrders(user.get().getId());
    

    应该用 thenCompose 串联。

  2. 需要事务一致性

    // 错误:扣库存和创建订单必须在同一个事务
    CompletableFuture<Void> f1 = deductInventory();
    CompletableFuture<Void> f2 = createOrder();
    
  3. 纯 CPU 密集计算 应该用 ParallelStreamForkJoinPool,而不是自定义线程池。

  4. 需要消息可靠性保证 用 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 配置线程池的核心逻辑

  1. 先判断任务类型(CPU 密集 / IO 密集)
  2. 基于 CPU 核心数计算线程数
  3. 用 ES 的经验公式计算队列大小
  4. 选择合适的拒绝策略
  5. 加上监控和告警
  6. 压测验证和调优

记住: 没有万能的配置,只有适合自己场景的配置。


参考资料:

  • Elasticsearch 官方源码:elasticsearch/server/src/main/java/org/elasticsearch/threadpool/
  • ES Issue #18613: 线程池改进
  • ES PR #18491: 移除无界队列
  • ES Issue #14448: 队列大小限制

全文完