API 网关设计与实现

API GATEWAY DESIGN & IMPLEMENTATION

API 网关
设计与实现

从一次请求,到一套可验证的网关设计。
以 ShenYu 为主线,读懂六种网关的工程取舍。

插件链与路由限流与熔断动态配置
开始阅读 ↗
《API 网关设计与实现》封面
源码分析固定在 ShenYu v2.6.0;教学模型与生产实现明确区分。涵盖 Spring Cloud Gateway、Kong、APISIX、Higress 与 Envoy Gateway,附可运行实验。

前言:从一次请求走进网关

API 网关最容易被理解成一个转发地址,也最容易因为这种理解而被低估。一个订单请求从客户端到服务,途中涉及路由、认证、流量治理、连接复用、超时传播、配置发布与监控。任何一个环节都可能把局部故障扩大成系统故障。本书讨论的核心问题是:怎样把这些能力组织成一个可解释、可测试、可演进的请求处理系统。

本书以 Apache ShenYu 为主线。先理解插件链怎样组织处理过程,再理解选择器和规则怎样决定策略范围,然后研究限流、熔断与配置同步。最后把这些问题放进 Spring Cloud Gateway、Kong、Apache APISIX、Higress 与 Envoy Gateway 的架构中比较。连同 ShenYu,一共六种网关。比较围绕控制模型、扩展边界与运维成本展开,不给出脱离环境的性能冠军。

目标读者是有 HTTP、Java 或后端开发经验,希望设计网关、阅读源码或完成技术选型的工程师。阅读 Java 代码需要理解接口、并发和 Reactor 的基本思想;其余章节尽量不依赖某一门语言。没有 Kubernetes 经验的读者可以先完成前十章,再阅读 Gateway API 部分。

贯穿全书的系统是一家虚构的电商平台:订单服务、库存服务、支付服务,以及由合作伙伴调用的开放 API。平台希望将身份校验、租户配额、灰度路由和观测统一起来。示例中的域名与租户均为虚构。

版本、证据与阅读约定

ShenYu 源码分析固定在 v2.6.0 标签;这不是“最新版本”的声明。书中核对了 ShenyuWebHandler 与 AbstractShenyuPlugin 的实际实现,并在参考资料中给出固定链接。其他产品按各自官方文档解释稳定的架构概念;其配置键、发行包、插件可用性和 Gateway API 支持范围会随版本变化。采用前应锁定版本与对应文档,不能把本书的架构比较理解成跨版本兼容承诺。

标记为“教学模型”的代码用于揭示不变量,不等同于任何项目的源码;标记为“配置示意”的片段不能直接当作部署文件。附录实验使用 Python 标准库,可离线运行。它验证匹配、限流、熔断、配置版本等核心语义,不替代真实网关的集成测试或生产压测。正文中的容量数值均为推导示例,没有声称完成六种产品的基准测试。

阅读路线可以有两条。开发者按顺序阅读,最后完成实验;架构师可先读第 1、10、16、17 章,再回到源码章节验证选型依据。每章末尾的思考题在附录提供参考答案。

第 1 章 网关边界与请求生命周期

1.1 先确定网关替谁解决问题

北向网关承接终端、合作伙伴或公网流量,处理统一入口、认证、配额和协议适配。东西向流量则发生在服务之间,常由客户端 SDK、服务网格或内部负载均衡器治理。两者可以共享基础设施,但信任边界和故障半径不同。把所有内部调用都绕到同一北向网关,会增加网络跳数和集中故障风险。

网关适合做跨服务、可复用且不需要深入业务事务的处理。例如验证令牌签名、提取可信租户、限制某个租户的请求速率。检查订单是否属于当前用户仍需业务服务完成,因为资源所有权需要业务数据。网关的认证结果不能代替服务的对象级授权。

我们把一次请求拆成接入、规范化、匹配、鉴权、治理、转发和响应七个阶段。实际实现可能重排某些阶段,但必须写出依赖:租户限流依赖可信身份,路由级指标依赖路由识别,请求体签名依赖明确的字节序列。没有依赖图,所谓插件顺序最终会成为隐含协议。

图 1:一次请求的主要处理阶段

1.2 数据平面与控制平面

数据平面处理请求,控制平面管理路由和策略。请求路径不应为每次调用查询管理数据库。正常模式是控制平面发布配置,数据平面在本地维护可读取的状态。控制平面短暂不可用时,网关可以继续使用最近的有效配置;代价是配置新鲜度下降。

这不是说所有策略都能离线运行。分布式限流需要共享状态,令牌吊销可能依赖外部服务。应把每一种依赖的失效行为写清楚:继续放行、拒绝、使用本地降级额度,还是只保护高风险路由。全局的“失败放行”开关通常无法表达这些差异。

一次完整发布包括保存、校验、分发、应用与生效确认。管理界面显示“保存成功”,只证明其中很小一部分。后文用配置版本把这个过程连起来。

1.3 延迟、吞吐与在途请求

在稳定系统中,Little 定律给出平均在途数约等于到达率乘平均停留时间。假设每秒 2,000 个请求,平均停留 100 毫秒,平均在途约 200。若后端变慢到 2 秒而流量未降,在途可增长到约 4,000。连接池、堆内存和等待队列会一起承压。

因此网关不能只限制 QPS,还要限制并发和排队。速率限制控制到达速度,舱壁控制某个依赖占用的资源,超时控制资源持有时长。三者分别对应不同的控制对象。

思考题。 为什么“网关 CPU 只有 20%”不能证明系统还有充足容量?

第 2 章 ShenYu 的模块地图与阅读方法

2.1 从职责而非目录数量入手

ShenYu 将管理、运行时、插件 API、插件实现和数据同步拆成模块。shenyu-admin 承担配置管理等控制职责;运行时接收请求并装配插件;shenyu-plugin-api 定义插件契约;shenyu-plugin-base 提供选择器、规则与匹配等通用能力。同步组件连接管理侧变化与运行时本地状态。

源码阅读最有效的起点是一个可回答的问题:“订单请求怎样进入限流插件?”沿着请求入口、插件链、插件基类、具体策略和响应回写依次前进,再追配置从何而来。先读完所有配置类往往只会获得名词,无法建立行为模型。

本书核对的入口位于 shenyu-web/.../handler/ShenyuWebHandler.java。其 handle 方法为请求创建 DefaultShenyuPluginChain,再执行链。链持有插件列表与索引。链对象每次请求新建,避免不同请求共享游标。

2.2 三组必须区分的对象

第一组是插件实例与插件配置。Java 插件实例是可执行代码;PluginData 等对象表达启用状态、排序或策略参数。已有代码不等于已启用,配置启用也不能凭空提供缺失的插件类。

第二组是选择器与规则。选择器描述某个插件下的作用范围,规则在此范围内进一步定位具体行为。它们属于某个插件的匹配体系,不是全网关只执行一次的统一路由表。鉴权插件和代理插件可以分别匹配,配置时必须检查范围是否一致。

第三组是数据缓存与匹配缓存。数据缓存保存配置对象,匹配缓存保存请求特征到匹配结果的映射。配置对象更新之后,如果旧匹配结果没有失效,就可能继续执行旧策略。这是动态配置章节的关键连接点。

2.3 建立可核验的源码笔记

每条结论记录标签、类名、方法名、分支条件和可观察结果。例如:“v2.6.0 中 DefaultShenyuPluginChain.execute 用 Mono.defer 包住取插件与递增索引;若 skip 返回 true,继续执行链。”这比“用了责任链模式”更能指导排障。

另一个实例是插件排序:sortPlugins 优先使用本地 PluginData 的 sort,缺少配置时退回 getOrder()。插件列表更新采用复制后替换的方式,并以 volatile 引用发布。不能据此推断所有插件内部状态也自动线程安全。

思考题。 为什么阅读源码时需要同时记录配置条件,而不能只摘出某个方法中的一行?

第 3 章 插件链:顺序、异步与短路

3.1 责任链的最小契约

ShenYu 插件通过 execute(exchange, chain) 参与请求处理,可以执行前置逻辑、调用后续链,也可以直接返回响应。链不强迫每个插件继续执行。限流拒绝、认证失败和缓存命中都可能短路。

下面是概念代码,省略真实接口细节:

Mono<Void> execute(Exchange exchange, Chain chain) {
    if (!allowed(exchange)) {
        return reject(exchange, 429);
    }
    return chain.execute(exchange)
        .doFinally(signal -> recordCompletion(signal));
}

前置逻辑的调用顺序与响应完成后的回调顺序不是同一个概念。更不能用 Java 方法返回的时刻代表 HTTP 请求完成。返回 Mono<Void> 表示交出了一个异步过程;框架订阅后过程才执行,完成、错误和取消都需要处理。

3.2 为什么入口使用 defer

核对的链实现通过 Mono.defer 延迟推进索引。它让实际执行发生在订阅阶段,而不是组装返回值时。但链是带游标的状态对象,不能因为用了 defer 就任意多次订阅同一个链,也不应把同一个 continuation 交给两个并发分支。

编写插件时应遵守一次控制流的原则:要么完成响应,要么将控制权交给后续链;不要先启动链,再单独写响应;不要手动调用 subscribe() 让工作脱离主请求。脱离的工作无法自然传播错误、超时与取消,还可能在连接关闭后继续占用资源。

3.3 顺序是一种可测试接口

考虑四个插件:身份验证、租户限流、请求签名、路径重写。如果签名覆盖原始路径,应在重写前验证;若覆盖规范化后的路径,客户端和网关必须遵循相同规范。限流若使用客户端提供的租户头而身份还没验证,攻击者就能通过随机租户绕过配额。

推荐先画数据依赖:原始请求产生规范化视图,身份校验产生可信主体,主体与路由产生限流键,路由与服务发现产生目标地址。将每条边写成集成测试。测试不必断言所有插件的固定编号,而应断言关键约束,例如未认证用户不能消耗其他租户额度。

3.4 阻塞与资源释放

Reactor 不会把阻塞操作自动变成非阻塞操作。同步数据库查询、文件读取和阻塞 SDK 放在事件循环上,会阻塞同一线程负责的其他连接。优先选择异步客户端;必须使用阻塞接口时,放到有界、隔离的执行资源中,同时设置队列与超时。把工作搬到另一个线程池也不会消除容量上限。

请求体可能由带引用计数的缓冲区承载。读取、缓存、重放时要遵守框架的释放规则,并限制大小。响应包装器修改内容时还需考虑 Content-Length、编码、压缩与流式传输。对流式接口做完整缓存,会改变产品语义。

思考题。 一个统计插件只在成功回调中释放并发许可,取消请求时会发生什么?

第 4 章 选择器、规则与确定性匹配

4.1 作用范围是配置的第一含义

一个订单代理选择器可以描述路径 /api/orders/**,其中规则进一步区分 POST 与 GET。鉴权选择器则可能覆盖 /api/**。如果支付路径后来迁移到 /pay/**,代理规则更新而鉴权范围未更新,就会产生安全缺口。因此应在发布前计算“可转发但无认证”的路由集合。

在核对的 AbstractShenyuPlugin.execute 中,基类先检查插件数据与启用状态,再查找选择器。没有匹配时,默认的 handleSelectorIfNull 继续执行后续插件。规则为空或未匹配时的默认处理也继续链。子类可以覆盖这些行为,不能把基类默认继续理解成整个网关保证返回 404 或拒绝。

4.2 不要把 continued 当成停止插件链

v2.6.0 中,选中的选择器若 continued 为 false,会直接构造默认规则并调用该插件的 doExecute;它控制是否继续匹配规则,不表示整个请求不再经过后续插件。是否继续后续链取决于插件自己的执行结果。

同一实现对全流量选择器还有专门分支,规则列表的选择方式不同于通常的条件匹配。这说明用户界面的“全匹配”“继续”之类选项必须回到具体版本语义核实。单凭字段名称推断行为很容易出错。

4.3 多个匹配结果如何决定赢家

不能把 ShenYu 的选择过程简化为“列表里第一条命中”。在核对的基类中,候选选择器经过启用状态和条件过滤;多个候选出现时,manyMatchSelector 将 AND 条件数量作为一项优先依据,再按 sort 决定。规则也有自己的多候选处理。Trie 与缓存路径又可能影响实际到达的分支。

为解释冲突,我们设计一个教学匹配规则:先按显式 priority 降序,再按路径长度降序,最后按 id 字典序。它不声称复刻 ShenYu,只用于展示“必须存在确定的全序”。在生产配置中更好的做法是让规则互斥,或在发布时拒绝无法解释的重叠。

4.4 路径规范化的安全语义

/api/orders、/api/orders/、双斜杠、点路径、编码斜杠和重复解码可能被不同组件解释成不同资源。网关按一种路径做鉴权,后端按另一种路径找资源,就可能形成绕过。应先制定协议:哪些输入拒绝,哪些规范化,签名覆盖原始还是规范化形式。

前缀匹配也需边界。例如希望匹配 /api 及其子路径,直接 startsWith('/api') 会误中 /apix。附录实验使用“相等或前缀后紧接斜杠”的规则。它没有实现完整 URI 安全解析,不能替代代理与后端的端到端验证。

思考题。 同一 URL 因请求头不同而进入不同规则时,只按 URL 缓存匹配结果是否正确?

第 5 章 匹配性能与缓存一致性

5.1 优化前先定义语义

线性扫描 N 条规则,每条 M 个条件,最坏情况下需要 O(NM) 次条件判断。但实际成本取决于正则、头解析、对象创建和缓存命中率,不能仅凭复杂度认定系统慢。先采集规则数量、匹配耗时分位数、候选数与缓存命中率,再选择优化方式。

前缀树适合路径结构,哈希索引适合精确键匹配,复杂表达式仍可能需要回退匹配。多层匹配器必须共享一致语义:不能在线性扫描时选 A,在 Trie 命中时选 B。测试应将优化结果与可信的慢速判定器进行差分比较。

5.2 为什么 URL 缓存有资格条件

核对的 ShenYu 基类中存在按插件名称与路径查询的选择器、规则匹配缓存。写缓存时会检查条件是否均为 URI 类型,并排除特定 RESTful 匹配情形。这里的资格条件是正确性的组成部分:如果匹配依赖头、查询参数、方法或身份,只记录路径就丢失了决定结果的输入。

负缓存也需要相同谨慎。某路径在配置版本 10 中没有匹配,版本 11 新增规则后,旧的“无匹配”不能继续生效。正缓存和负缓存都必须随配置变化失效。

5.3 让缓存依赖显式化

教学设计可使用键 (config_version, plugin_id, method, normalized_path, tenant_class)。但纳入更多字段会增加基数,尤其原始用户 id 或任意请求头可能导致缓存几乎不复用。正确策略不是无上限扩大键,而是决定哪些规则可缓存,以及使用有界缓存并监控逐出。

配置更新可选择全量清空匹配缓存,简单但可能在大规模发布时引发抖动;也可以按插件或规则影响范围清理,代价是实现复杂;还可以以版本隔离,让旧版本自然逐出。要根据更新频率与规则规模选择,并验证删除规则和回滚的情况。

思考题。 为什么配置更新后的第一个一分钟窗口往往更能暴露缓存问题?

第 6 章 限流:从令牌桶到租户配额

6.1 四种常见控制方法

固定窗口以自然时间段计数,简单但窗口边缘可能双倍突发。滑动日志记录事件时间,精确但空间与清理成本较高。滑动计数近似平滑窗口边界。令牌桶以速率补充令牌,并以桶容量允许突发;漏桶模型则更强调平滑输出或排队。

令牌桶有两个参数:补充速率 r 和容量 B。经过 Δt 后,令牌数变为 min(B, tokens + r × Δt)。请求需要的令牌数量为 c,只有令牌不少于 c 时才扣减放行。桶初始是否满、浮点误差如何处理、时间如何取得,都会影响观测结果。

如果 r 为每秒 100、B 为 200,空闲后可以立即接受 200 个单位消耗,再以每秒 100 恢复。它不是任意一秒最多 100 的硬保证。不同算法承诺不同,产品文档中的“每秒限流”必须具体解释。

6.2 本地与分布式限流

四个实例各自使用每秒 100 的本地桶,总放行能力可能达到每秒 400。自动扩容会改变总配额。严格的租户全局额度需要协调,例如 Redis 原子脚本、集中限额服务或经过设计的分片配额。共享状态操作应在一次原子事务中完成读时钟、补充、判断和扣减;分开的 GET 与 SET 会超发。

分布式依赖也会产生延迟和故障。Redis 不可用时,直接全量放行可能压垮后端,全量拒绝则可能把局部故障变成业务停摆。一种折中是每实例保留小额本地应急桶,允许有限降级;其总额度随实例数变化,必须计入风险预算。

ShenYu 的限流能力由具体插件和其依赖实现承载。阅读源码时沿“规则 handle 参数—算法选择—共享存储原子操作—拒绝响应”追踪;不能因网关使用 Reactor 就推断 Redis 调用必然非阻塞,也不能把某一种算法描述成全部版本的唯一实现。

6.3 限流键是一项安全设计

键可以由可信租户、路由、调用方等级组成。IP 限流对匿名流量有帮助,但共享出口会误伤,IPv6 地址变化又可能绕过。转发头只有来自受信代理时才可信。租户头必须经令牌或签名确认,避免攻击者自行更换身份。

避免把任意 URL 参数、原始令牌或无限长度字符串直接做存储键。攻击者可利用高基数使 Redis 内存膨胀。可以对规范化维度做长度限制与哈希,但哈希不会降低不同键的数量,仍需限制维度来源和生命周期。

6.4 公平性与响应契约

总限流保护基础设施,租户限流保护公平性,路由限流保护昂贵接口。一个请求可能需要通过多个预算;多维配额不是简单把所有计数器依次扣完,因为后面的拒绝可能造成前面额度被消耗。需要决定是否接受这种保守损失,或者用原子多维决策。

拒绝一般使用 429,响应带稳定错误码与适当的 Retry-After。Retry-After 可表达等待秒数或日期,但客户端仍应加入抖动,防止同一时刻重试形成尖峰。服务整体不可用与租户配额耗尽也应区分,不能把所有错误都包装成 200。

思考题。 限流改用本地缓存后延迟降低了,为什么客户仍可能投诉配额失真?

第 7 章 熔断、隔离、超时与重试

7.1 熔断器要回答三个问题

第一,什么算失败:连接错误、超时、特定状态码、慢调用,还是业务错误?第二,在多大的统计窗口与最小样本量下判断?第三,打开以后怎样恢复?将所有 4xx 算作后端失败,会让恶意请求触发熔断;把所有 5xx 排除又失去保护作用。

经典状态是关闭、打开、半开。关闭状态收集结果;达到阈值后打开,快速拒绝;等待一段时间进入半开,只允许少量探测;探测成功才恢复。没有半开并发限制,多实例同时恢复可能冲击刚刚恢复的服务。

熔断针对故障倾向,舱壁针对资源隔离。即使错误率低,某个慢接口也可能占满共享连接。按上游或业务等级设置并发舱壁,可以使支付缓慢时订单查询仍有资源。

图 2:熔断状态转换的主要路径

7.2 超时是一条预算链

假设客户端总预算 1,000 毫秒。网关内部处理预留 50,网络与回写预留 100,剩余约 850 可供上游调用与可能的重试。若首次调用超时配置为 800,再重试一次 800,实际策略已经超出客户端预算。

连接建立、TLS 握手、响应头等待、流读取空闲与总处理时长是不同超时。流式响应持续到达数据时,空闲超时可能永远不触发,但总时长仍可能很长。应根据接口语义配置,不能让上传大文件与普通查询共享同一短总超时。

取消应传播到上游,以释放连接和计算资源;但客户端断开不证明后端事务没有完成。对于支付提交,客户端看到超时后必须查询结果或使用幂等键,不能直接推断失败。

7.3 重试可能放大故障

若三个层级各自最多尝试三次,最坏情况下底层会承受 27 次尝试。应明确哪一层有重试权,并限制总重试预算。只在可安全重复的操作上使用重试,写操作需依赖明确的幂等机制,而不是仅因为方法名叫 POST 就全部禁止或全部允许。

指数退避与抖动可以降低同步重试;剩余预算不足时应停止。重试指标必须区分原始请求与上游尝试,否则网关 QPS 不变而上游负载翻倍时难以解释。

熔断应统计逻辑请求还是每次尝试,也需要明确。每次尝试统计更贴近依赖健康,但同一请求会占多个样本;逻辑结果统计更贴近用户体验,却可能隐藏重试掩盖的故障。

7.4 降级结果必须有业务含义

缓存旧商品描述可能可接受,伪造“支付成功”不可接受。降级响应应有可识别的状态与来源,必要时返回可重试错误。静态兜底并非天然优于快速失败。读缓存也要说明陈旧上限与租户隔离,不能将其他用户的结果当作公共兜底。

思考题。 熔断器打开后错误率下降,是否说明后端已经恢复?

第 8 章 动态配置:把发布当作分布式协议

8.1 一条配置经历了什么

ShenYu 的管理侧和运行侧通过同步机制传递数据;项目提供多种同步方式,具体可用模块以选定版本为准。WebSocket、HTTP 长轮询或协调存储等方式的差别不只是实时性,还包括断线恢复、全量校准、版本发现和部署复杂度。

不论采用哪一种方式,可靠设计都应表达以下状态:期望版本、接收版本、校验版本、应用版本以及生效时间。若管理侧只显示期望版本,无法判断某一网关实例是否仍在使用旧配置。

图 3:配置发布与应用确认

8.2 乱序、重复、断线与删除

网络消息可能重复和乱序。教学协议给每次发布分配单调递增的 revision,接收侧只应用更新版本。旧 revision 被忽略,重复 revision 幂等处理。若事件是增量,检测到缺口后需要全量拉取,不能简单跳到最高序号并假设中间变化不重要。

删除需要明确表示,不能把“消息里没有某对象”一律当作删除,因为消息可能只是部分更新。全量快照可用缺失表示删除,增量事件则需要删除类型或墓碑。恢复时还要防止旧快照把已删除对象复活。

版本号也不等于时间戳。不同节点时钟可能漂移;毫秒内多次修改可能碰撞。由单一发布事务或有一致性保证的序列生成 revision 更容易建立全序。

8.3 构建快照,然后原子切换

一个代理规则依赖上游、认证策略和限流参数。逐个修改共享 Map 时,请求可能读到“新路由配旧鉴权”。教学方案先在后台构建不可变快照:校验引用、预编译表达式、构建索引,再原子替换根引用。请求进入时捕获一个快照,整个生命周期使用该快照。

这是一种设计建议,不是对 ShenYu 所有缓存提供事务一致性的声明。前面看到的插件列表复制替换只保证那一列表引用的发布方式;多个缓存之间是否原子,需查看具体同步处理和插件实现。

大快照构建会产生瞬时双份内存和垃圾回收压力。可以采用分层不可变结构或按插件分区,但必须明确原子边界。不要为了省内存而让请求遍历一个正在修改的规则集合。

8.4 回滚与灰度发布

回滚应将旧内容以新 revision 重新发布,而不是把 revision 调低。这样既保留历史,也不破坏“只前进”的接收协议。灰度可按网关实例组、租户或流量比例进行,但配置变更自身不能依赖不可信请求头决定谁进入安全宽松版本。

发布门禁包括结构校验、引用完整性、路由冲突、鉴权覆盖、配额合法性和资源上限。发布后看应用确认、拒绝率、延迟分位数与配置版本分布。只看管理侧 HTTP 200 无法证明成功。

思考题。 为什么恢复数据库备份后,要特别检查配置版本与网关缓存?

第 9 章 安全边界与协议处理

9.1 从身份传递到信任链

网关校验 JWT 时,应约束算法、签发者、受众、有效期,并管理密钥轮换与缓存。收到未知 kid 不应无限制地触发外部取钥请求,否则攻击者可以让网关变成请求放大器。验签通过只是身份可信,还需验证权限与业务资源。

网关向后端转发的用户标识头应先删除客户端同名输入,再写入验证后的值。后端只能信任来自受控网络与已认证网关的头;否则攻击者可绕过入口直连服务并伪造身份。mTLS 可帮助验证通信双方身份,但不自动实现每个 API 的权限。

9.2 请求走私与解析分歧

多个 HTTP 实现串联时,Content-Length、Transfer-Encoding、重复头与非法空白的解释差异可能导致请求边界不一致。防护需要入口严格解析、协议转换规则与后端行为一致,不能仅靠一个业务插件补救底层解析器分歧。

对转发地址同样应限制。用户输入 URL 如果直接成为代理目标,就可能访问内部管理接口、云元数据端点或本地地址。允许名单、DNS 解析与连接阶段约束需要配合,单次字符串检查不足以应对重定向或 DNS 变化。

9.3 管理平面也是攻击面

网关管理 API 拥有改变流量目的地和认证策略的能力。应使用独立身份权限、最小访问范围、审计和变更记录。上传插件包相当于部署可执行代码,不能当作普通配置编辑;应进入代码审查与制品校验流程。

日志默认不记录完整令牌、密码或支付信息。即使做脱敏,也需考虑 URL 查询参数与错误堆栈泄漏。排障时可以临时提高采样与字段明细,但应有期限与访问控制。

思考题。 为什么把网关和后端放在同一个私有网络,仍不足以信任任意用户身份头?

第 10 章 六种网关的共同坐标系

10.1 先比较控制模型

产品 主要理解入口 典型扩展方式 主要运行与治理特征
ShenYu 插件、选择器、规则与管理侧同步 Java 插件与项目 SPI JVM 运行时,多协议插件与集中配置治理
Spring Cloud Gateway Route、Predicate、Filter Java 过滤器及 Spring 生态 应用式网关;需区分响应式 Server WebFlux 与 Server Web MVC
Kong Gateway Service、Route、Consumer、Plugin Lua 插件及受支持的其他扩展方式 基于 Nginx/OpenResty 生态,配置可采用不同部署模式
Apache APISIX Route、Upstream、Plugin、Consumer 等 Lua 插件及项目支持的扩展机制 基于 Nginx/OpenResty,动态路由与插件治理
Higress 网关控制面、Envoy 数据面及其配置模型 以 Wasm 等机制扩展 Kubernetes 与服务发现集成、网关与 AI 流量治理能力
Envoy Gateway Gateway API 资源与扩展策略 Envoy 能力及控制器支持的扩展资源 将声明式资源翻译为 Envoy 配置

表中的方式是常见入口,不是穷举,也不表示所有能力在任何发行版中免费或默认启用。对插件市场、商业能力、执行环境与支持范围,应以版本对应的官方说明为准。

10.2 四个容易混淆的名词

Envoy 是代理数据平面。Envoy Gateway 是围绕 Gateway API 管理 Envoy 的项目。Gateway API 是 Kubernetes 的网络资源 API 规范。Higress 使用 Envoy 等技术构建自己的网关体系。共享数据面技术不意味着共享控制面、策略 CRD 或插件兼容性。

同样,Kong 与 APISIX 都与 Nginx/OpenResty 生态相关,但配置模型、插件接口和管理协议不同。ShenYu 与 Spring Cloud Gateway 都可使用 Java 与响应式技术,也不意味着选择器规则能直接转成 Route 配置。

10.3 统一业务需求,再映射产品

用中立模型描述需求:域名、路径、方法、身份来源、上游、超时、重试、限流维度、插件依赖和发布策略。之后逐一映射为各产品配置。若先围绕某个产品字段写需求,会把它的限制误当成业务约束。

例如“每租户每分钟 600 次”至少还缺少窗口算法、突发容量、实例间一致性、失败策略和豁免规则。“支持限流”这一勾选项无法代表满足要求。选型评审应拿同一组请求序列观察实际放行结果。

思考题。 为什么共享 Envoy 数据面仍可能导致完全不同的运维体验?

第 11 章 Spring Cloud Gateway:应用式网关的取舍

11.1 路由与过滤器

Spring Cloud Gateway 通常以 Route 组织目标 URI、匹配 Predicate 与 Filter。对于响应式 Server WebFlux,过滤器围绕异步链处理请求与响应,熟悉 Spring 的团队容易把安全、配置与观测纳入既有应用工程体系。

但同一个项目中存在不同服务器模型。读者应明确使用 Server WebFlux 还是 Server Web MVC,再选择对应依赖与扩展接口。不能把 WebFlux 的 GlobalFilter 代码直接当成 MVC 版本的可用代码,也不能笼统宣称所有 Spring Cloud Gateway 都运行于同一线程模型。

配置示意如下,仅说明三个核心字段,属性前缀以所选版本为准:

# 概念映射,不是跨版本部署文件
route:
  id: orders
  uri: http://orders.internal
  predicates:
    - path-prefix: /api/orders
  filters:
    - strip-prefix-segments: 1

剥离路径前缀时,需核对上游期望 /orders 还是 /api/orders。路由成功但返回 404,往往是重写后的路径与后端不一致。

11.2 与 ShenYu 的主要差别

ShenYu 的通用插件基类为每个插件提供选择器与规则匹配;Spring Cloud Gateway 更常见的认知方式是先定位路由,再组合路由过滤器与全局过滤器。两者都能实现路由和治理,但策略作用域的表达不同。迁移时要重新验证鉴权过滤器是否覆盖每一条路由。

应用式网关的优势是能在一个工程中复用业务团队熟悉的库。风险也是代码边界容易膨胀:把业务聚合、数据查询和网关策略放在一起,可能让网关发布与业务发布互相牵制。应使用独立模块与接口约束,防止网关成为新的单体应用。

11.3 何时优先评估

若团队以 Spring 为主,有完善的 Java 运维、可观测和配置平台,需求主要是 HTTP API,且希望用代码控制复杂过滤逻辑,Spring Cloud Gateway 是合理候选。若大量需求是多协议适配或统一插件管理,可比较 ShenYu 的既有插件覆盖度,而不是全部自行开发。

两者的性能都应在真实过滤链、TLS、请求体和外部依赖下测试。空路由转发结果无法预测认证与限流开启后的容量。

思考题。 为何把所有策略写成全局过滤器,并不必然降低配置错误?

第 12 章 Kong:围绕 API 实体组织治理

12.1 Service、Route 与 Consumer

Kong 的 Service 表达后端服务相关配置,Route 表达客户端请求如何匹配到服务,Consumer 表达调用者身份,插件则可在支持的实体或作用域上配置。对开放 API 场景,这种实体模型使调用方治理较容易被显式表达。

但一个请求可能同时落入全局、服务、路由或调用者相关插件配置。具体优先级、组合能力与支持的作用域要以插件和版本为准。不能凭名字推断“最具体的总会合并全部字段”。迁移策略时应为每个调用者和路由组合生成预期决策。

12.2 配置部署模式改变故障模型

Kong 有不同的数据库与控制数据面部署方式,也支持声明式配置相关模式。选择哪一种模式会影响管理 API 行为、配置发布流程和某些插件能力。无数据库模式不代表没有控制工作,而是把配置生成、校验和分发责任转移到其他流程。

混合控制面与数据面的部署需要关注连接中断后数据面保留什么配置、证书如何轮换、配置兼容性如何验证。不能只看控制面 CPU;数据面是否成功接收配置才决定请求行为。

12.3 插件工程

Lua 插件可以在代理不同阶段执行。扩展前应理解阶段可用 API、是否允许网络 IO、是否在请求上下文中,以及共享状态的隔离。不要把 Java 的线程本地模型机械翻译成 Lua 插件模型。

相对 ShenYu,Kong 更适合希望围绕 API 实体与成熟网关生态组织治理的团队;Java 定制代码较多的团队则需要评估跨语言开发与调试成本。插件能否满足需求只是第一步,还应评估测试、升级与安全补丁责任。

思考题。 为什么“插件已经配置”不足以证明请求一定使用了该插件配置?

第 13 章 APISIX:动态路由与插件执行阶段

13.1 路由、上游与共享对象

APISIX 将路由、上游、服务、消费者与插件相关配置组织成可复用实体。动态配置让网关无需为每次策略变更重新加载整套进程,但配置更新仍有传播与应用过程。其常见部署涉及 etcd,实际也存在不同运行模式,应明确采用的版本和部署方式。

etcd 的一致性不等于每个 APISIX 实例在同一纳秒切换。数据面需要监听、解析并应用变化。控制存储健康、监听延迟和本地状态新鲜度是三个不同指标。

13.2 执行阶段与插件优先级

APISIX 插件可以参与 rewrite、access、header_filter、body_filter、log 等阶段,具体接口以插件开发文档为准。阶段决定何时可以改写请求、拒绝访问或记录结果。插件优先级在所属执行规则下决定顺序,不能用一个抽象数字解释所有阶段的前后关系。

如果在 body_filter 中为所有响应拼接完整内容,就可能破坏流式传输并放大内存。记录访问日志也需要限制字段与异步缓冲,避免日志下游阻塞反过来影响请求。对于密码校验或外部策略调用,应评估所用接口是否适合该阶段。

13.3 与 Kong 和 ShenYu 对比

APISIX 与 Kong 都适合从 OpenResty 生态理解插件执行,但实体模型、插件字段、控制接口与能力覆盖有差异。不能把同名的 limit-count 或类似能力按字面视为完全相同的算法和键语义。

与 ShenYu 比较时,APISIX 的常见扩展路径侧重 Lua 与其运行环境,ShenYu 侧重 JVM 与插件基类。已有团队能力、需要支持的协议、动态配置平台和性能测试结果共同决定选择。不要把语言偏好伪装成性能结论。

思考题。 如果配置存储可读但监听落后,网关表现会与存储完全不可用有什么不同?

第 14 章 Higress:Envoy、Wasm 与 AI 流量

14.1 控制面负责翻译,数据面负责执行

Higress 结合云原生网关控制能力与 Envoy 数据平面,并提供基于 Wasm 等机制的扩展路径。对用户而言,关键问题是业务配置如何转换为实际路由与过滤器,以及控制面拒绝或无法翻译配置时怎样观察。

使用 Envoy 不等于自动获得所有 Envoy 功能。控制器必须暴露、校验并生成相应配置,运维工具也需支持排障。比较 Higress 与直接管理 Envoy 时,要计算控制面提供的服务发现、证书和插件管理价值,以及其抽象带来的边界。

14.2 Wasm 的隔离边界

Wasm 能提供不同于直接嵌入宿主原生代码的执行与内存边界,但它不是无限资源沙箱。宿主调用、内存上限、执行时间和网络访问都需要管理。插件版本与宿主 ABI、SDK 版本的兼容性也要验证。

Wasm 插件适合跨团队扩展,但不意味着已有 Java 或 Lua 插件可以无修改迁移。一个外部鉴权插件的核心工程问题依然存在:超时、缓存、取消、失败策略、指标与密钥管理。换成另一种执行形式不会消除这些责任。

14.3 AI 网关为什么需要新的计量

大模型请求存在长连接、流式返回、输入输出 token 成本与供应商故障等特点。仅以请求次数计费可能严重失真:一个小问题与一次长上下文生成的资源成本不同。请求前可以预留估算额度,完成后根据可信的 usage 信息结算;流中断时需要定义如何处理未收到的最终用量。

路由到另一供应商时,模型能力、工具调用格式、内容安全和上下文限制都可能不同。自动切换必须被产品契约允许。对流式响应,如果已经向客户端发送内容,再重试并从头拼接会造成重复或语义混乱。

Higress 的 AI 相关能力值得在此场景评估,但必须验证所选版本的供应商协议、插件和配额语义。其他网关也可通过扩展支持部分需求,“AI 网关”名称本身不是完整能力证明。

思考题。 为什么用请求开始时扣除固定一个令牌,难以公平限制模型 API 成本?

第 15 章 Envoy Gateway 与 Kubernetes Gateway API

15.1 角色分离是 API 的一部分

Gateway API 将基础设施提供方、集群运营者与应用开发者的职责分开。GatewayClass 描述控制器类别,Gateway 描述监听器等基础设施入口,HTTPRoute 描述 HTTP 路由规则。Envoy Gateway 作为实现,将受支持的资源转成 Envoy 数据面配置。

这种模型使应用团队可以在授权范围内管理路由,而运营团队控制入口、证书与跨命名空间规则。它比把所有配置塞进同一个文件更利于权限分工,但需要明确谁拥有每个资源。

15.2 一个最小路由片段

下面是 Gateway API 的路由示例。它需要集群中已安装匹配的 CRD、控制器、名为 public 的 Gateway,以及命名空间内的 orders Service;本书未将此片段作为已部署集群的验收结果。

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: orders
spec:
  parentRefs:
    - name: public
  hostnames:
    - api.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /api/orders
      backendRefs:
        - name: orders
          port: 8080

对象创建成功不等于流量成功。先看 Route 的 Accepted、ResolvedRefs 等状态及原因,再看 Gateway 的条件、Service endpoint、数据面路由和请求日志。跨命名空间的后端引用还需满足 ReferenceGrant 等授权规则,不能把权限失败当作路由算法失败。

15.3 标准化范围与实现扩展

Gateway API 提供通用模型,但限流、外部鉴权、策略附着和高级 Envoy 特性可能依赖实现提供的扩展资源。使用扩展时应记录其 apiVersion、作用域、冲突处理与升级策略。支持 HTTPRoute 并不代表所有扩展功能可移植。

ShenYu 的选择器规则与 Gateway API 的路由资源无法逐字段一一映射。迁移应从业务意图重建:哪些域名与路径需要什么身份、路由到什么服务、采用哪种预算和故障行为。再根据控制器能力决定使用标准字段、扩展策略或自定义插件。

思考题。 为什么 kubectl apply 成功不代表配置已经在数据面生效?

第 16 章 可观测、容量与性能实验

16.1 指标必须能解释决策

基础指标包括请求数、状态码、延迟、在途请求、连接池等待与资源使用。治理指标还应包括路由命中、鉴权拒绝、限流拒绝、熔断状态、重试次数和配置版本。没有配置维度,发布后流量异常很难与变更关联。

指标标签应使用有界的路由 id、状态码和上游名,避免原始 URL、用户 id 或请求 id。高基数会使监控系统成本与性能恶化。逐请求细节进入采样日志或追踪,并经过敏感信息处理。

追踪上下文来自外部时也需校验长度与格式。不要无限信任外部采样标志,更不要把带凭证的请求头全部转成 span 属性。网关 span 应区分内部处理、外部鉴权和上游调用,让长尾来源可分辨。

16.2 构造有解释力的压测

测试矩阵至少包含小响应、大请求体、TLS、长连接、真实鉴权与限流、上游慢响应、错误与配置更新。每组固定硬件、实例数、版本、连接数、流量生成方式和测试时长,先预热,再测稳定区间和过载区间。

闭环压测客户端会等待响应后再发请求;当服务变慢时,到达率自然下降,可能掩盖排队。开放到达模型更适合观察给定到达率下是否积压,但生成器自身也可能达不到目标。必须报告实际发送率与延迟测量方式,关注 coordinated omission 问题。

不能把所有实例的 p99 求平均后称为整体 p99。应合并可合并的直方图或使用统一测量,再计算分位数。吞吐、成功率和延迟必须一起报告;通过快速返回错误获得的高 QPS 没有业务意义。

16.3 发布时的容量验证

规则从 100 增至 10,000,或启用大量正则,可能改变匹配成本。配置快照双份内存、缓存冷启动、JIT 与连接预热会让发布期成为特殊负载。应在稳定流量下更新配置,观察生效时间、错误窗口与内存峰值。

一个候选网关通过压测,只证明所测版本、拓扑和策略组合下的表现。书中不提供虚构的六产品 QPS 排名;读者应使用相同需求与服务模拟器在自己的环境中比较。

思考题。 为什么只报告平均延迟,无法评估用户看到的超时风险?

第 17 章 选型与迁移:用约束收敛答案

17.1 建立带权矩阵

先列硬约束:协议、身份体系、部署环境、许可证与预算、团队语言能力、控制平面集成、升级责任。任何不满足硬约束的候选先淘汰。剩余候选再按可维护性、治理覆盖、性能和生态评分。

场景 优先验证的候选 必须核验的问题
JVM 团队、多协议插件与集中管理 ShenYu 协议插件成熟度、同步一致性、插件升级
Spring 应用体系、复杂 Java 过滤逻辑 Spring Cloud Gateway 服务器模型、阻塞依赖、配置与应用发布耦合
开放 API 与调用者治理 Kong、APISIX 插件范围、部署模式、配额与管理平台集成
Kubernetes、Wasm 与 AI 流式治理 Higress SDK 兼容、流式行为、供应商计量
Gateway API 作为组织标准 Envoy Gateway 支持级别、扩展策略、跨命名空间权限

这张表给出验证起点,不是排他结论。例如 Kong 也能部署在 Kubernetes,ShenYu 也能服务 HTTP API,Envoy Gateway 也可承载复杂流量。应让真实需求决定验证优先级。

17.2 迁移时做语义对账

建立路由清单,记录匹配条件、优先级、路径重写、身份传播、超时、重试、配额键与响应错误码。用同一组请求在新旧网关离线回放,比较路由决策和策略结果。不要将生产写请求盲目镜像到真实后端,否则可能重复创建订单。

影子流量可发送到只读模拟上游,或只比较决策日志。对支付、库存扣减等写操作,需要明确的幂等键、隔离环境与副作用处理。镜像的响应不返回给用户,但镜像请求的副作用仍然存在。

切流可按调用方、域名或稳定比例逐步进行。DNS 缓存与长连接会让流量比例不同于配置比例,必须观测真实请求分布。回退路径应保留兼容身份头与证书,避免切回旧网关后认证失败。

17.3 插件迁移的真实成本

Java、Lua、Wasm 与 Envoy 原生扩展之间迁移,不只是翻译算法。还要重新实现生命周期、配置校验、异步外部调用、取消、指标、密钥与部署。估算成本时把测试与升级纳入,而不是只数源代码行数。

尽量将与代理无关的业务策略抽成外部策略服务或共享规范,但外部化会增加网络依赖。只有当统一治理价值大于新增延迟与可用性成本时,才值得这样做。

思考题。 选型矩阵分数最高,为什么仍可能不适合上线?

第 18 章 综合设计:电商平台网关方案

18.1 从需求推导结构

平台有匿名商品查询、登录订单查询、支付提交和合作伙伴批量接口。商品查询强调高可用,可容忍短时间旧数据;订单查询要求租户隔离;支付提交必须有幂等语义;批量接口需要单独的并发与请求体上限。

入口先严格解析协议、限制头与请求体,再识别路由与身份需求。身份验证产生可信主体,随后执行租户与路由预算。支付路由使用较严格的超时预算且不在网关盲目重试;合作伙伴接口采用独立上游连接池或舱壁,防止批量任务占满在线请求资源。

控制平面提供路由、策略和版本管理;运行时保存最近有效状态,并向控制面报告应用版本。高风险鉴权配置无法应用时保留旧配置并报警,不能自动关闭鉴权来恢复流量。

18.2 三个故障演练

演练一:限流存储超时。 将共享配额服务延迟提高到网关预算之外,验证本地应急桶是否限制总放行量,确认日志能区分配额耗尽与限额服务故障。观察健康路由是否受到共享线程或连接资源拖累。

演练二:配置连接中断。 断开部分实例与控制平面的连接,发布路由修改。预期隔离实例继续旧版本,监控显示版本分歧,而非假装全体成功。恢复后全量校准,确认删除与负缓存同步失效。

演练三:上游慢调用。 让支付服务变慢,验证舱壁、超时、取消与熔断顺序。订单查询仍应满足自己的目标。客户端断开后,在后端核实支付是否已提交,检查幂等查询流程。

18.3 上线验收的证据

验收文档应给出版本、制品、配置 revision、测试请求及响应、关键日志与指标。每项需求都有可重复的验证步骤,例如“租户 A 的额度耗尽不会影响租户 B”“未认证路径不能转发到受保护上游”“旧配置不会覆盖新配置”。

安全、容量与可用性不是独立打勾项。鉴权外部依赖会影响容量,配置传播会影响安全,限流失败策略会影响可用性。网关设计的成熟度取决于这些交叉点是否被明确处理。

思考题。 如果配置切换期间少数请求仍使用旧版本,是否一定是错误?

第 19 章 实现一个可验证的最小网关内核

19.1 为什么选择一个小模型

完整生产网关需要 HTTP 解析、连接池、TLS、协议升级与大量运维能力,无法用几十行代码可靠替代。本书实验刻意聚焦策略内核:请求上下文、确定性路由、令牌桶、熔断器、不可变配置快照和版本发布。

配套 examples/gateway_core.py 使用 Python 标准库,examples/test_gateway_core.py 提供确定性单元测试。测试通过手动传入单调时间,避免 sleep 导致不稳定。实验的契约与 ShenYu 源码契约明确分开,不要求算法逐行一致。

19.2 核心不变量

路由必须有确定性赢家,且 /api 不误中 /apix。令牌桶不允许透支,也不能补充超过容量。熔断器打开期间拒绝请求,半开只允许一个在途探测。配置发布忽略旧版本,拒绝重复路由 id 与非法上游,回滚内容使用新版本号。

请求在入口捕获快照后,即使发布新配置,该请求仍使用旧快照完成。这使一次请求的决策自洽。新请求使用新快照,因此配置切换期间短暂共存两个版本是有意设计。

19.3 实验步骤

在源码目录执行:

python3 -m unittest discover -s examples -v
python3 examples/gateway_core.py

第二个命令打印路由匹配、连续令牌消费以及配置更新后的结果。读者可修改规则优先级,观察重叠规则的赢家;将补充速率减半,验证等待时间;在熔断器失败阈值达到后,推进时间到恢复点,验证单探测限制。

这些实验没有启动真实 HTTP 代理,不包括 TLS、网络故障和并发生产实现。移植到生产前,应补充并发同步、持久化、外部依赖、协议集成和资源管理。小模型的价值是暴露设计不变量,不能因为通过单元测试就宣称实现了完整网关。

19.4 把模型连接回 ShenYu

在 ShenYu 中,对应的链入口是每请求创建的 DefaultShenyuPluginChain;匹配由插件基类与策略实现协作完成;策略状态由具体插件管理;配置通过同步体系更新本地数据。实验将这些职责放在少数类中,是为了可观察性,而不是替项目重新定义架构。

读者可进一步写一个只记录路由与配置版本的 ShenYu 插件,在测试环境中观察正常完成、拒绝、错误和取消路径。编译依赖必须固定在目标 ShenYu 版本,不在本书中提供假定跨版本通用的插件包。

思考题。 为什么使用真实等待时间测试令牌补充,比注入时间更容易产生偶发失败?

第 20 章 故障案例与排障路径

20.1 发布成功但新路径返回 404

先确认客户端实际请求的域名、方法和规范化路径。再查看请求落到哪个网关实例及其配置版本。如果实例版本落后,查同步链路与重连校准;若版本正确,检查匹配范围、路由优先级与路径重写;若网关确已转发,检查上游实际收到的路径。

不要一开始就全量重启。重启可能暂时清除缓存,却抹掉能够证明失效机制错误的现场。保存版本、匹配结果、缓存指标与上游日志,再在隔离实例验证假设。

20.2 延迟升高但上游耗时正常

将总延迟拆成接入等待、匹配、外部鉴权、限流调用、连接池等待、上游请求与回写。若上游 span 正常,可能是连接池排队、事件循环阻塞或日志输出拖慢。查看在途数与排队时长,比只看 CPU 更有效。

若仅大响应变慢,检查背压、客户端读取速度、压缩和响应缓存。如果某个插件把流式响应聚合成完整字节数组,首字节延迟与内存可能同时升高。

20.3 扩容后超发配额

确认配额是全局还是每实例。如果是本地桶,扩容本来就会增加总额度;如果是分布式桶,检查键是否包含实例 id、原子操作是否正确、时间是否一致以及失败时是否放行。比较逻辑请求与重试尝试,避免把上游重试误判为客户端超额。

20.4 部分租户认证异常

从密钥轮换、kid 缓存、受众与签发者、时钟偏差、身份头覆盖和路由作用范围排查。对于缓存命中路径,验证缓存键是否包含所有身份相关条件。对异常租户的日志做最小化采样,避免为了排障打印完整令牌。

20.5 一份可复用的事件记录

记录影响范围、首次异常时间、相关配置版本、复现请求、实际与预期决策、当前缓解措施、已排除假设和下一项验证。每个假设配一个能证伪的观察。例如“匹配缓存陈旧”应通过绕过缓存、比较配置对象与匹配结果来验证,而不是只写“可能是缓存”。

事故后改进应落到机制:补充配置确认、缓存失效测试、隔离依赖或收紧匹配范围。仅要求“以后小心发布”无法降低同类故障概率。

思考题。 为什么排障时同时记录路由 id 与配置 revision,比只记录 URL 更有用?

附录 A 思考题参考答案

  1. CPU 低时仍可能被连接池、内存、文件描述符、阻塞 IO 或远程依赖限制,排队也可能远离 CPU 指标。
  2. 方法行为受启用状态、匹配模式和缓存等条件约束;脱离条件的摘录容易被过度推广。
  3. 取消不进入成功路径,许可泄漏会使并发额度不断缩小,应在覆盖完成、错误和取消的终结路径释放。
  4. 不正确。相同 URL 的头可能决定不同结果,缓存必须纳入相关输入或禁止该类结果缓存。
  5. 配置更新触发缓存失效与冷启动;负缓存、引用更新和内存峰值问题会集中出现。
  6. 本地状态不能自动提供跨实例总配额,扩缩容与负载不均会改变实际体验。
  7. 打开时请求被快速拒绝,错误统计口径可能变化;必须看受控探测结果而不是总错误率下降。
  8. 数据回退可能造成版本倒退、删除复活和缓存不一致,需要重新建立单调发布历史并校准数据面。
  9. 私有网络内仍可能有被入侵工作负载或绕过入口的路径;需验证通信身份、限制访问并清除伪造头。
  10. 控制面决定配置表达、资源权限、分发、扩展和观测,数据面相同不等于这些能力相同。
  11. 全局过滤器也会有条件分支、路径排除和顺序依赖;需要验证最终覆盖与执行结果。
  12. 作用域、优先级、路由匹配和插件启用条件都可能使另一份配置生效。
  13. 监听落后时存储中有新配置而数据面仍使用旧状态;存储不可用还可能阻止写入和新实例初始化。
  14. 不同请求消耗的上下文、生成长度和供应商成本不同,固定次数不能表达成本公平性。
  15. API 对象已保存不等于控制器已接受、引用可解析、配置已下发且数据面可达。
  16. 少数极慢请求会被平均值稀释,分位数、超时率和排队信息才能体现长尾。
  17. 硬约束、测试证据或关键插件兼容性可能缺失;分数也可能使用了错误权重。
  18. 不一定。若请求入口固定快照,在途旧请求继续使用旧版是有意设计;必须符合预定一致性契约。
  19. 调度与机器负载会改变实际等待时间,注入单调时间可精确覆盖边界且加快测试。
  20. 相同 URL 在不同规则与配置版本下可能有不同含义,两个维度能把决策关联到具体发布。

附录 B 设计评审清单

评审请求语义时,明确规范化路径、可信身份来源、鉴权范围、配额维度与错误契约。评审运行时,明确事件循环边界、阻塞调用、连接池、舱壁、超时与取消。评审控制面,明确配置版本、删除语义、全量校准、应用确认和回滚。

评审可观测时,确认能从单个失败请求追到路由、插件决策、上游尝试与配置 revision,同时控制敏感数据与标签基数。评审容量时,包含 TLS、大体积、流式、长尾与配置发布过程。评审迁移时,用固定请求集对账,并处理影子写请求的副作用。

这些检查不要求每个系统实现同一种架构,而是要求每个决定有清楚的责任归属和可验证的行为。

附录 C 术语表

术语 本书含义
数据平面 实际接收、治理与转发请求的运行组件
控制平面 管理、校验和发布配置的组件
插件链 按契约交接请求控制权的一组扩展处理器
选择器 ShenYu 某插件下的范围选择对象
规则 选择范围中的进一步条件与策略参数
舱壁 限制某依赖占用并发等资源的隔离机制
半开 熔断器恢复前受限探测的状态
负缓存 保存“未匹配”或“不存在”的缓存结果
revision 发布协议中的单调版本标识
背压 下游处理能力对上游生产速度的约束机制
Wasm WebAssembly,一种可用于代理扩展的执行格式
xDS Envoy 动态配置相关协议族
Gateway API Kubernetes 面向服务网络的资源 API 体系
ReferenceGrant 授权特定跨命名空间引用的 Gateway API 资源

附录 D 参考资料与源码定位

以下链接用于继续核验与阅读。ShenYu 两个固定标签源码已保存在配套工程 research/。官方文档页面可能随时间变化,读者应在实施时切换到目标版本。其余链接作为一手资料入口,不表示书中完成了所有版本、模式与插件的逐项实测。

  1. Apache ShenYu 项目与文档:https://shenyu.apache.org/。查看插件、数据同步和部署章节。
  2. ShenYu v2.6.0 请求入口:ShenyuWebHandler.java。关注 handle、sortPlugins、DefaultShenyuPluginChain.execute。
  3. ShenYu v2.6.0 插件基类:AbstractShenyuPlugin.java。关注 execute、manyMatchSelector、缓存资格与未匹配处理。
  4. Spring Cloud Gateway 官方参考:https://docs.spring.io/spring-cloud-gateway/reference/。区分 Server WebFlux 与 Server Web MVC。
  5. Project Reactor 官方参考:https://projectreactor.io/docs/core/release/reference/。阅读订阅、调度、错误与资源处理。
  6. Kong Gateway 官方文档:https://developer.konghq.com/gateway/。阅读实体、插件作用域与部署拓扑。
  7. Apache APISIX 官方文档:https://apisix.apache.org/docs/。阅读架构、路由与插件开发阶段。
  8. Higress 官方文档:https://higress.io/。阅读架构、Wasm 插件与 AI 网关。
  9. Envoy Gateway 官方文档:https://gateway.envoyproxy.io/docs/。核对版本支持矩阵、Gateway API 与扩展策略。
  10. Envoy 官方文档:https://www.envoyproxy.io/docs/envoy/latest/。阅读 HTTP 连接管理、过滤器、超时与 xDS。
  11. Kubernetes Gateway API:https://gateway-api.sigs.k8s.io/。阅读角色、HTTPRoute、ReferenceGrant 与一致性支持。
  12. HTTP Semantics,RFC 9110:https://www.rfc-editor.org/rfc/rfc9110。核对方法语义、幂等与 Retry-After。
  13. HTTP/1.1,RFC 9112:https://www.rfc-editor.org/rfc/rfc9112。核对消息解析与请求边界。
  14. HTTP 429,RFC 6585:https://www.rfc-editor.org/rfc/rfc6585。核对 Too Many Requests 语义。
  15. JWT 最佳实践,RFC 8725:https://www.rfc-editor.org/rfc/rfc8725。核对算法约束与令牌验证边界。

附录 E 策略内核完整实现

本附录与配套工程文件同步生成,方便仅持有 EPUB 的读者复制实验。需要 Python 3.10 或以上版本。熔断模型采用连续失败阈值,不是滚动窗口错误率模型;同一时刻仅允许一个半开探测。生产代码需要并发同步与取消策略。

gateway_core.py

"""Educational policy kernel. Single-threaded; not an HTTP proxy."""
from dataclasses import dataclass
from math import isfinite

@dataclass(frozen=True)
class Route:
    id: str
    prefix: str
    upstream: str
    priority: int = 0

@dataclass(frozen=True)
class Snapshot:
    revision: int
    routes: tuple[Route, ...]

    def match(self, path: str):
        candidates = [r for r in self.routes
                      if r.prefix == '/' or path == r.prefix
                      or path.startswith(r.prefix + '/')]
        return min(candidates,
                   key=lambda r: (-r.priority, -len(r.prefix), r.id),
                   default=None)

class ConfigStore:
    def __init__(self):
        self.current = Snapshot(0, ())

    def publish(self, revision, routes):
        if not isinstance(revision, int) or revision <= self.current.revision:
            return False
        routes = tuple(routes)
        ids = set()
        for r in routes:
            if not r.id or r.id in ids:
                raise ValueError('empty or duplicate route id')
            ids.add(r.id)
            if not r.prefix.startswith('/') or (r.prefix != '/' and r.prefix.endswith('/')):
                raise ValueError('use canonical path prefixes')
            if r.upstream not in {'orders', 'inventory', 'payments'}:
                raise ValueError('upstream outside allowlist')
        # Real concurrent runtimes require their own publication primitive.
        self.current = Snapshot(revision, routes)
        return True

class TokenBucket:
    def __init__(self, rate, capacity, now=0.0):
        if not all(isfinite(x) for x in (rate, capacity, now)) or rate <= 0 or capacity <= 0:
            raise ValueError('positive finite rate and capacity required')
        self.rate, self.capacity = rate, capacity
        self.tokens, self.last = float(capacity), now

    def allow(self, now, cost=1.0):
        if not isfinite(now) or not isfinite(cost) or cost <= 0:
            raise ValueError('finite time and positive cost required')
        if now < self.last:
            raise ValueError('monotonic time required')
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens < cost:
            return False
        self.tokens -= cost
        return True

@dataclass(frozen=True)
class Ticket:
    generation: int
    serial: int
    probe: bool

class CircuitBreaker:
    """Consecutive failures, one half-open probe, explicit completion tickets.

    Generation rejects late results from requests admitted before a state change.
    Every admitted ticket must be completed once, including cancellation paths.
    """
    def __init__(self, threshold=3, recovery=5.0):
        if threshold < 1 or not isfinite(recovery) or recovery <= 0:
            raise ValueError('invalid breaker parameters')
        self.threshold, self.recovery = threshold, recovery
        self.state, self.failures = 'closed', 0
        self.opened_at = 0.0
        self.generation, self.serial = 0, 0
        self.pending = set()
        self.probe_busy = False
        self.last_time = float('-inf')

    def _time(self, now):
        if not isfinite(now) or now < self.last_time:
            raise ValueError('monotonic time required')
        self.last_time = now

    def _transition(self, state):
        self.state = state
        self.generation += 1
        self.pending.clear()
        self.probe_busy = False

    def acquire(self, now):
        self._time(now)
        if self.state == 'open':
            if now - self.opened_at < self.recovery:
                return None
            self._transition('half-open')
        probe = self.state == 'half-open'
        if probe and self.probe_busy:
            return None
        self.probe_busy = probe
        self.serial += 1
        ticket = Ticket(self.generation, self.serial, probe)
        self.pending.add(ticket)
        return ticket

    def complete(self, ticket, success, now):
        self._time(now)
        if ticket not in self.pending:
            return False  # duplicate, stale or unrecognized result
        self.pending.remove(ticket)
        if ticket.probe:
            if success:
                self._transition('closed')
                self.failures = 0
            else:
                self._transition('open')
                self.opened_at = now
        elif success:
            self.failures = 0
        else:
            self.failures += 1
            if self.failures >= self.threshold:
                self._transition('open')
                self.opened_at = now
        return True

if __name__ == '__main__':
    store = ConfigStore()
    store.publish(1, [Route('orders', '/api/orders', 'orders')])
    captured = store.current
    print('route:', captured.match('/api/orders/42'))
    bucket = TokenBucket(rate=1, capacity=2)
    print('burst:', [bucket.allow(0) for _ in range(3)])
    store.publish(2, [Route('inventory', '/api/items', 'inventory')])
    print('captured revision:', captured.revision, 'new revision:', store.current.revision)

test_gateway_core.py

import unittest
from gateway_core import Route, ConfigStore, TokenBucket, CircuitBreaker

class KernelTests(unittest.TestCase):
    def test_route_determinism_and_boundary(self):
        routes = [Route('z', '/api', 'orders'), Route('b', '/api/orders', 'orders'),
                  Route('a', '/api/orders', 'inventory')]
        for seq in (routes, list(reversed(routes))):
            s = ConfigStore(); s.publish(1, seq)
            self.assertEqual(s.current.match('/api/orders/42').id, 'a')
            self.assertIsNone(s.current.match('/apix'))
        s.publish(2, [*routes, Route('priority', '/', 'payments', 10)])
        self.assertEqual(s.current.match('/api/orders').id, 'priority')

    def test_snapshot_revision_and_rollback(self):
        s = ConfigStore(); old = [Route('a', '/a', 'orders')]
        self.assertTrue(s.publish(1, old))
        captured = s.current
        self.assertTrue(s.publish(2, []))
        self.assertFalse(s.publish(1, old))
        self.assertFalse(s.publish(2, old))
        self.assertIsNotNone(captured.match('/a'))
        self.assertIsNone(s.current.match('/a'))
        self.assertTrue(s.publish(3, old))
        self.assertIsNotNone(s.current.match('/a'))

    def test_reject_bad_config_without_partial_application(self):
        s = ConfigStore(); s.publish(1, [Route('a', '/a', 'orders')])
        for routes in ([Route('x', '/x', 'unknown')],
                       [Route('a', '/a', 'orders'), Route('a', '/b', 'inventory')],
                       [Route('x', '/x/', 'orders')]):
            with self.assertRaises(ValueError): s.publish(2, routes)
            self.assertEqual(s.current.revision, 1)

    def test_bucket_burst_refill_and_capacity(self):
        b = TokenBucket(2, 2)
        self.assertEqual([b.allow(0) for _ in range(3)], [True, True, False])
        self.assertFalse(b.allow(.49))
        self.assertTrue(b.allow(.5))
        self.assertTrue(b.allow(100, 2))
        self.assertFalse(b.allow(100))
        with self.assertRaises(ValueError): b.allow(99)
        with self.assertRaises(ValueError): b.allow(101, 0)

    def test_breaker_open_single_probe_and_recovery(self):
        b = CircuitBreaker(2, 5)
        for t in (0, 1): b.complete(b.acquire(t), False, t)
        self.assertEqual(b.state, 'open')
        self.assertIsNone(b.acquire(5.99))
        probe = b.acquire(6)
        self.assertIsNotNone(probe)
        self.assertIsNone(b.acquire(6))
        b.complete(probe, True, 6)
        self.assertEqual(b.state, 'closed')
        self.assertIsNotNone(b.acquire(6))

    def test_failed_probe_and_late_results(self):
        b = CircuitBreaker(1, 2)
        first, late = b.acquire(0), b.acquire(0)
        b.complete(first, False, 0)
        self.assertFalse(b.complete(late, True, 1))
        self.assertEqual(b.state, 'open')
        probe = b.acquire(2)
        b.complete(probe, False, 2)
        self.assertIsNone(b.acquire(3))
        next_probe = b.acquire(4)
        self.assertIsNotNone(next_probe)
        self.assertFalse(b.complete(probe, True, 4))
        b.complete(next_probe, True, 4)
        self.assertEqual(b.state, 'closed')

if __name__ == '__main__': unittest.main()

结语:设计的目标是可解释的行为

读完插件链,应该能够解释一个请求为什么继续或短路;读完匹配,应该能够解释为什么某条规则胜出;读完治理,应该能够解释为什么放行、拒绝或探测;读完配置,应该能够解释每个实例为何使用当前版本。

六种网关提供了不同的组织方式。长期可靠性来自清晰的边界、明确的失败语义和持续验证,而不是某一个产品名称。把这些要求落实为可观测的决策与可重复的实验,才算真正完成网关设计。