网关选型对比表里最常见的一句是"Zuul 是 Netflix 的,Gateway 是 Spring 的"。这句话没有信息量。真正决定线上行为的是:过滤器跑在哪个线程上,超时由谁掐,背压能不能传导,内存被谁吃掉。三件事全由执行模型决定,而两代网关的执行模型完全不同。
1. Zuul 1:Servlet 线程 + 每路由一个线程池
Zuul 1 建在 Servlet 容器之上,一个请求从 pre 到 post 全程占住一个 worker 线程。spring-cloud-starter-netflix-zuul 在 route 阶段套了 Hystrix + Ribbon,"每路由一个线程池"的隔离是这么来的:
zuul:
routes:
order:
path: /api/order/**
serviceId: order-service
ribbon-isolation-strategy: THREAD # 或 SEMAPHORE
thread-pool:
use-separate-thread-pools: true
thread-pool-key-prefix: zuul-
四个 filterType 决定代码插在哪一段:
| filterType | 执行时机 | 典型用途 | 能否短路 |
|---|---|---|---|
pre |
路由之前 | 鉴权、限流、加 header | 能,ctx.setSendZuulResponse(false) |
route |
真正转发 | 自定义 HTTP 客户端 | 能,替换 RequestContext 中的流 |
post |
响应返回后 | 改响应头、埋点、压缩 | 不能,只能改响应 |
error |
任一阶段抛异常 | 统一错误体、降级 | 能,写 ctx.setResponseDataStream |
关键在隔离粒度。Hystrix 线程池默认 coreSize=10、maxQueueSize=5,也就是单个路由同时最多约 15 个在途请求,第 16 个直接被 reject 走 fallback。Tomcat 的 maxThreads=200 并不等于这条路由的并发能力——多出来的请求只是在 Tomcat 线程上排队,等待时间计入上游超时。
| 参数 | 默认 | 影响 |
|---|---|---|
hystrix.threadpool.<svc>.coreSize |
10 | 单路由并发上限 |
hystrix.threadpool.<svc>.maxQueueSize |
-1(用 SynchronousQueue) | 设 5 即排队上限 |
ribbon.ReadTimeout |
5000ms | 读超时 |
ribbon.ConnectTimeout |
2000ms | 连超时 |
hystrix.command.<svc>.execution.isolation.thread.timeoutInMilliseconds |
1000ms | 必须大于 Read+Connect |
最后一行是最常见的坑:Hystrix 默认 1 秒,Ribbon 读超时 5 秒,结果是下游还在正常处理,网关已经超时降级了。
2. Zuul 2 与"没进主线"这件事
Zuul 2 换成了 Netty + 异步回调,解决了 Zuul 1 的阻塞问题,但它没进 Spring Cloud 主线:
| 版本线 | 网关现状 |
|---|---|
| Spring Cloud Edgware / Finchley | Zuul 1 是默认网关 |
| Spring Cloud Greenwich | Gateway 1.x 开始可用,Zuul 标记维护 |
| Spring Cloud 2020.0 (Ilford) | spring-cloud-starter-netflix-zuul 移除 |
| Netflix 侧 | Zuul 2 独立开源,Spring 未提供 starter |
结论很直接:今天选 Zuul 1 等于选一个已停止演进的阻塞网关;想用 Zuul 2 得自己包一层。 新项目没有理由不走 Gateway。
3. Spring Cloud Gateway:事件循环 + Reactor 链
Gateway 跑在 WebFlux/Reactor Netty 上,线程数不等于并发数。请求进来由少量事件循环线程处理,过滤器返回 Mono,只有真正的阻塞调用才需要切线程:
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (token == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
exchange.getAttributes().put("uid", parseUid(token));
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> log.info("done {}", exchange.getRequest().getPath())));
}
@Override
public int getOrder() { return -100; }
}
五个核心概念一一对应:
| 概念 | 作用 | 排序方式 |
|---|---|---|
RouteDefinition |
路由定义(id/uri/predicates/filters) | 无序 |
RoutePredicateFactory |
断言工厂,决定"这条路由匹配不匹配" | 按 shortcut 名匹配 |
GatewayFilterFactory |
路由级过滤器工厂 | 按 yaml 声明顺序 |
GlobalFilter |
全局过滤器,对所有路由生效 | Ordered |
HandlerMapping |
把请求匹配成 Route |
order 字段 |
GlobalFilter 与 GatewayFilter 最终被 FilteringWebHandler 合并成一个链,按 Ordered 从小到大执行。执行顺序不是"全局先于路由",而是混排——想让某个全局过滤器稳定在最前面,getOrder() 要小于 -1。
4. 过滤器模型逐项对照
| 能力 | Zuul 1 | Spring Cloud Gateway |
|---|---|---|
| 扩展点 | ZuulFilter 子类 |
GlobalFilter / GatewayFilterFactory |
| 阶段划分 | pre/route/post/error 四个字符串 |
无显式阶段,靠 Ordered 与链式 then |
| 排序 | filterOrder() 返回 int |
Ordered.getOrder() |
| 是否执行 | shouldFilter() |
断言不匹配即不进链 |
| 请求上下文 | RequestContext(ThreadLocal) |
ServerWebExchange(显式传参) |
| 异常处理 | error 类型过滤器 |
onErrorResume / ErrorWebExceptionHandler |
| 响应改写 | 包装 HttpServletResponse |
ServerHttpResponse + DataBuffer |
| 线程模型 | 一请求一线程 | 事件循环,回调 |
RequestContext 是 ThreadLocal,跨线程拿不到(Hystrix 线程里尤其明显,需要 HystrixRequestContext);ServerWebExchange 是方法参数,切线程也不丢。
5. 断言与过滤器工厂:一份能跑的 yaml
spring:
cloud:
gateway:
httpclient:
connect-timeout: 1000
response-timeout: 5s
routes:
- id: order
uri: lb://order-service
predicates:
- Path=/api/order/**
- Method=GET,POST
- Header=X-Tenant, t-[0-9]+
filters:
- StripPrefix=2
- RewritePath=/api/order/(?<seg>.*), /$\{seg}
- AddRequestHeader=X-Gw, edge-01
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
key-resolver: "#{@ipKeyResolver}"
- name: CircuitBreaker
args:
name: orderCb
fallbackUri: forward:/fallback/order
- name: Retry
args:
retries: 2
series: SERVER_ERROR
methods: GET
backoff:
firstBackoff: 50ms
maxBackoff: 500ms
factor: 2
| 工厂 | 作用 | 易错点 |
|---|---|---|
Path |
路径断言 | 与 StripPrefix 配合时注意层级数 |
StripPrefix=2 |
去掉前两段路径 | 下游收到的路径变了,context-path 要重算 |
RewritePath |
正则重写 | 替换串里的 $ 在 yaml 中要写 $\ |
RequestRateLimiter |
Redis 令牌桶 | 必须提供 KeyResolver Bean,否则启动报错 |
CircuitBreaker |
接 Resilience4j | 需要 spring-cloud-starter-circuitbreaker-reactor-resilience4j |
Retry |
重试 | 只对幂等方法开,POST 重试要自己确认幂等 |
RequestRateLimiter 的 replenishRate 是每秒补充令牌数,burstCapacity 是桶容量。两者设成一样等于严格匀速;burstCapacity 远大于 replenishRate 会让突发流量一次打穿下游。
6. 超时、背压、内存:三处硬差异
| 维度 | Zuul 1 | Gateway | 说明 |
|---|---|---|---|
| 超时层级 | Hystrix + Ribbon + 容器三层 | HttpClient + response-timeout + 下游 |
Zuul 要保证 Hystrix > Ribbon |
| 背压 | 无,靠线程池与队列满 | Reactor request(n) 传导 |
大响应体场景差异明显 |
| 线程占用 | 每请求 1 线程 + 每路由 10 线程池 | 事件循环 = CPU 核数相关 | 200 并发时 Zuul 至少 200 线程 |
| 内存形态 | 线程栈(约 1MB/线程) | 堆缓冲 + 连接对象 | 500 线程约 500MB 栈 |
| 长连接 | 受 max-per-route-connections 限制 |
连接池按 pool.max-connections |
都要显式配 |
| 流式响应 | 容易整包缓冲 | DataBuffer 可流式转发 |
大文件代理选 Gateway |
背压最容易被低估。Zuul 1 里下游慢,网关线程被占住直到 Tomcat 线程耗尽;Gateway 里下游慢,Mono 不产生 request,压力沿连接反传,表现为 TCP 窗口收缩而非线程堆积。同样是"慢",一个表现为线程打满,一个表现为延迟上升。
7. 排错:六种迁移与运行期的典型现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 迁移后所有请求 404 | Path 断言写的是去前缀后的路径 |
对照 StripPrefix 后的实际路径重写断言 |
| 网关超时但下游日志显示成功 | Hystrix 超时小于 Ribbon 读超时 | 调大 timeoutInMilliseconds 或关闭 Hystrix 超时 |
RequestRateLimiter 启动失败 |
缺 KeyResolver Bean |
定义 @Bean KeyResolver,按 IP 或用户 ID |
| 过滤器顺序不符合预期 | 只写了 @Order 注解,没实现 Ordered |
GlobalFilter 必须实现 Ordered 才生效 |
| 响应体被截断 | 在 post 阶段包装了流但没 flush |
用 ServerHttpResponse 写 DataBuffer 并 setComplete() |
| 迁移后拿不到用户上下文 | 依赖 ThreadLocal,切线程后丢失 | 改从 exchange.getAttributes() 取 |
8. 边界:什么情况本就不该用网关做
| 需求 | 更合适的做法 | 理由 |
|---|---|---|
| 服务间内部调用治理 | Dubbo/Sentinel 或 Service Mesh | 网关是南北向,东西向走 sidecar 更近 |
| 复杂协议转换(gRPC↔HTTP) | Envoy / grpc-gateway | Gateway 的协议支持有限 |
| 大文件上传下载 | Nginx 直接代理 | 网关做流式转发会吃掉堆 |
| 需要 L4 负载与 TLS 卸载 | Nginx / LB | 网关是 L7,L4 交给专门组件 |
| 极低延迟边缘 | Nginx + Lua | JVM 网关有固定启动与 GC 成本 |
迁移顺序:先把 ZuulFilter 按 pre/post 拆成 GlobalFilter(用 then 分段),再把 Ribbon 换成 spring-cloud-loadbalancer,最后把 Hystrix 换 CircuitBreaker + fallbackUri。一步全换的代价通常落在"过滤器顺序"与"错误响应格式"两处。
9. 自测
- Zuul 1 里单个路由的实际并发上限由哪个参数决定,为什么不是 Tomcat 的
maxThreads? - Hystrix 超时 1000ms、Ribbon 读超时 5000ms,线上会出现什么现象?
- Gateway 的
GlobalFilter与GatewayFilter谁先执行,由什么决定? StripPrefix=2之后Path断言应该写原始路径还是去前缀后的路径?- 为什么说背压是两代网关最本质的差异,它在线上的表现有什么不同?
- 从 Zuul 迁到 Gateway,
RequestContext里的数据该放到哪里?