4. 网关层设计:Spring Cloud Gateway 路由、限流、熔断、降级

好,咱们进入第四章。网关层。

说实话,很多团队做微服务,网关就是随便配个路由就上线了。结果呢?流量一上来,网关先挂了。我见过最夸张的一次,网关成了整个系统的瓶颈,下游服务一个没倒,网关自己先被冲垮了。嗯,这其实是个很尴尬的事。

所以这一章,我会把 Spring Cloud Gateway 的核心能力拆开揉碎。路由、限流、熔断、降级,这四个能力缺一不可。我个人习惯是把网关当成系统的「第一道防线」,而不是简单的「请求转发器」。

4.1 路由配置:别只写静态路由

路由是网关最基础的能力。但很多人只会写死路由,比如:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/user/**

这样写没问题,但不够灵活。我在项目中遇到过一个问题:某个服务需要灰度发布,新版本和老版本的路由规则不一样。如果全写死,每次改路由都要重启网关,这谁受得了?

我的做法是:路由配置动态化。把路由信息存到 Nacos 或 Redis 里,网关启动时加载,运行时监听变更。Spring Cloud Gateway 提供了 RouteDefinitionRepository 接口,你只需要实现它:

public class NacosRouteDefinitionRepository implements RouteDefinitionRepository {
    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
        // 从 Nacos 读取路由配置
        return Flux.fromIterable(loadRoutesFromNacos());
    }

    @Override
    public Mono<Void> save(Mono<RouteDefinition> route) {
        // 保存到 Nacos
        return route.flatMap(r -> saveToNacos(r));
    }

    @Override
    public Mono<Void> delete(Mono<String> routeId) {
        // 从 Nacos 删除
        return routeId.flatMap(id -> deleteFromNacos(id));
    }
}

这样,路由变更只需要改 Nacos 配置,网关自动刷新。你想想看,是不是省心多了?

我的小技巧: 路由优先级用 order 字段控制。数值越小,优先级越高。我习惯把精确匹配的路由放在前面,模糊匹配的放后面。

4.2 限流:别让流量冲垮你的网关

限流是网关的核心能力。没有限流,一个突发流量就能让你的系统瘫痪。

Spring Cloud Gateway 内置了 RequestRateLimiter 过滤器,基于 Redis 和令牌桶算法。配置起来很简单:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/user/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                key-resolver: "#{@userKeyResolver}"

这里 replenishRate 是每秒填充的令牌数,burstCapacity 是最大突发容量。说白了,就是每秒最多处理 10 个请求,突发情况下可以到 20 个。

但这里有个坑。我曾经遇到过:限流粒度太粗,导致某个恶意用户刷接口,把整个服务的额度都耗光了。怎么办?

按用户维度限流。 实现 KeyResolver 接口,根据用户 ID 或 IP 生成限流 key:

@Bean
public KeyResolver userKeyResolver() {
    return exchange -> {
        String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
        if (userId == null) {
            userId = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
        }
        return Mono.just(userId);
    };
}

这样,每个用户都有自己的令牌桶。一个用户刷接口,只影响他自己,不影响别人。

注意: 限流阈值要根据实际压测结果来定。我见过有人拍脑袋设了个 100 QPS,结果压测发现服务只能扛 50 QPS。嗯,上线当天就出问题了。

4.3 熔断:别让故障蔓延

熔断是防止雪崩的关键。下游服务挂了,网关不能一直重试,否则会把整个系统拖垮。

Spring Cloud Gateway 集成了 Resilience4j 的熔断器。配置方式如下:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/user/**
          filters:
            - name: CircuitBreaker
              args:
                name: userServiceCircuitBreaker
                fallbackUri: forward:/fallback/user

然后在 application.yml 里配置熔断参数:

resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
        failureRateThreshold: 50
        waitDurationInOpenState: 10000
        permittedNumberOfCallsInHalfOpenState: 3

这些参数什么意思?我解释一下:

  • slidingWindowSize:滑动窗口大小,统计最近 10 次请求
  • minimumNumberOfCalls:最少请求数,少于这个数不触发熔断
  • failureRateThreshold:失败率阈值,超过 50% 就熔断
  • waitDurationInOpenState:熔断持续时间,10 秒后进入半开状态
  • permittedNumberOfCallsInHalfOpenState:半开状态下允许的请求数

我曾经犯过一个错误:把 waitDurationInOpenState 设得太短,比如 1 秒。结果下游服务还没恢复,熔断器就频繁开闭,反而加重了系统负担。后来我改成 10 秒,效果好了很多。

核心原则: 熔断不是越快恢复越好。要给下游服务留出足够的恢复时间。我一般建议至少 5 秒以上。

4.4 降级:给用户一个体面的回应

熔断之后,不能直接返回 500 错误。用户看到「服务器内部错误」,体验很差。降级就是给用户一个体面的回应。

Spring Cloud Gateway 的降级很简单,配置一个 fallback 路由:

@RestController
public class FallbackController {
    @GetMapping("/fallback/user")
    public Mono<Map<String, Object>> userFallback() {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 503);
        result.put("message", "服务暂时不可用,请稍后重试");
        return Mono.just(result);
    }
}

但降级不只是返回一个提示。我建议做 分级降级

降级级别 触发条件 返回内容
一级降级 单个接口熔断 返回缓存数据或默认值
二级降级 整个服务熔断 返回友好提示,引导用户重试
三级降级 网关自身过载 返回 429,提示请求过多

举个例子。用户查询订单列表,如果订单服务熔断了,一级降级可以返回最近 5 分钟的缓存数据。用户看到的是旧数据,但至少不是空白页。你想想看,哪个体验更好?

我的经验: 降级数据最好提前准备好。比如在 Redis 里存一份热点数据的快照,熔断时直接读取。别等到熔断了再去查数据库,那会雪上加霜。

4.5 组合使用:一个完整的网关配置

最后,我把路由、限流、熔断、降级组合起来,给一个完整的配置示例:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/user/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                key-resolver: "#{@userKeyResolver}"
            - name: CircuitBreaker
              args:
                name: userServiceCircuitBreaker
                fallbackUri: forward:/fallback/user
            - name: Retry
              args:
                retries: 2
                statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE
                methods: GET

这里我还加了一个 Retry 过滤器。注意,重试次数不能太多,我一般设 2 次。重试太多,反而会加重下游负担。

嗯,到这里网关层的核心设计就讲完了。总结一下:

  • 路由要动态化,别写死
  • 限流要按用户维度,别一刀切
  • 熔断要给恢复留时间,别频繁开闭
  • 降级要分级处理,别只返回 500

下一章,我们会进入服务间通信层。到时候聊聊 Feign 的超时配置、重试策略,还有线程隔离。这些都是我在线上踩过的坑,到时候一一分享给你。