最近在调一个电商后端的订单详情页,前端一次点击要拉订单、商品、用户、库存四份数据,原来的实现是Controller里串行调四次Feign接口,首屏耗时稳定在800毫秒以上。后来我在网关聚合层做了请求合并,把四次内部调用改成并行分发,整体RTT直接砍掉一半还多。这篇就把SpringBoot网关请求聚合加并行调用优化的完整思路和落地代码整理出来,给同样被多微服务接口拖慢响应速度的朋友做个参考。
这次优化的核心其实就两件事:第一,把前端对多个微服务的多次网络请求,合并成一次请求到聚合层,减少网络往返次数;第二,聚合层内部用并行调用替代串行调用,把多次内部RPC的时间从“累加”变成“取最大值”。这两件事做完,接口延迟的改善是肉眼可见的。
1. 为什么需要请求聚合:一次点击背后的四次网络奔波
1.1 一个典型的微服务调用困局
微服务拆得越细,前端就越痛苦。一个订单详情页,订单基础信息在订单服务,商品快照在商品服务,卖家昵称和头像在用户服务,库存状态在库存服务。如果不做聚合,前端至少得发四次请求,后端Controller为了拼装页面数据,也得在服务端串行调用四个下游接口。
串行调用的延迟模型很直观:总耗时等于四次调用的耗时相加。假设一次内部RPC的完整耗时是100毫秒,四次串行就是400毫秒,这还没算上HTTP连接建立、序列化、网络传输这些额外开销。真实场景里,服务间调用走内网可能只有几毫秒的传输延迟,但下游服务本身的业务处理时间、数据库查询时间、缓存穿透回源时间,每一项都可能把单次调用拖到几十甚至上百毫秒。
更关键的是,RTT(Round-Trip Time,往返时间)对用户体验的影响是叠加式的。用户点击一次按钮,看到的响应时间就是整个链路所有串行环节的和。移动端弱网环境下,每多一次HTTP请求,还要额外承担一次TCP+TLS握手的时间,那就更慢了。所以从这个角度看,请求聚合要解决的核心问题,就是把“多次RTT”压缩成“一次RTT”。
1.2 聚合层的两种落地思路
请求聚合的落地位置一般有两种选择。第一种是客户端聚合,就是前端同时发出多个请求,等所有请求都返回后再渲染页面;第二种是服务端聚合,由一个聚合服务(可以放在网关,也可以是独立的BFF服务)代替前端做多次调用,然后把合并后的结果一次性返回给前端。
客户端聚合看起来改动小,但是有个硬伤:移动端弱网环境下,每个请求都要独立经过完整网络往返,三次握手、TLS握手这些开销省不掉,而且多个请求同时发出容易触发浏览器的连接数限制。服务端聚合则完全不同,前端只发一次请求,聚合层在内网并行分发到各个微服务,内网延迟低、连接池可复用,整体效率要高得多。
在实际项目中,我倾向于在网关层直接做轻量聚合,或者用一个独立的聚合服务挂在网关后面。如果聚合逻辑简单,比如只是合并三个接口的响应,用Spring Cloud Gateway的过滤器加WebClient就够;如果聚合逻辑复杂,涉及多种业务编排、条件分支、异步回调,那还是单独拆一个聚合服务更合适,避免网关代码变得臃肿。
2. 整体设计思路:聚合放哪里,并行怎么选
2.1 先想清楚聚合层放在哪
Spring Cloud Gateway基于WebFlux,本身是响应式的,天然适合做IO密集型的请求转发和合并。但它不适合放复杂的业务逻辑,因为网关是所有流量的必经之地,一旦在网关里写了太重度的业务编排,性能瓶颈和故障爆炸半径都会成大问题。
我的建议是分两种情况处理。如果只是做数据合并,不涉及复杂的业务判断,直接在网关过滤器里做并行请求再合并响应即可,这次项目就是这种方案。如果聚合逻辑很复杂,比如需要根据订单状态决定是否调用优惠券服务,或者需要把多个服务的响应做深度的字段映射和计算,那就在网关后面加一个专门的聚合服务,网关只负责把请求路由到聚合服务,聚合服务再并行调用各个微服务。
另外,还要考虑聚合层和下游服务之间的依赖关系。聚合层拆出来后,下游服务不需要感知聚合层的存在,接口签名保持不变,对现有的微服务架构侵入性很小。这也是请求聚合能快速落地的重要原因——不需要大规模改动现有服务,只需要新增一个聚合层。
2.2 并行调用的技术选型对比
并行调用是降低RTT的关键。在Java生态里,实现服务间并行调用主要有三种方式:CompletableFuture、虚拟线程、响应式编程。我这次用的是CompletableFuture,但三种方案在实际项目中都很常见,了解它们的差异能帮你做更合适的选型。
CompletableFuture在JDK 8引入,适合在传统Servlet线程模型下做异步编排。它的优点是对现有代码侵入小,把同步Feign调用包在supplyAsync里,再配合allOf等结果,就能把串行改成并行。缺点是代码写多了以后回调嵌套会比较绕,调试时线程栈也比较难看。
虚拟线程是Java 21带来的新方案,最大的优势是线程代价极低,可以把并行的代码写成完全同步的形态,不需要CompletableFuture,也不用改线程池。如果你的项目已经升级到SpringBoot 3.x加JDK 21,强烈推荐试试虚拟线程,代码可读性比CompletableFuture好一大截。
响应式编程(WebFlux)则是完全换一套编程模型,用Mono和Flux做流的组合,性能上限最高,但学习曲线陡,团队维护成本也高。我的经验是,如果只是做请求聚合,没必要为这个功能把整个技术栈换成响应式。
2.3 聚合接口的契约设计
聚合接口的入参和出参设计,直接影响后续的维护成本。入参方面,聚合接口一般直接接收原始请求参数,再加一个可选的字段列表参数(类似GraphQL的field selection),让调用方按需指定要哪些数据。不过这个会增加实现复杂度,如果面向的是单一前端场景,不必一上来就搞字段选择,直接固定返回完整数据即可。
出参方面,我习惯用统一的ResponseWrapper包裹,里面包含业务数据和错误信息。每个子服务的数据放在独立的字段里,这样对应关系清晰,前端解析也直观。子服务调用失败时,不一定让整个请求失败,而是给对应字段一个默认值或者空对象,并在错误信息里记录失败详情。
一个容易忽略的细节是TraceId透传。聚合层并行调用多个下游服务时,必须把同一个请求的TraceId传递到所有下游调用中,否则出问题排查时候选日志非常痛苦。这块需要在调用下游服务时,手动把TraceId塞到请求头里,后面会详细说。
3. 核心实现:SpringBoot网关聚合的实操细节
3.1 工程准备与依赖配置
先用一个简单的聚合服务来演示,不直接在Spring Cloud Gateway的过滤器里写,因为聚合服务的代码独立,更容易看清并行调用的写法,生产环境中也更推荐这种结构。如果你的项目就一个SpringBoot服务,同样适用。
我用的是SpringBoot 2.7.18,这个版本稳定性比较好,兼容性也广,如果你的项目已经升到SpringBoot 3.x,核心代码基本一致,只需要把javax改成jakarta即可。先看一下pom依赖。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot2</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> </dependencies>这里我保留了OpenFeign作为调用下游服务的客户端,因为它是同步阻塞模型,配合CompletableFuture比较好写。如果你用的是Spring Cloud Gateway原生的WebFlux环境,那就要用WebClient来写异步调用,写法差别后面会提到。
3.2 并行调用核心代码实现
假如下游有三个服务,分别提供订单信息、商品信息和用户信息,聚合接口要把这三个响应合并返回。先定义三个FeignClient。
@FeignClient(name = "order-service", url = "${service.order.url}") public interface OrderClient { @GetMapping("/order/{orderId}") OrderDTO getOrder(@PathVariable("orderId") String orderId, @RequestHeader("X-Trace-Id") String traceId); } @FeignClient(name = "product-service", url = "${service.product.url}") public interface ProductClient { @GetMapping("/product/{productId}") ProductDTO getProduct(@PathVariable("productId") String productId, @RequestHeader("X-Trace-Id") String traceId); } @FeignClient(name = "user-service", url = "${service.user.url}") public interface UserClient { @GetMapping("/user/{userId}") UserDTO getUser(@PathVariable("userId") String userId, @RequestHeader("X-Trace-Id") String traceId); }注意每个接口都传了TraceId,调用方把请求头里的TraceId透传下去。接下来是核心的并行聚合逻辑。我的做法是定义一个有界的业务线程池专门执行这些内部调用,不直接使用CompletableFuture默认的ForkJoinPool.commonPool,避免和其他异步任务抢线程。
@Configuration public class AsyncConfig { @Bean("ioThreadPool") public ThreadPoolTaskExecutor ioThreadPool() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // IO密集型场景,线程数可以按QPS和单次耗时的乘积来算,后面细说 executor.setCorePoolSize(16); executor.setMaxPoolSize(32); executor.setQueueCapacity(200); executor.setThreadNamePrefix("io-aggregate-"); // 这里的关键:用装饰器把主线程MDC里的TraceId传给子线程 executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }然后写聚合服务。
@Service public class OrderAggregateService { private static final Logger log = LoggerFactory.getLogger(OrderAggregateService.class); private final OrderClient orderClient; private final ProductClient productClient; private final UserClient userClient; private final ThreadPoolTaskExecutor ioThreadPool; public OrderAggregateService(OrderClient orderClient, ProductClient productClient, UserClient userClient, @Qualifier("ioThreadPool") ThreadPoolTaskExecutor ioThreadPool) { this.orderClient = orderClient; this.productClient = productClient; this.userClient = userClient; this.ioThreadPool = ioThreadPool; } public OrderAggregateVO aggregate(String orderId, String traceId) { long start = System.currentTimeMillis(); CompletableFuture<OrderDTO> orderFuture = CompletableFuture .supplyAsync(() -> orderClient.getOrder(orderId, traceId), ioThreadPool) .orTimeout(1500, TimeUnit.MILLISECONDS) .exceptionally(ex -> { log.error("[aggregate] query order failed, orderId={}", orderId, ex); return null; }); CompletableFuture<ProductDTO> productFuture = CompletableFuture .supplyAsync(() -> productClient.getProduct(orderId, traceId), ioThreadPool) .orTimeout(1500, TimeUnit.MILLISECONDS) .exceptionally(ex -> { log.error("[aggregate] query product failed, orderId={}", orderId, ex); return null; }); CompletableFuture<UserDTO> userFuture = CompletableFuture .supplyAsync(() -> userClient.getUser(orderId, traceId), ioThreadPool) .orTimeout(1500, TimeUnit.MILLISECONDS) .exceptionally(ex -> { log.error("[aggregate] query user failed, orderId={}", orderId, ex); return null; }); // 等待所有并行任务完成,但最多等1600毫秒 CompletableFuture.allOf(orderFuture, productFuture, userFuture) .get(1600, TimeUnit.MILLISECONDS); OrderAggregateVO vo = new OrderAggregateVO(); vo.setOrderInfo(orderFuture.join()); vo.setProductInfo(productFuture.join()); vo.setUserInfo(userFuture.join()); vo.setCostMillis(System.currentTimeMillis() - start); return vo; } }这段代码有几个细节值得说明。第一是orTimeout,它给每个子任务单独设了超时,即使某个下游服务卡死,整个聚合请求也不会卡死。第二是exceptionally,它把单个子服务的失败降级成null返回,避免一个服务挂了把整个接口拖垮。第三是allOf之后又用了get(1600, ...)做总超时控制,相当于给整体兜了一层。
3.3 聚合响应的组装与兜底策略
聚合响应的组装看起来简单,就是把三个Future的结果塞到VO里,但这里最容易忽略的是空值处理。子服务超时或失败时,exceptionally返回null,前端的页面可能因为缺字段而报错。我在生产环境里的做法是:为每个子响应定义DefaultValue,比如订单信息是null就返回一个空OrderDTO并附带errorCode,商品和用户信息同理,让前端根据errorCode做局部降级展示。
public class OrderAggregateVO { private OrderDTO orderInfo; private ProductDTO productInfo; private UserDTO userInfo; private Map<String, String> errors = new HashMap<>(); private long costMillis; }组装时,如果某个字段是null,就在errors里记录对应的服务名和错误码。前端拿到响应后,先看errors,再决定哪些区域可以渲染、哪些区域要展示兜底文案。这种局部降级策略,比整单失败对人友好得多。
另一个细节是响应体的字段映射。有些场景下,聚合层需要把多个服务的字段做合并计算,比如用户地址要拼上省市区名称,那就在VO的setter里做映射,尽量不要在Controller层写这些逻辑,保持Controller足够薄。如果字段映射逻辑很复杂,建议拆一个独立的Assembler类来专门做数据组装,方便写单元测试。
3.4 超时控制与熔断降级的落地方案
聚合层做并行调用后,超时和熔断要专门设计,否则容易出现两个典型问题:一是线程池被慢调用堵死,二是超时时间内任务还在后台跑,白白占用线程。
超时方面,我采用的是“单操作超时 + 整体超时”双重控制。每个子任务用orTimeout设定单次调用的超时上限(比如1500毫秒),整体聚合用allOf + get设定总超时上限(比如1600毫秒)。这个时间差很重要,如果子任务超时和整体超时一样,多个慢任务会把整体时间拖到超过预期。时间差的设置要参考真实耗时数据,一般建议子任务超时是P99耗时的1.5倍,整体超时是子任务超时的1.1到1.3倍。
熔断方面,我用的是Resilience4j。它比Hystrix轻量,而且对SpringBoot支持好。对每个下游Feign调用分别配置熔断器,当某个下游的错误率达到阈值,就快速失败,不再发起真实调用,避免把资源浪费在注定失败的服务上。
resilience4j: circuitbreaker: instances: orderService: slidingWindowSize: 20 failureRateThreshold: 50 waitDurationInOpenState: 10s permittedNumberOfCallsInHalfOpenState: 3 timelimiter: instances: orderService: timeoutDuration: 1500ms这里我把熔断器配置在聚合层,而不是下游服务,目的是让聚合层能感知到下游的健康状况。一旦某个下游进入熔断状态,聚合层可以直接跳过该服务的调用,返回一个降级响应,这对整体可用性的提升非常明显。
3.5 WebClient方式与虚拟线程的替代写法
如果你的聚合层直接在Spring Cloud Gateway里实现,那就不能用Feign了,因为网关是基于WebFlux的。这种情况下用WebClient写并行调用,代码大致长这样。
Mono<OrderDTO> orderMono = webClient.get() .uri("http://order-service/order/{orderId}", orderId) .header("X-Trace-Id", traceId) .retrieve() .bodyToMono(OrderDTO.class) .timeout(Duration.ofMillis(1500)) .onErrorResume(ex -> Mono.empty()); Mono<ProductDTO> productMono = webClient.get() .uri("http://product-service/product/{productId}", productId) .header("X-Trace-Id", traceId) .retrieve() .bodyToMono(ProductDTO.class) .timeout(Duration.ofMillis(1500)) .onErrorResume(ex -> Mono.empty()); return Mono.zip(orderMono, productMono, UserMono) .map(tuple -> assemble(tuple.getT1(), tuple.getT2(), tuple.getT3()));这套写法和CompletableFuture思路一致,只是把Future换成了Mono,用zip取代allOf。好处是全程响应式,不占太多线程;坏处是如果你对WebFlux不熟,排查问题会比较痛苦。
如果你的项目已经升级到JDK 21和SpringBoot 3.5,可以用虚拟线程,那代码就更爽了,几乎和写同步代码一样。
@Bean public Executor virtualThreadExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } public OrderAggregateVO aggregate(String orderId, String traceId) throws Exception { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { Future<OrderDTO> orderFuture = executor.submit(() -> orderClient.getOrder(orderId, traceId)); Future<ProductDTO> productFuture = executor.submit(() -> productClient.getProduct(orderId, traceId)); Future<UserDTO> userFuture = executor.submit(() -> userClient.getUser(orderId, traceId)); OrderAggregateVO vo = new OrderAggregateVO(); vo.setOrderInfo(orderFuture.get(1500, TimeUnit.MILLISECONDS)); vo.setProductInfo(productFuture.get(1500, TimeUnit.MILLISECONDS)); vo.setUserInfo(userFuture.get(1500, TimeUnit.MILLISECONDS)); return vo; } }虚拟线程和CompletableFuture最大的不同是,不需要专门维护线程池,每个任务起一个虚拟线程,用完就回收,代价极低。如果你正在做SpringBoot升级,顺便把JDK版本升到21,这个改动会非常值。
4. 压测数据与参数调优:到底能快多少
4.1 串行vs并行RTT对比
优化效果一定要用数据说话,不能凭感觉。我在测试环境做了对比压测,场景就是前面说的订单聚合接口,下游三个服务各自模拟了30到50毫秒的处理耗时,网络走内网。
| 场景 | 调用方式 | P50 RT | P95 RT | P99 RT |
|---|---|---|---|---|
| 优化前 | 串行调用3个服务 | 152ms | 230ms | 310ms |
| 优化后 | 并行调用3个服务 | 58ms | 120ms | 201ms |
从表格可以清楚看到,P50从152毫秒降到58毫秒,响应时间缩短了约60%,效果非常明显。P99虽然也有改善,但比P50的改善幅度小,主要原因是极端情况下某个下游服务慢,并行调用的总耗时受最慢服务的耗时所限,这符合木桶效应的预期。
如果你好奇RTT到底降了多少,可以用这个公式粗算:串行RTT约等于各个服务RT之和加网络开销,并行RTT约等于最慢服务的RT加网络开销。假设内网RTT是5毫秒,三个服务各自RT是50毫秒,那串行约165毫秒,并行约60毫秒左右,这个估算和实测值基本吻合。
4.2 线程池参数的计算方法
聚合层线程池的参数设计,说简单也简单,说复杂也容易掉坑。我习惯用两个角度来确定线程数:理论公式和压测调优。
理论公式方面,IO密集型的线程数可以按这个思路估算:所需线程数等于QPS乘以单次调用的平均耗时(秒)。比如业务QPS是200,单次下游调用平均耗时80毫秒,那需要的并发线程数就是200乘以0.08等于16。考虑到峰值流量和部分慢调用,再留30%到50%的余量,核心线程数设24到32就比较稳。我上面配置里CorePoolSize=16、MaxPoolSize=32,就是按这个思路定的。
压测调优方面,启动后用JMeter或者wrk做阶梯加压,观察线程池活跃线程数、队列长度、P99耗时三个指标。如果活跃线程数经常顶到maxPoolSize,说明线程不够;如果队列经常堆积,说明消费能力跟不上;如果P99抖动明显,多半是线程切换和GC导致,这时候要检查线程数是否过大。
线程池的拒绝策略也要注意。这里我选了CallerRunsPolicy,意思是线程池满了之后,新任务直接由调用方线程执行。对聚合层来说,这个策略能起到天然的背压作用,不会丢弃请求,但也意味着调用方线程会被阻塞。如果你更在意快速失败而不是保住请求,可以用AbortPolicy加自定义饱和处理逻辑。
4.3 常见问题排查实录
实际落地过程中踩过不少坑,挑几个最常见的说说排查过程和解决方案。
第一个问题是并行调用了,但接口响应时间反而变长。这种情况多半是线程池参数不合理,任务在队列里排队等线程,反而比串行还慢。排查方法是看线程池活跃数和队列长度,如果活跃线程把maxPoolSize占满,队列也积压了上千个任务,那就要调大线程数或者降低任务的耗时。
第二个问题是超时失效,部分任务超时后还在后台跑,慢慢把线程池拖垮。这里有个关键知识点:CompletableFuture的orTimeout和get超时只是让调用方不再等待结果,并不会真正中断正在执行的任务。要让任务真正停止,需要把超时和Future.cancel配合起来,但Feign调用的中断不一定生效,所以更可靠的办法是在下游接口层面做超时控制,比如Feign的connectTimeout和readTimeout。
第三个问题是日志排查困难,并行任务的日志散落在多个线程里,串不起来。解决方法是自定义TaskDecorator,把主线程MDC中的TraceId传给子线程。具体写法是,在Runnable被线程池执行前,捕获当前MDC上下文,在run方法里重新放进去,执行完再清掉。
public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { if (contextMap != null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; } }第四个问题是Spring Cloud Gateway环境里误用了同步Feign,把Netty的EventLoop线程给阻塞了,导致网关吞吐量断崖式下跌。这个坑遇到的人不少,网关层一定不要用阻塞客户端,用WebClient或者响应式Feign。
第五个问题是使用虚拟线程时,代码里做了ThreadLocal相关的操作。虚拟线程数量很多,ThreadLocal会导致内存膨胀,需要特别小心。我的建议是虚拟线程环境下尽量用ScopedValue(JDK 21正式特性),或者干脆把TraceId作为参数显式传递,避免依赖隐式上下文。
4.4 一个容易忽略的点:连接池和网络参数
聚合层并行发起大量请求,如果连接池没有提前调好,很容易出现连接数瓶颈。Feign默认用的是HTTP连接池,通过OkHttp或HttpClient实现,需要调整连接池大小和空闲连接回收参数。实战经验是,连接池最大连接数建议等于或略大于线程池的maxPoolSize,保证每个线程都能拿到连接。
另外还要注意HTTP客户端连接建立方式。服务端同一个目标服务,如果并发请求量大,建议开启HTTP/2多路复用,减少TCP连接数量。我这边用的是HttpClient5,开启HTTP/2后,连接数从几十个降到了几个,对下游服务的连接压力小了很多。
5. 聚合层的前置条件与扩展思考
5.1 适合做请求聚合的业务场景
不是所有场景都适合做请求聚合,它更适合读多写少、数据分散在多个服务的查询场景。典型的像首页Feed流、用户中心、订单详情页、商品详情页这些,都是天然适合聚合的。反过来,如果接口只是查询单一服务的少量数据,再包一层聚合反而增加链路深度,得不偿失。
另外,聚合层不建议承接写逻辑。写操作往往涉及事务、幂等、最终一致性,这些放在聚合层会很尴尬。读操作可以接受部分失败和局部降级,写操作则不行。所以我的原则是:聚合层只做查询合并,不做跨服务写操作编排,写场景交给专门的工作流引擎或Spring事务管理。
扩展的时候,聚合层还能承担部分缓存职责。高频聚合接口可以在聚合层做一层Redis缓存,比如热点商品信息、用户基础信息,缓存直接放在聚合层能降低对下游服务的压力。但要处理好缓存一致性问题,建议结合缓存预热和失效策略,避免出现脏数据。
5.2 监控与可观测性怎么补
接入并行调用后,监控指标要跟得上。除了常规的接口RT、QPS、错误率,聚合层至少要盯三个指标:线程池活跃线程数、队列堆积长度、子任务超时次数。这三个指标能在故障发生前预警,比如线程池活跃线程持续高位,说明下游服务可能要出问题,提前扩容或者熔断。
日志方面,除了TraceId透传,聚合层还要记录每个子任务的单独耗时。我用的是Spring Boot Actuator + Micrometer,为每个下游调用建立独立的Timer指标,比如aggregate_order_service_rt、aggregate_product_service_rt。这样出了问题,能快速定位是哪个下游拖慢了整体响应,而不是对着一个总耗时指标猜来猜去。
可观测性层面的另一个重点是告警规则。我设置了两类告警:一是P95超出基线持续5分钟,二是子任务超时率超过10%。这两个告警阈值要根据实际的压测数据定,不要拍脑袋,否则报警太频繁反而没人看。
5.3 结合Docker部署和K8s环境的实践
聚合层部署到Docker或K8s后,线程池参数还要结合实际环境再调整一次。容器环境下,CPU和内存都是受限的,即使宿主机有几十核,容器只分配了2个CPU,那线程数就不能按宿主机核数去算。IO密集型线程池对CPU核数不敏感,但对内存敏感,每个线程默认的栈空间加上任务对象,线程数太大容易把容器内存打满。
在单节点K8s环境里还要注意,如果聚合服务和下游服务混部在同一台机器上,网络延迟虽然低,但CPU竞争会很严重。压测的时候要观察容器CPU是否被打满,如果CPU长期超过70%,就要考虑扩容副本或者调优线程池参数。还有一个经验是,容器环境下JVM堆内存上限一定要显式配置,不要依赖默认值,否则很容易被系统杀掉。
我个人在实际操作中,把线程池参数做成了配置项,比如spring.task.io.core-size、spring.task.io.max-size,方便在K8s环境里通过环境变量覆盖。这样同一份代码,开发环境、测试环境、生产环境可以用不同的线程参数,减少了环境差异带来的问题。
5.4 后续还可以怎么扩展
聚合层做完之后,如果还想继续优化,有两个方向可以考虑。一个是把聚合层逐步演进成GraphQL网关,让前端可以按需声明要哪些数据,避免多传无用字段。另一个是引入响应式框架,比如把聚合层整体迁到WebFlux,不过这要看团队的技术储备,不能为了升级而升级。
最后再分享一个实战小技巧:给聚合接口的响应加上耗时统计字段,前端就能看到优化效果。上线之后观察线上RTT的P50和P95变化,比口头解释有用得多。这个字段不需要怎么设计,一个数字就行,但它的存在会时刻提醒你,每一次网络往返和每一次串行等待,都是有代价的。