Rust 从零实现 API 网关:6 个核心组件的设计与踩坑
"我们有 20 多个微服务,前端要对接 20 个地址,跨域配置写了 20 遍,鉴权逻辑每个服务抄了一份,限流?有的服务写了有的没写。"
—— 一个真实团队的痛点
先说结论
API 网关的本质就是一堆中间件的有序组合。听起来简单,但每个组件都有设计决策和生产陷阱。本文从零实现 6 个核心组件:反向代理、路由、限流、熔断、负载均衡、认证,重点讲为什么这么设计,而不是"能跑就行"。
本文不是教你搭一个能转发请求的玩具代理,而是带你理解每个组件背后的设计权衡。
如果你还没读过 从 hyper 手写一个 HTTP 框架,建议先读那篇,本文大量使用 tower::Service/Layer 抽象。
架构全景
一个请求经过网关时的完整链路:
┌─────────────────────────────────────────────────────────────┐
│ API Gateway │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────┐ │
│ │ 路由 │→│ 认证 │→│ 限流 │→│ 熔断 │→│ 负载 │ │
│ │ Router │ │ Auth │ │ Limit │ │ Breaker│ │均衡 │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ └──────┘ │
│ ↑ │ │
│ │ 中间件洋葱模型 ↓ │
│ ┌────┴───┐ ┌──────────┐ │
│ │ Client │ │ Backend │ │
│ └────────┘ │ Services │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
每一层都是一个 tower::Service,请求从外到内穿过,响应回来反向穿出。这个抽象是整个网关的基石。
// 一切皆 Service —— tower 的核心 trait
// 你在 axum 里写的 handler、middleware,底层都是这个
pub trait Service<Request> {
type Response;
type Error;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}第一步:反向代理
最核心的功能——把请求转发到后端服务。
正向代理 vs 反向代理
先搞清楚一个容易混淆的概念:
正向代理(Forward Proxy): 反向代理(Reverse Proxy):
客户端知道目标服务器是谁 客户端不知道后端有几台服务器
Client → Proxy → Server Client → Proxy → Server A
↘→ Server B
"帮我访问 google.com" ↘→ Server C
代理代表客户端
"帮我访问 api.example.com"
代理代表服务器
API 网关是反向代理。客户端只看到网关的地址,不知道后端有多少个服务、地址是什么。这个"隐藏"是网关所有功能的基础——因为所有请求都必经网关,我们才能在网关层做路由、限流、熔断、认证。
反向代理的连接模型
转发请求时,网关和后端之间的连接怎么管理?有三种模型:
模型一:短连接(最简单,性能最差)
每个请求都新建 TCP 连接,用完就关
Client ──TCP──→ Gateway ──TCP──→ Backend (每次新建)
──TCP──→ Backend (每次新建)
问题:三次握手 + TLS 握手的开销 × 请求数
模型二:连接池复用(推荐)
Gateway 维护一个到每个后端的连接池
Client ──TCP──→ Gateway ═══池═══→ Backend (复用连接)
═══池═══→ Backend (复用连接)
好处:省去建连开销,配合 [连接池设计](./0624.md) 使用
模型三:HTTP/2 多路复用(最优,但复杂)
一个 TCP 连接上并发多个请求
Client ──TCP──→ Gateway ═══单连接═══→ Backend
Stream 1 ──→
Stream 2 ──→ (并发)
Stream 3 ──→
好处:无队头阻塞,连接数最少
经验法则:先用连接池(模型二),等 QPS 上去了再考虑 HTTP/2 多路复用。过早优化连接模型是浪费时间。
async fn reverse_proxy(req: Request<Body>) -> Result<Response<Body>, Infallible> {
let client = HttpClient::builder(TokioExecutor::new()).build_http();
// 把网关地址替换成后端地址,透传 header 和 body
let backend_uri = format!("http://127.0.0.1:8081{}", req.uri().path());
let mut proxy_req = Request::builder()
.method(req.method()).uri(backend_uri).body(req.into_body()).unwrap();
*proxy_req.headers_mut() = req.headers().clone();
match client.request(proxy_req).await {
Ok(resp) => Ok(resp),
Err(_) => Ok(Response::builder().status(502).body(Body::from("Bad Gateway")).unwrap()),
}
}看起来能跑对吧?踩坑时间。
坑一:Body 只能读一次
hyper 的 Body 是流式的,读完就没了。如果你的中间件需要先读 Body(比如认证要解析 JWT payload),转发时 Body 就是空的。
// ❌ body 被消费后,转发出去的是空 body
let body_bytes = req.body_mut().collect().await.unwrap().to_bytes();
// ✅ 读完后重新构造
let body_bytes = req.body_mut().collect().await.unwrap().to_bytes();
let (mut parts, _) = req.into_parts();
let new_req = Request::from_parts(parts, Body::from(body_bytes));经验法则:如果中间件需要读 Body,读完后用
Bytes重新构造。axum 用RequestParts+extensions缓存解决这个问题,我们也可以借鉴。
坑二:没有超时的代理是定时炸弹
后端服务挂了怎么办?请求会一直等。生产环境必须设超时。
// 设超时,网关层应该比后端服务超时短(后端 60s → 网关 30s)
let resp = timeout(Duration::from_secs(30), client.request(proxy_req)).await;经验法则:网关层超时应该比后端服务超时短。后端设 60s,网关设 30s。否则用户看到的是网关超时,但后端还在处理,造成资源浪费。
坑三:Header 透传的陷阱
反向代理不是"把所有 Header 原封不动转发"就完事了。有几个 Header 必须处理:
必须删除的 Header:
- Hop-by-hop headers(Connection, Keep-Alive, TE...)
这些 Header 只在相邻两跳之间有意义,不应该透传到后端
必须添加的 Header:
- X-Forwarded-For: 客户端真实 IP(后端日志、限流都需要)
- X-Forwarded-Proto: 原始协议(http/https)
- X-Forwarded-Host: 原始 Host(后端生成链接时需要)
- X-Request-ID: 请求追踪 ID(贯穿整个调用链)
不应该透传的 Header:
- Authorization: 已经解析过了,把用户信息塞 extensions 更安全
透传 token 意味着后端也能拿到 secret,扩大了攻击面
fn prepare_proxy_headers(original: &HeaderMap, client_ip: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
// 过滤 hop-by-hop headers(只在相邻两跳有意义,不应透传)
let hop_by_hop = ["connection", "keep-alive", "te", "transfer-encoding"];
for (key, value) in original.iter() {
if !hop_by_hop.contains(&key.as_str()) {
headers.insert(key.clone(), value.clone());
}
}
// 添加代理 Header
headers.insert("x-forwarded-for", client_ip.parse().unwrap());
headers.insert("x-request-id", Uuid::new_v4().to_string().parse().unwrap());
headers
}经验法则:Header 透传要"选择性",不是"全量"。删除 hop-by-hop,添加代理 Header,隐藏敏感 Header。这是反向代理的基本功。
第二步:路由系统
路由是网关的入口,决定请求去哪个后端。听起来就是字符串匹配,但"怎么匹配"直接影响性能和正确性。
路由匹配的三种实现
方案一:遍历正则(最直观,最慢)
对每条路由规则编译一个正则,请求来了逐个匹配
复杂度:O(n),n = 路由数量
问题:100 条路由,最坏情况匹配 100 次
方案二:前缀树 / Trie(中等复杂度,较快)
把路由路径拆成字符/段,构建树形结构
/api/users → [api] → [users] → handler
/api/orders → [api] → [orders] → handler
共享前缀 "/api" 只存一次
复杂度:O(k),k = 路径长度,与路由数量无关
方案三:压缩前缀树 / Radix Tree(matchit 用的)
在 Trie 基础上压缩单子节点路径
/api/users 和 /api/orders 共享 "/api/" 前缀
只在分叉处拆分
复杂度:O(k),但常数更小,内存更省
普通 Trie: 压缩 Radix Tree (Radix Tree):
/ /
a api/
p ├─ users/
i └─ orders/
/ \
u o
s r
e d
r e
s r
s s
matchit 库用的就是 Radix Tree,加上对通配符 {param} 和通配路径 *rest 的支持。这也是为什么它比正则匹配快一个数量级。
use matchit::Router as MatchitRouter;
// 注册路由:path → backend_url
let mut router = MatchitRouter::new();
router.insert("/api/users/*", "http://user-service:8081".to_string()).unwrap();
router.insert("/api/orders/*", "http://order-service:8082".to_string()).unwrap();
// 匹配:Radix Tree O(k),k = 路径长度,与路由数量无关
let matched = router.at("/api/users/123").unwrap();
println!("{}", matched.value); // "http://user-service:8081"坑三:路由优先级冲突
当 /api/v1/users 和 /api/v1/* 同时存在时,谁先匹配?
大多数路由库(包括 matchit)的规则是:精确匹配优先于通配符。但如果你自己实现路由,很容易搞反。
路由表:
/api/v1/users → user-service
/api/v1/* → default-service
请求 /api/v1/users → 命中 user-service ✅(精确优先)
请求 /api/v1/orders → 命中 default-service ✅(通配符兜底)
经验法则:路由注册顺序很重要。先注册精确路由,再注册通配符路由。用成熟的路由库(matchit、actix-router)而不是自己手写字符串匹配。
动态路由
生产环境路由表不能写死,要支持热更新:
// 生产环境路由表不能写死,用 RwLock 支持热更新
struct DynamicRouter {
routes: Arc<RwLock<MatchitRouter<String>>>,
}
// 写入时获取写锁,原子替换整个路由表;读取时无锁并发第三步:限流
没有限流的网关,就是给后端服务挖坑。
为什么需要限流?——排队论视角
还记得连接池文章里提到的 Little's Law 吗?L = λ × W(系统中平均请求数 = 到达速率 × 平均处理时间)。
限流的本质是控制 λ(到达速率)。当 λ 超过后端承载能力时:
λ(到达速率) > μ(服务能力) → 队列无限增长 → 内存溢出 → 全部崩溃
限流后:
λ'(限流后的速率) ≤ μ → 多余请求快速拒绝(429) → 已接受的请求正常处理
残酷的事实:限流不是"拒绝用户",而是"保护已接入的用户"。不限流 = 所有人一起死。
固定窗口的边界突刺问题
最简单的限流:每秒重置计数器。但这个方案有个致命缺陷——窗口边界突刺。
时间线: |---- 第1秒 ----|---- 第2秒 ----|
请求分布: ●●●●●●●●●● ●●●●●●●●●●
↑ 第1秒末尾 ↑ 第2秒开头
10个请求 10个请求
全部放行 全部放行
问题:在第1秒末尾 + 第2秒开头的200ms内,涌入了20个请求!
实际瞬时速率达到 100 QPS,但限流配置的是 10 QPS
这就是为什么固定窗口被淘汰。滑动窗口和令牌桶都是为了解决这个问题。
三种算法深度对比
| 算法 | 实现原理 | 时间复杂度 | 核心差异 |
|---|---|---|---|
| 固定窗口 | 计数器+时间窗 | O(1) | 窗口边界有突刺,简单粗暴 |
| 滑动窗口 | 环形数组存历史 | O(w) | w=窗口数,精确但内存开销大 |
| 令牌桶 | 按速率填充令牌 | O(1) | 天然平滑,允许突发(桶内存量) |
| 漏桶 | 固定速率流出 | O(1) | 严格匀速,不允许突发 |
令牌桶 vs 漏桶——很多人搞混:
令牌桶(Token Bucket): 漏桶(Leaky Bucket):
令牌以速率 r 填充 请求以速率 r 流出
请求来了有令牌就放行 请求来了先进队列,按固定速率处理
允许突发(桶里攒了令牌就能瞬间放行) 不允许突发(永远匀速输出)
适合:API 限流(允许短时突发) 适合:流量整形(严格匀速)
经验法则:API 网关用令牌桶——用户偶尔发个突发请求是正常的,不应该被拒绝。流量整形(比如视频流带宽控制)用漏桶。
令牌桶工作原理:
┌─────────────────┐
│ Token Bucket │
│ │
│ ○ ○ ○ ○ ○ │ ← 令牌以固定速率填充
│ │
│ capacity: 10 │ ← 桶容量(允许的突发量)
│ rate: 100/s │ ← 填充速率(稳态 QPS)
└────────┬─────────┘
│
↓ 每个请求消耗一个令牌
┌────────────────┐
│ Request │
└────────────────┘
令牌够 → 放行
令牌不够 → 拒绝 (429 Too Many Requests)
核心逻辑很简单——按时间差补充令牌,有令牌就放行:
// DashMap 保证并发安全,每个 key 独立计数
struct RateLimiter {
buckets: DashMap<String, (f64, Instant)>, // key → (剩余令牌, 上次刷新)
capacity: f64, // 桶容量(允许的突发量)
rate: f64, // 每秒填充令牌数
}
impl RateLimiter {
fn allow(&self, key: &str) -> bool {
let mut entry = self.buckets.entry(key.to_string()).or_insert((self.capacity, Instant::now()));
let (tokens, last_refill) = entry.value_mut();
// 按时间差补充令牌,不超过桶容量
*tokens = self.capacity.min(*tokens + last_refill.elapsed().as_secs_f64() * self.rate);
*last_refill = Instant::now();
if *tokens >= 1.0 { *tokens -= 1.0; true } else { false }
}
}坑四:限流粒度选错
限流粒度不同,效果天差地别:
| 粒度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 按 IP | 简单,防刷 | 误伤 NAT 用户(公司出口 IP 相同) | 开放 API |
| 按用户 ID | 精准 | 需要先鉴权 | 登录用户 |
| 按 API 路径 | 保护特定接口 | 无法防单用户滥用 | 后端保护 |
经验法则:组合使用。对外 API 用"按 IP + 按路径"双层限流。对内网关用"按用户 ID"即可。千万别只用一种。
坑五:分布式限流的幻觉
上面的实现是单机限流。多实例部署时,每个实例独立计数,限流形同虚设。
分布式方案:Redis + Lua 脚本保证原子性——读取令牌数、判断、扣减在一个脚本里完成,避免竞态。
经验法则:单机限流用
dashmap,分布式用 Redis Lua。不要用 Redis 的INCR + EXPIRE做令牌桶,窗口边界会有突刺问题。
第四步:熔断器
限流是"控制进来的量",熔断是"检测后端的健康"。后端挂了还继续转发请求,只会让雪崩扩散。
为什么需要熔断?——级联故障
假设你有三个服务:A → B → C。C 挂了:
时间线:
T0: C 服务挂了,所有请求超时
T1: B 服务的连接池被 C 的超时请求占满(每个请求等 30s 才超时)
T2: A 服务的连接池被 B 的超时请求占满
T3: 用户看到的是 A 服务也挂了
C 挂 → B 慢 → A 挂 → 整个系统雪崩
这就是级联故障(Cascading Failure)。熔断器的作用是:快速失败,不让故障扩散。
熔断器 vs TCP 拥塞控制
熔断器的三态模型和 TCP 拥塞控制惊人地相似:
TCP 拥塞控制: 熔断器:
慢启动 → 拥塞避免 → 快速重传 Closed → Open → Half-Open
检测到丢包 → 减半发送速率 检测到高错误率 → 停止转发
逐步恢复发送速率 逐步恢复流量
核心思想一样:遇险则退,确认安全再进
设计哲学:熔断器不是"后端挂了就永远不访问",而是"给后端喘息的时间,然后小心翼翼地试探"。这个"试探"的策略,决定了熔断器的质量。
三态模型详解
错误率超过阈值
┌──────────┐ ──────────────→ ┌──────────┐
│ Closed │ │ Open │
│ (正常放行) │ ←────────────── │ (全部拒绝) │
└──────────┘ 探测成功 └────┬─────┘
↑ │
│ 超时后试探 │
└────────────────────────────┘
↓
┌───────────┐
│ Half-Open │
│(部分放行) │
└───────────┘
核心是三态状态机 + 滑动窗口记录请求结果:
struct CircuitBreaker {
state: State, // Closed / Open / HalfOpen
failure_threshold: f64, // 错误率阈值,如 0.5
cooldown: Duration, // Open → Half-Open 的冷却时间
window: VecDeque<(Instant, bool)>, // 滑动窗口:最近 N 次请求结果
}
impl CircuitBreaker {
fn should_allow(&mut self) -> bool {
match &self.state {
State::Closed => true,
State::Open { opened_at } => {
if opened_at.elapsed() >= self.cooldown {
self.state = State::HalfOpen { remaining: 3 };
true // 冷却结束,试探
} else { false }
}
State::HalfOpen { remaining } => *remaining > 0,
}
}
}坑六:Half-Open 恢复太激进
熔断器打开后,冷却期一过就恢复所有流量?如果后端只是"稍微好了一点",瞬间涌入大量请求又会把它打挂。
❌ 错误策略:Open → Half-Open → 立刻放行所有流量
✅ 正确策略:Open → Half-Open → 逐步放行(先 10%,再 30%,再 100%)
渐进恢复的思路——指数退避 + 放行比例递增:第 1 次试探放行 10%,成功后第 2 次放 20%,第 3 次 40%,逐步恢复到 100%。任何一次失败立刻回到 Open 状态,冷却时间加倍(10s → 20s → 40s,设上限)。
冷却时间也应该指数退避——第一次熔断等 10s,第二次等 20s,第三次等 40s。避免后端持续不稳定时,熔断器反复开关(这叫flapping)。
冷却时间序列:10s → 20s → 40s → 80s(上限)→ 持续 80s
↑
设置上限,别等到天荒地老
经验法则:Half-Open 状态应该渐进式恢复。先放少量请求试探,成功率达标后再逐步放开。像 TCP 拥塞控制一样——慢启动。冷却时间用指数退避,但要设上限。
滑动窗口的时间粒度
上面的实现用"最近 N 次请求"做滑动窗口。但还有一个选择:基于时间的滑动窗口。
基于计数的窗口:最近 100 个请求中错误率 ≥ 50%
优点:实现简单
缺点:请求少的时候不灵敏(100 个请求可能跨越 10 分钟)
基于时间的窗口:最近 30 秒内错误率 ≥ 50%
优点:时间维度可控,对低流量场景友好
缺点:需要定时清理过期数据,实现稍复杂
经验法则:高 QPS 服务用基于计数的窗口(简单高效),低 QPS 服务用基于时间的窗口(否则窗口跨度过长)。生产环境一般用分段滑动窗口(把 30s 分成 6 个 5s 的小窗口,滚动淘汰),兼顾精度和性能。
第五步:负载均衡
路由决定了请求去哪个服务,负载均衡决定了去哪个实例。
负载均衡算法
| 算法 | 原理与适用场景 |
|---|---|
| 轮询 | 依次分配,最简单。适合所有实例配置相同 |
| 加权轮询 | 按权重分配,权重高的分更多请求。适合异构集群 |
| 最少连接 | 分给当前连接数最少的实例。适合长连接场景 |
| 一致性哈希 | 相同 key 总是路由到同一实例。适合有状态服务 |
| 随机 | 随机选一个。适合大规模集群(大数定律抹平差异) |
一致性哈希——为什么它重要
普通哈希 % 实例数 有一个致命问题:实例数变化时,几乎所有请求的映射都会变。
普通哈希:hash(user_id) % 3 → [A, B, C]
新增实例 D:hash(user_id) % 4 → [A, B, C, D]
几乎所有 user_id 的映射都变了 → 缓存全部失效
一致性哈希:将实例和请求都映射到同一个哈希环上
请求顺时针找到的第一个实例就是目标
新增/删除实例只影响相邻区间的请求
A
╱ ╲
╱ ╲
╱ ╲
C ──────── B
╲ ╱
╲ ╱
╲ ╱
D
请求 R1 → 顺时针 → 命中 A
请求 R2 → 顺时针 → 命中 B
新增 E 在 A 和 B 之间 → 只有原来映射到 B 的部分请求受影响
健康检查
负载均衡器必须知道哪些实例是活的:
主动健康检查(推荐):
Gateway 每隔 N 秒向所有实例发 /health 请求
连续 M 次失败 → 标记为不可用,从负载均衡池移除
恢复后自动加回
被动健康检查:
根据实际请求的失败率判断
与熔断器配合:熔断器标记的不可用实例,负载均衡器自动跳过
经验法则:主动健康检查间隔设 5-10 秒,被动健康检查(熔断器)做兜底。两者互补——主动检查发现慢,但稳定;被动检查发现快,但可能误判。
坑八:优雅下线——连接排空
发布新版本时,旧实例要下线。但旧实例上还有正在处理的请求!如果直接杀掉:
❌ 错误做法:
1. 发送 SIGKILL 杀掉旧实例
2. 正在处理的请求全部中断
3. 用户看到 502
✅ 正确做法:连接排空(Connection Draining)
1. 负载均衡器停止向旧实例发新请求
2. 旧实例继续处理完已有请求
3. 等待排空超时(如 30s)
4. 超时后强制关闭剩余连接
5. 下线旧实例
经验法则:负载均衡器必须支持连接排空。排空时间设为后端最大请求处理时间的 1.5 倍。这是零停机发布的前提。
第六步:认证中间件
网关统一处理认证,后端服务只管业务逻辑。
JWT 的结构——为什么它适合网关
JWT(JSON Web Token)由三部分组成,用 . 分隔:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.SflKxwRJSMeKKF2QT4fwpM
Header(算法声明) Payload(业务数据) Signature(签名)
{"alg":"HS256"} {"user_id":123} HMAC-SHA256(header + "." + payload, secret)
为什么 JWT 特别适合网关层认证?
传统 Session 认证:
客户端 → 带 Cookie → 网关 → 查 Redis 验证 Session → 放行
问题:每次请求都要查 Redis,网关和 Session 存储强耦合
JWT 认证:
客户端 → 带 Bearer Token → 网关 → 本地验签(不查任何存储)→ 放行
优势:无状态,网关不需要访问数据库/Redis,只需要 secret key
关键洞察:JWT 的签名验证是纯 CPU 计算,不需要网络调用。这意味着网关层认证的延迟是微秒级的,不会成为瓶颈。
签名验证的数学原理
JWT 的安全性依赖于单向函数——知道 secret 可以算出签名,但知道签名无法反推出 secret:
签名 = HMAC-SHA256(header + "." + payload, secret)
验证过程:
1. 拆分 token 为 header.payload.signature
2. 用同样的 secret 计算 HMAC-SHA256(header + "." + payload)
3. 比对计算结果和 token 中的 signature
4. 一致 → 未被篡改;不一致 → 拒绝
为什么安全?
攻击者改了 payload(比如 user_id: 123 → 999)
但没有 secret,无法算出正确的签名
网关验签失败 → 拒绝请求
Access Token + Refresh Token 双令牌策略
JWT 一旦签发就无法撤销(除非维护黑名单)。所以生产环境一般用双令牌:
┌─────────────────────────────────────────────────────────┐
│ Access Token(短命,15min) │
│ - 每次请求携带,网关本地验签 │
│ - 过期后用 Refresh Token 换新的 │
├─────────────────────────────────────────────────────────┤
│ Refresh Token(长命,7d) │
│ - 只在 /auth/refresh 接口使用 │
│ - 存在服务端(Redis),可以主动撤销 │
└─────────────────────────────────────────────────────────┘
登录流程:
Client → POST /auth/login → Server
← { access_token, refresh_token }
正常请求:
Client → GET /api/users (Authorization: Bearer access_token) → Gateway
→ 本地验签 → 放行
Access Token 过期:
Client → GET /api/users → Gateway → 401
Client → POST /auth/refresh (refresh_token) → Server → 新 access_token
Client → 重试原请求
经验法则:Access Token 设 15 分钟,Refresh Token 设 7 天。Access Token 过期时间越短,泄露后的影响窗口越小。千万不要把 Refresh Token 放在前端 localStorage——用 HttpOnly Cookie。
用 tower 的 Layer + Service 模式实现,核心逻辑就三步:提取 token → 验签 → 注入用户信息:
fn call(&mut self, mut req: Request<Body>) -> Self::Future {
let secret = self.secret.clone();
let mut inner = self.inner.clone();
Box::pin(async move {
let token = req.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match token {
Some(token) => {
let key = DecodingKey::from_secret(secret.as_bytes());
match decode::<Claims>(token, &key, &Validation::default()) {
Ok(data) => {
req.extensions_mut().insert(data.claims); // 用户信息塞 extensions
inner.call(req).await
}
Err(_) => Ok(Response::builder().status(401).body(Body::from("Invalid Token")).unwrap()),
}
}
None => Ok(Response::builder().status(401).body(Body::from("Missing Token")).unwrap()),
}
})
}坑七:认证失败的响应格式不统一
网关返回 {"error": "Invalid Token"},后端返回 {"code": 401, "msg": "未授权"}。前端要写两套错误处理。
经验法则:网关层的错误响应格式必须和后端统一。建议定义一个标准错误结构体,网关和所有后端服务共用。
组合成完整网关
中间件的洋葱模型
中间件不是"流水线",而是"洋葱"。每一层都可以在请求到达内层之前和响应回到外层之后做处理:
请求进入 ───────────────────────────────────────────→
│ │
│ Auth: 检查 token │
│ │ │ │
│ │ RateLimit: 检查配额 │ │
│ │ │ │ │ │
│ │ │ Breaker: 检查后端状态 │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ Proxy: 转发 │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ ↓ │ │ │ │ │
│ │ │ │ 后端响应 │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ 添加响应头 │ │ │ │ │
│ │ │ └────┘ │ │ │ │ │
│ │ │ 记录熔断状态 │ │ │ │ │
│ │ └────────┘ │ │ │ │ │
│ │ 记录限流计数 │ │ │ │ │
│ └────────────┘ │ │ │ │ │
│ 注入用户信息到 extensions │ │ │ │ │
└────────────────────────┘ │ │ │ │ │
←────────────────────────────────────────────────── 响应返回
这就是为什么 tower 用 Layer 而不是简单的"管道"——每一层可以包装内层,拥有完全的控制权。
Layer 的组合数学
tower::Layer 的强大之处在于组合性。每个 Layer 只做一件事,但可以自由组合:
如果顺序反了:RateLimit → Auth
匿名请求也会消耗限流配额 → 恶意用户用无效 token 就能打满限额
经验法则:层的顺序遵循"越便宜的检查越靠前"原则。Auth(CPU 微秒级)→ RateLimit(内存操作)→ CircuitBreaker(内存操作)→ Proxy(网络调用)。快速拒绝的层放前面,避免不必要的网络开销。
用 tower 的 ServiceBuilder 把组件串起来,顺序就是请求的执行顺序:
let gateway = ServiceBuilder::new()
.layer(AuthLayer { secret: "my-secret".into() }) // ① 先认证
.layer(RateLimitLayer::new(1000, 100)) // ② 再限流
.layer(CircuitBreakerLayer::new(0.5, 100, Duration::from_secs(30))) // ③ 熔断
.service(ReverseProxyService::new(router)); // ④ 最后转发层的顺序很重要:
请求进入 → Auth → RateLimit → CircuitBreaker → LoadBalancer → Proxy → 后端
↑ ↑
│ │
认证通过才限流 选一个健康的实例
经验法则:Auth 放最外层——未认证的请求不应该消耗限流配额。CircuitBreaker 放最内层——它保护的是后端,应该在最后一步才判断。
与现成方案的对比
什么时候自研,什么时候用现成方案?
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 自研网关 | 业务逻辑定制多 | 完全可控,轻量 | 需要维护,功能少 |
| Kong | 标准 API 管理 | 生态丰富,插件多 | 依赖 Nginx+Lua |
| APISIX | 高性能场景 | 性能强,插件热更 | 学习曲线 |
| Envoy | Service Mesh | 协议支持全面 | 配置复杂,C++ |
| axum+tower | Rust 技术栈 | 类型安全,零开销 | 需要自己组合 |
我的建议:
- 团队小、需求简单 → 直接用 Kong/APISIX,别造轮子
- 需要深度定制、嵌入业务逻辑 → 自研,用 axum+tower 起步
- 已经是 Rust 技术栈 → 自研收益最大,代码复用率高
总结:你的网关需要哪些组件?
六个问题帮你决策:
- 需要反向代理吗? → 只要有多服务就需要,这是网关的根基
- 需要路由吗? → 单体应用不需要,微服务必须有
- 需要限流吗? → 对外 API 必须有,内网可以后做
- 需要熔断吗? → 后端有强依赖(调用链超过 3 层)才需要
- 需要负载均衡吗? → 后端多实例就必须有
- 需要认证吗? → 有用户体系就必须有
从最简单的反向代理开始,按需添加组件。不要一开始就造一个"万能网关"。
本文涉及的原理清单
| 原理 | 出处 | 核心思想 |
|---|---|---|
| 排队论 Little's Law | 限流 | L = λ × W,控制到达速率 |
| 令牌桶 vs 漏桶 | 限流 | 突发容忍 vs 严格匀速 |
| 固定窗口边界突刺 | 限流 | 窗口边界瞬时速率翻倍 |
| Radix Tree | 路由 | 压缩前缀树,O(k) 匹配 |
| 级联故障 | 熔断 | 一个服务挂,全链路雪崩 |
| 三态模型 | 熔断 | Closed → Open → Half-Open |
| 指数退避 | 熔断 | 避免 flapping(反复开关) |
| 滑动窗口 | 熔断/限流 | 基于计数 vs 基于时间 |
| JWT 签名验证 | 认证 | 单向函数,本地验签无网络调用 |
| 双令牌策略 | 认证 | Access Token 短命 + Refresh Token 长命 |
| 洋葱模型 | 中间件 | 请求层层进入,响应反向穿出 |
| 一致性哈希 | 负载均衡 | 实例增减只影响相邻区间 |
| Hop-by-hop Header | 反向代理 | 只在相邻两跳有意义的 Header |
最后的教训:网关本身不产生业务价值,它的价值是让后端服务更稳定、更安全。如果你的团队只有 3 个服务、日活 1000,一个 nginx.conf 就够了。等规模上来了再考虑自研。
相关文章:
- 从 hyper 手写一个 HTTP 框架 —— 理解 tower::Service 的底层机制
- 连接池设计原理 —— 网关转发请求时的连接管理
- 微服务架构实战 —— 网关在微服务中的定位
- 可观测性实战 —— 网关的监控与告警