Spring Cloud Gateway自定义过滤器:全局限流、鉴权、灰度路由的完整实现
Spring Cloud Gateway自定义过滤器:全局限流、鉴权、灰度路由的完整实现
适读人群:有Spring Cloud基础的后端工程师 | 阅读时长:约25分钟 | Spring Boot 3.2 / Spring Cloud 2023.0
开篇故事
去年双十一前两周,我们团队压测发现网关层撑不住。当时的架构是Nginx + Zuul,流量一上来Zuul的线程池就打满了,接口响应直接从50ms飙到3秒以上。运营催着上活动,技术这边手忙脚乱。
我带着两个同事连续熬了三天,把Zuul整体迁到Spring Cloud Gateway。迁的过程才发现,之前在Zuul里那些东西——全局限流、JWT鉴权、灰度路由——在Gateway里的实现方式完全不一样。Zuul是同步阻塞模型,Gateway是基于Reactor的异步非阻塞模型,很多原来的写法直接搬过来根本跑不起来。
最惨的一次,我把一个自定义过滤器里的阻塞IO操作忘了换成响应式写法,结果把整个EventLoop线程池给block住了,所有请求超时,线上直接告警。那次故障复盘开了两个小时,之后我把Gateway过滤器的正确写法整理成了内部文档,今天把这份经验完整写出来,希望能帮你少踩这些坑。
一、核心问题分析
Spring Cloud Gateway的过滤器分为两种类型:GatewayFilter(路由级别)和GlobalFilter(全局级别)。大多数教程只讲怎么写,却很少讲清楚三个关键问题:
第一,过滤器执行顺序的控制。Gateway里的过滤器通过Ordered接口来控制执行顺序,数字越小越先执行。但很多人不知道,GlobalFilter和GatewayFilter之间的顺序是混排的,这意味着你的全局鉴权过滤器可能会在某个路由的限流过滤器之后执行,导致已经被限流的请求还走了一遍鉴权逻辑,白白浪费性能。
第二,响应式编程的正确使用姿势。Gateway基于WebFlux,过滤器里的操作必须是非阻塞的。凡是涉及数据库查询、Redis操作,都必须用响应式客户端(比如ReactiveRedisTemplate),不能用普通的RedisTemplate。这个坑我亲自掉进去过,后面详细讲。
第三,过滤器之间的数据传递。鉴权过滤器解析完JWT之后,需要把用户信息传给下游过滤器和后端服务。正确的做法是把数据塞进ServerWebExchange的attributes里,然后在后续过滤器中取出来。
明确这三个问题之后,我们来看完整实现。
二、原理深度解析
2.1 Gateway请求处理链路
2.2 过滤器的生命周期
每个过滤器调用chain.filter(exchange)之前的代码是pre阶段,之后的代码是post阶段。这跟Servlet Filter的doFilter前后逻辑是类似的,只是换成了响应式写法:
2.3 限流算法选型
生产环境我用过三种限流算法,各有适用场景:
Gateway内置的RequestRateLimiterGatewayFilterFactory用的是Redis令牌桶,我们自定义的限流也基于这个思路,但扩展支持用户维度、IP维度的细粒度控制。
三、完整代码实现
3.1 项目依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>3.2 全局鉴权过滤器
package com.laozhang.gateway.filter;
import com.laozhang.gateway.util.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
/**
* 全局JWT鉴权过滤器
* 执行顺序 order=100,确保在限流之前执行
*/
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
// 不需要鉴权的白名单路径
private static final List<String> WHITE_LIST = Arrays.asList(
"/auth/login",
"/auth/register",
"/public/**",
"/actuator/**"
);
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final JwtUtil jwtUtil;
public AuthGlobalFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 白名单直接放行
if (isWhiteListed(path)) {
return chain.filter(exchange);
}
// 获取Authorization Header
String token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (token == null || !token.startsWith("Bearer ")) {
return unauthorized(exchange, "缺少Authorization Header");
}
String jwtToken = token.substring(7);
try {
Claims claims = jwtUtil.parseToken(jwtToken);
String userId = claims.getSubject();
String userRole = claims.get("role", String.class);
// 将用户信息写入exchange attributes,供后续过滤器使用
exchange.getAttributes().put("userId", userId);
exchange.getAttributes().put("userRole", userRole);
// 同时在请求Header里带上用户信息,传递给后端服务
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header("X-User-Id", userId)
.header("X-User-Role", userRole)
.build();
ServerWebExchange mutatedExchange = exchange.mutate()
.request(mutatedRequest)
.build();
log.debug("鉴权通过,userId={}, path={}", userId, path);
return chain.filter(mutatedExchange);
} catch (Exception e) {
log.warn("JWT解析失败,path={}, error={}", path, e.getMessage());
return unauthorized(exchange, "Token无效或已过期");
}
}
private boolean isWhiteListed(String path) {
return WHITE_LIST.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}
private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"code\":401,\"message\":\"%s\"}", message);
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return 100;
}
}3.3 JWT工具类
package com.laozhang.gateway.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration:86400}")
private long expiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String userId, Map<String, Object> extraClaims) {
return Jwts.builder()
.setClaims(extraClaims)
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(getSigningKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}3.4 全局限流过滤器(Redis令牌桶)
package com.laozhang.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
/**
* 全局限流过滤器
* 支持:IP维度限流 + 用户维度限流
* 执行顺序 order=200,在鉴权之后执行
*/
@Slf4j
@Component
public class RateLimitGlobalFilter implements GlobalFilter, Ordered {
// Lua脚本实现令牌桶限流,保证原子性
private static final String RATE_LIMIT_SCRIPT =
"local key = KEYS[1]\n" +
"local capacity = tonumber(ARGV[1])\n" +
"local refillRate = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[3])\n" +
"local requested = tonumber(ARGV[4])\n" +
"\n" +
"local fill_time = capacity / refillRate\n" +
"local ttl = math.floor(fill_time * 2)\n" +
"\n" +
"local last_tokens = tonumber(redis.call('get', key .. ':tokens'))\n" +
"if last_tokens == nil then\n" +
" last_tokens = capacity\n" +
"end\n" +
"\n" +
"local last_refreshed = tonumber(redis.call('get', key .. ':ts'))\n" +
"if last_refreshed == nil then\n" +
" last_refreshed = 0\n" +
"end\n" +
"\n" +
"local delta = math.max(0, now - last_refreshed)\n" +
"local filled_tokens = math.min(capacity, last_tokens + (delta * refillRate))\n" +
"local allowed = filled_tokens >= requested\n" +
"local new_tokens = filled_tokens\n" +
"if allowed then\n" +
" new_tokens = filled_tokens - requested\n" +
"end\n" +
"\n" +
"redis.call('setex', key .. ':tokens', ttl, new_tokens)\n" +
"redis.call('setex', key .. ':ts', ttl, now)\n" +
"\n" +
"return { allowed and 1 or 0, new_tokens }";
private final ReactiveStringRedisTemplate redisTemplate;
private final RedisScript<List<Long>> rateLimitScript;
public RateLimitGlobalFilter(ReactiveStringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.rateLimitScript = RedisScript.of(RATE_LIMIT_SCRIPT, (Class<List<Long>>) (Class<?>) List.class);
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 优先用userId维度限流,没有userId则用IP维度
String userId = exchange.getAttribute("userId");
String limitKey;
int capacity;
int refillRate;
if (userId != null) {
limitKey = "rate_limit:user:" + userId;
capacity = 100; // 用户桶容量100个令牌
refillRate = 10; // 每秒补充10个令牌
} else {
String ip = getClientIp(exchange);
limitKey = "rate_limit:ip:" + ip;
capacity = 30; // IP桶容量30个令牌
refillRate = 5; // 每秒补充5个令牌
}
long now = System.currentTimeMillis() / 1000;
List<String> keys = Arrays.asList(limitKey);
return redisTemplate.execute(
rateLimitScript,
keys,
String.valueOf(capacity),
String.valueOf(refillRate),
String.valueOf(now),
"1"
).next().flatMap(results -> {
// results是List<Long>,第一个元素是1/0表示是否允许
if (results instanceof List) {
@SuppressWarnings("unchecked")
List<Long> resultList = (List<Long>) results;
boolean allowed = resultList.get(0) == 1L;
if (allowed) {
return chain.filter(exchange);
} else {
log.warn("限流触发,limitKey={}", limitKey);
return tooManyRequests(exchange);
}
}
return chain.filter(exchange);
}).onErrorResume(e -> {
// Redis故障时降级放行,不影响业务
log.error("限流Redis异常,降级放行,error={}", e.getMessage());
return chain.filter(exchange);
});
}
private String getClientIp(ServerWebExchange exchange) {
String xForwardedFor = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = exchange.getRequest().getHeaders().getFirst("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return exchange.getRequest().getRemoteAddress() != null
? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
: "unknown";
}
private Mono<Void> tooManyRequests(ServerWebExchange exchange) {
var response = exchange.getResponse();
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = "{\"code\":429,\"message\":\"请求过于频繁,请稍后重试\"}";
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return 200;
}
}3.5 灰度路由过滤器
灰度路由的核心思路是:客户端请求里带上灰度标记(通常是Header),过滤器读取这个标记,然后修改路由目标,把请求转发到灰度版本的服务实例上。
package com.laozhang.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;
/**
* 灰度路由过滤器
* 根据请求Header X-Gray-Tag决定是否路由到灰度实例
* 灰度实例通过Nacos metadata中的version=gray标记
* 执行顺序 order=300,在限流之后执行
*/
@Slf4j
@Component
public class GrayRouteGlobalFilter implements GlobalFilter, Ordered {
private static final String GRAY_TAG_HEADER = "X-Gray-Tag";
private static final String GRAY_VERSION_META = "gray";
private final DiscoveryClient discoveryClient;
public GrayRouteGlobalFilter(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String grayTag = exchange.getRequest().getHeaders().getFirst(GRAY_TAG_HEADER);
// 没有灰度标记,走正常路由
if (grayTag == null || grayTag.isEmpty()) {
return chain.filter(exchange);
}
// 获取当前路由对应的服务名
URI routeUri = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR);
if (routeUri == null || !"lb".equals(routeUri.getScheme())) {
return chain.filter(exchange);
}
String serviceId = routeUri.getHost();
List<ServiceInstance> grayInstances = discoveryClient.getInstances(serviceId)
.stream()
.filter(instance -> GRAY_VERSION_META.equals(instance.getMetadata().get("version")))
.collect(Collectors.toList());
if (grayInstances.isEmpty()) {
log.warn("未找到灰度实例,serviceId={},回退到正常路由", serviceId);
return chain.filter(exchange);
}
// 简单轮询选择灰度实例
ServiceInstance grayInstance = grayInstances.get(
(int) (System.currentTimeMillis() % grayInstances.size())
);
URI grayUri = URI.create(
"http://" + grayInstance.getHost() + ":" + grayInstance.getPort() + routeUri.getPath()
);
// 覆盖路由目标URI
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, grayUri);
log.info("灰度路由,serviceId={},目标实例={}:{}", serviceId, grayInstance.getHost(), grayInstance.getPort());
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 300;
}
}3.6 自定义GatewayFilter(路由级别日志)
全局过滤器覆盖所有路由,路由级别的过滤器可以针对特定路由定制。下面实现一个请求日志过滤器工厂:
package com.laozhang.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.stereotype.Component;
import java.time.Instant;
/**
* 路由级别的请求日志过滤器工厂
* 在routes配置中通过 - RequestLog 引用
*/
@Slf4j
@Component
public class RequestLogGatewayFilterFactory
extends AbstractGatewayFilterFactory<RequestLogGatewayFilterFactory.Config> {
public RequestLogGatewayFilterFactory() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
long startTime = Instant.now().toEpochMilli();
String path = exchange.getRequest().getURI().getPath();
String method = exchange.getRequest().getMethod().name();
String userId = exchange.getAttribute("userId");
if (config.isLogRequest()) {
log.info("[REQ] {} {} userId={}", method, path, userId);
}
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
long duration = Instant.now().toEpochMilli() - startTime;
int statusCode = exchange.getResponse().getStatusCode() != null
? exchange.getResponse().getStatusCode().value()
: 0;
if (config.isLogResponse()) {
log.info("[RESP] {} {} status={} duration={}ms userId={}",
method, path, statusCode, duration, userId);
}
// 慢请求告警
if (duration > config.getSlowThresholdMs()) {
log.warn("[SLOW] {} {} duration={}ms,超过慢请求阈值{}ms",
method, path, duration, config.getSlowThresholdMs());
}
}));
};
}
public static class Config {
private boolean logRequest = true;
private boolean logResponse = true;
private long slowThresholdMs = 1000;
// getters and setters
public boolean isLogRequest() { return logRequest; }
public void setLogRequest(boolean logRequest) { this.logRequest = logRequest; }
public boolean isLogResponse() { return logResponse; }
public void setLogResponse(boolean logResponse) { this.logResponse = logResponse; }
public long getSlowThresholdMs() { return slowThresholdMs; }
public void setSlowThresholdMs(long slowThresholdMs) { this.slowThresholdMs = slowThresholdMs; }
}
}四、生产配置与调优
4.1 application.yml完整配置
server:
port: 8080
spring:
application:
name: api-gateway
cloud:
gateway:
# 全局默认过滤器
default-filters:
- name: RequestLog
args:
logRequest: true
logResponse: true
slowThresholdMs: 500
routes:
# 用户服务路由
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/user/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 100
key-resolver: "#{@userKeyResolver}"
# 订单服务路由(允许灰度)
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- StripPrefix=1
# 公开API(白名单,跳过鉴权)
- id: public-api
uri: lb://auth-service
predicates:
- Path=/auth/**
# 全局超时配置
httpclient:
connect-timeout: 3000
response-timeout: 10s
data:
redis:
host: redis-cluster
port: 6379
password: ${REDIS_PASSWORD}
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 5
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-must-be-long-enough}
expiration: 86400
# 暴露actuator端点用于健康检查
management:
endpoints:
web:
exposure:
include: health,info,gateway
endpoint:
health:
show-details: when-authorized4.2 KeyResolver配置(配合内置限流器)
package com.laozhang.gateway.config;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;
@Configuration
public class RateLimitConfig {
/**
* 用户维度限流Key
* 从JWT解析后写入的X-User-Id Header中获取
*/
@Bean
public KeyResolver userKeyResolver() {
return exchange -> {
String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
if (userId != null) {
return Mono.just("user:" + userId);
}
// 回退到IP
String ip = exchange.getRequest().getRemoteAddress() != null
? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
: "unknown";
return Mono.just("ip:" + ip);
};
}
/**
* API路径维度限流Key
*/
@Bean
public KeyResolver pathKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getURI().getPath());
}
}4.3 Gateway性能调优参数
spring:
cloud:
gateway:
httpclient:
# 连接池配置(底层是Reactor Netty)
pool:
type: ELASTIC # ELASTIC适合突发流量,FIXED适合稳定高并发
max-connections: 500 # 最大连接数
acquire-timeout: 5000 # 获取连接超时
max-idle-time: 60s # 空闲连接最大存活时间
max-life-time: 300s # 连接最大生命周期
# Netty线程数调优(默认是CPU核心数*2)
# 网关是IO密集型,可以适当增大
server:
netty:
worker-count: 32五、踩坑实录
坑一:在GlobalFilter里用了阻塞Redis操作,把EventLoop打爆了。
这是我吃过的最惨的一个坑。当时为了复用已有的限流逻辑,直接把RedisTemplate注入进来用,代码大概是这样:
// 错误示例!!不要这样写!
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String key = "rate:" + getClientIp(exchange);
// 这是阻塞操作!会block掉EventLoop线程!
Long count = redisTemplate.opsForValue().increment(key);
if (count > 100) {
return tooManyRequests(exchange);
}
return chain.filter(exchange);
}上线后压测刚上到500并发,所有请求开始超时,Gateway进程CPU飙到100%。用JStack一看,所有EventLoop线程都阻塞在Redis操作上。
正确做法是用ReactiveStringRedisTemplate,让整个操作链是响应式的:
// 正确写法
return reactiveRedisTemplate.opsForValue()
.increment(key)
.flatMap(count -> {
if (count > 100) {
return tooManyRequests(exchange);
}
return chain.filter(exchange);
});如果你的场景下必须用阻塞IO,一定要用Mono.fromCallable(() -> blockingOp()).subscribeOn(Schedulers.boundedElastic())包起来,切换到弹性线程池执行。
坑二:过滤器顺序设置错误,鉴权在限流之后执行。
有段时间收到用户反馈,说限流提示太频繁,感觉限流阈值设低了。排查了半天才发现,限流Key用的是IP,而不是UserId,因为鉴权过滤器(order=200)在限流过滤器(order=100)之后执行,限流的时候attributes里还没有userId,于是退化成IP维度限流了。
整个团队同一个公司办公室出口IP是同一个,自然就限流了。把鉴权改成order=100,限流改成order=200,问题解决。
坑三:灰度路由和负载均衡的冲突问题。
灰度路由过滤器直接修改了GATEWAY_REQUEST_URL_ATTR,把lb://order-service替换成了具体IP,但Gateway内置的LoadBalancer过滤器也在处理这个属性。两个过滤器都在修改同一个属性,互相覆盖,导致灰度路由时好时坏。
解决方案是在修改URI时同时把scheme从lb改成http,这样LoadBalancer过滤器看到scheme不是lb就不会再处理了:
// 修改后的灰度路由,确保scheme是http而不是lb
URI grayUri = URI.create(
"http://" + grayInstance.getHost() + ":" + grayInstance.getPort()
);
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, grayUri);
// 同时设置这个属性,让路由不再走负载均衡
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR, "http");坑四:请求体只能读一次的问题。
有次需要在过滤器里读取请求体做参数校验,第一次能读到,但后端服务收到的请求体是空的。这是因为Reactor Netty的请求体默认只能读一次,消费之后就没了。
解决方案是用ServerWebExchangeUtils.cacheRequestBody先把请求体缓存起来:
return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange,
(serverHttpRequest) -> chain.filter(exchange.mutate()
.request(serverHttpRequest)
.build()));注意这个操作会把请求体全部读进内存,对于大文件上传场景要谨慎使用,建议只在必要的路由上启用。
六、总结
Spring Cloud Gateway的自定义过滤器是网关层最核心的扩展点。全局过滤器处理横切关注点(鉴权、限流、追踪),路由过滤器处理特定业务逻辑(日志、参数转换)。
几个关键原则:第一,响应式编程是Gateway的基础,任何阻塞操作都会造成系统级问题;第二,过滤器顺序必须精心设计,鉴权应该在限流之前,否则限流维度会错误;第三,灰度路由要注意和内置LoadBalancer的交互;第四,读取请求体必须先缓存。
Gateway的性能上限主要取决于Reactor Netty的线程模型,在IO密集型场景下表现非常好,同等配置下比Zuul吞吐量高2-3倍是常见的。
