一、从一次线上事故说起

假设你是一个小公司的后端开发,你们做了一个电商系统,用户下单先请求网关,网关再去调用商品服务、库存服务、优惠券服务。一切看起来都很正常。直到有一天大促,优惠券服务突然卡死,响应时间从 50 毫秒涨到了 5 秒。你发现网关里的线程全都堵在等待优惠券服务响应上,接着其它服务的请求也进不来了,整个系统像多米诺骨牌一样倒下。这时候你才意识到,光有网关还不够,还得让网关学会“见势不妙就快速放弃”,这就用到了熔断器。

Zuul 是 Spring Cloud 全家桶里的老牌网关,负责把外部请求转发到内部服务。Hystrix 是 Netflix 开源的容错库,它的熔断功能就像家里电闸的保险丝——电流太大就自动跳闸,保护电器不被烧掉。把两者放一起,就能让网关在某个下游服务不健康的时候,快速返回降级结果,而不是傻傻等着超时,把网关和整个系统拖垮。

二、Zuul 网关到底是个啥

2.1 网关的作用

网关是所有外部请求进系统的“总大门”。统一收口,做路由转发、鉴权、限流、日志等等。没有网关的话,前端直接调用几十个服务,运维管理乱,安全控制难,耦合也重。有了 Zuul,就能把公共逻辑下沉到网关这一层,每个服务专心做自己的业务。

2.2 Zuul 的工作机制

Zuul 的核心是一堆过滤器(Filter),它是“管道+过滤器”的模式。请求进来后,会依次经过 pre 过滤器(比如鉴权)、routing 过滤器(真正转发到服务)、post 过滤器(比如加响应头)。如果有异常,还有 error 过滤器。这有点像流水线作业,每个工人只负责一道工序。Zuul 处理一个请求时,调用链如下:

请求进来 -> pre 过滤器 -> routing 过滤器 -> post 过滤器 -> 响应返回

过滤器之间通过请求上下文传递数据。Zuul 内置了路由规则,你只需要在配置文件里写好“哪个路径转发到哪个服务”,它就能把请求发过去。

三、Hystrix 熔断器又是怎么回事

3.1 为什么需要熔断

在微服务架构里,一个请求可能依赖好几个服务。如果其中一个服务挂了,而其它服务还拼命给它发请求,那这个服务的线程会越积越多,最后资源耗尽,整个系统都不可用。熔断器给每个依赖调用设置一个“健康阈值”,当失败次数太多或错误率达到阈值,熔断器“打开”,后续请求直接走降级逻辑,不再真实的去调用那个有问题的服务。过一段时间再放一小部分流量进去试试,如果恢复了,熔断器“闭合”,恢复正常调用。

3.2 Hystrix 的核心概念

Hystrix 一个重要概念是“命令模式”。你把对下游服务的每次调用包装成一个 HystrixCommand 或者 HystrixObservableCommand。执行时,Hystrix 会先检查熔断器是否打开,如果开着就直接执行 fallback(降级),否则才真正去调用。调用过程中,Hystrix 会用独立线程池来跑,并且设置超时时间。如果超时或者线程池满了,都会走降级逻辑。

Hystrix 有五种状态:关闭、打开、半开。关闭的时候正常请求,当失败率达到阈值(默认 50%)且 10 秒内请求数达到至少 20 个,熔断器打开。打开后,等待一个 sleep 窗口(默认 5 秒),然后进入半开状态,放一个请求试水,如果成功就关闭,否则继续打开。

四、Zuul 与 Hystrix 是怎么勾搭上的

4.1 集成原理

Zuul 和 Hystrix 的集成不是靠硬编码,而是 Spring Cloud 在 Zuul 的 routing 过滤器里自动加上了 Hystrix 包装。具体来说,Zuul 内部有一个 RibbonRoutingFilter,这个过滤器会用 RibbonCommandFactory 创建出 HystrixCommand,然后像调用普通命令一样去执行路由转发。也就是说,你在 Zuul 里配置的路由,本质上是被 Hystrix 包裹起来的。

可以简单理解为:Zuul 负责接收请求、决定往哪转发;Hystrix 负责在转发过程中做保护。一旦下游服务超时、连接失败、返回错误,Hystrix 就会介入,这时可以定义一个降级处理器,返回一段友好的提示给调用方。

4.2 线程隔离和超时

Hystrix 默认使用线程池隔离。每个路由(或者每个服务)会对应一个独立的线程池,这样某个服务的线程池满了,不会影响其他服务。Zuul 集成 Hystrix 后,每个路由的线程池大小、超时时间都可以单独配置。如果并发量特别大,还可以改用信号量隔离,避免线程上下文切换开销,但信号量隔离没有“排队”“超时等待”这些功能,选择时得看具体场景。

五、动手写一个例子

这一节我们用一段完整的代码演示,技术栈是 Java + Spring Cloud Netflix (Finchley 版本)。假设我们要做一个最简单的网关,它把 /product 开头的请求转发到商品服务。

5.1 创建一个网关服务

先建一个 Maven 工程,在 pom.xml 里引入依赖:

<!-- 使用Java 8,Spring Boot 2.0.x,Spring Cloud Finchley -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.6.RELEASE</version>
</parent>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Finchley.SR2</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- Zuul网关启动器,里面会连带引入Hystrix相关依赖 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
    </dependency>
    <!-- 注册中心,用Eureka做服务发现 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
</dependencies>

注意,spring-cloud-starter-netflix-zuul 里已经带了 Hystrix,所以你不需要再单独引 Hystrix 的依赖。接着写启动类:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@SpringBootApplication
@EnableZuulProxy // 开启Zuul代理功能,自动集成Hystrix
public class GatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

5.2 配置路由和熔断参数

在 application.yml 里配置路由规则,以及 Hystrix 的超时和线程池参数。因为我们使用了 Eureka,所以可以直接用服务名作为 serviceId 来路由。

server:
  port: 8080

spring:
  application:
    name: api-gateway

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

zuul:
  # 路由前缀,所有请求前面加上 /api
  prefix: /api
  routes:
    # 商品服务路由
    product:
      path: /product/**
      serviceId: product-service
    # 订单服务路由
    order:
      path: /order/**
      serviceId: order-service

# Hystrix的全局配置
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            # 超时时间:3000毫秒,超过就降级
            timeoutInMilliseconds: 3000
      circuitBreaker:
        # 熔断器打开的阈值:10秒内请求数达到10个
        requestVolumeThreshold: 10
        # 错误率阈值:50%的请求失败就打开熔断器
        errorThresholdPercentage: 50
        # 熔断打开后睡眠时间:5秒后进入半开状态
        sleepWindowInMilliseconds: 5000

上面的配置演示了全局默认值。如果你想针对某个服务单独配置,可以写成这样:

hystrix:
  command:
    # 这里用 "服务名" 或者 "服务名+命令后缀" 都可以
    product-service:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 1500

5.3 写一个降级处理类

当路由转发失败、超时、或者熔断器打开时,Hystrix 会执行 fallback。在 Zuul 里,你需要实现 FallbackProvider 接口,重写 getRoute 和 fallbackResponse 方法。getRoute 返回你要给哪个路由降级,如果是 * 或 null,则表示所有路由通用。

import org.springframework.cloud.netflix.zuul.filters.route.FallbackProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

/**
 * 网关降级处理类,对商品服务的路由做降级。
 * 如果未来要支持更多服务,可以按 route 分开写。
 */
@Component
public class ProductServiceFallback implements FallbackProvider {

    /**
     * 指定这个降级类作用于哪个路由。
     * 返回 "*" 表示对所有路由生效;
     * 返回 "product-service" 表示只对商品服务降级。
     */
    @Override
    public String getRoute() {
        return "product-service";
    }

    /**
     * 降级后返回给调用方的响应体。
     * 这里写了一段JSON格式的提示信息。
     */
    @Override
    public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
        return new ClientHttpResponse() {

            @Override
            public HttpStatus getStatusCode() throws IOException {
                // 返回HTTP 200,这样外部客户端的HTTP层不会报错
                return HttpStatus.OK;
            }

            @Override
            public int getRawStatusCode() throws IOException {
                return 200;
            }

            @Override
            public String getStatusText() throws IOException {
                return "OK";
            }

            @Override
            public void close() {
                // 不需要额外释放资源的空实现
            }

            @Override
            public InputStream getBody() throws IOException {
                // 要返回给客户端的JSON内容
                String body = "{\"code\":503,\"message\":\"服务暂时不可用,请稍后再试\"}";
                return new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
            }

            @Override
            public HttpHeaders getHeaders() {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_JSON);
                return headers;
            }
        };
    }
}

如果你希望所有路由都走同一个降级逻辑,可以把 getRoute() 改成返回 "*",同时你还能在 fallbackResponse 里拿到当前路由对应的 Throwable,根据异常类型做不同提示。比如超时返回“慢”,连接失败返回“挂了”:

import org.springframework.cloud.netflix.zuul.filters.route.FallbackProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

@Component
public class GlobalFallback implements FallbackProvider {

    @Override
    public String getRoute() {
        // 对所有路由生效
        return "*";
    }

    @Override
    public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
        return new ClientHttpResponse() {

            @Override
            public HttpStatus getStatusCode() throws IOException {
                return HttpStatus.OK;
            }

            @Override
            public int getRawStatusCode() throws IOException {
                return 200;
            }

            @Override
            public String getStatusText() throws IOException {
                return "OK";
            }

            @Override
            public void close() {
            }

            @Override
            public InputStream getBody() throws IOException {
                // 根据路由的名字给出不同的提示,方便调试
                String message = " 请稍后重试";
                if (cause != null && cause.getMessage() != null) {
                    message = cause.getMessage() + message;
                }
                String body = "{\"code\":503,\"message\":\"[" + route + "] " + message + "\"}";
                return new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
            }

            @Override
            public HttpHeaders getHeaders() {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_JSON);
                return headers;
            }
        };
    }
}

5.4 测试一下效果

启动 Eureka 服务注册中心,再启动你的商品服务(假设跑在 8081 端口,并且注册到 Eureka),然后启动网关。在浏览器访问:

http://localhost:8080/api/product/info

网关会把这个请求路由到商品服务。现在故意把商品服务停止,再访问同样的地址,网关会在 3 秒钟左右返回降级结果:

{"code":503,"message":"[product-service] 请稍后重试"}

同时 Hystrix 的指标监控页(如果引入了 spring-boot-starter-actuator)可以访问:

http://localhost:8080/hystrix.stream

你会在数据流里看到很多指标,比如请求总数、错误百分比、熔断器状态。连续多失败几次,错误率超过 50%,熔断器就会打开。这个时候你再重启商品服务,但熔断器还是开着的,需要等 5 秒睡眠窗口过去后,进去半开状态试一下,成功后就自动恢复。

六、应用场景和适用边界

这套方案最适合的是“内部微服务之间关系紧密、业务链路相对固定”的场景。比如你有一个 B 端系统,前端请求必须经过网关进入几个核心服务,服务间用 REST 调用,整体并发量中等。这时候用 Zuul + Hystrix 能显著提高系统的鲁棒性。

但如果你的服务数量特别多,或者你用的是异步非阻塞的响应式架构(比如 WebFlux),那 Zuul 1.x 是基于 Servlet 的,同步阻塞模型,在超高并发下吞吐量会有瓶颈。另外,如果团队已经上了云原生的 Service Mesh 或 API 网关(比如 Spring Cloud Gateway、Kong、APISIX),那 Zuul + Hystrix 的这套组合就稍显过时,不过它的思路依然是所有服务保护框架的基石。

七、技术优缺点

7.1 优点

第一,简单直接。Spring Cloud 封装好了,你只需要写配置和降级类,不用关心复杂的底层原理。第二,保护效果明显。超时、线程池隔离、熔断、降级,一套组合拳能够最大限度防止一个下游服务拖垮整个网关。第三,监控方便。Hystrix 提供了实时指标流,可以接入 Turbine、仪表盘等,快速定位问题服务。

7.2 缺点

第一,内存和 CPU 成本高。Hystrix 为每个线程池维护队列和线程,线程切换也带来开销,在对延迟极其敏感的场景里,这可能成为瓶颈。第二,配置复杂,容易踩坑。超时时间、线程池大小、熔断阈值之间约束太多,调参不好容易误触发熔断。第三,维护状态不佳。Netflix 在 2018 年宣布 Hystrix 停止新功能开发,虽然项目能继续用,但遇到新需求可能需要自己二次开发。第四,Zuul 1 的同步模型性能不如异步网关。所以在高并发场景下,需要谨慎评估。

八、开发中的注意事项

第一,超时时间要设得比真实业务慢一点点。比如商品服务一般需要 200 毫秒,你设置成 3000 毫秒;如果设得太紧,稍微一个网络抖动就熔断了,反而影响用户体验。一般建议先压测出 P99 延迟(99% 的请求在多少毫秒内完成),再设置超时时间。第二,线程池大小要给足,但不能太大。太小了容易排队,太大了反而浪费资源。你可以统计网关的平均并发数,预留 1.5 倍到 2 倍。第三,fallback 里不要做太重的操作。降级接口一般是快速返回一个静态结果,不要再调用数据库、MQ 或其它服务,否则降级的过程自己又拖垮了别的系统。第四,熔断器的阈值要结合流量来设置。如果系统本来就是低流量,比如每分钟才 10 个请求,那 requestVolumeThreshold 设成 20 是不合适的,因为很难达到触发条件。你可以把阈值调低一些。第五,一定要注意 Hystrix 的线程池名称是跟路由的 serviceId 关联的,如果你配了多个路由指向同一个服务,线程池可能被共享,某个路由堵住会间接影响另一个路由,这时可以用 key 属性手动指定不同的命令名称。

九、总结

Zuul 网关和 Hystrix 熔断器的集成,本质上是“路由转发”和“容错保护”两种思想的结合。Zuul 帮你把请求分发到合适的地方,Hystrix 帮你盯着每一个转发过程,一旦下游不对劲就立刻阻断、降级、撬开熔断开关。虽然现在有了更新的网关和云原生方案,但这里面蕴含的经验永远不过时:不要让任何一个不可控的依赖拖垮你整个系统,要学会快速失败、优雅降级。这一套技术组合,在中小团队里依然是非常实用且高效的选择。

希望这篇博客能帮你理清 Zuul 和 Hystrix 之间的配合关系。如果你现在正在用 Spring Cloud 做微服务,不妨照着上面的示例动手跑一跑,亲自体验一下熔断器打开的时候那种“过山车”般的感觉。实践出真知,多踩坑才能记得更牢。