生产级 API 网关设计

本文设计一个生产级的 API 网关:作为广告投放平台与运营后台的统一接入层,承担路由、协议转换(HTTP 转 Dubbo)、认证、鉴权、流量治理、灰度、安全与可观测性。设计参考了 Envoy、Apache APISIX、Kong、Netflix Zuul 2、Spring Cloud Gateway 等成熟网关的架构,以及 Google SRE 关于过载与级联故障的方法论,参考资料见第 19 章。

项 说明
技术栈 Spring Cloud Gateway(Spring WebFlux + Reactor Netty)、Nacos(注册中心与配置中心)、Dubbo 3、Redis、Kafka、Sentinel、MySQL + MyBatis、Prometheus、OpenTelemetry
规模假设 峰值 2 万 QPS、上游服务约 50 个、路由约 1000 条、注册用户百万级。文中涉及容量的数字都基于这组假设,用来说明计算方法
代码 文中的代码与配置均未实测;Spring Cloud Gateway 的配置项名称随版本变化,以所用版本的官方文档为准

1. 网关的职责与边界

1.1 网关解决什么问题

微服务把一个系统拆成几十个服务之后,鉴权、限流、日志、协议适配这类横切逻辑如果由每个服务各自实现,会出现三个问题:实现不一致、重复开发、无法统一管控。网关把这些逻辑收敛到入口处,后端服务只关心业务。

能力 说明
统一入口 对外只暴露一个域名,内部服务的拆分、合并、迁移对调用方透明
路由与协议转换 按路径、域名、请求头把请求转发到正确的服务;把 HTTP 转换成 Dubbo 等内部协议
认证与鉴权 校验身份令牌、开放接口签名,按 RBAC 检查接口权限
流量治理 限流、熔断、超时、重试、过载保护、降级
发布支撑 灰度、蓝绿、流量镜像
安全 黑白名单、请求大小限制、敏感信息脱敏、跨域
可观测性 访问日志、指标、链路追踪的统一起点

1.2 职责边界

网关负责 网关不负责
与业务无关的横切逻辑 业务规则、业务数据的读写
粗粒度的接口权限(这个用户能否调用这个接口) 细粒度的数据权限(这个用户能看哪些广告主的数据),只负责把身份和范围传给后端,由后端执行
协议转换、请求头改写 多个服务的结果编排与聚合。聚合属于 BFF(面向前端的后端)层,放进网关会让网关承载业务逻辑,发布频繁、故障影响面大
统一的错误响应格式 业务错误码的定义

网关是所有流量的必经之路,它的可用性上限就是整个系统的可用性上限。所以设计原则是:网关里的逻辑越少越好,每一项能力都要考虑依赖故障时的退化方式(第 15 章)。

1.3 部署层次

flowchart LR
    C[客户端<br/>浏览器、App、开放平台调用方] --> L4[四层负载均衡<br/>云 SLB / LVS]
    L4 --> EDGE[边缘层<br/>Nginx / APISIX / 云 WAF<br/>TLS 终止、全局 IP 限流、WAF]
    EDGE --> GW[业务网关集群<br/>Spring Cloud Gateway]
    GW --> S1[HTTP 服务]
    GW --> S2[Dubbo 服务]
    GW -.-> AUTH[认证服务<br/>登录、注册、令牌]
能力 说明
统一入口 对外只暴露一个域名,内部服务的拆分、合并、迁移对调用方透明
路由与协议转换 按路径、域名、请求头把请求转发到正确的服务;把 HTTP 转换成 Dubbo 等内部协议
认证与鉴权 校验身份令牌、开放接口签名,按 RBAC 检查接口权限
流量治理 限流、熔断、超时、重试、过载保护、降级
发布支撑 灰度、蓝绿、流量镜像
安全 黑白名单、请求大小限制、敏感信息脱敏、跨域
可观测性 访问日志、指标、链路追踪的统一起点
层 职责 原因
四层负载均衡 把 TCP 连接分发到边缘层节点 高吞吐,不解析 HTTP
边缘层 TLS 终止、证书管理、按 IP 的全局限流、WAF、静态资源 C/C++ 实现的代理在这一层性能最好,抵御攻击流量不消耗业务网关的资源
业务网关 认证、RBAC、按用户和应用的限流、HTTP 转 Dubbo、灰度 这些逻辑和 Java 业务生态(Dubbo 接口、权限数据、Nacos)关系紧密,用 Java 实现开发和维护成本最低

规模较小时,边缘层可以直接由云厂商的负载均衡与 WAF 承担。


2. 业界网关的设计参考

2.1 主要网关的架构选择

网关 数据面实现 配置存储与下发 扩展方式 值得借鉴的设计
Nginx / OpenResty C 与 LuaJIT,多进程事件驱动 配置文件,修改后 reload Lua 脚本 每个 worker 进程独立处理连接,进程之间不共享状态
Kong OpenResty PostgreSQL,或无数据库的声明式配置 插件,按固定的阶段执行 插件生命周期分阶段:重写、访问控制、转发前、响应头、响应体、日志
Apache APISIX OpenResty etcd,通过 watch 实时推送 插件,支持热加载 路由用基数树匹配,与路由数量无关;配置变更不需要 reload
Envoy C++,多线程事件循环 控制面通过 xDS 协议动态下发 过滤器链 数据面与控制面分离;熔断阈值、离群检测、重试预算、恐慌阈值等流量治理机制最完整
Netflix Zuul 2 Java,Netty 内部配置系统 入站、出站、终点三类过滤器 从阻塞改为异步的经验:上游并发保护、连接管理、调试难度的上升
Spring Cloud Gateway Java,Reactor Netty YAML,或自定义路由仓库 GatewayFilter、GlobalFilter 与 Spring 生态集成:lb:// 服务发现、Spring Security、Redis 限流

2.2 提炼出的设计原则

原则 来源 在本设计中的体现
数据面与控制面分离 Envoy、APISIX、Kong 网关实例只负责处理请求;路由、权限、限流策略由管理后台写入 Nacos 和数据库,网关订阅变更(第 13 章)
配置热更新,不重启、不 reload APISIX、Envoy 路由、限流规则、灰度规则变更后秒级生效,原子替换整份快照
过滤器链与插件化 所有网关 每项能力是一个有明确顺序的过滤器,可以按路由开关(第 3.2 节)
非阻塞 IO,事件循环里绝不阻塞 Envoy、Zuul 2、Spring Cloud Gateway 所有外部调用(Redis、Dubbo、认证服务)都是异步的(第 14 章)
保护上游,也保护自己 Envoy 熔断阈值、Zuul 2 上游并发保护 每个上游有并发上限、超时、熔断;网关自身有过载保护(第 9 章)
有界的重试 Envoy 重试预算、Google SRE 重试总量不超过请求量的固定比例,防止重试放大故障(第 9.3 节)
依赖故障时的退化方式要事先设计 Google SRE Redis、Nacos、Kafka 各自故障时网关如何工作,都有明确规定(第 15.2 节)
可观测性优先 所有网关 每个请求都有请求 ID、链路 ID、按路由和上游的指标

3. 总体架构

3.1 组件

flowchart TB
    subgraph CP[控制面]
        ADMIN[网关管理后台<br/>路由、限流、灰度、开放应用、权限]
        DB[(MySQL<br/>路由、权限、开放应用、审计)]
        NACOS_C[Nacos 配置中心]
        ADMIN --> DB
        ADMIN --> NACOS_C
    end
    subgraph DP[数据面:网关集群,无状态,水平扩展]
        GW1[网关实例]
        GW2[网关实例]
    end
    subgraph DEP[运行时依赖]
        REDIS[(Redis 集群<br/>配额限流、令牌黑名单、nonce、权限缓存)]
        NACOS_R[Nacos 注册中心]
        KAFKA[Kafka<br/>访问日志、审计事件]
        AUTH[认证服务]
        PERM[权限服务]
    end
    NACOS_C -- 配置推送 --> DP
    NACOS_R -- 服务实例 --> DP
    DP --> REDIS
    DP --> KAFKA
    DP -- 刷新令牌、登录 --> AUTH
    DP -- 加载权限 --> PERM
    DP --> UP[上游:HTTP 服务、Dubbo 服务]
    DP --> OBS[Prometheus、链路追踪后端]
组件 职责
网关实例 处理请求,本身无状态。所有需要跨实例共享的状态放在 Redis
管理后台 维护路由、限流规则、灰度规则、开放应用、权限点,所有变更经过校验、审批、审计后发布
认证服务 注册、登录、签发与刷新令牌、登出。网关只做令牌校验,不保存用户数据
权限服务 维护用户、角色、权限点,提供「用户 → 权限集合」的查询,变更时发布通知
能力 说明
统一入口 对外只暴露一个域名,内部服务的拆分、合并、迁移对调用方透明
路由与协议转换 按路径、域名、请求头把请求转发到正确的服务;把 HTTP 转换成 Dubbo 等内部协议
认证与鉴权 校验身份令牌、开放接口签名,按 RBAC 检查接口权限
流量治理 限流、熔断、超时、重试、过载保护、降级
发布支撑 灰度、蓝绿、流量镜像
安全 黑白名单、请求大小限制、敏感信息脱敏、跨域
可观测性 访问日志、指标、链路追踪的统一起点

3.2 请求处理流水线

请求按固定顺序经过一组过滤器。顺序的原则:成本低、能尽早拒绝的检查放在前面,需要身份信息的检查放在认证之后。

顺序 过滤器 作用 失败时
1 请求 ID 与链路上下文 生成或沿用 X-Request-Id、W3C traceparent —
2 基础校验 请求头大小、请求体大小、非法字符、HTTP 方法 400、413
3 IP 黑白名单 按真实客户端 IP 判断(第 11.2 节) 403
4 入口限流(按 IP) 在认证之前限流,防止攻击流量打满认证逻辑 429
5 路由匹配 找到路由,取出路由的元数据:是否需要登录、权限码、限流策略、超时 404
6 开放接口签名校验 只作用于开放平台路由:AppKey、HMAC 签名、时间戳、nonce(第 7.5 节) 401
7 认证 校验 JWT,检查黑名单与令牌版本(第 7.2 节) 401
8 鉴权 RBAC 接口权限检查(第 8 章) 403
9 业务限流 按用户、应用、接口限流(第 9.1 节) 429
10 灰度决策 计算灰度标记,写入请求头与负载均衡上下文(第 10 章) —
11 请求改写 删除外部传入的内部头,写入用户身份头,改写路径 —
12 熔断与并发保护 按上游检查熔断状态与并发上限(第 9.2 节) 503
13 转发 HTTP 转发,或 HTTP 转 Dubbo 泛化调用(第 6 章) 502、504
14 响应处理 统一错误格式,删除内部响应头,加安全响应头 —
15 日志与指标 在请求完成时异步记录(第 12 章) 不影响响应

在 Spring Cloud Gateway 中,每一项实现为一个 GlobalFilter 并通过 getOrder() 固定顺序;只对部分路由生效的能力(例如签名校验)实现为 GatewayFilter,在路由上声明。

@Component
public class AuthFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        RouteMeta meta = exchange.getAttribute(RouteMeta.ATTR);          // 路由匹配阶段放入的元数据
        if (meta == null || !meta.loginRequired()) {
            return chain.filter(exchange);
        }
        return tokenVerifier.verify(exchange.getRequest())                // 返回 Mono<Principal>,内部是异步的
                .flatMap(p -> chain.filter(withPrincipal(exchange, p)))
                .onErrorResume(AuthException.class, e -> Responses.error(exchange, 401, e.code()));
    }

    @Override
    public int getOrder() { return Orders.AUTH; }                         // 所有顺序常量集中定义在一处
}

3.3 技术选型

选择 原因 代价
Spring Cloud Gateway 与 Spring Boot、Nacos、Dubbo、Spring Security、Sentinel 集成成本最低;Java 团队可以直接维护;HTTP 转 Dubbo 需要依赖 Java 生态 单机性能低于 Envoy、APISIX;响应式编程的调试难度较高
边缘层用 Nginx 或 APISIX TLS 终止和抵御大流量攻击放在性能最好的一层 多一跳,增加约亚毫秒级的延迟
Redis 集群 开放应用的配额限流、黑名单、nonce 需要跨实例共享,且要求亚毫秒级延迟 成为关键依赖,需要设计降级(第 15.2 节)
Nacos 注册中心与配置中心合一,后端服务已在使用 配置推送依赖它,需要本地快照兜底

4. 接入层与连接管理

4.1 客户端侧

配置 建议 原因
TLS 在边缘层终止;网关与边缘层之间在内网用 HTTP,或用 mTLS 证书集中管理;网关节省加解密的 CPU
协议 对外支持 HTTP/1.1 与 HTTP/2 HTTP/2 减少连接数,移动端收益明显
请求头大小上限 例如 16 KB 超大请求头会消耗内存,也是攻击手段
请求体大小上限 按路由设置,默认例如 2 MB;上传接口单独放宽并走流式转发 防止内存被大请求体耗尽
空闲连接超时 例如 60 秒 释放不活跃的长连接
慢速请求 读取请求头、请求体都设置超时 防止慢速攻击(Slowloris)长期占用连接

4.2 上游侧连接池

网关到上游 HTTP 服务使用 Reactor Netty 的连接池,关键参数(Spring Cloud Gateway 的 httpclient.pool 配置组):

参数 建议 原因
每个上游的最大连接数 按上游容量设置,例如 500 防止某个上游变慢时,网关为它建立无限多的连接
获取连接的等待超时 例如 50 毫秒 连接池耗尽时快速失败,而不是排队到整体超时
连接最大空闲时间 小于上游服务端的空闲超时,例如上游是 60 秒就设 50 秒 否则网关可能拿到一条刚被上游关闭的连接,请求失败并报 Connection reset。这是网关最常见的偶发错误之一
连接最大存活时间 例如 10 分钟 让连接定期重建,上游扩缩容后流量能重新均衡
后台清理 开启 定期关闭空闲和过期的连接
连接超时、响应超时 全局默认值加路由级覆盖 见第 9.3 节

空闲时间为什么要小于上游:上游空闲超时到期时关闭连接并发出 FIN,网关的内核收到 FIN 后,Netty 在事件循环中读到 EOF,触发 channelInactive,连接池移除这条连接。从上游发出 FIN 到连接池移除连接之间有一个时间窗口,网关恰好在这个窗口内取出这条连接发送请求时,上游对已关闭的套接字回复 RST:

sequenceDiagram
    participant G as 网关
    participant U as 上游
    Note over U: 空闲超时到期
    U->>G: FIN(仍在传输,或网关尚未处理)
    G->>U: 从连接池取出这条连接,发送请求
    U-->>G: RST
    Note over G: Connection reset 或<br/>Connection prematurely closed

网关的空闲时间短于上游时,空闲连接总是由网关一方关闭,被关闭的连接不会再被取出,这个竞态不会发生。

另外两种情况下网关收不到 FIN:上游机器断电或断网;路径上的 NAT、负载均衡、防火墙因空闲超时删除了连接记录而不通知两端(例如 AWS NLB 的 TCP 空闲超时默认 350 秒)。前者在写入后等到超时,后者收到中间设备的 RST 或者等到超时。应对办法是让网关的空闲时间同时小于路径上所有设备的空闲超时,并开启 TCP keepalive 或 HTTP/2 PING 探测连接。

Dubbo 上游使用 Dubbo 自己的长连接,每个提供者实例默认共享少量连接,通过请求 ID 多路复用。


5. 路由

5.1 路由模型

id: order-create
priority: 100                          # 多条路由同时匹配时,优先级高的生效
predicates:
  host: api.example.com
  path: /api/v1/orders                 # 支持路径模板:/api/v1/orders/{id}
  methods: [POST]
upstream:
  type: dubbo                          # http | dubbo
  interface: com.example.order.OrderService
  method: createOrder
  version: 1.0.0
  group: default
  paramTypes: [com.example.order.CreateOrderReq]
  paramBinding: body                   # 参数如何从 HTTP 请求中组装,见第 6.2 节
meta:
  loginRequired: true
  permission: order:create             # RBAC 权限码
  rateLimit: {key: user, rate: 20, burst: 40}
  timeoutMs: 800
  retry: {enabled: false}              # 下单不是幂等的,不重试
  grayEnabled: true
status: published                      # draft → reviewing → published → disabled
version: 37

路由与服务的对应关系、权限码、限流策略、超时都放在同一个路由对象上。好处是管理后台修改一处就能完整地描述一个接口的治理策略,审计时也能看到完整的上下文。

5.2 匹配性能

Spring Cloud Gateway 默认按顺序逐条评估路由的断言,耗时随路由数量线性增长。路由达到上千条时,匹配本身就会消耗明显的 CPU。

优化 做法
先按域名分组 第一级用哈希表按 Host 找到候选路由组
路径用前缀树 第二级把路径模板编译成基数树,按路径段匹配,带参数的段作为通配节点。APISIX 采用基数树,匹配耗时与路由数量无关
结果缓存 以「方法 + 域名 + 规范化后的路径」为键缓存匹配结果;路径带 ID 时缓存的是路径模板,不是具体路径,避免缓存无限增长
编译一次 路由快照变更时重新构建索引,构建完成后原子替换,请求路径上只读

实现方式:自定义 RouteLocator,或者在路由匹配之前加一个过滤器,用索引直接确定路由,再把路由对象放进交换上下文(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR)。

5.3 动态路由

sequenceDiagram
    participant A as 管理后台
    participant D as MySQL
    participant N as Nacos 配置
    participant G as 网关实例
    A->>A: 校验:语法、路径冲突、上游存在、权限码存在
    A->>D: 保存新版本(版本号 +1),记录审计
    A->>N: 发布路由快照(包含版本号)
    N-->>G: 推送变更
    G->>G: 解析并校验整个快照,构建匹配索引
    G->>G: 原子替换当前快照,发布刷新事件
    G->>G: 记录生效的版本号,指标中暴露
要点 说明
整份快照替换 不做增量更新,避免各实例因漏掉某条增量而状态不一致;路由上千条时整份快照也只有几百 KB
版本号 每份快照带版本号,网关在指标和响应头(调试模式)中暴露当前版本,排查时能确认所有实例是否一致
校验失败不替换 推送来的快照解析或校验失败时,继续使用旧快照并告警
本地快照 每次生效后写入本地文件;启动时 Nacos 不可用,就从本地快照加载
灰度发布路由 新路由先只在一部分网关实例上生效(实例按标签订阅不同的配置分组),确认无误再全量
回滚 管理后台可以一键发布任意历史版本

5.4 服务发现与负载均衡

要点 设计
服务发现 HTTP 上游使用 lb://服务名,由 Nacos 提供实例列表;Dubbo 上游由 Dubbo 自己订阅注册中心
负载均衡策略 默认加权轮询;对长耗时、耗时差异大的上游使用最少活跃请求;按用户哈希用于需要会话粘性的场景
被动健康检查 借鉴 Envoy 的离群检测:某个实例连续出现 5 次 5xx 或连接错误,就暂时摘除 30 秒,摘除时间随摘除次数增加;被摘除的实例数不超过该上游实例总数的一定比例,防止全部被摘除
恐慌阈值 借鉴 Envoy:健康实例占比低于 50% 时,忽略健康状态,在所有实例中均衡。此时多数实例被判为不健康,往往是判断本身出了问题(例如共同依赖的下游故障),继续只把流量集中到少数实例会把它们也压垮
同可用区优先 优先选择同可用区的实例,可用区内健康实例不足时再跨区
预热 新上线的实例在一段时间内权重从低到高逐步增加,给 JIT 编译和缓存预热留出时间

6. 协议转换:HTTP 转 Dubbo

网关两侧是两条连接、两种协议:网关接收客户端的 HTTP 请求,再以 Dubbo 协议向提供者发起调用。以下每一项都需要网关映射:

客户端 → 网关 网关 → 提供者
传输协议 HTTP/1.1 或 HTTP/2 Dubbo 协议:TCP 长连接,16 字节二进制头部(魔数 0xdabb、请求 ID、状态),多个请求复用一条连接
数据格式 JSON Hessian2 二进制
调用目标 URL 路径与 HTTP 方法,例如 POST /api/orders 接口名、方法名、参数类型、版本、分组,例如 com.xx.OrderService#createOrder(CreateOrderRequest)
寻址 DNS、负载均衡 从注册中心获取提供者列表,客户端负载均衡
附加信息 请求头 attachments(隐式参数)
错误 HTTP 状态码 Dubbo 响应状态与异常

实现这种转换有三种方式:

方式 做法 代价
逐个接口编写转换代码 网关或 BFF 层依赖各服务的接口 jar 包,每个接口写一个 Controller 调用对应的 Dubbo 方法 新增、修改接口都要改代码并发布网关;网关依赖几十个 jar 包
泛化调用 网关只使用 GenericService,接口名、方法名、参数类型作为路由配置,参数用 Map 表示 本章采用的方式,见下文
提供者直接暴露 HTTP Dubbo 3 的 Triple 协议或 Dubbo 的 REST 协议,网关只做 HTTP 反向代理 需要后端升级或改造

6.1 为什么用泛化调用

后端核心服务以 Dubbo 接口提供。常规的 Dubbo 调用要求网关依赖每个服务的接口 jar 包,接口一变网关就要重新构建发布,网关和几十个服务的发布节奏被绑在一起。泛化调用只需要接口名、方法名、参数类型和参数值,参数和返回值都用 Map 表示,网关不依赖任何接口 jar 包,新增接口只需在管理后台配置一条路由。

网关不生成业务的 Java 对象,从 Map 到真实参数类型的转换由提供者完成:

flowchart LR
    A[客户端<br/>JSON 文本] -->|Jackson 解析| B[网关<br/>Map / List / String / Number]
    B -->|GenericService.$invoke<br/>方法名 + 参数类型名 + Map| C[Dubbo 消费者<br/>Hessian2 序列化 Map]
    C -->|二进制,TCP| D[提供者<br/>Hessian2 反序列化为 Map]
    D -->|GenericFilter:PojoUtils.realize<br/>按方法签名把 Map 转成真实类型| E[CreateOrderRequest 对象<br/>调用真正的方法]
    E -->|返回值 PojoUtils.generalize<br/>转回 Map,带 class 字段| D
环节 执行方 内容
JSON 转 Map 网关(Jackson) 通用解析,不涉及业务类型
调用 $invoke 网关 传入方法名、参数类型名(字符串,例如 "com.xx.CreateOrderRequest",来自路由配置或元数据中心,网关不加载这个类)、参数值 Map
序列化 Dubbo 消费者 按 Hessian2(Dubbo 默认的序列化方式)把 Map 编码为二进制,线上传输的是 Map
Map 转 DTO 提供者的 GenericFilter 按方法名和参数类型找到真实方法,PojoUtils.realize 根据字段类型用反射把 Map 填充为对象;嵌套对象和 List<Item> 按字段的泛型类型递归转换;Map 中的 class 键指定具体类型,用于多态
返回值转 Map 提供者 PojoUtils.generalize 把返回对象转成带 class 字段的 Map(第 6.3 节要求网关去掉这个字段)

泛化调用把「按类型转换」从消费者移到了提供者,提供者本来就有这些类。转换中需要注意:JSON 数字被解析为 Integer、Long 或 Double,BigDecimal 类型的金额经过 Double 可能丢失精度,金额在 JSON 中用字符串传递;日期字符串能否转换为 Date、LocalDateTime 取决于格式;枚举用名称字符串传递。

6.2 参数绑定

绑定方式 来源 适用
body 整个 JSON 请求体作为第一个参数(一个 DTO) 推荐:Dubbo 接口统一采用「一个请求对象」的签名
mixed 路径参数、查询参数、请求体按配置合并成一个 Map 兼容少量已有接口
args 按配置把多个来源分别映射为多个参数 不推荐,配置复杂

网关还会把认证得到的身份信息放进 Dubbo 的隐式参数(attachments),而不是请求参数,后端通过 RpcContext 读取。这样业务参数里不会混入身份字段,也避免了客户端在请求体里伪造用户 ID。

6.3 实现

public Mono<Object> invoke(DubboRoute r, Map<String, Object> arg, Identity id) {
    GenericService svc = referenceCache.get(r.key());                  // 按「接口 + 版本 + 分组」缓存
    RpcContext.getClientAttachment().setAttachment("uid", id.userId());
    RpcContext.getClientAttachment().setAttachment("traceparent", id.traceparent());
    CompletableFuture<Object> f = svc.$invokeAsync(r.method(), r.paramTypes(), new Object[] {arg});
    return Mono.fromFuture(f)                                           // 不阻塞事件循环线程
            .timeout(Duration.ofMillis(r.timeoutMs()))
            .onErrorMap(DubboErrors::toGatewayError);                   // 超时、无可用提供者、业务异常分别映射
}
要点 说明
引用缓存 ReferenceConfig 的创建需要订阅注册中心、建立连接,耗时可达数百毫秒,必须按「接口 + 版本 + 分组」缓存复用;路由删除后延迟销毁对应的引用
冷启动 网关启动时为所有已发布的 Dubbo 路由预先创建引用,避免第一批请求承担创建的耗时
异步 使用 $invokeAsync,转换成 Mono。同步的 $invoke 会阻塞 Netty 事件循环线程,少量慢请求就能让整个网关失去响应
超时 网关侧的超时与 Dubbo 的调用超时一致,避免网关已经返回失败、提供者还在执行
重试 Dubbo 默认的 Failover 会重试 2 次,对写接口必须关闭(retries=0),重试策略统一由网关路由配置决定
序列化 泛化调用的 Map 转换有额外开销;大对象接口的耗时要单独压测
错误映射 超时 → 504;无可用提供者 → 503;参数类型不匹配 → 400;业务异常 → 按约定的业务错误码返回 200 或 4xx
返回值 返回的 Map 中带有 class 字段(泛化调用的类型信息),序列化成 JSON 前要去掉

Dubbo 3 的 Triple 协议本身可以通过 HTTP 调用,新接口可以考虑直接暴露 Triple,减少对泛化调用的依赖。


7. 认证

7.1 令牌设计

令牌 形式 有效期 存储 用途
访问令牌 JWT,RS256 签名 30 分钟 不存储 每个请求携带,网关本地验签
刷新令牌 随机的不透明字符串 14 天 Redis:refresh:{hash} → 用户 ID、设备、令牌族 ID 访问令牌过期后换取新令牌

访问令牌的声明:sub(用户 ID)、iss、aud、iat、exp、jti(令牌 ID)、ver(用户的令牌版本号)、tenant(所属广告主或代理商)。不放权限列表:权限变更后要等令牌过期才能生效,而且令牌体积会随权限增加而膨胀。

密钥管理:认证服务持有私钥;网关通过 JWKS 端点获取公钥,按令牌头中的 kid 选择公钥。轮换密钥时,新旧两把公钥同时发布,旧私钥停止签发后,等最长有效期过去再下线旧公钥。

7.2 网关的校验步骤

  1. 从 Authorization: Bearer 中取出令牌。
  2. 解析令牌头,alg 必须是 RS256,拒绝 none 和对称算法,防止算法混淆攻击。
  3. 按 kid 找到公钥验签。
  4. 检查 exp、nbf、iss、aud,允许几十秒的时钟偏差。
  5. 检查黑名单与令牌版本(下一节)。
  6. 把用户 ID、租户、令牌 ID 放进交换上下文,供后续的鉴权、限流使用。

前 4 步完全在本地完成,没有网络调用。第 5 步需要查 Redis,是认证环节唯一的外部依赖。

7.3 登出、强制下线与续期

操作 实现
续期 前端收到 401 且错误码为「令牌过期」时调用刷新接口。认证服务验证刷新令牌后签发新的访问令牌和新的刷新令牌,旧刷新令牌立即作废(刷新令牌轮换)
刷新令牌被盗的检测 已作废的刷新令牌再次被使用,说明它被复制了,作废同一令牌族的所有令牌,要求重新登录
登出 删除刷新令牌;把访问令牌的 jti 写入 Redis 黑名单,过期时间设为令牌的剩余有效期
强制下线(改密码、封号、管理员踢下线) 把用户的令牌版本号加一,写入 Redis:tokver:{uid}。网关校验时比较令牌中的 ver 与 Redis 中的值,不一致就拒绝
单设备登录 登录时按「用户 + 设备类型」保存当前有效的令牌族,新登录作废旧的令牌族

减少 Redis 查询:黑名单与令牌版本都需要查 Redis。网关在本地用一个短过期时间(例如 5 秒)的缓存保存用户的令牌版本号,并订阅「强制下线」事件主动失效本地缓存。这样强制下线的生效延迟在秒级,大部分请求不需要访问 Redis。

7.4 登录与注册

登录、注册由认证服务处理,网关只负责转发和入口限流。

环节 设计
密码存储 BCrypt,成本因子 10 到 12;单次校验耗时几十到上百毫秒,属于有意为之
注册 手机验证码或邮箱验证;验证码 6 位、5 分钟有效、最多尝试 5 次;同一手机号每分钟限发 1 条、每天限发 10 条
登录失败 按账号统计失败次数:3 次后要求图形验证码,5 次后锁定 15 分钟;按 IP 统计,防止用一个 IP 尝试大量账号(撞库)
错误提示 不区分「账号不存在」与「密码错误」
登录接口限流 BCrypt 校验消耗 CPU,登录接口单独设置较低的限流阈值,防止被用来消耗认证服务的计算资源

7.5 开放接口认证:AppKey 与 HMAC 签名

开放平台的调用方是服务端程序,没有用户登录。每个接入方分配 AppKey 与 AppSecret,请求按以下规则签名(思路与 AWS 的 Signature V4 相同):

规范请求 = HTTP 方法 + "\n"
         + 路径 + "\n"
         + 按参数名排序后的查询字符串 + "\n"
         + 请求体的 SHA-256 十六进制 + "\n"
         + 时间戳 + "\n"
         + nonce
签名     = Base64( HMAC-SHA256( AppSecret, 规范请求 ) )

请求头:X-App-Key、X-Timestamp(毫秒)、X-Nonce(随机字符串)、X-Signature
校验步骤 说明
应用存在且启用 应用信息缓存在网关本地,变更时由配置推送刷新
时间戳窗口 与服务器时间相差超过 5 分钟就拒绝
nonce 防重放 SET nonce:{appKey}:{nonce} 1 NX PX 300000,设置失败说明这个 nonce 在窗口期内已经用过
签名比较 用常量时间比较(MessageDigest.isEqual),防止通过响应时间差逐字节猜出签名
应用级限流与 IP 白名单 每个应用配置自己的配额和允许的来源 IP
要点 说明
请求体参与签名 网关需要读取并缓存请求体,再交给后续的转发使用(第 17 章「请求体只能读一次」)
密钥存储 AppSecret 在数据库中加密保存,只在创建时向接入方展示一次
密钥轮换 每个应用允许同时存在两把有效密钥,接入方切换完成后再禁用旧密钥
回调通知 平台回调接入方时同样签名,接入方用同一套规则校验

8. 鉴权:RBAC

8.1 数据模型

sys_user(id, username, password_hash, status, tenant_type, tenant_id, token_version)
sys_role(id, code, name, data_scope)              data_scope:本人、本租户、本租户及下级、全部
sys_permission(id, code, type, name, method, path_pattern, parent_id)
      type:菜单、按钮、接口
      接口权限示例:code = campaign:update,method = PUT,path_pattern = /api/v1/campaigns/{id}
sys_user_role(user_id, role_id)
sys_role_permission(role_id, permission_id)

tenant_type 区分平台运营、广告主、代理商三类账号;代理商账号可以管理其名下的多个广告主。

8.2 网关的鉴权流程

  1. 路由的元数据中已经有这条接口的权限码(第 5.1 节)。不配置权限码的接口只要求登录。
  2. 取出用户的权限码集合(下一节的缓存),判断是否包含该权限码。
  3. 超级管理员角色跳过检查,但请求记入审计日志。

把「路径 → 权限码」的对应关系放在路由上,而不是在每次请求时用路径去遍历所有权限点做模式匹配,鉴权就只剩一次集合查找。

8.3 权限缓存

层 内容 过期
本地缓存(Caffeine) 用户 ID → 权限码集合(不可变的 Set)与权限版本号 写入后 5 分钟,容量按活跃用户数设置
Redis 同上 30 分钟
权限服务 从数据库计算 —

失效:角色、权限、用户角色关系变更时,权限服务删除相关用户在 Redis 中的缓存,并发布一条消息(Redis Pub/Sub 或 Nacos 配置变更)。网关实例收到后清除本地缓存中对应的用户;角色权限变更影响大量用户时,直接递增全局的权限版本号,本地缓存中版本号落后的条目全部失效。

缓存穿透与并发加载:本地缓存未命中时,同一个用户的并发请求只发起一次加载(Caffeine 的异步加载缓存本身保证这一点)。

权限服务不可用时:使用本地缓存中已过期但仍保留的数据继续鉴权,并告警;完全没有缓存的用户请求返回 503,不做默认放行。鉴权失败放行(fail open)意味着任何人都能调用任何接口,代价远大于短时间的不可用。

8.4 数据权限

网关只能判断「能不能调用这个接口」,无法判断「能看哪些行」。数据权限的执行分工:

环节 职责
网关 在请求头中写入 X-Tenant-Type、X-Tenant-Id、X-Data-Scope,对 Dubbo 调用写入隐式参数
后端服务 拦截器把这些信息放进请求上下文;MyBatis 插件根据数据范围自动追加过滤条件,例如广告主账号只能查到 advertiser_id = 当前租户 的数据,代理商账号只能查到名下广告主的数据

数据权限在 SQL 层统一追加,而不是依赖每个业务方法自己加条件,避免遗漏。

8.5 身份信息的传递与防伪造

措施 说明
删除外部传入的内部头 所有 X-User-*、X-Tenant-* 请求头在入口处一律删除,再由网关写入可信的值
后端只接受网关的流量 后端服务不对外暴露端口;通过网络策略或安全组只允许网关访问
内部签名(可选) 网关对身份头计算 HMAC 并附加签名头,后端校验,防止内网中的其他服务伪造身份。安全要求高时使用网关到服务的 mTLS

9. 流量治理

9.1 限流

保护性限流与配额性限流:限流按目的分为两类,实现方式不同。

保护性限流 配额性限流
目的 保护后端容量,抵御攻击和爬虫 履行对接入方承诺的配额,可能涉及计费
精度要求 近似即可,越早拦截越好 全集群精确;可能有按天、按月的长周期配额
实现 Sentinel 单机限流,阈值按网关实例数平分 Redis 令牌桶,以及 Redis 计数器
外部依赖 无,计数在网关实例本地完成,延迟最低 Redis

分层:

层 维度 位置 目的 实现
1 客户端 IP 边缘层 + 网关入口 抵御攻击与爬虫,在认证之前生效 边缘层 Nginx 的 limit_req;网关中用 Sentinel 按客户端 IP 单机限流
2 用户 认证之后 防止单个用户的脚本占满资源 Sentinel 热点参数限流(按用户 ID,单机、近似);要求精确时改用 Redis 令牌桶
3 开放应用 签名校验之后 按接入方的配额,超出返回 429 Redis 令牌桶,按天、按月的配额用 Redis 计数器
4 接口 路由级 保护后端单个接口的处理能力 Sentinel 单机限流
5 全局 集群级 保护后端整体的容量 Sentinel 单机限流;要求集群级精确时用 Sentinel 集群流控

Sentinel 单机限流:通过 Sentinel 的 Spring Cloud Gateway 适配模块配置 GatewayFlowRule,规则按路由或自定义的 API 分组生效,并可以按客户端 IP、请求头、URL 参数、Cookie、Host 分别计数(底层是热点参数限流)。计数是每个网关实例本地的滑动窗口,阈值 100 QPS、10 个网关实例时,集群放行约 1000 QPS。

注意点 做法
阈值随实例数变化 规则中配置的是单实例阈值 = 总阈值 / 网关实例数。管理后台从注册中心获取实例数,扩缩容后重新计算并通过 Nacos 推送规则
流量在实例间不均 长连接、按哈希的负载均衡使部分实例的流量偏多,这些实例先触发限流。阈值留出余量,或改用集群流控
高基数维度 热点参数限流只缓存访问最多的一部分参数值,每个实例各自计数;按用户的精确配额用 Redis
集群流控 部署 Token Server,各网关实例向它申请令牌,集群总量精确;Token Server 不可用时退化为单机限流。网关规则对集群模式的支持情况以所用 Sentinel 版本的文档为准

Redis 令牌桶:开放应用的配额直接使用 Spring Cloud Gateway 内置的 RedisRateLimiter。它是 Redis + Lua 实现的令牌桶,支持每个请求消耗多个令牌,自定义 KeyResolver 按 AppKey 取键。下面的脚本展示令牌桶的原理:状态保存在 Redis,用 Lua 脚本原子地完成「按时间补充令牌、判断、扣减」。需要同时检查多个桶、或与本地预取配额配合时,按这个思路自行实现。

-- KEYS[1]:桶的键   ARGV:容量、每秒补充的令牌数、本次需要的令牌数
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)   -- 使用 Redis 服务器的时间,避免各网关实例的时钟偏差
local cap, rate, need = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or cap
local ts = tonumber(b[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate / 1000)
local allowed = tokens >= need
if allowed then tokens = tokens - need end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000) * 2)
return {allowed and 1 or 0, math.floor(tokens)}

脚本中调用 TIME 这类非确定性命令后再写入,需要 Redis 5.0 及以上(默认采用效果复制)。按天、按月的配额用 INCR 计数器,键中带上日期或月份,过期时间设为周期结束之后。

减少对 Redis 的压力:调用量大的接入方的配额键是热点键,每个请求都访问会集中打到 Redis 的一个分片上。

做法 说明
本地预取配额 每个网关实例一次从 Redis 取一批令牌(例如 100 个)放在本地消耗,用完再取。代价是精度略有下降
键分散 不同应用、不同用户的键天然分散到不同分片,不需要处理

响应:返回 429,并带上 Retry-After 头;可以附带剩余配额头,便于调用方自我调节。

Redis 不可用时:保护性限流由 Sentinel 在本地执行,不受影响;配额性限流退化为本地限流(每个实例按「配额 / 实例数」限流),而不是完全放行或完全拒绝。

9.2 熔断与上游隔离

一个上游变慢,会让网关为它积累大量等待中的请求和连接,最终拖垮网关自身,进而影响所有其他上游。Envoy 和 Zuul 2 都把按上游的并发上限作为最基础的保护。

机制 设计
上游并发上限 每个上游设置最大并发请求数,超过直接返回 503。Envoy 的熔断阈值包括最大连接数、最大排队请求数、最大并发请求数、最大并发重试数,默认分别为 1024、1024、1024、3
熔断 用 Sentinel(提供 Spring Cloud Gateway 适配,可以按路由和自定义的 API 分组配置规则):慢调用比例或异常比例超过阈值时熔断,一段时间后进入半开状态试探
离群检测 第 5.4 节:摘除持续出错的单个实例,而不是熔断整个上游
隔离的粒度 按上游服务、按路由。不要所有上游共享一个全局的并发池

熔断状态与规则存放在 Nacos,由 Sentinel 控制台修改并推送。

9.3 超时与重试

超时:

超时 默认值(示例) 说明
连接超时 1 秒 内网建立连接通常在毫秒级,超时说明目标不可达
路由响应超时 按路由配置,默认 3 秒 按接口的 P99 耗时加余量设置
整体截止时间 客户端可以通过请求头告知 网关把剩余时间写入向下游传递的请求头(gRPC 中是 grpc-timeout,HTTP 中可自定义),下游据此放弃已经没有意义的工作

重试:

规则 说明
只重试幂等请求 默认只重试 GET、HEAD;POST 只有在请求带有幂等键、后端保证幂等时才重试
只重试可重试的错误 连接失败、连接被重置、503;超时不重试,因为请求可能已经在执行
换实例重试 重试时选择另一个实例
退避加随机抖动 避免所有重试同时到达
重试预算 重试请求数不超过正常请求数的一定比例(例如 10% 到 20%),Envoy 的重试预算默认是 20%。上游大面积故障时,每个请求都重试 2 次会把流量放大到 3 倍,加速它的崩溃;有了预算,重试量被自动压低

9.4 过载保护

固定阈值的限流需要人工估计容量,容量随版本、机器、依赖变化时就会失准。网关自身还需要自适应的过载保护。

机制 说明
自适应并发限制 参考 Netflix 的 concurrency-limits 和 Envoy 的自适应并发过滤器:持续测量请求延迟,延迟上升说明开始排队,就自动降低允许的并发数;延迟恢复后再逐步提高。依据是排队论中的利特尔定律:并发数 = 吞吐量 × 延迟
按优先级丢弃 参考 Google SRE 的请求重要性分级:路由标记为关键(下单、支付、登录)、普通、可丢弃(统计上报、推荐)。过载时先拒绝可丢弃的,最后才拒绝关键的
排队超时 请求在网关排队等待的时间超过它的截止时间就直接丢弃,不再转发一个已经不会有人接收结果的请求
系统指标保护 网关实例的 CPU、事件循环延迟、堆外内存超过阈值时,拒绝新请求并返回 503,让负载均衡把流量转到其他实例

9.5 降级

降级方式 示例
返回默认值 推荐接口熔断时返回空列表或热门列表
返回缓存 配置类、字典类接口在后端不可用时返回最近一次的成功结果
功能开关 管理后台一键关闭某些非核心路由,返回统一的「功能暂不可用」
静态页面 大促期间对某些页面直接返回静态内容

降级逻辑配置在路由上,由网关执行;需要业务判断的降级仍由后端实现。


10. 灰度发布与流量调度

10.1 灰度规则

规则 示例 特点
请求头 X-Gray: true 测试人员手动验证
用户白名单 内部员工、指定的广告主 先给可控的用户使用
按用户哈希的比例 hash(userId) % 100 < 5 同一用户始终落在同一侧,体验一致
地域、客户端版本 某个城市、App 版本号大于等于某个值 按维度逐步放开

规则按路由或按服务配置,存放在 Nacos,可以随时调整比例。

10.2 实现

flowchart LR
    REQ[请求] --> RULE[灰度过滤器<br/>按规则计算灰度标记]
    RULE --> HDR[写入请求头 X-Gray-Tag=v2]
    HDR --> LB[自定义负载均衡器<br/>只在元数据 version=v2 的实例中选择]
    LB --> SVC[服务 A v2]
    SVC -- Feign / Dubbo 拦截器继续传递标记 --> SVC2[服务 B:有 v2 实例走 v2,否则走正常版本]
要点 说明
实例打标签 灰度实例在 Nacos 的元数据中注册 version=v2
负载均衡 自定义 ReactorServiceInstanceLoadBalancer,根据请求中的灰度标记过滤实例;没有灰度实例时退回到正常实例
全链路传递 网关写入的标记由后端服务的 Feign、Dubbo 拦截器继续传递,下游的负载均衡按同样的规则选择实例;消息队列在消息属性中携带标记
观测与回滚 按灰度标记拆分监控指标,对比新旧版本的错误率、延迟;指标异常时把比例调回 0,即可回滚

10.3 蓝绿发布

线上同时保留两套完整的环境:蓝环境运行当前版本,承接全部流量;绿环境部署新版本,不承接用户流量。绿环境验证通过后,网关把流量一次性从蓝切到绿;新版本出问题时再切回蓝,回滚只需要几秒。切换后蓝环境保留一段时间,作为回滚的退路,下一次发布时它变成部署新版本的那一套。

flowchart LR
    subgraph 切换前
        G1[网关] -- 100% --> B1[蓝:v1]
        G1 -. 0%,内部验证 .-> N1[绿:v2]
    end
    subgraph 切换后
        G2[网关] -. 0%,保留用于回滚 .-> B2[蓝:v1]
        G2 -- 100% --> N2[绿:v2]
    end

与灰度发布(金丝雀发布)的对比:

蓝绿发布 灰度发布
流量切换 全量一次切换 按比例或按用户逐步放大
新版本出问题时影响的范围 切换后所有用户 只有灰度范围内的用户
回滚 切回蓝环境,秒级 把灰度比例调回 0,秒级
资源 发布期间需要两倍的实例 只需要少量灰度实例
新旧版本同时服务用户 不会,同一时刻只有一个版本承接流量 会,要求新旧版本的接口和数据互相兼容
适用 变更较大、希望新旧版本不混跑的发布;验证主要在切换前完成 日常发布,用小范围的真实流量发现问题

网关中的实现:两套实例在注册中心的元数据中分别标记 env=blue、env=green,网关的路由配置中记录当前生效的颜色;切换就是在 Nacos 中修改这一项配置,网关监听到变更后,负载均衡器只在对应颜色的实例中选择。切换前用请求头(例如 X-Env: green)把测试流量导入绿环境做验证,复用第 10.2 节的标记路由。

注意点:

问题 做法
数据库只有一套 表结构变更必须向前兼容,新旧版本都能读写:先加字段、新旧版本都不依赖被删除的字段,回滚窗口结束后再清理旧字段
切换时的在途请求 蓝环境处理完已经接收的请求后再下线,长连接要等连接迁移完
缓存、消息消费者 绿环境的消费者在切换前不能消费线上消息,或者使用独立的消费组,避免两套版本同时处理同一条消息

10.4 流量镜像

把线上请求复制一份发给新版本,新版本的响应直接丢弃,不影响用户。用于验证重构后的服务在真实流量下的正确性和性能。只镜像读请求,或者让新版本连接隔离的数据存储,避免写入重复数据。


11. 安全

11.1 请求层面

措施 说明
大小与格式限制 请求头、请求体、URL 长度上限;拒绝不合法的字符和编码
路径规范化 先规范化路径(解码、去掉 ..、合并重复的 /)再做路由匹配和鉴权,防止 /api/admin/../public 这类写法绕过鉴权规则
跨域 在网关统一配置,后端不再配置,避免出现重复的响应头;允许携带凭据时,来源必须写成具体的域名
安全响应头 见下表
内部响应头 删除后端返回的 Server、X-Powered-By 和内部调试信息
WAF SQL 注入、XSS 的特征检测放在边缘层或云 WAF,业务网关只做基础的格式校验

安全响应头:这些头指示浏览器采取更严格的行为,对浏览器访问的页面和接口生效。

响应头 典型取值 作用 防御的攻击
Strict-Transport-Security(HSTS,RFC 6797) max-age=31536000; includeSubDomains 浏览器在 max-age 秒内记住:访问这个域名只用 HTTPS,输入 http:// 也在本地直接改为 https://,证书错误时不允许用户点击继续 SSL 剥离:中间人拦截第一个 HTTP 请求,让用户一直停留在 HTTP 上。浏览器只在 HTTPS 响应中接受这个头,第一次访问前的保护依赖 HSTS 预加载列表(加上 preload 并向浏览器提交域名)。includeSubDomains 会让所有子域名都必须支持 HTTPS
X-Content-Type-Options nosniff 禁止浏览器根据内容猜测类型(MIME 嗅探),严格按 Content-Type 处理;类型不符的脚本和样式表直接拒绝加载 用户上传的文件内容是 JavaScript,以 text/plain 或图片类型返回,被浏览器猜测为脚本执行,造成 XSS
X-Frame-Options DENY 或 SAMEORIGIN 禁止页面被其他网站用 <iframe> 嵌入(SAMEORIGIN 只允许同源嵌入) 点击劫持:恶意网站把你的页面放进透明的 iframe,诱导用户点击其中的「确认」「删除」按钮。新的写法是 CSP 的 frame-ancestors 指令,两者同时设置以兼容旧浏览器

配套的还有:Content-Security-Policy(限制脚本、样式等资源的来源,是防 XSS 的主要响应头)、Referrer-Policy(控制跳转时 Referer 头中带出多少 URL 信息,避免把带参数的 URL 泄露给第三方)。X-XSS-Protection 已被主流浏览器移除,设置为 0 或者不设置。

11.2 真实客户端 IP

经过多层代理后,X-Forwarded-For 中有多个 IP,最左边的值可以被客户端伪造。只信任已知代理追加的部分:从右往左数,跳过可信代理(边缘层、负载均衡)的数量,取到的第一个 IP 才是真实的客户端 IP。Spring Cloud Gateway 的 XForwardedRemoteAddressResolver.maxTrustedIndex 就是按这个规则配置的。IP 黑白名单、按 IP 的限流、审计日志都依赖这个值,取错了就会被轻易绕过。

X-Forwarded-For 之外的获取方式:

方式 原理 适用
TCP 连接的源地址 套接字对端的地址,无法伪造,但只是上一跳的地址 客户端直连网关时;有代理时它是代理的地址,用来判断上一跳是否为可信代理
X-Real-IP Nginx 的惯例,由第一层代理写入单个 IP(proxy_set_header X-Real-IP $remote_addr),后面的代理原样透传 只有一层可信代理时最简单;第一层代理必须覆盖客户端自带的同名头
Forwarded(RFC 7239) 标准化的头,格式为 Forwarded: for=203.0.113.7;proto=https;by=10.0.0.1,多跳用逗号分隔 标准写法,信任规则与 X-Forwarded-For 相同;实际使用的组件少于 X-Forwarded-For
PROXY 协议 四层负载均衡在 TCP 连接建立后、应用数据之前,先发送一行(v1 为文本)或一个二进制头(v2),写明原始的源地址和端口 四层负载均衡(HAProxy、AWS NLB、云厂商的四层 SLB)不解析 HTTP,无法添加请求头时使用;接收方必须显式开启,否则会把这行数据当作请求内容
TOA 部分云厂商的四层负载均衡在 FullNAT 模式下把客户端地址放进 TCP 选项,后端机器加载内核模块后读取 依赖特定云厂商的内核模块,实现相关
CDN 专有头 CDN 节点写入客户端地址,例如 Cloudflare 的 CF-Connecting-IP,部分 CDN 使用 True-Client-IP 前面有 CDN 时;只在请求来自 CDN 的回源地址段时才信任

所有基于请求头的方式都遵循同一个原则:头的值只有在由可信代理写入或覆盖时才可信。边缘层对来自公网的请求,删除或覆盖客户端自带的这些头;网关根据 TCP 源地址判断上一跳是否属于可信代理,再决定是否采信。Nginx 的 realip 模块(set_real_ip_from 声明可信代理的地址段,real_ip_header 指定读取哪个头,real_ip_recursive on 从右往左跳过可信代理)实现的就是这个规则。

11.3 其他

措施 说明
上游白名单 路由的目标只能是注册中心里的服务或白名单中的地址,防止通过配置把请求转发到任意内网地址(SSRF)
管理接口隔离 网关的管理端点、Actuator 只在内网的管理端口上开放,并要求认证
日志脱敏 访问日志中的手机号、身份证号、令牌、签名、AppSecret 在记录前掩码
动态黑名单 风控系统识别出的恶意 IP、设备、账号,通过配置推送实时生效

OWASP API Security Top 10 列出了 API 最常见的风险(失效的对象级授权、失效的认证、资源消耗不受限制等),可以作为安全评审的清单。


12. 可观测性

12.1 访问日志

字段 说明
时间、请求 ID、链路 ID 关联同一个请求的所有日志
客户端 IP、User-Agent、用户 ID、开放应用 ID 谁发起的
方法、路径模板、路由 ID、上游、上游实例 去了哪里
状态码、网关错误码、拒绝原因 结果。拒绝原因区分限流、熔断、鉴权失败、超时
总耗时、上游耗时、网关自身耗时 定位慢在哪一段
请求和响应的字节数 流量统计
灰度标记、路由快照版本 排查灰度和配置问题

异步写入 Kafka:日志先放进一个有界的内存队列,由后台线程批量发送给 Kafka。队列满时丢弃日志并计数,绝不阻塞请求;Kafka 不可用时退化为写本地文件,由采集程序补发。日志在 Kafka 中再由下游消费,写入 Elasticsearch、ClickHouse 用于检索与分析。

12.2 指标

用 Micrometer 暴露 Prometheus 指标。

指标 类型 标签
请求数 Counter 路由 ID、方法、状态码分类(2xx、4xx、5xx)
请求耗时 Histogram 路由 ID
上游耗时 Histogram 上游服务
被拒绝的请求数 Counter 原因:限流、熔断、鉴权、认证、过载
当前并发数 Gauge 上游服务
连接池使用情况 Gauge 上游服务
事件循环延迟、堆外内存 Gauge 实例
配置快照版本 Gauge 实例

标签的基数:标签用路由 ID 和路径模板,不能用原始路径(带 ID 的路径有无数种取值)、用户 ID、IP,否则时间序列数量会爆炸。

告警:按路由的错误率与 P99 延迟、被拒绝请求的突增、某个上游的熔断、配置版本在实例之间不一致、Redis 和 Nacos 的连接异常。

12.3 链路追踪

要点 说明
入口生成 没有 traceparent 头时由网关生成,并在响应头中返回请求 ID,方便用户反馈问题时提供
向下传递 HTTP 上游通过 traceparent 头;Dubbo 上游通过隐式参数
响应式上下文 Reactor 中不能依赖 ThreadLocal,因为一个请求的处理会在多个线程之间切换。使用 Micrometer Tracing 与 context-propagation 库,在 Reactor 的上下文和日志的 MDC 之间自动传递链路信息
采样 正常请求按比例采样,出错和慢请求全部保留

13. 控制面与配置管理

功能 说明
路由管理 创建、修改、下线路由;路径冲突检测;从 OpenAPI 文档或 Dubbo 接口元数据导入
策略管理 限流、熔断、超时、重试、灰度规则
开放应用管理 创建应用、分配与轮换密钥、配额、IP 白名单
权限点管理 与路由关联的接口权限码
发布流程 草稿 → 校验 → 审批 → 灰度发布到部分网关实例 → 全量;任意版本一键回滚
审计 每次变更记录操作人、时间、前后差异
管理后台自身的权限 管理后台同样使用 RBAC,路由、权限的修改需要不同的角色

配置下发的一致性:所有网关实例从 Nacos 订阅同一份快照。实例在指标中上报当前生效的版本号,控制面汇总后能看到是否所有实例都已生效,长时间不一致时告警。


14. 性能与容量

14.1 响应式模型的规则

响应式编程是异步、非阻塞的写法:代码描述「结果到达后做什么」,而不是调用后等待结果,等待期间线程去处理其他连接。

阻塞式(Spring MVC + Tomcat) 响应式(Spring WebFlux + Reactor Netty)
线程模型 每个请求占用一个线程,直到处理结束 少量事件循环线程处理所有连接
等待 I/O 时 线程阻塞 线程登记回调后处理其他连接;I/O 完成后在事件循环上执行后续步骤
并发能力 受线程数限制,增加线程会增加内存与上下文切换的开销 少量线程维持数万个连接,适合网关这类以等待下游为主的场景
写法 顺序代码 Mono(0~1 个元素)、Flux(0~N 个元素)加操作符组成的处理链,订阅后才执行;下游通过 request(n) 实现背压(Reactive Streams 规范)
// 阻塞式
User u = userRepo.findById(id);
Order o = orderClient.latest(u.getId());
return new View(u, o);

// 响应式:方法立即返回 Mono,结果到达后依次执行后续步骤
return userRepo.findById(id)
        .flatMap(u -> orderClient.latest(u.getId()).map(o -> new View(u, o)));

Spring Cloud Gateway 运行在 Reactor Netty 上,少量的事件循环线程(Reactor Netty 默认取 CPU 核数,最少 4 个,由系统属性 reactor.netty.ioWorkerCount 调整;实现相关,可能随版本变化)处理所有连接。一个请求里如果有一次阻塞调用,这个线程上的所有连接都会停顿。

规则 说明
不在过滤器中阻塞 禁止使用同步的 Redis 客户端、JDBC、同步的 HTTP 客户端、Thread.sleep、block()
外部调用一律异步 Redis 用 Lettuce 的响应式接口(ReactiveRedisTemplate),Dubbo 用 $invokeAsync,HTTP 用 WebClient
必须阻塞时切换线程 用 subscribeOn(Schedulers.boundedElastic()) 把阻塞操作放到专门的线程池,并限制其并发量
测试中检测阻塞 引入 BlockHound,单元测试中一旦在事件循环线程上发生阻塞调用就失败

14.2 内存

要点 说明
不缓存整个请求体 默认流式转发;只有签名校验、需要改写请求体的路由才缓存,并受大小上限约束
释放 DataBuffer 自己读取请求体或响应体时,用完要调用 DataBufferUtils.release,否则会泄漏 Netty 的池化内存(多在堆外)。测试环境开启 -Dio.netty.leakDetection.level=paranoid
堆外内存上限 设置 -XX:MaxDirectMemorySize,并在容器内存限制中为它留出空间
GC G1 或分代 ZGC;网关对象生命周期短,绝大部分在新生代回收

14.3 容量估算

以文首表格中的规模假设为例:峰值 2 万 QPS。假设压测得到单个 8 核实例在 P99 延迟满足要求时能稳定处理 4000 QPS(这个数字只用于说明方法,实际要以压测为准):

需要的实例数 = 峰值 QPS / 单实例容量 / 目标利用率 = 20000 / 4000 / 0.6 ≈ 9
再加上 N+2 冗余(同时故障或发布时仍有余量),部署 11 个实例,分布在至少 2 个可用区

压测要覆盖:纯转发、带认证和鉴权、带限流(Sentinel 本地限流、访问 Redis 的配额限流)、HTTP 转 Dubbo、大请求体,以及上游变慢时网关自身的表现(是否会被拖垮)。关注 P99、P999 延迟与错误率,而不只是平均吞吐。


15. 高可用与容灾

15.1 部署

要点 说明
无状态、多实例、多可用区 任意实例故障时由负载均衡摘除
优雅下线 先从负载均衡摘除,等待进行中的请求完成,再关闭(server.shutdown=graceful)
启动预热 预先创建 Dubbo 引用、加载路由与权限缓存,就绪探针通过后才接收流量
网关自身的发布 滚动发布,先发布一个实例观察,再逐批发布

15.2 依赖故障时的行为

每个外部依赖都要事先规定故障时的行为。原则是:与安全相关的检查失败时拒绝(fail closed),非关键的功能失败时退化(degrade)。

依赖故障 网关的行为
Redis 不可用 保护性限流由 Sentinel 在本地执行,不受影响;配额限流退化为本地限流;令牌黑名单与令牌版本使用本地缓存中的值,新的强制下线暂时无法生效;nonce 无法校验时,开放接口拒绝请求(防重放属于安全检查)
Nacos 配置中心不可用 继续使用内存中的配置;重启时从本地快照加载
Nacos 注册中心不可用 使用缓存的实例列表继续转发
权限服务不可用 使用本地缓存(包括已过期的条目);没有缓存的用户返回 503
认证服务不可用 已有的访问令牌照常校验(本地验签),登录和刷新令牌失败
Kafka 不可用 访问日志写本地文件,恢复后补发
某个上游不可用 熔断与降级,不影响其他上游

15.3 演练

定期在预发或生产环境中注入故障:停掉 Redis、断开 Nacos、让某个上游变慢、杀掉部分网关实例,验证上表的行为与告警都符合预期。


16. 测试与发布

测试 内容
单元测试 每个过滤器用 WebTestClient 或 MockServerWebExchange 测试;路由匹配、签名计算、限流脚本单独测试
集成测试 用 Testcontainers 启动 Redis、Nacos;用 WireMock 模拟 HTTP 上游,用测试用的 Dubbo 提供者模拟 Dubbo 上游
安全测试 伪造身份头、篡改签名、重放请求、路径穿越、超大请求、alg: none 的令牌
性能测试 见第 14.3 节
配置回归 每次发布路由快照前,用一组固定的请求样本验证路由结果,防止新路由意外覆盖已有的路由

17. 常见问题与踩坑

问题 原因 处理
偶发 Connection reset、Connection prematurely closed 网关连接池中的连接空闲时间超过了上游服务端的空闲超时,被上游关闭 连接的最大空闲时间设得比上游短;开启后台清理;对幂等请求在这类错误上重试一次
某个上游变慢后整个网关变慢 过滤器中有阻塞调用;或没有按上游限制并发 BlockHound 检查;按上游的并发上限与熔断
请求体只能读一次 请求体是一个流,签名校验读取之后,转发时就没有内容了 读取后缓存(ServerWebExchangeUtils.cacheRequestBody),用缓存的内容构造新的请求
堆外内存持续增长 自行读取 DataBuffer 后没有释放 用完调用 DataBufferUtils.release;开启泄漏检测
配额限流不准或 Redis 被打满 大接入方的配额键是热点;各实例时钟不一致 本地预取配额;在 Lua 脚本中使用 Redis 的时间
扩缩容后限流的总量变了 Sentinel 单机限流的阈值按实例配置 按实例数重新计算并推送规则;要求精确时用集群流控
鉴权被绕过 路径没有规范化;或信任了客户端传来的身份头 先规范化再匹配;入口处删除所有内部头
限流误伤一大批用户 按 IP 限流时,大量用户共用公司或运营商的出口 IP 认证后的接口按用户限流;IP 限流阈值设宽
跨域响应头重复 网关和后端都配置了跨域 只在网关配置
链路 ID 在日志中丢失 响应式代码中线程切换,ThreadLocal 与 MDC 不再有效 使用 context-propagation
刚扩容的上游实例被打挂 新实例 JIT 和缓存都是冷的,却立即承担满额流量 实例预热:权重逐步增加
签名校验失败率高 调用方服务器时钟不准;参数排序或编码规则理解不一致 时间窗口适当放宽;提供多语言的 SDK 与签名示例
重试导致数据重复 对写请求做了重试;或 Dubbo 默认的 Failover 重试 写请求不重试,Dubbo 路由 retries=0;后端用幂等键
路由变更后部分实例行为不一致 某些实例没有收到推送,或校验失败保留了旧配置 指标中暴露配置版本,版本不一致时告警

18. 面试时怎么讲这个项目

18.1 一段话介绍

我们在广告投放平台和运营后台前面做了一个统一的 API 网关,基于 Spring Cloud Gateway,部署在边缘的 Nginx 和后端服务之间。路由存放在 Nacos 里动态下发,后端核心服务是 Dubbo 接口,网关通过泛化调用把 HTTP 转成 Dubbo,新增接口只需要在后台配置一条路由,不用发布网关。认证方面,登录后签发 RS256 的 JWT 访问令牌和存放在 Redis 里的刷新令牌,支持刷新令牌轮换、登出黑名单和按令牌版本号强制下线;开放平台的接入方用 AppKey 加 HMAC 签名,配合时间戳和 nonce 防重放。鉴权是 RBAC,接口权限码配置在路由上,用户的权限集合在本地 Caffeine 和 Redis 两级缓存,变更时通过消息广播失效;数据权限由网关传递租户和数据范围,后端用 MyBatis 插件按广告主、代理商过滤。流量治理上,限流分两类:保护后端容量的 IP、接口、全局限流用 Sentinel 单机限流,阈值按网关实例数平分,不依赖外部组件;开放应用的配额要求全集群精确,用 Redis + Lua 的令牌桶,也就是 Spring Cloud Gateway 的 RedisRateLimiter。Sentinel 同时按路由熔断,每个上游有并发上限,重试只针对幂等请求并有重试预算。灰度按用户哈希比例或白名单,自定义负载均衡器按 Nacos 元数据选择实例,灰度标记全链路传递。访问日志异步批量写 Kafka,指标暴露给 Prometheus,链路 ID 用 W3C 的 traceparent 传递到 Dubbo。

18.2 常见追问

追问 回答要点
为什么选 Spring Cloud Gateway 而不是 APISIX、Kong 网关里有 HTTP 转 Dubbo、RBAC 权限数据、租户隔离这些和 Java 业务生态紧密相关的逻辑,用 Java 开发维护成本最低;TLS 和大流量防护交给前面的 Nginx
网关的性能瓶颈在哪里 认证鉴权和配额限流中的 Redis 访问、HTTP 转 Dubbo 的序列化转换、路由匹配。分别用本地缓存、预取配额、路由索引优化
过滤器里能不能同步查数据库 不能,会阻塞事件循环;所有外部调用都异步,必须阻塞的放到 boundedElastic 线程池
JWT 怎么做到主动失效 短期的访问令牌 + 黑名单 + 令牌版本号 + 刷新令牌轮换(第 7.3 节)
Redis 挂了网关怎么办 第 15.2 节的退化表:保护性限流本来就在本地,配额限流退化为本地,安全相关的检查拒绝请求
怎么保证路由变更不出事故 校验、审批、灰度到部分实例、版本号对比、一键回滚、配置回归样本
限流的数值怎么定 先压测得到后端的容量,按容量的一定比例设置;再配合自适应并发限制应对容量的变化
Sentinel 已经能限流,为什么还要 Redis 令牌桶 Sentinel 默认单机计数,适合保护性限流,阈值按实例数平分,不依赖外部组件;开放应用的配额要求全集群精确、有按天按月的周期,用 Redis 令牌桶和计数器。Sentinel 集群流控也能做到集群精确,但要多维护一个 Token Server
一个上游拖垮网关怎么防 按上游的并发上限、连接池上限、超时、熔断,不共享全局资源
HTTP 转 Dubbo 有哪些坑 引用要缓存和预热;必须异步调用;写接口关闭 Dubbo 的重试;返回值里的 class 字段要去掉
网关自己怎么发布 滚动发布、优雅下线、启动预热、就绪探针

19. 参考资料

资料 内容
Envoy 官方文档,Architecture overview(envoyproxy.io/docs) 监听器与过滤器链、集群、xDS;熔断阈值(Circuit breaking)、离群检测(Outlier detection)、恐慌阈值、重试与重试预算、自适应并发
Apache APISIX 官方文档(apisix.apache.org/docs) 基于 etcd 的配置推送、基数树路由、插件的执行阶段与热加载
Kong 官方文档(docs.konghq.com) 插件的执行阶段、无数据库的声明式配置
Spring Cloud Gateway 参考文档(docs.spring.io/spring-cloud-gateway) 路由、断言、过滤器、RedisRateLimiter、HTTP 客户端与连接池配置
Netflix 技术博客,Zuul 2: The Netflix Journey to Asynchronous, Non-Blocking Systems(2016) 从阻塞改为异步的收益与代价、连接管理、上游并发保护
Netflix/concurrency-limits(GitHub) 基于延迟的自适应并发限制算法
Google《Site Reliability Engineering》第 21、22 章:Handling Overload、Addressing Cascading Failures 客户端自适应限流、请求重要性分级、截止时间传递、重试的放大效应
OWASP API Security Top 10 API 安全风险清单
RFC 7519(JWT)、RFC 7515(JWS)、RFC 6749(OAuth 2.0)、RFC 7636(PKCE) 令牌与授权相关的标准
RFC 6585 HTTP 429 状态码
AWS Signature Version 4 文档 规范请求与签名的计算方式,开放接口签名的参考
Apache Dubbo 官方文档,泛化调用 GenericService、$invokeAsync、引用配置的缓存
Alibaba Sentinel 官方文档,网关流控、集群流控 Spring Cloud Gateway 适配、按路由与 API 分组的规则、Token Server