流量没暴涨,网关却挂了
网关性能问题有一个很容易误判的地方:
看起来它只是"转发流量",真正的业务压力应该在下游服务。
但这次事故之后,我对这个判断有了新的认识。
我们有一个 C 端网关,是一个有些年头的老项目,基于 Spring Cloud Gateway / WebFlux / Reactor 构建。它最早承担的职责比较清晰:统一入口、鉴权、路由转发、链路透传。
但老项目最典型的问题是:它不会突然变复杂,而是会一点点变复杂。
最近一段时间,我们陆续在这个网关上加了不少能力:
- 限流;
- 灰度路由;
- 一些活动相关的业务链路;
每一次改动单独看都不大,也都能解释得通。
加限流,是为了保护后端;
加灰度,是为了支撑多版本发布;
加业务链路,是为了减少下游重复实现;
问题是,这些改动都落在了网关的请求热路径上。
最后,在一个周六下午,流量只是比常规 QPS 高一些,并没有出现那种肉眼可见的洪峰,网关却被拖垮了,最终表现为小程序不可用。
这件事最反直觉的地方就在这里:
QPS 没有明显暴涨,网关为什么还是爆了?
后面复盘下来,根本原因不是某一次"大改动"把网关打挂,而是一次次小改动,把网关原本预期的吞吐能力一点点吃掉了。
等到周六下午流量稍微高一些时,系统已经没有足够余量了。
我们后来围绕这个网关做了一轮系统压测和优化。最终,在 4C8G 环境下,压测结果从 500 QPS 提升到 4200 QPS ,提升约 8 倍以上。
这篇文章就复盘一下:一个老项目里的 Spring Cloud Gateway,是怎么被一次次"小成本"拖慢,又是怎么一步步把性能拉回来的。
一、事故现场:没有明显流量洪峰,但网关还是不可用了
故障表现很直接:
C 端活动网关异常,导致小程序不可用。
这种问题一出现,第一反应通常是流量。
尤其是发生在周六下午,业务流量本来就可能比工作日更高,所以最容易怀疑:是不是活动流量突然冲上来了?
但看完监控后,结论并不支持这个判断。
故障时的 QPS 相比常规水平确实高一些,但并没有明显暴涨;和上周同期相比,也没有看到特别夸张的尖刺。
这就排除了一个最直观的解释:
网关不是被突然出现的巨大流量直接打爆的。
如果流量没有明显异常,但系统还是扛不住,通常说明两个问题之一:
- 单请求处理成本变高了;
- 系统内部资源利用效率变差了。
对网关来说,这两个问题往往会叠加。
因为网关是所有请求的入口,任何进入请求链路的小成本,都会被总流量放大。
二、先排除几个容易误判的方向
事故排查时,不能一上来就盯着代码改,还是要先把范围收窄。
当时主要看了三个方向。
1. 是不是流量突然增大?
前面已经说过,故障时 QPS 比常规水平高一些,但没有明显激增。
这说明流量不是唯一解释。
真正的问题是:
为什么只是比平时高一些,网关就没有余量了?
这个问题后来成了整次优化的主线。
2. 是不是下游服务异常?
继续看下游。
当时重点排查了用户、运营等下游服务,没有发现明显超时,也没有看到某个下游把网关请求整体拖住。
所以问题进一步收敛到网关自身:
网关不是明显被某个下游服务拖死的,它自己的处理链路大概率已经很重。
3. 是不是内存泄露或者 GC 问题?
这一步看到了比较关键的线索。
故障期间,网关出现了类似下面的 Netty ByteBuf 泄露提示:
text
LEAK: ByteBuf.release() was not called before it's garbage-collected.
See https://netty.io/wiki/reference-counted-objects.html for more information.
Created at:
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(...)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(...)
org.springframework.core.io.buffer.NettyDataBufferFactory.allocateBuffer(...)
org.springframework.core.codec.CharSequenceEncoder.encodeValue(...)
reactor.core.publisher.FluxMap$MapSubscriber.onNext(...)
org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onNext(...)
...
同时,内存和 GC 监控也对得上:
- 故障前 GC 次数上升;
- GC 时间变长;
- S 区产生大量对象;
- Old 区也开始上升;
- 这些变化与事故时间吻合。
这里需要注意一点:
ByteBuf 泄露日志不应该被简单等同于最终根因。
它更像是一个信号:网关在 Netty、Reactor、对象分配、链路包装这条路径上,已经出现了明显资源压力。
后续测试环境压测也验证了类似现象。
我们将网关调整为 2C2G,并把连接池最大连接数分别调整为 20 和 100 做压测,看到的现象是:
- QPS 上升时,响应时间明显变长;
- GC 次数和 GC 时间变长;
- 现象和故障期间类似。
到这里,问题从"为什么小程序不可用",变成了一个更具体的问题:
一个基于 Spring Cloud Gateway网关,为什么只是承接比平时高一些的流量,就开始明显吃力?
三、真正的问题:不是流量突然变大,而是预期 QPS 被一点点吃掉了
复盘老项目时,我觉得最容易忽略的是"性能余量的流失"。
很多性能问题不是这样发生的:
text
某次上线 → 引入严重 bug → 服务立刻不可用
而是这样发生的:
text
第一次加逻辑:影响不明显
第二次加逻辑:影响不明显
第三次加逻辑:影响不明显
...
某个周六下午:流量稍微高一点 → 系统没有余量了
网关尤其容易出现这个问题。
因为每个需求都觉得自己只是"在入口处加一点逻辑":
- 限流只判断一下规则;
- 灰度只解析一下配置;
- 鉴权只查一下 token;
- 日志只多打几个字段;
- Trace 只做一下链路包装;
- 负载均衡只过滤一下实例 metadata。
单独看都不夸张。
但它们都在请求热路径上。
最后,网关的单请求成本被一层层垫高,原本以为能扛住的 QPS,实际已经被拖到了一个很低的位置。
我们后续压测出来的基线也印证了这一点:
| 环境 | 优化前 | 优化后 |
|---|---|---|
| 4C8G 压测环境 | 500 QPS | 4200 QPS |
这说明问题不是"机器不够",也不是"网关天然不行",而是请求链路里有太多隐性成本。
四、优化总览:不是一个点救了系统,而是一串隐性成本被清掉了
这次主要优化项如下:
| 优化项 | 收益 | 重要程度 |
|---|---|---|
| 日志异步化 + 去除调用栈信息 | 关键提升项 | 高 |
| 消除响应式链路阻塞调用 | 关键提升项 | 高 |
| Sleuth 采样率优化 | 明显提升 | 中 |
| 灰度规则预解析 | 降低请求时解析成本 | 中 |
| 灰度规则短路匹配 | 降低规则计算成本 | 中 |
| 服务实例缓存 | 降低实例过滤成本 | 中 |
| 尝试替换 Spring Cloud LoadBalancer | 反而下降 | 弯路 |
其中最关键的两个问题是:
- 日志里输出
%method、%line,导致每次日志都要取调用栈; - Spring Cloud Gateway 的响应式链路中混入了阻塞式 Redis 调用。
这两个问题叠加在一起,把网关吞吐压得很低。
五、第一个大坑:日志异步化不一定有用,真正贵的是调用栈
一开始看日志配置,最容易想到的优化是:
同步日志改成异步日志。
这个方向没错,但不完整。
原来的日志 pattern 里有两个很常见的字段:
xml
%method
%line
比如原来的日志里会输出类似这样的内容:
xml
"class": "%logger{60}.%method,%line"
从排查问题的角度看,这两个字段很方便。
但在高并发网关里,它们的成本非常高。
因为 %method 和 %line 不是天然存在的,通常需要通过调用栈解析获取。也就是说,每打一条日志,都可能触发一次调用栈信息提取, 在webflux下成本居高。
这件事在普通业务服务里可能不明显,但在网关这种高频入口里,会被迅速放大。
只改异步日志,效果很弱
我们做过几组对比:
| 配置 | QPS | 说明 |
|---|---|---|
| 同步日志 + 调用栈信息 | 500 | 基线 |
| 异步日志 + 调用栈信息 | 约 530 | 基本无明显提升 |
| 异步日志 + 去除调用栈信息 | 约 2000 | 关键突破 |
这个结果很反直觉。
一开始以为"同步改异步"就能解决日志阻塞问题,但数据说明:
如果仍然保留调用栈信息,异步日志并不能解决主要瓶颈。
真正关键的是禁用 caller data:
xml
<includeCallerData>false</includeCallerData>
同时去掉日志格式里的:
xml
%method
%line
优化后的核心配置类似这样:
xml
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize>
<!-- 关键:禁用调用栈信息获取 -->
<includeCallerData>false</includeCallerData>
<!-- 队列满时不阻塞业务线程 -->
<neverBlock>true</neverBlock>
<discardingThreshold>0</discardingThreshold>
<appender-ref ref="FILE"/>
</appender>
日志内容也从:
xml
"class": "%logger{60}.%method,%line"
调整为:
xml
"class": "%logger{60}"
这一步带来的效果非常明显。
它说明一个问题:
日志异步化解决的是写入阻塞,但
%method、%line带来的调用栈解析成本,发生在日志事件构造阶段。这个成本不去掉,异步也救不了。
这里的取舍是什么?
当然,这不是没有代价。
去掉方法名和行号之后,日志定位会变得没那么直接。
所以这里的取舍是:
| 得到什么 | 失去什么 |
|---|---|
| 吞吐明显提升 | 日志不再直接显示方法名、行号 |
| Reactor 线程不再被日志拖慢 | 排查问题时需要依赖类名、TraceId、关键日志内容 |
| 队列满时不阻塞业务线程 | neverBlock=true 时存在日志丢失风险 |
我的建议是:
- 生产环境避免在高频日志中输出
%method、%line; - 开发、测试环境可以保留;
- 生产环境依赖 TraceId、类名、业务关键字段来定位;
- 如果开启
neverBlock=true,必须接受"日志优先级低于业务吞吐"的取舍,并做好日志丢弃监控。
六、第二个大坑:响应式网关里混进了阻塞式 Redis
Spring Cloud Gateway 基于 WebFlux / Reactor。
它的核心优势是:少量 EventLoop / Reactor 线程处理大量并发请求。
但这个模型有一个前提:
链路上的操作不能随便阻塞。
我们的鉴权链路里,原来做了类似这样的事情:
java
public class AuthGatewayFilter implements GatewayFilter {
private StringRedisTemplate stringRedisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = getToken(exchange);
String userInfo = stringRedisTemplate.opsForValue()
.get("_APP_TOKEN_" + token);
if (userInfo != null) {
stringRedisTemplate.expire(
"_APP_TOKEN_" + token,
90,
TimeUnit.DAYS
);
}
return chain.filter(exchange);
}
}
这段代码在传统 Spring MVC 服务里很常见。
但放在 Spring Cloud Gateway 里,问题就很大。
因为 StringRedisTemplate 是阻塞式调用。请求进来后,Reactor 线程执行到 Redis 查询和续期时,会被阻塞住。
而网关里的 Reactor 工作线程数量通常不多,一旦这些线程被阻塞,吞吐会迅速下降。
这也是响应式系统里最容易踩的坑:
代码返回了
Mono,不代表整条链路就是非阻塞的。
优化方向:鉴权续期下沉到登录中心
这块我们做了架构调整。
优化前:
text
Client
→ Gateway:阻塞式 Redis 校验 + 续期
→ Backend
优化后:
text
Client
→ Gateway:响应式 WebClient 调用
→ Login-Center:Token 校验 + 续期
→ Backend
网关不再直接使用阻塞式 Redis 做 token 查询和续期,而是通过响应式 WebClient 调用登录中心,由登录中心负责 Redis 操作。
示意代码如下:
java
@Component
public class LoginCenterClient {
private final WebClient webClient;
public LoginCenterClient(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("lb://login-center")
.build();
}
public Mono<LoginCenterValidateResponse> validateAndRenewToken(String token) {
return webClient.post()
.uri("/api/token/validate-and-renew")
.bodyValue(new LoginCenterValidateRequest(token))
.retrieve()
.bodyToMono(LoginCenterValidateResponse.class)
.timeout(Duration.ofMillis(200))
.onErrorResume(e -> Mono.just(LoginCenterValidateResponse.failed()));
}
}
网关过滤器变成完整的响应式链路:
java
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = getToken(exchange);
if (token == null || token.isEmpty()) {
return responseUnauthorized(exchange);
}
return loginCenterClient.validateAndRenewToken(token)
.flatMap(response -> {
if (!response.isSuccess()) {
return responseUnauthorized(exchange);
}
exchange.getRequest().mutate()
.header("REQ_TOKEN_ATTR", response.getUserInfo());
return chain.filter(exchange);
});
}
这里还有一个替代方案:
如果鉴权逻辑必须留在网关内部,那至少要把
StringRedisTemplate替换成ReactiveRedisTemplate,避免阻塞 Reactor 线程。
这一步的核心收益不是"少了一次 Redis",而是:
网关线程不再被阻塞式 I/O 卡住,Reactor 模型才能真正发挥作用。
同理网关侧的RPC调用也必须是 响应式
七、第三个优化:Sleuth 不上报,也可能有成本
项目里同时用了两套链路能力:
- Pinpoint:主要用于 UI 展示;
- Sleuth:主要用于 TraceId 生成和链路透传。
Sleuth 默认采样率是 10%。
即使不关心每条链路的 Span 上报,采样判断、上下文包装、Reactor 链路增强本身也会带来额外开销。
所以我们把采样率调整为 0:
yaml
spring:
sleuth:
sampler:
probability: 0.0
这一步之后,TraceId 生成和透传仍然保留,但不做采样和 Span 导出。
这里的经验是:
链路追踪不是免费能力,尤其在响应式链路里,任何上下文包装都会进入高频调用路径。
如果系统里已经有其他 APM 承担观测职责,就要重新评估 Sleuth 的采样率和使用边界。
八、第四个优化:灰度规则不要每次请求都解析 JSON
这个网关还有一个重要职责:灰度路由。
灰度规则由 Nacos 动态下发,规则表达式大概会描述:
- 哪个泳道;
- 哪些规则组;
- 规则优先级;
- AND / OR 逻辑;
- 请求头、用户、门店、版本等匹配条件。
原来的流程是:
text
Nacos 推送规则 JSON
→ 内存保存 JSON String
→ 每次请求解析 JSON
→ 转成 RuleExpression 对象
→ 执行规则匹配
关键问题在这里:
java
RuleExpression rule = JsonUtil.of(ruleExpression, RuleExpression.class);
如果每次请求都解析 JSON,那么这个成本会进入网关最核心的请求路径。
优化方式也很直接:
text
Nacos 推送规则 JSON
→ 配置更新时立即解析成对象
→ 过滤未启用规则
→ 按优先级排序
→ 请求时直接使用 RuleExpression 对象
示意代码:
java
static synchronized void updateConfig(LaneConfig config) {
if (config == null) {
return;
}
preParseRules(config);
currentConfig = config;
}
预解析过程里做三件事:
java
private static void preParseRules(LaneConfig config) {
for (LaneInfo lane : config.getLanes()) {
for (RuleGroupInfo group : lane.getRuleGroups()) {
if (hasText(group.getRuleExpression())) {
RuleExpression expression =
JsonUtil.of(group.getRuleExpression(), RuleExpression.class);
group.setParsedRuleExpression(expression);
}
}
List<RuleGroupInfo> sortedValidGroups = lane.getRuleGroups()
.stream()
.filter(group -> group.getEnabled() != null && group.getEnabled() == 1)
.filter(group -> group.getParsedRuleExpression() != null)
.sorted(Comparator.comparing(
RuleGroupInfo::getPriority,
Comparator.nullsLast(Integer::compareTo)))
.collect(Collectors.toList());
lane.setRuleGroups(sortedValidGroups);
}
}
请求路径里只做匹配:
java
RuleExpression rule = group.getParsedRuleExpression();
boolean matched = RuleMatchEngine.match(rule, exchange);
这一步的收益不只是 QPS 提升,还有 GC 压力下降。
这类优化的本质是:
配置变化频率低,请求访问频率高,所以应该把解析、校验、排序这类成本前置到配置更新时,而不是放在每次请求里。
九、第五个优化:AND / OR 规则匹配要短路
灰度规则通常支持复杂表达式,比如:
text
A AND (B OR C)
原来的实现方式类似这样:
java
List<Boolean> childResults = expression.getChildren()
.stream()
.map(child -> evaluateRuleExpression(child, exchange))
.collect(Collectors.toList());
if (expression.getRelation() == LogicRelation.AND) {
return childResults.stream().allMatch(Boolean::booleanValue);
}
if (expression.getRelation() == LogicRelation.OR) {
return childResults.stream().anyMatch(Boolean::booleanValue);
}
这段代码逻辑上没问题,但性能上有浪费。
因为:
A AND B中,如果 A 已经是 false,就没必要计算 B;B OR C中,如果 B 已经是 true,就没必要计算 C;- Stream 还会产生额外对象和函数调用开销。
优化后改成普通 for 循环:
java
private static boolean evaluateRuleExpression(RuleExpression expression,
ServerWebExchange exchange) {
List<RuleExpression> children = expression.getChildren();
if (children == null || children.isEmpty()) {
return false;
}
LogicRelation relation = expression.getRelation();
if (relation == LogicRelation.AND) {
for (RuleExpression child : children) {
if (!evaluateRuleExpression(child, exchange)) {
return false;
}
}
return true;
}
if (relation == LogicRelation.OR) {
for (RuleExpression child : children) {
if (evaluateRuleExpression(child, exchange)) {
return true;
}
}
return false;
}
return false;
}
这个优化单看不是最大,但它说明一个原则:
网关高频路径上,不要轻易把"看起来优雅"的写法放到热路径里。规则越复杂、请求越高频,短路收益越明显。
十、第六个优化:服务实例过滤结果可以短时间缓存
这个网关使用的是 Spring Cloud Gateway 2.2.5 Release,负载均衡仍然基于 Ribbon。
灰度路由场景下,每次请求都需要做类似操作:
text
根据 serviceId 获取服务实例列表
→ 根据 laneCode 过滤实例 metadata
→ 使用轮询策略选择一个实例
原来的代码大概是:
java
List<ServiceInstance> allInstances =
discoveryClient.getInstances(serviceId);
List<ServiceInstance> filteredInstances =
chooseInstances(allInstances, laneCode);
return roundRobinLoadBalancer.choose(
serviceId,
laneCode,
filteredInstances
);
虽然 discoveryClient.getInstances(serviceId) 不一定每次都发网络请求,很多时候是从 Nacos 客户端本地缓存读,但这条路径仍然有成本:
- 每次都要查询实例列表;
- 每次都要遍历 metadata;
- 每次都要执行泳道过滤逻辑。
于是我们引入了 Guava LoadingCache,缓存维度是:
text
serviceId:laneCode
核心配置:
java
this.instanceCache = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS)
.maximumSize(2000)
.build(new CacheLoader<String, List<ServiceInstance>>() {
@Override
public List<ServiceInstance> load(String key) {
String[] parts = parseKey(key);
String serviceId = parts[0];
String laneCode = parts[1];
List<ServiceInstance> allInstances =
discoveryClient.getInstances(serviceId);
List<ServiceInstance> filtered =
chooseInstances(allInstances, laneCode);
return filtered != null ? filtered : Collections.emptyList();
}
});
请求时:
java
String cacheKey = serviceId + ":" + (laneCode == null ? "__NULL__" : laneCode);
List<ServiceInstance> filteredInstances = instanceCache.get(cacheKey);
if (filteredInstances.isEmpty()) {
return super.choose(exchange);
}
return roundRobinLoadBalancer.choose(
serviceId,
laneCode,
filteredInstances
);
参数设计如下:
| 参数 | 值 | 说明 |
|---|---|---|
expireAfterWrite |
5 秒 | 平衡实时性和性能 |
maximumSize |
2000 | 按 100 个服务 × 20 个泳道估算 |
| Cache Key | serviceId:laneCode |
精确缓存服务 + 泳道组合 |
这一步的风险也很明确:
服务上下线、metadata 变化,最多会有 5 秒左右延迟。
所以它适合用于:
- 服务实例变化不频繁;
- 允许秒级收敛延迟;
- 缓存失效时有默认降级逻辑的场景。
十一、一个优化弯路:替换 Spring Cloud LoadBalancer 后,性能反而下降
优化过程中,我们还走过一个弯路。
当时的想法是:
Ribbon 已经比较老了,Spring Cloud LoadBalancer 是官方推荐的新方案,而且更适合响应式,是不是替换后性能会更好?
于是尝试关闭 Ribbon,切到 Spring Cloud LoadBalancer:
yaml
spring:
cloud:
loadbalancer:
ribbon:
enabled: false
结果并不理想。
| 负载均衡器 | QPS | CPU 使用率 | 说明 |
|---|---|---|---|
| Ribbon | 4200 | 65% | 已优化基线 |
| Spring Cloud LoadBalancer | 约 3300 | 78% | 性能下降 |
这个结果说明:
新方案不一定在当前版本、当前链路、当前项目里更快。
为了定位原因,我们用 Arthas 抓高 CPU 线程栈,看到大量耗时集中在 Spring 类型匹配和 Bean 查找:
text
"reactor-http-epoll-4" cpuUsage=88.29% RUNNABLE
at org.springframework.core.ResolvableType.isAssignableFrom(...)
at org.springframework.core.ResolvableType.isInstance(...)
at org.springframework.beans.factory.support.AbstractBeanFactory.isTypeMatch(...)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doGetBeanNamesForType(...)
at org.springframework.context.support.AbstractApplicationContext.getBeanNamesForType(...)
at org.springframework.beans.factory.BeanFactoryUtils.beanNamesForTypeIncludingAncestors(...)
at org.springframework.cloud.context.named.NamedContextFactory.getInstance(...)
at org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter.choose(...)
at org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter.filter(...)
核心问题是:
Spring Cloud LoadBalancer 会通过 NamedContextFactory 为不同服务维护独立上下文,用于支持服务级别配置隔离。
但在当前使用的 Spring Cloud Gateway 2.2.5 / Hoxton 版本里,ReactiveLoadBalancerClientFilter 这条路径没有很好地缓存 LoadBalancer 实例,每次请求都会从 NamedContextFactory 查找 Bean。
而查找 Bean 又触发了:
text
getBeanNamesForType
→ isTypeMatch
→ ResolvableType.isAssignableFrom
这些泛型类型匹配和反射逻辑,在网关高频路径里被放大后,就变成了明显 CPU 开销。
最后我们没有强行迁移,而是选择:
保留 Ribbon,等后续统一升级 Spring Cloud 版本时,再一起迁移 LoadBalancer。
这件事给我的教训很直接:
性能优化不能靠"技术趋势"判断,必须靠压测和现场指标判断。
十二、最终优化效果汇总
把几轮优化放在一起看:
| 优化轮次 | 优化内容 | 结果 / 收益 | 主要原因 | 代价 |
|---|---|---|---|---|
| 1 | 异步日志 + 去除调用栈 | 500 → 约 2000 QPS | 避免 %method、%line 触发调用栈解析 |
少了方法名、行号 |
| 2 | 消除阻塞式 Redis | 核心提升项 | Reactor 线程不再被阻塞 | 鉴权逻辑需要调整职责边界 |
| 3 | Sleuth 采样率设为 0 | 明显提升 | 减少采样和 Span 处理开销 | 链路采样能力下降 |
| 4 | 灰度规则预解析 | 中等收益 | 请求时不再解析 JSON | 配置更新时要做好校验 |
| 5 | 灰度规则短路匹配 | 小幅收益 | AND / OR 提前返回 | 代码从 Stream 改为显式循环 |
| 6 | 服务实例缓存 | 中等收益 | 减少实例查询和 metadata 过滤 | 服务变化有秒级延迟 |
| 弯路 | 替换 Spring Cloud LoadBalancer | 4200 → 约 3300 QPS | 当前版本 Bean 查找开销高 | 最终未采用 |
最终,在 4C8G 压测环境下,网关从 500 QPS 提升到 4200 QPS。
这个结果不是某个参数带来的,而是把高频链路里的隐性成本逐个清掉之后的结果。
十三、这次优化真正解决的不是"网关慢",而是三类隐性成本
复盘下来,这次优化不是某一个神奇参数带来的,而是清掉了三类隐性成本。
1. 线程被阻塞
典型代表:
java
StringRedisTemplate
RestTemplate
JdbcTemplate
Thread.sleep
同步文件 I/O
在 MVC 服务里,这些东西不一定立刻出问题。
但在 WebFlux / Reactor 里,阻塞调用会直接伤害事件循环和工作线程利用率。
判断标准不是:
我是不是返回了 Mono?
而是:
我的 Mono 链路里有没有真正阻塞线程的操作?
2. 每次请求都做重复计算
典型代表:
- 每次解析 JSON;
- 每次编译表达式;
- 每次排序规则;
- 每次过滤服务实例;
- 每次扫描 metadata;
- 每次获取调用栈。
这些操作单次看不一定贵,但网关是高频入口,会把它们全部放大。
优化思路是:
text
配置更新时做解析
启动时做预热
低频变化做缓存
请求路径只做必要判断
3. 框架能力不是免费午餐
典型代表:
- Sleuth;
- APM;
- Spring Cloud LoadBalancer;
- NamedContextFactory;
- 自动上下文传递;
- 泛型类型匹配;
- 日志调用栈增强。
这些能力都很有价值,但一旦进入高频链路,就必须通过压测确认成本。
十四、写在最后:老项目最怕的不是一次大改,而是小成本持续进热路径
这次事故之后,我对网关有了一个更明确的认识:
网关不是简单的转发层,它是所有请求都会经过的高频执行引擎。
在业务服务里,一个小的低效点可能只是局部问题;
但在网关里,它会乘以所有入口流量。
所以网关优化不要一上来就想着换框架、加机器、调连接池。
更应该先问几个问题:
- 请求路径里有没有阻塞调用?
- 每次请求有没有重复解析、重复排序、重复扫描?
- 日志、Trace、APM 有没有进入高频路径?
- 最近加的限流、灰度、业务链路,是不是都在热路径上?
- 是否有框架自动增强带来的隐藏成本?
- 每个优化有没有压测数据证明?
- 优化后有没有明确的线上风险和降级策略?
这次我们把 Spring Cloud Gateway 从 500 QPS 压测提升到 4200 QPS,靠的不是某一个"银弹",而是把高频链路上的隐性成本逐个找出来、验证掉、前置掉或者缓存掉。
性能优化最后拼的不是技巧,而是习惯:
不猜根因,先看指标;
不迷信框架,先做压测;
不把成本放在请求路径,能前置就前置,能缓存就缓存,能异步就异步,但每一步都要知道代价。