流量没暴涨,网关却挂了:Spring Cloud Gateway 从 500 QPS 优化到 4200 QPS

流量没暴涨,网关却挂了

网关性能问题有一个很容易误判的地方:

看起来它只是"转发流量",真正的业务压力应该在下游服务。

但这次事故之后,我对这个判断有了新的认识。

我们有一个 C 端网关,是一个有些年头的老项目,基于 Spring Cloud Gateway / WebFlux / Reactor 构建。它最早承担的职责比较清晰:统一入口、鉴权、路由转发、链路透传。

但老项目最典型的问题是:它不会突然变复杂,而是会一点点变复杂。

最近一段时间,我们陆续在这个网关上加了不少能力:

  • 限流;
  • 灰度路由;
  • 一些活动相关的业务链路;

每一次改动单独看都不大,也都能解释得通。

加限流,是为了保护后端;

加灰度,是为了支撑多版本发布;

加业务链路,是为了减少下游重复实现;

问题是,这些改动都落在了网关的请求热路径上。

最后,在一个周六下午,流量只是比常规 QPS 高一些,并没有出现那种肉眼可见的洪峰,网关却被拖垮了,最终表现为小程序不可用。

这件事最反直觉的地方就在这里:

QPS 没有明显暴涨,网关为什么还是爆了?

后面复盘下来,根本原因不是某一次"大改动"把网关打挂,而是一次次小改动,把网关原本预期的吞吐能力一点点吃掉了。

等到周六下午流量稍微高一些时,系统已经没有足够余量了。

我们后来围绕这个网关做了一轮系统压测和优化。最终,在 4C8G 环境下,压测结果从 500 QPS 提升到 4200 QPS ,提升约 8 倍以上

这篇文章就复盘一下:一个老项目里的 Spring Cloud Gateway,是怎么被一次次"小成本"拖慢,又是怎么一步步把性能拉回来的。


一、事故现场:没有明显流量洪峰,但网关还是不可用了

故障表现很直接:

C 端活动网关异常,导致小程序不可用。

这种问题一出现,第一反应通常是流量。

尤其是发生在周六下午,业务流量本来就可能比工作日更高,所以最容易怀疑:是不是活动流量突然冲上来了?

但看完监控后,结论并不支持这个判断。

故障时的 QPS 相比常规水平确实高一些,但并没有明显暴涨;和上周同期相比,也没有看到特别夸张的尖刺。

这就排除了一个最直观的解释:

网关不是被突然出现的巨大流量直接打爆的。

如果流量没有明显异常,但系统还是扛不住,通常说明两个问题之一:

  1. 单请求处理成本变高了;
  2. 系统内部资源利用效率变差了。

对网关来说,这两个问题往往会叠加。

因为网关是所有请求的入口,任何进入请求链路的小成本,都会被总流量放大。


二、先排除几个容易误判的方向

事故排查时,不能一上来就盯着代码改,还是要先把范围收窄。

当时主要看了三个方向。

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 反而下降 弯路

其中最关键的两个问题是:

  1. 日志里输出 %method%line,导致每次日志都要取调用栈;
  2. 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;
  • 自动上下文传递;
  • 泛型类型匹配;
  • 日志调用栈增强。

这些能力都很有价值,但一旦进入高频链路,就必须通过压测确认成本。


十四、写在最后:老项目最怕的不是一次大改,而是小成本持续进热路径

这次事故之后,我对网关有了一个更明确的认识:

网关不是简单的转发层,它是所有请求都会经过的高频执行引擎。

在业务服务里,一个小的低效点可能只是局部问题;

但在网关里,它会乘以所有入口流量。

所以网关优化不要一上来就想着换框架、加机器、调连接池。

更应该先问几个问题:

  1. 请求路径里有没有阻塞调用?
  2. 每次请求有没有重复解析、重复排序、重复扫描?
  3. 日志、Trace、APM 有没有进入高频路径?
  4. 最近加的限流、灰度、业务链路,是不是都在热路径上?
  5. 是否有框架自动增强带来的隐藏成本?
  6. 每个优化有没有压测数据证明?
  7. 优化后有没有明确的线上风险和降级策略?

这次我们把 Spring Cloud Gateway 从 500 QPS 压测提升到 4200 QPS,靠的不是某一个"银弹",而是把高频链路上的隐性成本逐个找出来、验证掉、前置掉或者缓存掉。

性能优化最后拼的不是技巧,而是习惯:

不猜根因,先看指标;

不迷信框架,先做压测;

不把成本放在请求路径,能前置就前置,能缓存就缓存,能异步就异步,但每一步都要知道代价。

相关推荐
MemoriKu1 小时前
Flutter 相册 APP 视频模态稳定化实战:从视频抽帧、Embedding 元数据到 Android 真机启动修复
android·开发语言·前端·flutter·架构·音视频·embedding
ICT系统集成阿祥1 小时前
什么是AI ECN?
后端
XovH1 小时前
Redis 从入门到精通:数据结构Hash 与 List
后端
Cache技术分享1 小时前
432. Java 日期时间 API - 时间工具 TemporalQuery 详解
前端·后端
XovH1 小时前
Redis 从入门到精通:初识 Redis
后端
ai产品老杨1 小时前
【架构深评】基于 Docker 与 边缘计算,如何打通 GB28181/RTSP 与 X86/ARM 异构算力的企业级 AI 视频流网关?(附源码交付)
人工智能·docker·架构
咖啡八杯2 小时前
GoF设计模式——桥接模式
面试·架构
uhakadotcom2 小时前
在 Python 开发中 transitions 的使用
后端·面试·github
Rust研习社2 小时前
通过手写一个迷你 grep 来学习 Rust 的所有权与借用
后端