50天独立打造企业级API网关(七):性能优化实战——从阻塞锁到直接内存的8个关键细节
系列文章第7篇 | 50天手搓Spring Cloud Gateway:44项功能+561测试用例的完整实践
系列导航
- 第一篇:控制平面/数据平面架构设计与动态路由实现
- 第二篇:安全防护体系与性能优化
- 第三篇:弹性设计与限流降级
- 第四篇:全链路可观测性与AI Copilot智能运维
- 第五篇:Kubernetes部署与测试保障
- 第六篇:高级路由与负载均衡实战
- 第七篇:性能优化实战——从阻塞锁到直接内存的8个关键细节 ← 本篇
前言
在前6篇文章中,我们介绍了网关的架构设计、安全防护、弹性设计、可观测性和K8s部署。但你可能会问:这些功能都实现了,那性能怎么样?
说实话,性能优化不是一开始就考虑的。在开发初期,我把重点放在功能实现上。直到第一次压测时,我才发现几个隐藏的性能坑:
- 限流器在高并发下阻塞了EventLoop线程
- 健康检查一次性探测上千节点,把网关CPU打满
- Redis故障后全局限流降级,导致流量暴涨
- 文件上传时直接内存不足,导致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;
}
}
}
关键点解释:
-
HeartbeatEvent是什么:
- Spring Cloud DiscoveryClient定期向Nacos发送心跳
- 每次心跳都会触发HeartbeatEvent
- 心跳频率默认5秒(可配置)
- 事件携带最新的服务实例列表
-
discoveryClient.getInstances(applicationName):
- applicationName = "my-gateway"(网关的服务名)
- 返回List,每个实例代表一个网关Pod
- size()就是当前集群节点数
-
为什么用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 1 | Level 2 | Level 3 |
|---|---|---|---|
| 触发条件 | 心跳事件 | 1小时未更新 | Level 1+2都失败 |
| 响应速度 | 实时(秒级) | 慢(小时级) | 立即(静态) |
| 数据来源 | Nacos实时查询 | Nacos兜底查询 | YAML配置 |
| 适用场景 | 正常运行 | 网络临时故障 | Nacos完全宕机 |
| 准确性 | 最高 | 高(可能滞后) | 保守估计 |
关键设计理念:
- 正常情况不浪费资源:Level 1每5秒更新,Level 2判断后跳过
- 异常情况有兜底:三层保护确保节点数始终有值
- 宁可保守不可激进: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使用直接内存的场景:
- 请求体处理:大文件上传时,请求体存储在直接内存
- 响应体处理:大文件下载时,响应体存储在直接内存
- 网络缓冲区: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 实用建议(避免踩坑)
- 响应式编程禁阻塞锁:EventLoop线程绝不能用synchronized或lock.lock()
- 批量操作要限流:大量并发操作用Semaphore或批次处理控制
- 降级方案要考虑总量:降级配置要考虑"所有节点都降级"的场景
- 缓存更新避免clear:增量更新保证始终可用
- Redis禁用KEYS:生产环境SCAN是唯一选择
- GC监控晋升率:promotionRate/allocationRatio判断对象生命周期
- 性能分析区分维度:区分"自身耗时"和"总耗时"
- Netty网关配直接内存:文件上传下载场景必须配置MaxDirectMemorySize
附录:完整代码位置
| 优化点 | 文件路径 | 关键代码 |
|---|---|---|
| CAS+tryLock限流 | RateLimiterWindow.java | tryAcquire()方法 |
| 批量健康检查 | HealthCheckScheduler.java | performBatchedHealthCheck() |
| Shadow Quota | ShadowQuotaManager.java | updateShadowQuotas() |
| 节点数三级检测 | ShadowQuotaManager.java | onApplicationEvent(), fallbackUpdateNodeCount() |
| 路由增量更新 | CompiledRouteCache.java | performIncrementalUpdate() |
| Redis SCAN | ShadowQuotaManager.java | updateGlobalQpsSnapshots() |
| GC晋升率监控 | PrometheusService.java | getGCMetrics() |
| 过滤器耗时分析 | FilterChainTracker.java | FilterExecution.getSelfTimeMs() |
| WebClient连接池 | WebClientConfig.java | connectionProvider() |
| 直接内存配置 | JVM启动参数 | -XX:MaxDirectMemorySize=1g |
参考资料
- Performance Optimization - 完整性能优化文档
- Rate Limiting - 限流功能详解
- Filter Chain Analysis - 过滤器性能分析
- Monitoring & Alerts - GC监控与诊断
关于作者
李钊,网关开发,7年+分布式系统经验,专注于API网关、微服务架构、云原生技术领域。
50天独立开发企业级API网关平台,涵盖44项核心功能、561个测试用例,从架构设计到生产环境部署全流程实践。
专业服务
如果你需要构建类似的API网关或微服务平台,我可以提供以下服务:
- API网关定制开发:根据业务需求定制开发网关功能
- 架构设计与咨询:微服务架构设计、技术选型、性能优化
- 性能调优:JVM调优、连接池优化、限流降级方案
- AI集成:AI Copilot开发、智能运维、自动化诊断
联系方式:
- Email: lizhao5695@gmail.com
- Upwork: www.upwork.com/freelancers…
- GitHub: github.com/leoli5695
- BiliBili(项目演示):www.bilibili.com/video/BV1S2…
这就是我踩过的7个性能坑。希望这些实践经验能帮到同样做网关的同学。记住:性能优化不是一开始就做的,而是在压测和监控中逐步发现和解决的。