Spring Boot 3.5 + Java 25 + Cloud Native 系列(二):API Gateway 架构
目录
在上一篇总览文章中,我们看到了 Shop Platform 的整体架构分层。API Gateway 作为整个系统当前统一的外部入口,承载了身份验证、流量控制、路由分发、文档聚合等多项职责。
📦 本文基于的完整项目源码:https://github.com/meirongdev/shop
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 在系统中所处的位置和职责:
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 实例,否则继续匹配下一条稳定路由:
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-Id、X-Username、X-Roles、X-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);
}
}
工作原理:
- 剥离:客户端请求中携带的
X-Buyer-Id、X-Username等 Header 被视为不可信,会被getHeader()覆盖 - 注入:Gateway 从 JWT 中提取的
principalId、username、roles、portal写入新的 RequestWrapper - MDC 富化:同时将
principalId、buyerId、username、portal、requestId写入 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 的请求转发流程:
- Tomcat 接受请求,分配虚拟线程
TrustedHeadersFilter在虚拟线程上执行(包含 Redis 查询、JWT 解析)- 路由转发到下游服务时,当前虚拟线程阻塞等待响应
- 收到下游响应后,虚拟线程唤醒,写回响应给客户端
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 是整个系统的信任边界:
当前主线源码里能直接验证的信任链是“JWT → Trusted Headers → RestClient 透传”。Kubernetes 清单中也明确写到,早期静态 X-Internal-Token 校验方案已经由 NetworkPolicy 的入口约束替代,所以本文不再把 InternalAccessFilter 当作现状能力展开。
参考与实现位置#
- Spring Cloud Gateway Server MVC:https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway-server-webmvc.html
- Spring Security JWT Resource Server:https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
- Spring Boot Virtual Threads:https://docs.spring.io/spring-boot/reference/features/spring-application.html#features.spring-application.virtual-threads
- 仓库实现入口:
services/api-gateway/src/main/resources/application.yml、services/api-gateway/src/main/java/dev/meirong/shop/gateway/config/GatewaySecurityConfig.java、services/api-gateway/src/main/java/dev/meirong/shop/gateway/filter/TrustedHeadersFilter.java、services/api-gateway/src/main/java/dev/meirong/shop/gateway/filter/RateLimitingFilter.java、services/api-gateway/src/main/java/dev/meirong/shop/gateway/config/CanaryRequestPredicates.java、shared/shop-common/shop-common-core/src/main/java/dev/meirong/shop/common/http/HeaderPropagationAutoConfiguration.java
小结#
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(公开,可结合源码一起看)