Yujun's Blog
多线程提升API性能的正确姿势
多线程提升API性能的正确姿势
在我们的日常工作中,经常会遇到这样的场景,比如负责的某个API接口,明明每一步逻辑都不复杂,但因为它需要调用好几个其他服务或者查询好几次数据库,整个接口响应很慢。用户在抓狂,监控在报警,老板在催你优化。
今天,这篇教程将手把手带你走出这个困境。我们将通过一个极其常见的业务场景——电商商品详情页的聚合数据加载,来学习如何运用多线程技术,让的API性能飞起来。
场景分析:为什么我们的接口这么慢?
当用户打开一个商品详情页时,我们需要展示哪些信息?
- 商品基本信息:名称、描述、规格等。(来自商品库,耗时T1 = 30ms)
- 实时价格与库存:可能需要单独调用计价中心和库存中心。(来自库存服务,耗时T2 = 50ms)
- 营销活动信息:这个商品正在参加什么满减、拼团活动?(来自营销库,耗时T3 = 40ms)
- 用户评价摘要:好评率、最新几条评价。(来自评价库,耗时T4 = 25ms)
如果用最直观的串行方式编码,代码会是这样:
public ProductDetailVO getProductDetail(String productId) { // 步骤1: 获取商品信息 ProductInfo info = productRpcService.getProductInfo(productId); // 耗时 30ms // 步骤2: 获取库存信息 StockInfo stock = stockRpcService.getStock(productId); // 耗时 50ms // 步骤3: 获取营销信息 MarketingInfo marketing = marketingDb.queryByProductId(productId); // 耗时 40ms // 步骤4: 获取用户评价 CommentSummary comments = commentDb.querySummary(productId); // 耗时 25ms // 组装返回结果... return new ProductDetailVO(info, stock, marketing, comments); }
总耗时 = 30 + 50 + 40 + 25 = 145ms。
而在互联网场景中,用户体验是生命线,接口性能更是重中之重。一个核心API的响应时间(P99),往往被严格限制在50ms-100ms以内。
这个耗时对于一个核心页面来说,是灾难性的。问题出在哪?
核心瓶颈:这四个数据查询任务互相之间没有任何依赖关系!我们在苦苦等待查询库存的时候,CPU完全可以去同时查询营销和评价信息。串行执行,就是对宝贵的响应时间的巨大浪费。
所以,一个明显的优化思路是:将这四个独立的IO密集型任务,从排队过安检模式,变为多通道同时安检模式——也就是并发执行。
解决方案:CompletableFuture
在Java中,实现并发的方式有很多,比如直接用 Thread
、FutureTask
、ExecutorService
。但在Java 8及以后,我们有了一个更强大、更易于编排的工具:CompletableFuture
。
CompletableFuture
的优势在于其流式API和强大的组合能力,能让异步代码写得像同步代码一样清晰。
接下来我们来改造下上面的代码。
Step 1:准备一个线程池
多线程不是无成本的,频繁创建和销毁线程开销很大。在项目中,我们必须使用线程池来管理线程。
// 在配置类(Configuration)中定义一个全局的线程池Bean @Configuration public class ThreadPoolConfig { @Bean("io密集型线程池") // 给Bean起个明确的名字 public ExecutorService ioExecutorService() { // 获取CPU核心数,用于计算线程数 int coreCount = Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor executor = new ThreadPoolExecutor( coreCount, // 核心线程数 coreCount * 2, // 最大线程数 60L, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new LinkedBlockingQueue<>(2000), // 任务队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 ); return executor; } }
面试小贴士:为什么核心线程数设为coreCount,最大设为coreCount * 2?这是IO密集型任务的常见配置。因为IO任务大部分时间在等待(比如等数据库返回),CPU是空闲的,所以可以设置比CPU核心数更多的线程来提高吞吐。
Step 2:使用 CompletableFuture.supplyAsync
提交并行任务
现在,我们来重写 getProductDetail
方法。
@Service public class ProductDetailService { @Resource(name = "io密集型线程池") // 注入我们定义的线程池 private ExecutorService executorService; // ... 注入各种RPC和DAO服务 public ProductDetailVO getProductDetail(String productId) throws ExecutionException, InterruptedException { // 1. 提交所有异步任务到线程池 CompletableFuture<ProductInfo> infoFuture = CompletableFuture.supplyAsync(() -> { // 任务1:获取商品信息 return productRpcService.getProductInfo(productId); }, executorService); CompletableFuture<StockInfo> stockFuture = CompletableFuture.supplyAsync(() -> { // 任务2:获取库存信息 return stockRpcService.getStock(productId); }, executorService); CompletableFuture<MarketingInfo> marketingFuture = CompletableFuture.supplyAsync(() -> { // 任务3:获取营销信息 return marketingDb.queryByProductId(productId); }, executorService); CompletableFuture<CommentSummary> commentsFuture = CompletableFuture.supplyAsync(() -> { // 任务4:获取用户评价 return commentDb.querySummary(productId); }, executorService); // 2. 等待所有任务完成 // CompletableFuture.allOf() 接收一个CompletableFuture数组,当所有任务都完成时,它才会完成。 CompletableFuture.allOf(infoFuture, stockFuture, marketingFuture, commentsFuture).join(); // 3. 从Future中获取结果并组装 ProductInfo info = infoFuture.get(); StockInfo stock = stockFuture.get(); MarketingInfo marketing = marketingFuture.get(); CommentSummary comments = commentsFuture.get(); return new ProductDetailVO(info, stock, marketing, comments); } }
通过改造,四个任务被同时提交到了线程池中并行执行。此时,整个数据准备阶段的总耗时不再是累加,而是取决于最慢的那个任务。
总耗时 ≈ max(30, 50, 40, 25) = 50ms。
接口性能从 145ms 优化到了 50ms,提升了近 65%,这是一个巨大的飞跃。
进阶与最佳实践
上面的代码已经能很好地工作了,但在真实的企业级项目中,我们还需要考虑更多细节。
- 优雅的异常处理
如果其中一个异步任务失败了怎么办?get()
方法会抛出 ExecutionException
。我们可以用 exceptionally
来处理。
CompletableFuture<ProductInfo> infoFuture = CompletableFuture.supplyAsync(...) .exceptionally(ex -> { // 如果获取商品信息失败,可以返回一个默认的、空的ProductInfo对象 log.error("获取商品信息失败", ex); return new ProductInfo(); // 服务降级 });
- 超时控制
不能让一个慢服务拖垮整个接口。可以结合 orTimeout (Java 9+) 或 completeOnTimeout (Java 9+) 来设置超时。
CompletableFuture<StockInfo> stockFuture = CompletableFuture.supplyAsync(...) .orTimeout(60, TimeUnit.MILLISECONDS); // 设置60毫秒超时
如果 stockRpcService 超过60ms还没返回,stockFuture.get() 会抛出 TimeoutException,我们可以捕获它并进行降级处理。
- 结果组合的更优写法
allOf(...).join() 之后再挨个 .get() 有点繁琐。我们可以利用 thenCombine 或 thenApply 等方法,以更函数式的方式组合结果。但对于初学者,allOf + get 的方式更直观易懂。
思考:何时应该使用多线程?
多线程是把双刃剑,它能提升性能,但也会增加代码复杂度和资源消耗。请记住这个黄金法则:
当业务流程中包含多个【互相独立】的【IO密集型】任务时,就是使用多线程进行并优化的最佳时机。
- 互相独立:任务A的执行不依赖任务B的结果。
- IO密集型:任务的大部分时间都在等待网络或磁盘响应,比如数据库查询、RPC调用、文件读写。
如果任务是CPU密集型(如复杂的数学计算),或者任务之间有强依赖关系(必须先拿到A的结果才能执行B),那么使用多线程可能无法带来性能提升,甚至会因为线程切换的开销而变慢。
核心要点回顾:
- 识别瓶颈:分析业务流程,找出那些可以并行的、独立的IO任务。
- 创建线程池:在项目中全局管理线程,避免手动创建。
- 提交任务:使用 CompletableFuture.supplyAsync 将任务提交到线程池。
- 等待并聚合:使用 CompletableFuture.allOf(...).join() 等待所有任务完成。
- 获取结果:从 Future 对象中获取各个任务的执行结果并组装。
- 考虑健壮性:做好异常处理和超时控制,保证系统的稳定性。