50天独立打造企业级API网关(七):性能优化实战——从阻塞锁到直接内存的8个关键细节

14 阅读26分钟

50天独立打造企业级API网关(七):性能优化实战——从阻塞锁到直接内存的8个关键细节

系列文章第7篇 | 50天手搓Spring Cloud Gateway:44项功能+561测试用例的完整实践


系列导航

  • 第一篇:控制平面/数据平面架构设计与动态路由实现
  • 第二篇:安全防护体系与性能优化
  • 第三篇:弹性设计与限流降级
  • 第四篇:全链路可观测性与AI Copilot智能运维
  • 第五篇:Kubernetes部署与测试保障
  • 第六篇:高级路由与负载均衡实战
  • 第七篇:性能优化实战——从阻塞锁到直接内存的8个关键细节 ← 本篇

前言

在前6篇文章中,我们介绍了网关的架构设计、安全防护、弹性设计、可观测性和K8s部署。但你可能会问:这些功能都实现了,那性能怎么样?

说实话,性能优化不是一开始就考虑的。在开发初期,我把重点放在功能实现上。直到第一次压测时,我才发现几个隐藏的性能坑:

  1. 限流器在高并发下阻塞了EventLoop线程
  2. 健康检查一次性探测上千节点,把网关CPU打满
  3. Redis故障后全局限流降级,导致流量暴涨
  4. 文件上传时直接内存不足,导致OOM

这些坑不是理论推演出来的,而是实实在在踩到的。这篇文章就分享这8个踩坑过程和具体解决方案,不谈理论指标,只说实际问题


一、限流器的阻塞锁踩坑:EventLoop线程饥饿

1.1 问题是怎么发现的

网关开发完成后,我用JMeter做了第一次压测。配置很简单:

并发线程数: 1000
QPS: 约5000请求/秒
路由: 单个测试路由
限流配置: 全局限流10000 QPS

压测开始后,我发现一个奇怪的现象:

  • 前5秒:正常,响应时间在50ms左右
  • 5秒后:响应时间突然飙到500ms以上
  • 10秒后:大量请求超时失败

我用jstack查看线程堆栈,发现大量线程停在同一个位置:

"reactor-http-nio-3" - Thread t@123
   java.lang.Thread.State: BLOCKED
        at com.leoli.gateway.util.RateLimiterWindow.tryAcquire(RateLimiterWindow.java:73)
        - waiting to lock <0x1234> (a java.util.concurrent.locks.ReentrantLock)
        at ...

原来限流器用的是synchronized阻塞锁!在5000 QPS下,大量线程在争抢同一个锁,EventLoop线程被阻塞

1.2 为什么EventLoop阻塞是致命的

Spring Cloud Gateway基于WebFlux(响应式编程),所有请求处理都在EventLoop线程上执行。EventLoop线程数量有限(默认CPU核心数),一旦这些线程被阻塞:

场景:EventLoop线程被限流器阻塞

Thread 1 (EventLoop): 持有锁,处理限流计数
Thread 2-8 (EventLoop): 等待锁,阻塞在限流器入口
Thread 9-1000 (业务线程): 等待EventLoop处理响应

结果:
  - EventLoop线程饥饿,无法处理新请求
  - 请求积压,响应时间飙升
  - 网关吞吐量急剧下降

这就是响应式编程的致命陷阱:在EventLoop线程上使用阻塞锁。

1.3 解决方案:CAS + tryLock混合策略

我把限流器改成非阻塞设计,用CAS(Compare-And-Swap)和tryLock替代synchronized:

// RateLimiterWindow.java - 非阻塞限流实现

public boolean tryAcquire() {
    long now = System.currentTimeMillis();
    
    // 第一步:窗口过期检查(CAS更新时间戳)
    if (now - windowStartTime.get() >= windowSizeMs) {
        if (windowStartTime.compareAndSet(windowStartTime.get(), now)) {
            // CAS成功,负责重置计数器
            currentCount.set(0);
        }
        // 继续执行,不管CAS成功还是失败
    }
    
    // 第二步:Fast path - CAS计数(低竞争时的快速路径)
    int count = currentCount.get();
    if (count < totalCapacity) {
        if (currentCount.compareAndSet(count, count + 1)) {
            return true;  // CAS成功,无需任何锁!
        }
        // CAS失败(竞争激烈),进入Slow path
    } else {
        return false;  // 容量耗尽,直接拒绝
    }
    
    // 第三步:Slow path - tryLock(高竞争时的兜底,永不阻塞!)
    if (lock.tryLock()) {  // tryLock不会阻塞!
        try {
            // 再次检查(可能已被其他线程重置)
            count = currentCount.get();
            if (count < totalCapacity) {
                currentCount.incrementAndGet();
                return true;
            }
            return false;
        } finally {
            lock.unlock();
        }
    }
    
    // tryLock获取失败 - 立即拒绝,不等待!
    return false;  // 宁愿拒绝请求,也不能阻塞EventLoop
}

关键点:tryLock和lock的区别

lock.lock();      // 阻塞等待,直到获取锁(危险!)
lock.tryLock();   // 立即返回true/false,不等待(安全!)

1.4 优化效果(不说指标,只说现象)

修改后再压测:

  • 之前:压测10秒后响应时间飙到500ms,大量超时
  • 之后:压测持续30秒,响应时间稳定在50-80ms,无超时

jstack检查,EventLoop线程再也没有BLOCKED状态,全部在RUNNABLE处理请求。


二、健康检查的批量踩坑:上千节点并发探测把CPU打满

2.1 问题是怎么发现的

网关部署到生产后,接入了100多个后端服务,总共约800个节点。某天凌晨,监控系统报警:

网关CPU使用率飙升到90%
健康检查日志显示大量超时
部分后端节点被误判为不健康

我查看健康检查代码,发现问题:

// 之前的问题代码(简化版)

@Scheduled(fixedRate = 30000)
public void performHealthCheck() {
    List<Instance> instances = discoveryService.getAllInstances();
    // 800个节点,全部并发启动HTTP探测!
    for (Instance instance : instances) {
        CompletableFuture.runAsync(() -> {
            activeChecker.probe(instance);  // HTTP请求探测
        });
    }
}

800个节点同时启动HTTP探测:

时间点T0:
  - 800个线程同时启动
  - 800个HTTP请求同时发出
  - 网关CPU瞬间被打满
  - 网络带宽被健康检查占用
  - 正常业务请求响应变慢

时间点T0+5秒:
  - 部分探测超时(因为网关本身过载)
  - 健康节点被误判为不健康
  - 路由表更新,摘掉"不健康"节点
  - 业务流量集中到剩余节点
  - 剩余节点压力更大

这是一个典型的资源争抢问题:健康检查和业务请求争夺CPU和网络资源。

2.2 解决方案:批次处理+并发限制

我改成批次处理+Semaphore并发控制:

// HealthCheckScheduler.java - 批次健康检查

@Component
public class HealthCheckScheduler {
    
    @Value("${gateway.health.check-batch-size:100}")
    private int checkBatchSize;  // 每批100个节点
    
    @Value("${gateway.health.max-concurrent-per-batch:20}")
    private int maxConcurrentPerBatch;  // 每批最多20个并发
    
    private Semaphore concurrentCheckSemaphore;
    
    @PostConstruct
    public void init() {
        concurrentCheckSemaphore = new Semaphore(maxConcurrentPerBatch);
    }
    
    @Scheduled(fixedRate = 30000)
    public void performRegularHealthCheck() {
        List<InstanceKey> instances = instanceDiscovery.findInstancesForRegularCheck();
        
        // 800个节点分成8批,每批100个
        int batchCount = (instances.size() + checkBatchSize - 1) / checkBatchSize;
        
        for (int i = 0; i < batchCount; i++) {
            List<InstanceKey> batch = instances.subList(
                i * checkBatchSize, 
                Math.min((i + 1) * checkBatchSize, instances.size())
            );
            
            // 每批内部并发执行,但限制并发数
            performConcurrentHealthCheckBatch(batch);
        }
    }
    
    private void performConcurrentHealthCheckBatch(List<InstanceKey> batch) {
        List<CompletableFuture<Void>> futures = batch.stream()
            .map(instance -> CompletableFuture.runAsync(() -> {
                try {
                    // 获取许可(最多20个并发)
                    concurrentCheckSemaphore.acquire();
                    try {
                        activeChecker.probe(instance.getServiceId(), 
                                           instance.getIp(), 
                                           instance.getPort());
                    } finally {
                        // 释放许可
                        concurrentCheckSemaphore.release();
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }))
            .collect(Collectors.toList());
        
        // 等待本批次完成(超时保护)
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .get(30, TimeUnit.SECONDS);
    }
}

配置说明:

gateway:
  health:
    check-batch-size: 100         # 每批检查100个节点
    max-concurrent-per-batch: 20  # 每批最多20个并发探测

2.3 执行流程对比

之前:

T0: 800个探测同时启动 → CPU飙升

之后:

T0:    第1批100个节点启动(最多20个并发)
T+5s:  第1批完成,第2批启动
T+10s: 第2批完成,第3批启动
...
T+40s: 全部8批完成

2.4 多级频率设计减少检查量

还有一个优化:稳定节点降低检查频率:

// 三级检查频率

@Scheduled(fixedRate = 30000)   // 30秒:常规检查
public void performRegularHealthCheck() {
    // 只检查:新注册节点、刚失败节点、非稳定节点
}

@Scheduled(fixedRate = 120000)  // 2分钟:稳定节点检查
public void performStableHealthCheck() {
    // 只检查:连续10次成功的稳定节点
}

@Scheduled(fixedRate = 180000)  // 3分钟:降级节点检查
public void performDegradedHealthCheck() {
    // 只检查:连续失败5次的降级节点
}

实际效果:

800个节点分布:
  - 700个稳定节点:2分钟检查一次(每分钟350个)
  - 80个常规节点:30秒检查一次(每分钟160个)
  - 20个降级节点:3分钟检查一次(每分钟7个)
  
合计:每分钟约517个检查(vs 之前1600个)
减少约68%

三、Redis故障后的流量暴涨踩坑:Shadow Quota平滑降级

3.1 问题是怎么发现的

全局限流依赖Redis共享计数器。某天Redis服务器重启,我观察到:

T0: Redis重启,连接失败
T0+1s: 网关切换到本地限流
T0+5s: 后端服务开始报警,流量过大
T0+10s: 后端服务崩溃

我查看限流降级逻辑,发现问题:

// 问题代码(简化版)

public boolean tryAcquire(String routeId) {
    try {
        // 尝试Redis全局限流
        return redisRateLimiter.tryAcquire(routeId);
    } catch (Exception e) {
        // Redis失败,降级到本地限流
        // 问题:直接用配置的globalQps作为本地限流阈值!
        return localRateLimiter.tryAcquire(routeId, config.getGlobalQps());
    }
}

关键问题:假设全局限流10000 QPS,3个网关节点,配置如下:

gateway:
  rate-limiter:
    global-qps: 10000  # 全局总限流

Redis故障后:

节点A降级: localLimit = 10000 QPS
节点B降级: localLimit = 10000 QPS  
节点C降级: localLimit = 10000 QPS

总流量 = 30000 QPS(暴涨3倍!)
后端服务承受不住,崩溃

这就是降级逻辑的陷阱:降级配置没有考虑节点数量。

3.2 解决方案:Shadow Quota预计算

核心思想:Redis正常时,预先计算每个节点的本地配额:

// ShadowQuotaManager.java - Shadow Quota管理

@Component
public class ShadowQuotaManager implements ApplicationListener<HeartbeatEvent> {
    
    private final AtomicInteger cachedNodeCount = new AtomicInteger(1);
    private final ConcurrentHashMap<String, AtomicLong> shadowQuotas = new ConcurrentHashMap<>();
    
    // Level 1:监听Nacos心跳(实时更新节点数)
    @Override
    public void onApplicationEvent(HeartbeatEvent event) {
        int nodeCount = discoveryClient.getInstances("my-gateway").size();
        cachedNodeCount.set(nodeCount);
    }
    
    // 每秒更新Shadow Quota
    @Scheduled(fixedRate = 1000)
    public void updateShadowQuotas() {
        if (redisHealthy) {
            // Redis正常:读取全局QPS,预计算每个节点的配额
            for (String routeId : shadowQuotas.keySet()) {
                long globalQps = getGlobalQpsFromRedis(routeId);
                int nodeCount = cachedNodeCount.get();
                
                // Shadow Quota = globalQps / nodeCount
                long shadowQuota = globalQps / nodeCount;
                shadowQuotas.get(routeId).set(shadowQuota);
            }
        }
    }
    
    // Redis故障时使用预计算的配额
    public long getShadowQuota(String routeId, int configQps) {
        AtomicLong quota = shadowQuotas.get(routeId);
        if (quota != null && quota.get() > 0) {
            return quota.get();  // 使用预计算值
        }
        
        // 没有预计算值,至少按节点数均分
        return configQps / cachedNodeCount.get();
    }
}

降级后的流量分配:

Redis正常时(预计算):
  globalQps = 10000
  nodeCount = 3
  shadowQuota = 10000 / 3 = 3333
  
Redis故障时(降级):
  节点A: localLimit = 3333 QPS
  节点B: localLimit = 3333 QPS
  节点C: localLimit = 3333 QPS
  
总流量 ≈ 10000 QPS(平稳!)

3.3 节点数如何获取:三级检测机制详解

Shadow Quota的核心公式:

shadowQuota = globalQps / nodeCount

关键问题:nodeCount从哪里来?怎么保证准确性?

这不是一个简单的数字,而是动态变化的集群状态。我设计了三级检测机制:


Level 1: Nacos心跳监听(实时感知)

网关集群通过Nacos注册,每个节点上线/下线都会触发HeartbeatEvent。监听这个事件可以实时感知节点数变化:

// ShadowQuotaManager.java - Level 1实现

@Component
public class ShadowQuotaManager implements ApplicationListener<HeartbeatEvent> {
    
    // 缓存的节点数(原子变量,线程安全)
    private final AtomicInteger cachedNodeCount = new AtomicInteger(1);
    
    // 上次更新时间戳(用于判断Level 1是否失效)
    private final AtomicLong lastNodeCountUpdateTime = new AtomicLong(0);
    
    // 服务名(用于查询Nacos中同服务的实例)
    @Value("${spring.application.name:gateway}")
    private String applicationName;
    
    // Level 1:监听Nacos心跳事件
    @Override
    public void onApplicationEvent(HeartbeatEvent event) {
        if (!shadowQuotaEnabled) {
            return;  // Shadow Quota未启用,跳过
        }
        
        try {
            // 从Nacos获取当前网关集群的节点列表
            updateNodeCountFromDiscovery();
            
            // 记录更新时间(用于Level 2判断)
            lastNodeCountUpdateTime.set(System.currentTimeMillis());
            
            log.debug("Node count updated via heartbeat: {}", cachedNodeCount.get());
        } catch (Exception e) {
            log.warn("Failed to update node count: {}", e.getMessage());
        }
    }
    
    // 从DiscoveryClient获取节点数
    private boolean updateNodeCountFromDiscovery() {
        if (discoveryClient == null) {
            // DiscoveryClient未注入(本地开发环境)
            cachedNodeCount.set(fallbackNodeCount);
            log.debug("Discovery client not available, using fallback: {}", fallbackNodeCount);
            return false;
        }
        
        try {
            // 关键代码:查询Nacos中"my-gateway"服务的所有实例
            // discoveryClient来自Spring Cloud DiscoveryClient
            // applicationName = spring.application.name = "my-gateway"
            int nodeCount = discoveryClient.getInstances(applicationName).size();
            
            // 有效节点数计算(三个值取最大)
            // minNodeCount: 最小节点数(防止除0)
            // nodeCount: 实际查询到的节点数
            // fallbackNodeCount: YAML配置的兜底值
            int effectiveCount = Math.max(minNodeCount, Math.max(nodeCount, fallbackNodeCount));
            
            cachedNodeCount.set(effectiveCount);
            log.debug("Node count from discovery: {} (actual: {}, fallback: {}, min: {})",
                     effectiveCount, nodeCount, fallbackNodeCount, minNodeCount);
            return true;
        } catch (Exception e) {
            // Nacos查询失败(网络问题/Nacos宕机)
            log.warn("Failed to get node count: {}", e.getMessage());
            cachedNodeCount.set(fallbackNodeCount);
            return false;
        }
    }
}

关键点解释:

  1. HeartbeatEvent是什么:

    • Spring Cloud DiscoveryClient定期向Nacos发送心跳
    • 每次心跳都会触发HeartbeatEvent
    • 心跳频率默认5秒(可配置)
    • 事件携带最新的服务实例列表
  2. discoveryClient.getInstances(applicationName):

    • applicationName = "my-gateway"(网关的服务名)
    • 返回List,每个实例代表一个网关Pod
    • size()就是当前集群节点数
  3. 为什么用Math.max:

    场景:nodeCount=0(临时故障导致查询结果为空)
    
    如果直接用nodeCount=0:
      shadowQuota = globalQps / 0 → 除零错误!
    
    使用Math.max(minNodeCount, ...):
      effectiveCount = Math.max(1, 0) = 1
      shadowQuota = 10000 / 1 = 10000 (至少每个节点能限流)
    

Level 1触发频率:

Nacos心跳每5秒一次,但节点数变化不一定每次都触发:

  • 节点新增:Pod启动注册到Nacos,触发HeartbeatEvent → 立即感知
  • 节点删除:Pod停止从Nacos注销,触发HeartbeatEvent → 立即感知
  • 心跳正常:节点数不变,每5秒触发一次(更新lastNodeCountUpdateTime)

Level 2: 定时兜底查询(1小时触发)

Level 1依赖心跳事件,但心跳监听可能失效:

  • Nacos和网关之间网络故障
  • HeartbeatEvent监听器被错误禁用
  • Spring Cloud DiscoveryClient内部Bug

为了防止Level 1失效导致节点数长期错误,设计了定时兜底查询:

// ShadowQuotaManager.java - Level 2实现

// 兜底查询频率:1小时(3600000毫秒)
private static final long FALLBACK_THRESHOLD_MS = 3600000;

@Scheduled(fixedRate = FALLBACK_THRESHOLD_MS)
public void fallbackUpdateNodeCount() {
    if (!shadowQuotaEnabled) {
        return;
    }
    
    // 计算距离上次Level 1更新的时间
    long elapsedSinceLastUpdate = System.currentTimeMillis() - lastNodeCountUpdateTime.get();
    
    // 关键判断:仅在Level 1超过1小时未更新时触发!
    if (elapsedSinceLastUpdate >= FALLBACK_THRESHOLD_MS) {
        log.warn("Node count listener may be stale ({}ms since last update), triggering fallback",
                 elapsedSinceLastUpdate);
        
        // 再次尝试从DiscoveryClient查询
        if (!updateNodeCountFromDiscovery()) {
            // Discovery也失败,使用Level 3(YAML配置)
            cachedNodeCount.set(fallbackNodeCount);
            log.warn("Discovery failed, using YAML fallback: {}", fallbackNodeCount);
        }
    }
}

为什么不每小时都查询?

看代码逻辑:if (elapsedSinceLastUpdate >= FALLBACK_THRESHOLD_MS)

这个判断意味着:

  • 正常情况:Level 1每5秒更新,lastNodeCountUpdateTime始终新鲜 → Level 2不执行
  • 异常情况:Level 1超过1小时未更新 → Level 2触发兜底查询

为什么是1小时?

// ScheduleConstants.java

/**
 * Threshold for triggering fallback node count query.
 * If listener hasn't updated for 1 hour, trigger fallback.
 */
long QUOTA_FALLBACK_THRESHOLD_MS = 3600000;  // 1小时

节点数变化是低频事件:

  • 日常运行:节点数稳定不变
  • 扩缩容:可能几天一次
  • 节点故障:偶尔发生

1小时的兜底频率足够应对极端情况,又不浪费资源


Level 3: YAML静态配置(保底方案)

当Level 1和Level 2都失败时(网络完全故障,Nacos完全宕机),需要一个静态兜底值:

// ShadowQuotaManager.java - Level 3配置

/**
 * Fallback node count from YAML config (Level 3 protection).
 * Used when both listener and discovery client fail.
 */
@Value("${gateway.rate-limiter.shadow-quota.fallback-node-count:1}")
private int fallbackNodeCount;

/**
 * Minimum node count (safety value to prevent division by zero).
 */
@Value("${gateway.rate-limiter.shadow-quota.min-node-count:1}")
private int minNodeCount;

YAML配置:

gateway:
  rate-limiter:
    shadow-quota:
      enabled: true
      min-node-count: 1        # 最小节点数(防除零)
      fallback-node-count: 3   # 兜底节点数(需人工配置)

fallback-node-count如何设置?

建议:根据实际部署规模配置

单机部署: fallback-node-count: 1
  - Redis+Nacos都失败 → 假设自己是唯一节点
  - shadowQuota = globalQps / 1 = globalQps
  - 本地限流用全局值,流量不会暴涨

集群部署(3节点): fallback-node-count: 3
  - Redis+Nacos都失败 → 假设3个节点
  - shadowQuota = globalQps / 3
  - 每个节点按平均值限流

集群部署(10节点): fallback-node-count: 10
  - 同理,按实际规模配置

Level 3触发场景:

// Level 3触发条件(极端情况)

场景1: Level 1失效超过1小时
  + Level 2尝试DiscoveryClient查询
  + DiscoveryClient抛出异常(Nacos宕机)
  → cachedNodeCount.set(fallbackNodeCount)

场景2: 本地开发环境
  + discoveryClient == null(未注入)
  → updateNodeCountFromDiscovery()返回false
  → cachedNodeCount.set(fallbackNodeCount)

场景3: 应用启动时
  + @PostConstruct初始化
  + updateNodeCountFromDiscovery()可能失败(服务还未完全启动)
  → cachedNodeCount.set(fallbackNodeCount)

三级机制完整流程图
┌─────────────────────────────────────────────────────────────────────┐
│                    NODE COUNT DETECTION                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  启动初始化                                                          │
│  @PostConstruct                                                     │
│  ├─ updateNodeCountFromDiscovery()                                 │
│  ├─ 成功 → cachedNodeCount = 实际节点数                              │
│  ├─ 失败 → cachedNodeCount = fallbackNodeCount(Level 3)            │
│  └─ lastNodeCountUpdateTime = 当前时间                              │
│                                                                     │
│  正常运行(Level 1主导)                                               │
│  HeartbeatEvent(每5秒)                                              │
│  ├─ updateNodeCountFromDiscovery()                                 │
│  ├─ discoveryClient.getInstances("my-gateway")                     │
│  ├─ cachedNodeCount = 实际节点数                                     │
│  └─ lastNodeCountUpdateTime = 当前时间                              │
│                                                                     │
│  Level 1失效(Level 2兜底)                                           │
│  @Scheduled(每小时)                                                 │
│  ├─ elapsedSinceLastUpdate = 当前时间 - lastNodeCountUpdateTime    │
│  ├─ elapsedSinceLastUpdate < 1小时 → 跳过(Level 1正常)              │
│  ├─ elapsedSinceLastUpdate >= 1小时 → Level 1可能失效               │
│  ├─ updateNodeCountFromDiscovery()                                 │
│  ├─ 成功 → cachedNodeCount = 实际节点数                              │
│  └─ 失败 → cachedNodeCount = fallbackNodeCount(Level 3)            │
│                                                                     │
│  极端情况(Level 3保底)                                               │
│  ├─ Nacos完全宕机                                                   │
│  ├─ DiscoveryClient == null(本地开发)                               │
│  └─ cachedNodeCount = fallbackNodeCount(YAML配置)                  │
│                                                                     │
│  Shadow Quota计算                                                    │
│  @Scheduled(每秒)                                                   │
│  ├─ globalQps = Redis.get(routeId)                                 │
│  ├─ shadowQuota = globalQps / cachedNodeCount                      │
│  └─ shadowQuotas[routeId] = shadowQuota                            │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

实际运行日志示例

我观察到的生产环境日志:

// 正常情况(Level 1主导)
2025-05-06 10:00:00 DEBUG Node count updated via heartbeat: 3
2025-05-06 10:00:05 DEBUG Node count updated via heartbeat: 3
2025-05-06 10:00:10 DEBUG Node count updated via heartbeat: 3
...

// 节点扩容(新增1个Pod)
2025-05-06 10:05:00 DEBUG Node count updated via heartbeat: 4
2025-05-06 10:05:00 INFO  Shadow quota recalculated: route-123, quota=2500 (was 3333)

// 节点缩容(删除1个Pod)
2025-05-06 11:00:00 DEBUG Node count updated via heartbeat: 3
2025-05-06 11:00:00 INFO  Shadow quota recalculated: route-123, quota=3333 (was 2500)

// Level 1失效(网络故障1小时)
2025-05-06 12:00:00 WARN  Node count listener may be stale (3600000ms since last update)
2025-05-06 12:00:00 WARN  Failed to get node count from discovery: Connection refused
2025-05-06 12:00:00 WARN  Discovery failed, using YAML fallback: 3

// Redis故障降级(使用预计算的shadowQuota)
2025-05-06 12:05:00 WARN  Redis unavailable, activating shadow quota failover
2025-05-06 12:05:00 INFO  Shadow quotas activated: 15 routes, nodeCount=3
2025-05-06 12:05:00 INFO  Route route-123 using shadow quota: 3333 QPS

三级机制的优势
特性Level 1Level 2Level 3
触发条件心跳事件1小时未更新Level 1+2都失败
响应速度实时(秒级)慢(小时级)立即(静态)
数据来源Nacos实时查询Nacos兜底查询YAML配置
适用场景正常运行网络临时故障Nacos完全宕机
准确性最高高(可能滞后)保守估计

关键设计理念:

  1. 正常情况不浪费资源:Level 1每5秒更新,Level 2判断后跳过
  2. 异常情况有兜底:三层保护确保节点数始终有值
  3. 宁可保守不可激进:fallbackNodeCount宁可偏大(防止限流过严),不可偏小(防止流量暴涨)
// effectiveCount计算逻辑分析

int nodeCount = discoveryClient.getInstances("my-gateway").size();  // 实际查询
int effectiveCount = Math.max(minNodeCount, Math.max(nodeCount, fallbackNodeCount));

// 举例说明
// 实际3节点, fallback配置3, min配置1

情况1: nodeCount=3(正常)
  effectiveCount = Math.max(1, Math.max(3, 3)) = 3 ✓

情况2: nodeCount=0(查询失败)
  effectiveCount = Math.max(1, Math.max(0, 3)) = 3 ✓(使用fallback)

情况3: nodeCount=5(扩容后)
  effectiveCount = Math.max(1, Math.max(5, 3)) = 5 ✓(使用实际值)

结论:
  - 实际值 > fallback → 使用实际值(感知扩容)
  - 实际值 < fallback → 使用fallback(保守策略)
  - 实际值 = 0 → 使用fallback(保底方案)

四、路由缓存刷新踩坑:clear()导致请求失败

4.1 问题是怎么发现的

某次路由配置更新后,监控系统显示短暂的请求失败:

路由更新时间: T0
失败请求时间段: T0 ~ T0+0.1秒
失败原因: No route found
失败数量: 约100个请求

我查看路由缓存刷新逻辑:

// 问题代码(简化版)

public void refreshCache(List<Route> newRoutes) {
    // 第一步:清空缓存!
    compiledRouteCache.clear();  // 缓存变成空!
    
    // 第二步:逐个重新编译并放入缓存
    for (Route route : newRoutes) {
        Route compiled = compileRoute(route);
        compiledRouteCache.put(route.getId(), compiled);
    }
}

问题:clear()后到重新填充完成,存在一个缓存空窗口:

时间T0:
  clear()执行 → 缓存空了!
  
时间T0 ~ T0+100ms:
  请求1: cache.get(routeId) → null"No route found"
  请求2: cache.get(routeId) → null"No route found"
  ...
  请求100: cache.get(routeId) → null"No route found"
  
时间T0+100ms:
  重新填充完成 → 缓存恢复

对于1000条路由,重新编译需要约100ms。这100ms内,所有请求都找不到路由。

4.2 解决方案:增量更新,先放后删

核心思想:先放入新路由,再删除旧路由:

// CompiledRouteCache.java - 增量更新

private IncrementalUpdateResult performIncrementalUpdate(List<Route> newRoutes) {
    Set<String> newRouteIds = new HashSet<>();
    for (Route route : newRoutes) {
        newRouteIds.add(route.getId());
    }
    
    // 第一步:放入新路由(新增或更新)
    for (Route route : newRoutes) {
        Route existing = compiledRouteCache.get(route.getId());
        if (existing == null) {
            // 新路由:添加
            compiledRouteCache.put(route.getId(), route);
        } else if (!routesEqual(existing, route)) {
            // 已存在但内容变更:更新
            compiledRouteCache.put(route.getId(), route);
        }
        // 已存在且内容相同:跳过
    }
    
    // 第二步:删除不再存在的路由
    Iterator<Map.Entry<String, Route>> iterator = 
        compiledRouteCache.entrySet().iterator();
    while (iterator.hasNext()) {
        if (!newRouteIds.contains(iterator.next().getKey())) {
            iterator.remove();
        }
    }
    
    // 注意:整个过程没有clear()!
    // 新旧路由短暂共存,但不会出现空窗口
}

更新过程对比:

之前:

clear() → 缓存空 → 请求失败 → 重新填充 → 恢复

之后:

放入新路由 → 新旧共存 → 删除旧路由 → 完成
          ↑
      无空窗口,请求始终能找到路由

五、Redis KEYS踩坑:阻塞整个Redis服务器

5.1 问题是怎么发现的

Shadow Quota每秒更新,需要查询Redis中某个路由的所有限流键。我最初用的是KEYS命令:

// 问题代码

public void updateShadowQuotas() {
    for (String routeId : shadowQuotas.keySet()) {
        // KEYS命令扫描所有匹配的键
        Set<String> keys = redisTemplate.keys("rate_limit:*:" + routeId + "*");
        
        for (String key : keys) {
            Long count = redisTemplate.opsForZSet().zCard(key);
            totalQps += count;
        }
    }
}

某天凌晨,Redis监控系统报警:

Redis响应时间飙升到500ms
所有Redis操作超时
网关限流功能失效

我查看Redis日志:

[WARNING] KEYS command executed: rate_limit:*:route-123*
[WARNING] Command blocked for 523ms
[WARNING] 50,000 keys scanned

问题:KEYS命令是O(N)复杂度,会阻塞Redis服务器:

Redis单线程处理命令:
  KEYS rate_limit:*  → 扫描50,000个键 → 阻塞500ms
  其他命令 → 全部等待 → 超时

5.2 解决方案:SCAN替代KEYS

SCAN命令是增量迭代,不阻塞:

// ShadowQuotaManager.java - 使用SCAN

private void updateGlobalQpsSnapshots() {
    for (String routeId : shadowQuotas.keySet()) {
        long totalQps = 0;
        
        // SCAN替代KEYS(非阻塞)
        String pattern = "rate_limit:*:" + routeId + "*";
        ScanOptions scanOptions = ScanOptions.scanOptions()
            .match(pattern)
            .count(100)  // 每次迭代返回约100个键
            .build();
        
        try (Cursor<String> cursor = redisTemplate.scan(scanOptions)) {
            while (cursor.hasNext()) {
                String key = cursor.next();
                Long count = redisTemplate.opsForZSet().zCard(key);
                totalQps += count;
            }
        }
        
        // 计算Shadow Quota
        long shadowQuota = totalQps / cachedNodeCount.get();
        shadowQuotas.get(routeId).set(shadowQuota);
    }
}

SCAN vs KEYS对比:

KEYS:
  单次命令扫描全部 → 阻塞500ms → 其他命令等待

SCAN:
  多次迭代(每次约100个键) → 每次约5ms → 其他命令可穿插执行
  总时间可能更长,但Redis始终可用

六、GC过早晋升踩坑:Young Gen对象快速进Old Gen

6.1 问题是怎么发现的

网关运行一周后,某天突然出现频繁Full GC:

[GC日志]
[Full GC (Allocation Failure)]: 1024M->900M, pause 800ms
[Full GC (Allocation Failure)]: 1024M->910M, pause 750ms
[Full GC (Allocation Failure)]: 1024M->920M, pause 800ms

每分钟触发2-3次Full GC,每次暂停约800ms。网关响应时间明显抖动。

我用Prometheus分析GC指标:

// PrometheusService.java - GC指标采集

private Map<String, Object> getGCMetrics() {
    // 晋升速率(对象从Young Gen进Old Gen的速度)
    String promoRateQuery = "rate(jvm_gc_memory_promoted_bytes_total[1m])";
    double promoRate = extractValue(queryPrometheus(promoRateQuery), 0.0);
    gc.put("promotionRateMBPerSec", promoRate / 1024 / 1024);
    
    // 分配速率(新对象分配速度)
    String allocRateQuery = "rate(jvm_gc_memory_allocated_bytes_total[1m])";
    double allocRate = extractValue(queryPrometheus(allocRateQuery), 0.0);
    gc.put("allocationRateMBPerSec", allocRate / 1024 / 1024);
    
    // 晋升比例(判断对象生命周期)
    if (allocRate > 0) {
        double promotionRatio = promoRate / allocRate * 100;
        gc.put("promotionRatio", promotionRatio);
    }
    
    // 健康判断
    if (promoRate > 10 * 1024 * 1024 && allocRate > 50 * 1024 * 1024) {
        gc.put("healthStatus", "WARNING");
        gc.put("healthReason", "高晋升速率+高分配速率,Survivor区太小,对象过早晋升");
    }
}

采集结果显示:

晋升速率: 15 MB/s  (偏高)
分配速率: 60 MB/s  (正常偏高)
晋升比例: 25%      (过高,正常应<10%)

问题:25%的新对象在Young GC后存活,晋升到Old Gen。说明:

Survivor区太小 → 对象在Survivor中存活次数不足15次 → 过早晋升到Old Gen
Old Gen快速填满 → 频繁Full GC

6.2 解决方案:增大Survivor区

网关JVM启动参数调整:

# 之前(默认配置)
-Xms2g -Xmx2g

# 之后(调整年轻代和Survivor比例)
-Xms2g -Xmx2g
-Xmn1g            # 年轻代占堆50%(增大)
-XX:SurvivorRatio=6  # Eden:Survivor=6:1:1(Survivor占年轻代12.5%)

# 解释:
# 年轻代=1GB,分为Eden(750MB)+S0(125MB)+S1(125MB)
# Survivor从默认的几十MB增大到125MB
# 对象有更多机会在Survivor中存活足够次数,不晋升到Old Gen

GC表现改善:

之前:
  Young GC: 每10秒一次
  Full GC: 每分钟2-3次

之后:
  Young GC: 每8秒一次(略微增加,因为年轻代增大)
  Full GC: 每10分钟0-1次(大幅减少)

七、过滤器性能分析踩坑:混淆"总耗时"和"自身耗时"

7.1 问题是怎么发现的

监控页面显示某过滤器耗时很长:

Filter: AuthFilter
Total Duration P95: 500ms

我以为是AuthFilter逻辑慢,花时间优化认证逻辑,但效果不明显。后来才发现:这个500ms包含了下游服务的响应时间!

7.2 问题根源:两种耗时定义混淆

Spring Cloud Gateway的过滤器有pre和post两个阶段:

// 过滤器执行流程

public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    // ===== pre阶段 =====
    long startTime = System.nanoTime();
    
    // 认证逻辑
    String token = exchange.getRequest().getHeaders().getFirst("Authorization");
    if (token == null) {
        return Mono.error(new UnauthorizedException());
    }
    long preEndTime = System.nanoTime();  // pre阶段结束
    
    // ===== 调用下游 =====
    return chain.filter(exchange)  // 这里包含下游服务响应时间!
        .doOnSuccess(response -> {
            // ===== post阶段 =====
            long postStartTime = System.nanoTime();  // post阶段开始
            
            // 后置逻辑(如日志记录)
            log.info("Request completed");
            
            long endTime = System.nanoTime();  // post阶段结束
            
            // 问题:记录的是Total Duration = endTime - startTime
            //      包含了下游服务响应时间!
        });
}

Total Duration计算:

Total Duration = endTime - startTime
              = (preEndTime - startTime) + (postStartTime - preEndTime) + (endTime - postStartTime)
              = preLogicTime + downstreamTime + postLogicTime
              
其中downstreamTime是下游服务响应时间,可能占90%以上!

7.3 解决方案:区分"自身耗时"和"总耗时"

我改进了FilterChainTracker,记录两种耗时:

// FilterChainTracker.java - 区分两种耗时

public static class FilterExecution {
    private long startTime;       // pre阶段开始
    private long preEndTime;      // pre阶段结束(调用chain.filter之前)
    private long postStartTime;   // post阶段开始(下游返回后)
    private long endTime;         // post阶段结束
    
    // 总耗时(包含下游)
    public long getDurationMs() {
        return (endTime - startTime) / 1_000_000;
    }
    
    // 自身耗时(仅过滤器逻辑,不含下游)
    public long getSelfTimeMs() {
        long preTime = (preEndTime - startTime);
        long postTime = (endTime - postStartTime);
        return (preTime + postTime) / 1_000_000;
    }
    
    // 下游耗时
    public long getDownstreamTimeMs() {
        return (postStartTime - preEndTime) / 1_000_000;
    }
}

监控展示改进:

之前(只显示Total Duration):
  AuthFilter P95: 500ms
  (误以为是过滤器慢,实际是下游服务慢)

之后(区分显示):
  AuthFilter:
    Total Duration P95: 500ms (包含下游)
    Self Duration P95: 5ms    (仅过滤器逻辑)
    Downstream P95: 495ms     (下游服务响应)
  
  结论:过滤器本身很快(5ms),慢的是下游服务!

八、Netty直接内存踩坑:文件上传时OOM

8.1 问题是怎么发现的

网关上线后,用户反馈文件上传功能有问题:

用户操作:上传10MB的图片文件
结果:上传失败,错误信息"Out of memory"
日志:java.lang.OutOfMemoryError: Direct buffer memory

我查看JVM配置:

# 当前JVM配置
-Xms2g -Xmx2g   # 堆内存2GB

问题:配置了堆内存,但没有配置直接内存!

8.2 为什么Netty需要直接内存

Spring Cloud Gateway基于WebFlux+Netty,Netty的网络I/O使用直接内存(Direct Memory):

堆内存(Heap Memory):
  - JVM管理的内存区域
  - 对象分配在这里
  - GC回收管理
  - 读写需要从堆复制到内核(开销大)

直接内存(Direct Memory):
  - 操作系统管理的内存(堆外内存)
  - 不受JVM GC管理
  - 通过ByteBuffer.allocateDirect()分配
  - 直接与操作系统交互,零拷贝
  - 网络I/O性能更好

Netty使用直接内存的场景:

  1. 请求体处理:大文件上传时,请求体存储在直接内存
  2. 响应体处理:大文件下载时,响应体存储在直接内存
  3. 网络缓冲区:TCP连接的读写缓冲区

8.3 直接内存的计算方式

Netty默认直接内存大小:

// Netty默认配置(io.netty.util.internal.PlatformDependent)

// 最大直接内存 = JVM最大堆内存
// 如果未配置-XX:MaxDirectMemorySize,Netty会使用堆大小

默认行为:
  -Xmx2g → MaxDirectMemory = 2GB
  
但注意:
  堆内存 + 直接内存 + 元空间 + 线程栈 = 总物理内存占用
  2GB堆 + 2GB直接内存 = 至少4GB物理内存需求!

8.4 文件上传时的直接内存占用

假设场景:

并发上传请求: 50个用户同时上传文件
文件大小: 每个10MB
直接内存需求: 50 × 10MB = 500MB

如果同时有100个请求:
  直接内存需求 = 100 × 10MB = 1GB
  
如果文件更大(100MB):
  直接内存需求 = 100 × 100MB = 10GB → OOM!

关键点:直接内存是按并发请求数计算,不是按总请求数!

8.5 解决方案:配置MaxDirectMemorySize

我调整JVM参数:

# 网关JVM完整配置(考虑Netty直接内存)

# 堆内存(处理请求逻辑、对象分配)
-Xms2g -Xmx2g

# 年轻代(高频请求对象)
-Xmn1g
-XX:SurvivorRatio=6

# 直接内存(Netty网络I/O、文件上传下载)
-XX:MaxDirectMemorySize=1g

# 元空间(类加载)
-XX:MaxMetaspaceSize=256m

# GC配置
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

# 直接内存监控(Netty内部使用)
-Dio.netty.leakDetection.level=advanced

# GC日志
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=10m

内存分配解释:

总物理内存占用计算:

堆内存:       2GB
直接内存:     1GB
元空间:       256MB
线程栈:       约100MB(200线程 × 512KB)
Netty内部:    约50MB

合计:         约3.4GB

容器配置:     至少4GB内存(留有余量)

8.6 直接内存监控

直接内存不在堆内,JVM默认不监控。我用Netty的泄漏检测:

# 启用Netty泄漏检测(生产环境推荐advanced级别)

-Dio.netty.leakDetection.level=advanced

# 日志输出示例

LEAK: ByteBuf.release() was not called before it's garbage-collected.
Recent access records:
  #1: io.netty.buffer.AbstractReferenceCountedByteBuf.touch(AbstractReferenceCountedByteBuf.java:77)
  #2: org.springframework.cloud.gateway.filter.NettyRoutingFilter.handle(NettyRoutingFilter.java:156)
  
说明:某个ByteBuf未正确释放,导致直接内存泄漏

8.7 文件上传的最佳实践

除了配置直接内存,我还在网关层限制了文件大小:

# application.yml

spring:
  codec:
    max-in-memory-size: 10MB   # 内存中缓冲的最大请求体大小
  
  webflux:
    multipart:
      max-file-size: 50MB      # 最大文件大小
      max-request-size: 100MB  # 最大请求大小(多个文件总和)

解释:

max-in-memory-size: 10MB
  - 请求体超过10MB时,Netty会切换到临时文件存储
  - 不再占用直接内存
  - 防止大文件占用过多直接内存

max-file-size: 50MB
  - 单个文件最大50MB
  - 防止恶意上传超大文件

max-request-size: 100MB
  - 整个multipart请求最大100MB
  - 防止多个文件叠加占用过多资源

8.8 实际效果对比

配置前:

上传10MB文件:
  直接内存占用: ~10MB/请求
  50并发上传: 直接内存占用500MB
  100并发上传: 直接内存占用1GB
  超过MaxDirectMemory(默认=堆大小) → 可能OOM

配置后:

上传10MB文件:
  - <=10MB: 存储在直接内存
  - >10MB: 自动切换到临时文件
  50并发上传: 直接内存占用约500MB(可控)
  100并发上传: 大文件切换到临时文件,直接内存占用有限
  MaxDirectMemorySize=1GB → 有明确上限

九、总结:性能优化的实践经验

9.1 踎坑的共同特征

回顾这8个坑,有一个共同点:都是"正常情况下没问题,极端情况下出问题":

阻塞锁:正常QPS没问题,高并发才阻塞EventLoop
批量健康检查:节点少没问题,上千节点才打满CPU
Shadow Quota降级:Redis正常没问题,故障时才流量暴涨
路由缓存clear:路由少没问题,1000条路由才有空窗口
Redis KEYS:键少没问题,10万键才阻塞500ms
GC过早晋升:流量小没问题,流量大才频繁Full GC
过滤器耗时混淆:单看没问题,对比才发现包含下游时间
Netty直接内存:小文件没问题,大文件并发上传才OOM

教训:性能问题往往在极端场景暴露,压测和监控至关重要。

9.2 优化思路总结

每个坑的解决思路也有共性:

问题思路手法
阻塞锁不等待CAS+tryLock
批量检查分批限流Semaphore控制并发
降级流量暴涨预计算Redis正常时计算Shadow Quota
缓存空窗口先放后删增量更新替代clear
Redis阻塞分步迭代SCAN替代KEYS
GC频繁调整比例增大Survivor区
耗时混淆区分维度Self Time vs Total Time
直接内存OOM明确上限MaxDirectMemorySize配置

9.3 实用建议(避免踩坑)

  1. 响应式编程禁阻塞锁:EventLoop线程绝不能用synchronized或lock.lock()
  2. 批量操作要限流:大量并发操作用Semaphore或批次处理控制
  3. 降级方案要考虑总量:降级配置要考虑"所有节点都降级"的场景
  4. 缓存更新避免clear:增量更新保证始终可用
  5. Redis禁用KEYS:生产环境SCAN是唯一选择
  6. GC监控晋升率:promotionRate/allocationRatio判断对象生命周期
  7. 性能分析区分维度:区分"自身耗时"和"总耗时"
  8. Netty网关配直接内存:文件上传下载场景必须配置MaxDirectMemorySize

附录:完整代码位置

优化点文件路径关键代码
CAS+tryLock限流RateLimiterWindow.javatryAcquire()方法
批量健康检查HealthCheckScheduler.javaperformBatchedHealthCheck()
Shadow QuotaShadowQuotaManager.javaupdateShadowQuotas()
节点数三级检测ShadowQuotaManager.javaonApplicationEvent(), fallbackUpdateNodeCount()
路由增量更新CompiledRouteCache.javaperformIncrementalUpdate()
Redis SCANShadowQuotaManager.javaupdateGlobalQpsSnapshots()
GC晋升率监控PrometheusService.javagetGCMetrics()
过滤器耗时分析FilterChainTracker.javaFilterExecution.getSelfTimeMs()
WebClient连接池WebClientConfig.javaconnectionProvider()
直接内存配置JVM启动参数-XX:MaxDirectMemorySize=1g

参考资料


关于作者

李钊,网关开发,7年+分布式系统经验,专注于API网关、微服务架构、云原生技术领域。

50天独立开发企业级API网关平台,涵盖44项核心功能、561个测试用例,从架构设计到生产环境部署全流程实践。


专业服务

如果你需要构建类似的API网关或微服务平台,我可以提供以下服务:

  • API网关定制开发:根据业务需求定制开发网关功能
  • 架构设计与咨询:微服务架构设计、技术选型、性能优化
  • 性能调优:JVM调优、连接池优化、限流降级方案
  • AI集成:AI Copilot开发、智能运维、自动化诊断

联系方式

这就是我踩过的7个性能坑。希望这些实践经验能帮到同样做网关的同学。记住:性能优化不是一开始就做的,而是在压测和监控中逐步发现和解决的