在上一篇总览文章中,我们看到了 Shop Platform 的整体架构分层。API Gateway 作为整个系统当前统一的外部入口,承载了身份验证、流量控制、路由分发、文档聚合等多项职责。

📦 本文基于的完整项目源码:https://github.com/meirongdev/shop

上一篇:(一)Shop Platform 总览

2026-04 实践更新 当前主线代码已经从 HS256 共享密钥切到 RS256 + JWKS,并把网关限流的 Lua 执行器统一到 RedissonClient + RScript。下面相关代码片段已按仓库当前实现同步。


为什么选 Spring Cloud Gateway Server MVC#

Spring Cloud Gateway 最初基于 WebFlux(Reactor)构建,是一个响应式网关。但在 2024–2025 年间,Spring Cloud 团队推出了 Spring Cloud Gateway Server MVC——一个基于 Servlet/MVC 的网关实现。

我们选择 MVC 而非 WebFlux,主要考虑了三点:

1. 统一编程模型

项目里 15+ 个后端服务全部是 Spring Boot MVC + Virtual Threads。如果网关用 WebFlux,就意味着团队需要同时维护两套编程模型:一套响应式(网关),一套同步阻塞(所有下游服务)。调试、日志追踪、异常处理的风格都不一样,增加了认知负担。

2. Virtual Threads 目前能满足需求

Virtual Threads 让每个 I/O 阻塞操作都运行在轻量级虚拟线程上,网关的并发能力不再完全依赖非阻塞 I/O 模型。对于 API Gateway 这种典型的 I/O 密集型应用(读 JWT、查 Redis、转发请求),它在当前项目的流量模型里已经提供了可接受的吞吐量,同时保留了同步代码的可读性。

3. YAML 路由配置,日常路由调整尽量不改代码

Spring Cloud Gateway Server MVC 的大部分路由都通过 application.yml 配置。新增/修改常规路由通常不需要改 Java 代码;只有像自定义 Canary Predicate 这样的扩展能力,才需要补充少量实现代码。这对频繁变动的路由场景(灰度、A/B 测试、新服务接入)依然很友好。


整体架构#

Gateway 在系统中所处的位置和职责:

flowchart TD Client["Client
Browser / KMP WASM / Mobile"] GW["API Gateway :8080
CORS → JWT 校验 → Trusted Headers
→ Redis Lua 限流 → 路由匹配"] S1["buyer-bff / order-service
..."] S2["seller-bff / marketplace
..."] S3["loyalty-service
activity-service
..."] Client -->|"HTTPS"| GW GW -->|"X-Buyer-Id / X-Username
X-Roles / X-Portal"| S1 GW -->|"X-Buyer-Id / X-Username
X-Roles / X-Portal"| S2 GW -->|"X-Buyer-Id / X-Username
X-Roles / X-Portal"| S3

请求经过的过滤器链(按顺序):

CORS → Spring Security (JWT) → TrustedHeadersFilter → RateLimitingFilter → Route Dispatcher

路由配置:YAML 驱动#

所有路由定义在 application.yml 中,按优先级排序(先匹配的生效):

灰度路由优先#

spring:
  cloud:
    gateway:
      server:
        webmvc:
          routes:
            # 灰度路由需要放在稳定路由之前
            - id: buyer-api-canary
              uri: ${BUYER_BFF_V2_URI:http://buyer-bff-v2:8080}
              predicates:
                - Path=/api/buyer/**
                - name: Canary
                  args:
                    routeId: buyer-api
              filters:
                - StripPrefix=1

灰度路由使用自定义 Canary Predicate,检查 Redis Set 中是否包含该 buyerId。如果命中则转发到 V2 实例,否则继续匹配下一条稳定路由:

graph TD Req["请求 /api/buyer/**"] ReadId["读取 X-Buyer-Id"] Redis{"Redis Set
gateway:canary:buyer-api
包含此 buyerId?"} V2["转发 buyer-bff-v2
(灰度实例)"] Stable["继续匹配 → 转发 buyer-bff
(稳定实例)"] Fallback["Redis 不可用 → 降级到稳定版本"] Req --> ReadId --> Redis Redis -->|"存在"| V2 Redis -->|"不存在"| Stable Redis -.->|"异常"| Fallback

完整路由矩阵#

路径前缀 目标服务 说明
/api/buyer/** buyer-bff (或 v2 canary) 买家 API,灰度路由优先
/api/seller/** seller-bff (或 v2 canary) 卖家 API,灰度路由优先
/api/loyalty/** loyalty-service 积分服务
/api/activity/** activity-service 活动服务
/api/webhook/** webhook-service 开放平台 Webhook
/api/subscription/** subscription-service 订阅服务
/auth/** auth-server 认证服务,JWT 无需认证
/buyer/** buyer-portal Kotlin SSR 门户,SEO 友好
/seller/** seller-portal 卖家门户静态发布单元
/buyer-app/** buyer-app KMP WASM SPA
/public/buyer/** buyer-bff 白名单,游客可访问

OpenAPI 文档聚合路由#

Gateway 同时充当 OpenAPI/Swagger 文档的统一入口。每个服务的 /v3/api-docs/{service} 都被路由到对应的后端,Gateway 自己也会提供 /v3/api-docs/gateway

- id: api-docs-buyer-bff
  uri: ${BUYER_BFF_URI:http://buyer-bff:8080}
  predicates:
    - Path=/v3/api-docs/buyer
  filters:
    - RewritePath=/v3/api-docs/buyer, /v3/api-docs

SpringDoc 在 Gateway 侧按服务聚合文档入口,Swagger UI 可直接切换分组,前端或联调同学不需要记住每个服务的地址。


JWT 校验:Spring Security OAuth2 Resource Server#

当前实现里,Gateway 统一承担 JWT 校验。下游服务信任 Gateway 注入的 X-Buyer-IdX-UsernameX-RolesX-Portal 等 Header,不再重复解析 Token。

安全配置#

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
    return http.cors(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(
                            "/actuator/**", "/auth/**", "/buyer/**",
                            "/seller/**", "/public/**",
                            "/swagger-ui.html", "/swagger-ui/**",
                            "/v3/api-docs/**").permitAll()
                    .requestMatchers("/api/**").authenticated()
                    .anyRequest().permitAll())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(jwtDecoder)))
            .build();
}

@Bean
JwtDecoder jwtDecoder(GatewayProperties properties) {
    return NimbusJwtDecoder.withJwkSetUri(properties.jwksUri()).build();
}

关键设计:

  • 无状态SessionCreationPolicy.STATELESS,不创建 Session,每个请求独立验证 JWT
  • 白名单路由/auth/**/public/**、SSR 门户等路径不需要 JWT,支持游客访问
  • API 路径认证要求/api/** 在当前实现里需要携带有效 JWT
  • JWKS 拉取校验:Gateway 不再持有 auth-server 的共享密钥,而是通过 /.well-known/jwks.json 拉取并缓存 RSA 公钥

Trusted Headers 注入与信任链#

JWT 校验通过后,TrustedHeadersFilter 从 JWT 中提取身份信息,注入到下游服务信任的 Header 中。

核心实现:TrustedHeadersRequestWrapper#

public class TrustedHeadersRequestWrapper extends HttpServletRequestWrapper {

    private static final Set<String> STRIPPED_HEADERS = Set.of(
            "x-buyer-id", "x-player-id", "x-user-id",
            "x-username", "x-roles", "x-portal");

    private final Map<String, String> injectedHeadersByLowercase;

    public TrustedHeadersRequestWrapper(HttpServletRequest request,
                                        String requestId,
                                        String buyerId,
                                        String username,
                                        String roles,
                                        String portal) {
        super(request);
        // 注入信任 Header
    }

    @Override
    public String getHeader(String name) {
        String key = normalize(name);
        // 客户端伪造的 Header 被剥离,只返回 Gateway 注入的值
        if (injectedHeadersByLowercase.containsKey(key) || STRIPPED_HEADERS.contains(key)) {
            return injectedHeadersByLowercase.get(key);
        }
        return super.getHeader(name);
    }
}

工作原理:

  1. 剥离:客户端请求中携带的 X-Buyer-IdX-Username 等 Header 被视为不可信,会被 getHeader() 覆盖
  2. 注入:Gateway 从 JWT 中提取的 principalIdusernamerolesportal 写入新的 RequestWrapper
  3. MDC 富化:同时将 principalIdbuyerIdusernameportalrequestId 写入 MDC,所有下游日志自动携带这些字段

MDC 传播#

// TrustedHeadersFilter.doFilterInternal()
if (principalId != null) {
    MDC.put("principalId", principalId);
    if ("BUYER".equalsIgnoreCase(portal)) {
        MDC.put("buyerId", principalId);
    } else if ("SELLER".equalsIgnoreCase(portal)) {
        MDC.put("sellerId", principalId);
    }
}
MDC.put("username", username);
MDC.put("portal", portal);
MDC.put("requestId", requestId);

下游服务不需要做任何 JWT 解析,只需要读 X-Buyer-Id 等可信 Header 就知道当前请求的身份。日志中也自动带了 buyerId,排查问题时可以直接按用户过滤日志。并且共享模块里的 HeaderPropagationInterceptor 会继续把这些 Header 透传到后续 RestClient 调用中。

游客身份的实际入口#

当前仓库里的游客身份不是靠 Gateway 写自定义 Cookie,而是由 auth-server 签发 guest JWT,principalId 形如 guest-buyer-*。Gateway 仍然按同样的 Trusted Headers 机制透传身份;后续由 buyer-bff 根据 guest-buyer- 前缀把购物车落到 Redis,并在结账时走游客订单链路。


Redis Lua 限流#

限流发生在路由转发之前,保护下游服务不被突发流量打垮。

令牌桶 + Lua 原子脚本#

@Component
@Order(SecurityProperties.DEFAULT_FILTER_ORDER + 20)
public class RateLimitingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        try {
            String baseKey = resolveRateLimitKey(request);
            Long allowed = redissonClient.getScript(StringCodec.INSTANCE).eval(
                    RScript.Mode.READ_WRITE,
                    TOKEN_BUCKET_SCRIPT,
                    RScript.ReturnType.INTEGER,
                    List.<Object>of(baseKey + ":tokens", baseKey + ":ts"),
                    String.valueOf(ratePerSec),
                    String.valueOf(capacity),
                    String.valueOf(nowMs));
            if (allowed == null || allowed == 0L) {
                response.setStatus(429);
                response.setHeader("Retry-After", "60");
                return;
            }
        } catch (RedisException exception) {
            log.warn("Rate limit check failed, allowing request through: {}", exception.getMessage());
        }
        chain.doFilter(request, response);
    }
}

设计要点:

  • 令牌桶:key 为 rl:{id}:tokens / rl:{id}:ts,按 requestsPerMinute / 60 的速率持续补充令牌,突发流量最多允许到 burst
  • 先识别身份:有 X-Buyer-Id 的按用户限流,没有的按 IP 限流(防爬虫/刷接口)
  • Lua 原子操作:令牌补充、扣减、TTL 刷新在同一脚本里完成,避免竞态条件
  • 失败降级:Redis 不可用时 catch RedisException,放行请求而不阻断(可用性优先)
  • 可配置阈值:当前默认 100 次/分钟/用户,通过环境变量 GATEWAY_RATE_LIMIT_RPM 调整

注:当前实现已经不是固定窗口,而是更平滑的令牌桶;如果后续还要进一步做分租户 / 分接口权重限流,再考虑扩展为更细粒度的 key 设计和分层配额。


灰度路由:Redis Whitelist + Header 匹配#

灰度发布(Canary Release)允许将特定用户流量导向新版本服务,而不影响其他用户。

自定义 PredicateSupplier#

@Component
public class CanaryRequestPredicates implements PredicateSupplier {

    private static final String KEY_PREFIX = "gateway:canary:";

    public static RequestPredicate canary(String routeId) {
        return request -> {
            String buyerId = request.servletRequest().getHeader(BUYER_ID);
            if (buyerId == null || buyerId.isBlank() || redisTemplate == null) {
                return false;
            }
            try {
                return Boolean.TRUE.equals(
                    redisTemplate.opsForSet()
                        .isMember(KEY_PREFIX + routeId, buyerId));
            } catch (DataAccessException exception) {
                log.warn("Canary lookup failed for route {}, routing to stable: {}",
                        routeId, exception.getMessage());
                return false;
            }
        };
    }
}

工作流程:

请求 /api/buyer/**
    │
    ├─ 读取 X-Buyer-Id
    │
    ├─ 查 Redis Set gateway:canary:buyer-api
    │   ├─ 存在 → 转发到 buyer-bff-v2 (灰度实例)
    │   └─ 不存在 → 继续匹配 → 转发到 buyer-bff (稳定实例)
    │
    └─ Redis 不可用 → 降级到稳定版本(安全策略)

如何操作灰度#

通过 Redis CLI 或管理脚本将用户加入/移出白名单:

# 将 buyer-123 加入灰度名单
SADD gateway:canary:buyer-api buyer-123

# 查看当前灰度用户
SMEMBERS gateway:canary:buyer-api

# 移除灰度用户
SREM gateway:canary:buyer-api buyer-123

灰度路由的 YAML 配置需要放在稳定路由之前,因为 Spring Cloud Gateway 按顺序匹配,第一个匹配成功的路由生效。


Virtual Threads 配置#

Gateway 启用 Virtual Threads 的方式很直接:

spring:
  threads:
    virtual:
      enabled: true

这一行配置让 Servlet 请求处理链路可以运行在虚拟线程上。对于 API Gateway 的请求转发流程:

  1. Tomcat 接受请求,分配虚拟线程
  2. TrustedHeadersFilter 在虚拟线程上执行(包含 Redis 查询、JWT 解析)
  3. 路由转发到下游服务时,当前虚拟线程阻塞等待响应
  4. 收到下游响应后,虚拟线程唤醒,写回响应给客户端

Virtual Threads 的阻塞是 JVM 级别的“挂起”,不直接长期占用操作系统线程。这让 Redis/JWT/下游 HTTP 这些典型阻塞点依然可以保留同步写法,同时把并发成本压到比较可接受的范围内。


从 WebFlux 迁移到 MVC 的得失#

项目早期网关使用的是 WebFlux(响应式),后来迁移到 Spring Cloud Gateway Server MVC + Virtual Threads。

收益#

维度 WebFlux MVC + Virtual Threads
编程模型 响应式 Mono/Flux 链条 同步阻塞,代码直观
调试体验 堆栈信息不直观 传统断点调试更直接
团队学习成本 需要学习 Reactive 范式 标准 Spring MVC,学习成本更低
过滤器实现 需要 WebFilter,响应式 标准 OncePerRequestFilter
错误处理 onErrorResume 链条 标准 @ExceptionHandler

损失#

维度 说明
极限并发 WebFlux 在极端高并发(10w+ QPS)场景下仍有优势,但 Gateway 作为内部网关通常不需要这个量级
流式响应 SSE 等流式场景 WebFlux 天然支持,MVC + VT 也可用但需要额外注意连接保活

结论#

对这个项目当前的流量模型和团队习惯来说,Virtual Threads + MVC 是我们当时比较务实的选择。如果你本来就有明显的响应式基础设施、超高并发目标,或者大量 SSE / 流式处理需求,WebFlux 仍然是合理方案。


当前的安全信任链#

Gateway 是整个系统的信任边界:

sequenceDiagram participant C as 客户端 participant G as Gateway participant DS as 领域服务 C->>G: 携带 JWT 请求 G->>G: ① 验证 JWT 签名 + 有效期 G->>G: ② 提取 principalId / username / roles / portal G->>G: ③ 剥离客户端伪造的信任 Header G->>G: ④ 注入 X-Buyer-Id / X-Username / X-Roles / X-Portal / X-Request-Id G->>G: ⑤ 设置 MDC 上下文 (日志追踪) G->>DS: 携带 Trusted Headers 转发 DS->>DS: HeaderPropagationInterceptor 继续透传

当前主线源码里能直接验证的信任链是“JWT → Trusted Headers → RestClient 透传”。Kubernetes 清单中也明确写到,早期静态 X-Internal-Token 校验方案已经由 NetworkPolicy 的入口约束替代,所以本文不再把 InternalAccessFilter 当作现状能力展开。


参考与实现位置#


小结#

API Gateway 在本项目中的职责远不止路由分发:

  • JWT 校验:Spring Security OAuth2 Resource Server,通过 JWKS 拉取 RS256 公钥校验
  • Trusted Headers:从 JWT 提取身份注入下游请求,剥离客户端伪造 Header
  • Redis Lua 限流:按用户/IP 的分钟级令牌桶限流,失败时降级放行
  • 灰度路由:Redis Set 白名单 + 自定义 PredicateSupplier
  • OpenAPI 聚合:按服务分组的 Swagger 文档统一入口
  • Virtual Threads:以同步代码模型获得异步并发能力

下一篇将深入 BFF 聚合层:Buyer/Seller BFF 如何通过 Virtual Threads 并发编排多个下游调用、Resilience4j 熔断与降级策略、以及游客购物车的完整实现。

项目仓库:github.com/meirongdev/shop(公开,可结合源码一起看)