ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SpringBoot网关请求聚合与并行调用优化实践:接口响应时间砍半

SpringBoot网关请求聚合与并行调用优化实践:接口响应时间砍半 最近在调一个电商后端的订单详情页前端一次点击要拉订单、商品、用户、库存四份数据原来的实现是Controller里串行调四次Feign接口首屏耗时稳定在800毫秒以上。后来我在网关聚合层做了请求合并把四次内部调用改成并行分发整体RTT直接砍掉一半还多。这篇就把SpringBoot网关请求聚合加并行调用优化的完整思路和落地代码整理出来给同样被多微服务接口拖慢响应速度的朋友做个参考。这次优化的核心其实就两件事第一把前端对多个微服务的多次网络请求合并成一次请求到聚合层减少网络往返次数第二聚合层内部用并行调用替代串行调用把多次内部RPC的时间从“累加”变成“取最大值”。这两件事做完接口延迟的改善是肉眼可见的。1. 为什么需要请求聚合一次点击背后的四次网络奔波1.1 一个典型的微服务调用困局微服务拆得越细前端就越痛苦。一个订单详情页订单基础信息在订单服务商品快照在商品服务卖家昵称和头像在用户服务库存状态在库存服务。如果不做聚合前端至少得发四次请求后端Controller为了拼装页面数据也得在服务端串行调用四个下游接口。串行调用的延迟模型很直观总耗时等于四次调用的耗时相加。假设一次内部RPC的完整耗时是100毫秒四次串行就是400毫秒这还没算上HTTP连接建立、序列化、网络传输这些额外开销。真实场景里服务间调用走内网可能只有几毫秒的传输延迟但下游服务本身的业务处理时间、数据库查询时间、缓存穿透回源时间每一项都可能把单次调用拖到几十甚至上百毫秒。更关键的是RTTRound-Trip Time往返时间对用户体验的影响是叠加式的。用户点击一次按钮看到的响应时间就是整个链路所有串行环节的和。移动端弱网环境下每多一次HTTP请求还要额外承担一次TCPTLS握手的时间那就更慢了。所以从这个角度看请求聚合要解决的核心问题就是把“多次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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-openfeign/artifactId /dependency dependency groupIdio.github.resilience4j/groupId artifactIdresilience4j-spring-boot2/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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(); CompletableFutureOrderDTO 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; }); CompletableFutureProductDTO 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; }); CompletableFutureUserDTO 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 MapString, 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写并行调用代码大致长这样。MonoOrderDTO 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()); MonoProductDTO 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()) { FutureOrderDTO orderFuture executor.submit(() - orderClient.getOrder(orderId, traceId)); FutureProductDTO productFuture executor.submit(() - productClient.getProduct(orderId, traceId)); FutureUserDTO 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 RTP95 RTP99 RT优化前串行调用3个服务152ms230ms310ms优化后并行调用3个服务58ms120ms201ms从表格可以清楚看到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就比较稳。我上面配置里CorePoolSize16、MaxPoolSize32就是按这个思路定的。压测调优方面启动后用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) { MapString, 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会导致内存膨胀需要特别小心。我的建议是虚拟线程环境下尽量用ScopedValueJDK 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变化比口头解释有用得多。这个字段不需要怎么设计一个数字就行但它的存在会时刻提醒你每一次网络往返和每一次串行等待都是有代价的。
返回列表