2276 字
约 7 分钟
20
S17-X03 · Zuul 与 Gateway:两代网关的过滤器模型差异

网关选型对比表里最常见的一句是"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. 自测

  1. Zuul 1 里单个路由的实际并发上限由哪个参数决定,为什么不是 Tomcat 的 maxThreads?
  2. Hystrix 超时 1000ms、Ribbon 读超时 5000ms,线上会出现什么现象?
  3. Gateway 的 GlobalFilter 与 GatewayFilter 谁先执行,由什么决定?
  4. StripPrefix=2 之后 Path 断言应该写原始路径还是去前缀后的路径?
  5. 为什么说背压是两代网关最本质的差异,它在线上的表现有什么不同?
  6. 从 Zuul 迁到 Gateway,RequestContext 里的数据该放到哪里?
S17-X03 · Zuul 与 Gateway:两代网关的过滤器模型差异
https://www.clxhxhhr.top/posts/5653/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。