生产级 API 网关设计
生产级 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,在路由上声明。
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:
upstream:
type: dubbo # http | dubbo
interface: com.example.order.OrderService
method: createOrder
version: 1.0.0
group: default
paramTypes:
paramBinding: body # 参数如何从 HTTP 请求中组装,见第 6.2 节
meta:
loginRequired: true
permission: order:create # RBAC 权限码
rateLimit:
timeoutMs: 800
retry: # 下单不是幂等的,不重试
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
| 要点 | 说明 |
|---|---|
| 引用缓存 | 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 网关的校验步骤
- 从
Authorization: Bearer中取出令牌。 - 解析令牌头,
alg必须是 RS256,拒绝none和对称算法,防止算法混淆攻击。 - 按
kid找到公钥验签。 - 检查
exp、nbf、iss、aud,允许几十秒的时钟偏差。 - 检查黑名单与令牌版本(下一节)。
- 把用户 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 网关的鉴权流程
- 路由的元数据中已经有这条接口的权限码(第 5.1 节)。不配置权限码的接口只要求登录。
- 取出用户的权限码集合(下一节的缓存),判断是否包含该权限码。
- 超级管理员角色跳过检查,但请求记入审计日志。
把「路径 → 权限码」的对应关系放在路由上,而不是在每次请求时用路径去遍历所有权限点做模式匹配,鉴权就只剩一次集合查找。
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 ;
Order o ;
return ;
// 响应式:方法立即返回 Mono,结果到达后依次执行后续步骤
return userRepo.
.;
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 |
暂无评论,欢迎留下第一条评论。