从0到1搭建 API 网关:限流+鉴权+路由+日志,手写一个迷你 Gateway
引言
团队新来了个挺优秀的同学,简历上写着"深度使用 Spring Cloud Gateway"。线上网关出了个诡异问题:某个接口偶发 502,我问他"转发这块的连接池配置在哪",他愣了一下:"网关不就是个配置文件吗?"
不是嘲讽谁。用框架和懂原理,从来是两回事。 Spring Cloud Gateway 把 3000 多个类打包成一个 starter,也把"网关到底怎么工作"这件事打包黑盒里了:
- 为什么全局过滤器(GlobalFilter)的 order 会决定整个链路行为?
- 为什么网关的 EventLoop 线程上绝不能写阻塞代码?
- 令牌桶限流怎么做到并发下"不超卖"?
- 负载均衡的"平滑加权"和普通加权差在哪?
- 一次转发请求头要改写哪些字段,漏改一个就是 502?
这些问题,读框架源码要翻几千行;自己写一个 800 行的迷你网关,全部答案扑面而来。
所以这篇文章我们做一件事:不依赖 Spring Cloud Gateway,甚至不依赖 Spring,用 Netty + 800 行核心代码,手写一个能跑的网关,包含完整四大能力:路由转发(YAML 路由表)、令牌桶限流、JWT 鉴权、请求/响应日志,外加三种负载均衡(轮询/随机/平滑加权)。
最终效果先睹为快:
$ curl "http://localhost:8080/api/login?user=tom"
{"token":"eyJhbGciOiJIUzI1NiJ9..."}
$ curl -H "Authorization: Bearer eyJhbGciOi..." http://localhost:8080/api/user/123
{"service":"user-service-1","port":9001,...} # 再打一次变成 user-service-2,轮询生效
$ curl http://localhost:8080/api/user/123
{"code":401,"message":"缺少 Bearer Token"} # 鉴权生效
# /api/order/** 限流 5 次/秒,连打 11 次后:
{"code":429,"message":"请求过于频繁,请稍后再试"} # 限流生效
完整源码已整理上传 GitHub(地址见文末),每章代码都可独立运行。开始之前记住一句话:我们的目的不是重复造轮子,而是把轮子拆开,看看辐条是怎么编的。
一、网关到底干了什么:先拆解,再动手
1.1 网关的本质
扒掉所有花哨功能,API 网关的本质是一个可编程的反向代理:
┌─────────────── API 网关 ────────────────┐
客户端 ────→│ 日志 → 限流 → 鉴权 → 路由匹配 → 负载均衡 │────→ 上游服务 A
│ (策略层) (转发层) │
└─────────────────────────────────────────┘
- 反向代理:替上游服务收请求、转发、回传响应(Nginx 干的事)
- 可编程:在"收"和"转"之间插入任意策略逻辑(Nginx 需要写 Lua/插件才能干的事)
所有网关——Nginx、Spring Cloud Gateway、Kong、自研——万变不离这个模型。差别只在:策略怎么声明(注解/YAML/插件)、转发用什么实现(同步/异步)、策略和转发怎么编排。
1.2 一个请求的完整生命周期
我们迷你网关的处理流水线,后面每一章对应其中一格:
① 客户端连接
↓ Netty Worker EventLoop(非阻塞,全程不切换线程)
② HttpServerCodec 解码 HTTP 字节流 → HttpRequest 对象
↓
③ HttpObjectAggregator 聚合请求体 → FullHttpRequest(完整请求)
↓
④ GatewayFrontHandler 网关入口,串起全部业务逻辑:
│
├─ ⑤ 过滤器链 pre:访问日志 → 令牌桶限流 → JWT 鉴权(任一短路直接回 401/429)
├─ ⑥ 路由匹配:按最长前缀命中路由表
├─ ⑦ 负载均衡:轮询/随机/加权选出一个上游实例
├─ ⑧ 异步转发:ProxyClient 连接上游、改写请求头、发送
├─ ⑨ 收上游响应 → 过滤器链 post(打印耗时日志)
└─ ⑩ 回写客户端
两个设计决策提前说清楚,它们决定了整个代码骨架:
- 全异步:⑧ 转发是网络 IO,绝不能在 EventLoop 线程里同步等待——用
CompletableFuture挂回调,转发期间线程继续服务别的请求。这是 Netty 网关和"Servlet 网关"最根本的差异 - FullHttpRequest 聚合:demo 级实现把请求体完整聚合到内存再转发(简单可靠);流式转发(边收边发)是生产优化项,文末清单里会提
1.3 技术选型:为什么是 Netty + 零 Spring
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| Spring Cloud Gateway | 功能全、生态好 | 黑盒,学不到转发细节;组件重 | 本文要打破的对象 |
| Servlet(Tomcat + HttpClient) | 写法熟悉 | 每请求占一个线程,同步模型,体会不到"网关为什么快" | 体会本质用 |
| Netty 裸写 | 异步模型一览无余,依赖仅 3 个 | 要自己处理协议细节(codec 已备好) | 本文选择 |
依赖清单(总共 3 个核心库):
<dependencies>
<!-- 网络 IO 与 HTTP 编解码 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.112.Final</version>
</dependency>
<!-- JWT 校验 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<!-- YAML 路由表解析 -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.2</version>
</dependency>
<!-- 日志(略 slf4j/logback 两个常规依赖) -->
</dependencies>
JDK 要求 17+(用了 record、switch 表达式、instanceof 模式匹配)。
二、项目结构总览
先上完整目录,后面逐模块讲解(GitHub 同款结构):
mini-gateway
├── pom.xml
└── src
├── main
│ ├── java/space/jiangyi/gateway
│ │ ├── MiniGatewayApplication.java # 启动入口
│ │ ├── config
│ │ │ ├── GatewayConfigLoader.java # YAML 加载 + 校验
│ │ │ ├── RouteParser.java # routes 节点解析
│ │ │ └── model/GatewayProperties.java
│ │ ├── server
│ │ │ ├── GatewayServer.java # Netty 启动 + pipeline 组装
│ │ │ └── GatewayFrontHandler.java # 入口 Handler(核心编排)
│ │ ├── route
│ │ │ ├── RouteTable.java # 路由表(最长前缀匹配)
│ │ │ ├── model/{Route, ServiceInstance}.java
│ │ │ └── lb/LoadBalancer.java # 轮询/随机/平滑加权
│ │ ├── ratelimit
│ │ │ ├── TokenBucket.java # 无锁令牌桶
│ │ │ └── RateLimitManager.java # 规则匹配 + 分桶
│ │ ├── auth
│ │ │ ├── JwtVerifier.java # 验签/验过期
│ │ │ └── context/RequestContext.java
│ │ ├── filter
│ │ │ ├── GatewayFilter.java # 过滤器接口
│ │ │ ├── FilterChain.java # 过滤器链
│ │ │ └── impl/{AccessLogFilter, RateLimitFilter, JwtAuthFilter}.java
│ │ ├── proxy
│ │ │ └── ProxyClient.java # HTTP 转发客户端
│ │ ├── util/{PathMatchUtil, IpUtil}.java
│ │ └── demo/DemoBackends.java # 本地演示后端
│ └── resources/application.yml # 路由表 + 限流 + JWT 配置
└── test/java/space/jiangyi/gateway
├── TokenBucketTest.java # 并发不超卖验证
└── RouteTableTest.java # 匹配与负载均衡验证
模块职责一览:
| 模块 | 职责 | 对应章节 |
|---|---|---|
| config | YAML → 配置模型,启动时校验 | 三 |
| route + lb | 路由表、实例模型、负载均衡 | 三、四 |
| ratelimit | 令牌桶算法、按维度分桶 | 五 |
| filter + auth | 过滤器链、JWT 鉴权 | 六 |
| server + proxy | Netty 服务器、转发客户端 | 七 |
| demo | 三个本地后端 + 登录签发 JWT | 八 |
三、基础模块:配置加载与路由表
3.1 路由表长什么样(application.yml)
gateway:
port: 8080 # 网关监听端口
jwt:
secret: "mini-gateway-demo-secret-key-please-change-me-32bytes"
white-list: # 免鉴权路径
- "/api/login"
- "/api/health"
routes: # 路由表:前缀匹配,转发时剥离前缀
- id: user-service
prefix: /api/user
lb: ROUND_ROBIN
instances:
- "http://127.0.0.1:9001"
- "http://127.0.0.1:9002"
- weight: 3 # 支持加权
url: "http://127.0.0.1:9003"
- id: order-service
prefix: /api/order
lb: WEIGHTED
instances:
- weight: 3
url: "http://127.0.0.1:9101"
- weight: 1
url: "http://127.0.0.1:9102"
语义:GET /api/user/123 命中 user-service,剥离 /api/user 前缀后,实际转发到 http://127.0.0.1:900x/123。
3.2 路由模型:两个小类
/** 服务实例:URL + 权重 */
public record ServiceInstance(String url, int weight) {
public ServiceInstance {
if (weight < 1) weight = 1;
}
public static ServiceInstance of(String url) { return new ServiceInstance(url, 1); }
public java.net.URI toUri(String pathWithQuery) {
return java.net.URI.create(url + pathWithQuery);
}
}
/** 路由:前缀 → 实例列表 + 负载均衡策略 */
public class Route {
private String id;
private String prefix;
private String lb; // ROUND_ROBIN / RANDOM / WEIGHTED
private List<ServiceInstance> instances;
// getter/setter 略
/** /api/user/123 是否命中前缀 /api/user */
public boolean matches(String path) {
return path.equals(prefix) || path.startsWith(prefix + "/");
}
/** 剥离前缀:/api/user/123?d=1 → /123?d=1(query 要保留!) */
public String stripPrefix(String path) {
int q = path.indexOf('?');
String query = q >= 0 ? path.substring(q) : "";
String p = q >= 0 ? path.substring(0, q) : path;
String stripped = p.substring(prefix.length());
return (stripped.startsWith("/") ? stripped : "/" + stripped) + query;
}
}
stripPrefix 里保留 query string 是容易被忽略的细节——/api/user/123?detail=true 剥离后必须还是 /123?detail=true,丢了 query 就是线上 bug。
3.3 配置加载与路由表(最长前缀匹配)
/** 路由表:构建时按前缀长度倒排,匹配即"最长前缀优先" */
public class RouteTable {
private final List<Route> routes;
public RouteTable(List<Route> routes) {
// /api/user 比 /api 更长,排在前面优先命中——最长前缀匹配的全部秘密
this.routes = routes.stream()
.sorted(Comparator.comparingInt((Route r) -> r.getPrefix().length()).reversed())
.toList();
}
public Route match(String path) {
for (Route route : routes) {
if (route.matches(path)) return route;
}
return null;
}
}
YAML 解析用 snakeyaml 读完整个 Map 后手动绑定(结构简单,几十行,不用引 Spring)。同时做启动期校验:路由前缀冲突直接拒绝启动——配置错误在发布时暴露,比运行时 404 好 100 倍。
private static void validate(GatewayProperties props) {
long distinct = props.getRoutes().stream().map(Route::getPrefix).distinct().count();
if (distinct != props.getRoutes().size()) {
throw new IllegalStateException("路由前缀冲突:相同 prefix 只允许出现一次");
}
}
四、负载均衡:轮询、随机与平滑加权
4.1 统一接口 + 三实现
public interface LoadBalancer {
ServiceInstance choose(List<ServiceInstance> instances);
static LoadBalancer of(LoadBalanceType type) {
return switch (type) {
case ROUND_ROBIN -> new RoundRobinLoadBalancer();
case RANDOM -> new RandomLoadBalancer();
case WEIGHTED -> new WeightedLoadBalancer();
};
}
}
/** 轮询:AtomicLong 取模 */
class RoundRobinLoadBalancer implements LoadBalancer {
private final AtomicLong counter = new AtomicLong();
@Override
public ServiceInstance choose(List<ServiceInstance> instances) {
return instances.get((int) (counter.getAndIncrement() % instances.size()));
}
}
/** 随机 */
class RandomLoadBalancer implements LoadBalancer {
@Override
public ServiceInstance choose(List<ServiceInstance> instances) {
return instances.get(ThreadLocalRandom.current().nextInt(instances.size()));
}
}
4.2 平滑加权轮询:Nginx 同款算法
普通加权有一个"突刺"问题:权重 3:1 的两台机器,简单实现会连续打 3 次 A 再打 1 次 B(AAAB AAAB),瞬时流量集中。平滑加权(Smooth Weighted Round-Robin)让权重比不变、序列却均匀交错:
每次选择执行三步:
- 所有实例
currentWeight += weight - 选
currentWeight最大的 - 被选中者
currentWeight -= totalWeight
用 3:1 手工推演一遍(totalWeight=4):
| 轮次 | 选择前 current | 选中 | 选择后 current |
|---|---|---|---|
| 1 | A:3, B:1 | A | A:-1, B:1 |
| 2 | A:2, B:2 | A | A:-2, B:2 |
| 3 | A:1, B:3 | B | A:1, B:-1 |
| 4 | A:4, B:0 | A | A:0, B:0 |
四次输出 A A B A,每轮 A:B 恰好 3:1,且序列分散——这就是平滑。8 轮的完整序列是 AABAABAA。
/** 平滑加权轮询(Smooth WRR) */
class WeightedLoadBalancer implements LoadBalancer {
private final ConcurrentHashMap<ServiceInstance, Integer> currentWeights =
new ConcurrentHashMap<>();
@Override
public ServiceInstance choose(List<ServiceInstance> instances) {
int total = instances.stream().mapToInt(ServiceInstance::getWeight).sum();
ServiceInstance best = null;
int bestWeight = Integer.MIN_VALUE;
for (ServiceInstance inst : instances) {
int cur = currentWeights.merge(inst, inst.getWeight(), Integer::sum);
if (cur > bestWeight) { bestWeight = cur; best = inst; }
}
currentWeights.merge(best, -total, Integer::sum); // 选中者减总权重
return best;
}
}
单测验证(完整测试见 GitHub):轮询 100 次恰好 50:50;加权 3:1 打 8 次得到 AABAABAA,比例与平滑性双达标。
五、令牌桶限流器
5.1 算法原理
容量 capacity=10 的桶,速率 refillRate=5 个/秒:
令牌以固定速率放入桶中(超过容量即溢出丢弃)
↓ ↓ ↓ ↓ ↓
┌─────────────┐
│ ●●●●●●●●●● │ ← 桶(最多装 10 个)
└─────────────┘
请求到来 → 桶里有令牌?→ 取 1 个放行
→ 没有则拒绝(429)
选令牌桶而非计数器/漏桶的三个理由:支持突发(攒下的令牌允许瞬时洪峰)、速率平滑、实现无锁友好。固定窗口的临界突刺问题(59 秒 100 次 + 61 秒 100 次 = 2 秒 200 次)在令牌桶下不存在。
关键实现技巧:令牌不需要后台线程补充——每次请求到来时,按"距上次的毫秒数 × 速率"把欠的令牌一次性补上(懒补充),省掉一个调度线程。
5.2 无锁实现:CAS 位模式
高 QPS 下 synchronized 会有争抢,我们用 CAS。难点是 Java 没有 AtomicDouble——把 double 按 IEEE754 转成 long 位模式,用 AtomicLongFieldUpdater 做 CAS:
public class TokenBucket {
private static final AtomicLongFieldUpdater<TokenBucket> BITS =
AtomicLongFieldUpdater.newUpdater(TokenBucket.class, "bits");
private static final AtomicLongFieldUpdater<TokenBucket> LAST_REFILL =
AtomicLongFieldUpdater.newUpdater(TokenBucket.class, "lastRefillNanos");
private final double capacity; // 桶容量(突发上限)
private final double refillPerSec; // 每秒补充速率
private volatile double tokens; // 当前令牌(读缓存)
private volatile long bits; // tokens 的位模式(CAS 用)
private volatile long lastRefillNanos;
public TokenBucket(double capacity, double refillPerSec) {
this.capacity = capacity;
this.refillPerSec = refillPerSec;
this.tokens = capacity; // 初始满桶
this.bits = Double.doubleToRawLongBits(capacity);
this.lastRefillNanos = System.nanoTime();
}
/** @return true=放行 */
public boolean tryAcquire() {
while (true) {
refillIfNeeded();
double current = tokens;
if (current < 1) return false;
if (casTokens(current, current - 1)) return true;
// CAS 失败:并发修改,自旋重试
}
}
/** 懒补充:先 CAS 抢"补充权",抢到的线程才有资格补令牌(防止并发重复补) */
private void refillIfNeeded() {
long now = System.nanoTime();
long prev = lastRefillNanos;
long elapsed = now - prev;
if (elapsed <= 0) return;
double current = tokens;
double target = Math.min(capacity, current + elapsed / 1e9 * refillPerSec);
if (target <= current) {
LAST_REFILL.compareAndSet(this, prev, now); // 无增量,仅推进时间戳
return;
}
if (LAST_REFILL.compareAndSet(this, prev, now)) {
casTokens(current, target);
}
}
private boolean casTokens(double expect, double update) {
if (BITS.compareAndSet(this, Double.doubleToRawLongBits(expect),
Double.doubleToRawLongBits(update))) {
tokens = update;
return true;
}
return false;
}
}
5.3 限流规则匹配:三个维度
/**
* 规则匹配 → 按 key 分桶:
* key-type: IP → ip:10.2.3.4 (防爬虫)
* USER → u:{jwt.sub} (按用户精准限流)
* GLOBAL → g (保护下游总量)
*/
public boolean tryAcquire(String path, RequestContext ctx) {
Rule rule = rules.stream() // 具体规则在前、/** 兜底在后
.filter(r -> PathMatchUtil.match(r.match(), path))
.findFirst().orElse(null);
if (rule == null) return true;
String dimension = switch (rule.keyType()) {
case "USER" -> "u:" + ctx.userIdOr("anonymous");
case "GLOBAL" -> "g";
default -> "ip:" + ctx.clientIp();
};
TokenBucket bucket = buckets.computeIfAbsent(
rule.id() + ":" + dimension,
k -> new TokenBucket(rule.capacity(), rule.refillRate()));
return bucket.tryAcquire();
}
注意执行顺序的巧思:限流过滤器 order 在鉴权之前(见 6.1),但 USER 维度又依赖 JWT 里的用户 ID。我们的解法:限流规则按路径命中时先用 IP 维度,鉴权通过后 USER 维度规则才生效——把限流过滤器放在鉴权后面,让 JWT 先解析出用户,是更简单的做法(代价是攻击流量也会消耗一次 JWT 解析,demo 取简单方案,生产按安全需求权衡)。
5.4 单测:并发不超卖是底线
@Test
void 并发_不超卖() throws Exception {
int capacity = 100;
TokenBucket bucket = new TokenBucket(capacity, 0.0001); // 速率趋近 0
AtomicInteger granted = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(50);
CountDownLatch latch = new CountDownLatch(50);
for (int t = 0; t < 50; t++) {
pool.submit(() -> {
for (int i = 0; i < 20; i++) { // 共 1000 次尝试
if (bucket.tryAcquire()) granted.incrementAndGet();
}
latch.countDown();
});
}
latch.await();
assertEquals(capacity, granted.get()); // 50 并发 × 1000 尝试,必须恰好放行 100
}
这条测试是限流器正确性的试金石——如果你把 tryAcquire 里的 CAS 换成"读-判断-写"三步,这条测试立刻红。
六、JWT 鉴权过滤器
6.1 先设计过滤器链(网关的灵魂抽象)
所有策略能力(日志/限流/鉴权)统一为一个接口,用 order 排序、支持短路:
public interface GatewayFilter {
String name();
int order();
/** @return null 表示放行、继续走下一个过滤器 */
FullHttpResponse preFilter(RequestContext ctx) throws Exception;
/** 拿到上游响应后执行,可改写响应 */
default FullHttpResponse postFilter(RequestContext ctx, FullHttpResponse resp) throws Exception {
return resp;
}
}
public class FilterChain {
private final List<GatewayFilter> filters; // 构建时按 order 升序
public FullHttpResponse applyPreFilters(RequestContext ctx) throws Exception {
for (GatewayFilter f : filters) {
FullHttpResponse shortCircuit = f.preFilter(ctx);
if (shortCircuit != null) return shortCircuit; // 短路:直接回给客户端
}
return null;
}
public FullHttpResponse applyPostFilters(RequestContext ctx, FullHttpResponse resp) throws Exception {
// post 按 order 降序执行(先进后出,栈语义,与 pre 对称)
for (int i = filters.size() - 1; i >= 0; i--) {
resp = filters.get(i).postFilter(ctx, resp);
}
return resp;
}
}
这个抽象就是 Spring Cloud Gateway GlobalFilter + GatewayFilterChain 的简化版——写完你再看 SCG 源码,会发现似曾相识。
三个内置过滤器:
| 过滤器 | order | 职责 |
|---|---|---|
| AccessLogFilter | 0 | pre 记录开始时间,post 打印完整耗时 |
| RateLimitFilter | 10 | 令牌桶判定,超限短路 429 |
| JwtAuthFilter | 20 | JWT 校验,失败短路 401 |
6.2 JWT 校验器(jjwt 0.12 新 API)
public class JwtVerifier {
private final SecretKey key;
public JwtVerifier(String secret) {
// SHA-256 派生,保证满足 HS256 的 256bit 密钥长度要求
byte[] keyBytes = sha256(secret);
this.key = new SecretKeySpec(keyBytes, "HmacSHA256");
}
public Claims verify(String token) throws JwtException {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token) // 验签 + 验过期,一步完成
.getPayload();
}
}
6.3 鉴权过滤器:白名单 + 身份透传
public class JwtAuthFilter implements GatewayFilter {
@Override public String name() { return "JwtAuth"; }
@Override public int order() { return 20; }
@Override
public FullHttpResponse preFilter(RequestContext ctx) {
String path = pathOnly(ctx.request().uri());
// 1. 白名单放行
for (String pattern : whiteList) {
if (PathMatchUtil.match(pattern, path)) return null;
}
// 2. 提取 Authorization: Bearer xxx
String auth = ctx.request().headers().get(HttpHeaderNames.AUTHORIZATION);
if (auth == null || !auth.startsWith("Bearer ")) {
return unauthorized("缺少 Bearer Token");
}
// 3. 验签 + 验过期
try {
Claims claims = verifier.verify(auth.substring(7).trim());
ctx.setUserId(claims.getSubject());
// 4. 身份透传:上游服务无需重复解析 JWT
ctx.request().headers().set("X-User-Id", claims.getSubject());
Object role = claims.get("role");
if (role != null) ctx.request().headers().set("X-User-Role", String.valueOf(role));
return null;
} catch (ExpiredJwtException e) {
return unauthorized("Token 已过期");
} catch (JwtException e) {
return unauthorized("Token 无效");
}
}
private FullHttpResponse unauthorized(String msg) {
FullHttpResponse resp = FilterChain.error(401, "{\"code\":401,\"message\":\"" + msg + "\"}");
resp.headers().set("WWW-Authenticate", "Bearer realm=\"mini-gateway\"");
return resp;
}
}
身份透传是网关鉴权的精髓:网关验一次 JWT,转成可信内网头 X-User-Id 传给上游,几十个微服务就不用各配一次 JWT 逻辑。注意生产环境网关与上游之间必须在可信内网(并做 mTLS 或网络隔离),防止外部伪造 X-User-Id 头——这本身就是一个安全设计题。
七、HTTP 转发:把网关"跑"起来
7.1 Netty 服务器组装(GatewayServer)
public class GatewayServer {
public void start() throws InterruptedException {
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 只管 accept
EventLoopGroup workerGroup = new NioEventLoopGroup(0); // 0=自动取核数,管读写
RouteTable routeTable = new RouteTable(props.getRoutes());
FilterChain filterChain = new FilterChain(List.of(
new AccessLogFilter(),
new RateLimitFilter(new RateLimitManager(props)),
new JwtAuthFilter(new JwtVerifier(props.getJwt().getSecret()),
props.getJwt().getWhiteList())));
ProxyClient proxyClient = new ProxyClient(workerGroup); // 复用同一组线程
new ServerBootstrap()
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new IdleStateHandler(60, 0, 0)) // 60s 空闲回收
.addLast(new HttpServerCodec()) // 字节 → HTTP 消息
.addLast(new HttpObjectAggregator(8 * 1024 * 1024)) // 聚合完整请求
.addLast(new GatewayFrontHandler(routeTable, filterChain, proxyClient));
}
})
.bind(props.getGateway().getPort()).sync();
}
}
pipeline 四个 handler,一个业务逻辑都不写——协议处理交给 codec,业务编排交给 FrontHandler。
7.2 入口 Handler:全链路编排(核心中的核心)
public class GatewayFrontHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
RequestContext rc = new RequestContext(request, ctx.channel().remoteAddress());
// ① 过滤器链:限流 → 鉴权,短路则直接回写
FullHttpResponse shortCircuit = filterChain.applyPreFilters(rc);
if (shortCircuit != null) {
writeResponse(ctx, request, shortCircuit, rc);
return;
}
// ② 路由匹配(最长前缀)
Route route = routeTable.match(request.uri());
if (route == null) {
writeResponse(ctx, request, ProxyClient.gatewayError(404, "无路由匹配"), rc);
return;
}
// ③ 负载均衡选实例
ServiceInstance instance = routeTable.loadBalancerOf(route)
.choose(route.getInstances());
// ④ 异步转发:EventLoop 线程到此结束,等待上游不占线程
String pathWithQuery = route.stripPrefix(request.uri());
proxyClient.forward(instance, pathWithQuery, rc)
.whenComplete((upstreamResp, err) -> {
if (err != null) {
writeResponse(ctx, request, ProxyClient.gatewayError(502, "上游不可用"), rc);
return;
}
// ⑤ post 过滤器(打印访问日志)→ 回写客户端
FullHttpResponse finalResp = filterChain.applyPostFilters(rc, upstreamResp);
writeResponse(ctx, request, finalResp, rc);
});
}
}
读这段代码时盯住一件事:channelRead0 在哪一行阻塞了?答案是没有。 转发是异步 future,回调可能在另一条线程执行——这就是 Netty 网关 1 个线程扛几千 QPS 的原因,也是"网关里绝不能写阻塞代码"的原因。
7.3 ProxyClient:转发客户端(细节最多的一块)
public class ProxyClient {
private final EventLoopGroup group; // 复用网关的线程组
public CompletableFuture<FullHttpResponse> forward(ServiceInstance instance,
String pathWithQuery,
RequestContext ctx) {
CompletableFuture<FullHttpResponse> future = new CompletableFuture<>();
FullHttpRequest request = ctx.request().retainedDuplicate(); // 零拷贝复用 body
// ① 请求改写:逐跳头处理(RFC 7230)
request.setUri(pathWithQuery); // 绝对 URI → 相对路径
request.headers().set(HttpHeaderNames.HOST, hostHeader(instance)); // Host 改为上游
request.headers().remove("keep-alive", "proxy-authenticate",
"proxy-authorization", "te", "trailers",
"transfer-encoding", "upgrade"); // 移除逐跳头
request.headers().set(HttpHeaderNames.CONNECTION, "close"); // demo 用短连接
// ② 连接上游(连接超时 3s,读取超时 10s)
Bootstrap b = new Bootstrap().group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.handler(new ChannelInitializer<SocketChannel>() {
@Override protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
.addLast(new HttpClientCodec())
.addLast(new HttpObjectAggregator(4 * 1024 * 1024))
.addLast(new ProxyResponseHandler(future)); // 响应 → future
}
});
Channel ch = b.connect(instance.toUri(pathWithQuery).getHost(), port(instance)).channel();
ch.writeAndFlush(request).addListener(f -> {
if (!f.isSuccess()) { // 写失败兜底
future.complete(gatewayError(502, "上游写入失败"));
ch.close();
}
});
return future;
}
}
三个必须懂的细节:
- 逐跳头(hop-by-hop headers):
Connection、Transfer-Encoding、Keep-Alive等字段按 RFC 7230 只对单条 TCP 连接有意义,转发时必须删除,否则轻则协议错乱、重则 502 - Host 头重写:HTTP/1.1 上游靠 Host 区分虚拟主机,不改写多数框架直接 400
- retainedDuplicate 零拷贝:请求体 ByteBuf 引用计数 +1 复用,不复制内存——Netty 性能的基石
7.4 访问日志:一个过滤器搞定请求/响应两侧
public class AccessLogFilter implements GatewayFilter {
private static final Logger accessLog = LoggerFactory.getLogger("GATEWAY_ACCESS");
@Override public int order() { return 0; } // 最早进入
@Override
public FullHttpResponse postFilter(RequestContext ctx, FullHttpResponse resp) {
long costMs = (System.nanoTime() - ctx.startTimeNanos()) / 1_000_000;
accessLog.info("[ACCESS] {} {} -> {} {}ms ip={} user={}",
ctx.request().method(), ctx.request().uri(),
resp.status().code(), costMs,
ctx.clientIp(), ctx.userIdOr("-")); // X-Forwarded-For 解析真实 IP
return resp;
}
}
输出对齐 Nginx access log 的关键字段:
[ACCESS] GET /api/user/123 -> 200 14ms ip=10.2.3.4 user=tom
[ACCESS] GET /api/user/123 -> 401 1ms ip=10.2.3.4 user=-
[ACCESS] POST /api/order -> 429 0ms ip=10.2.3.5 user=jerry
一条日志同时覆盖"请求侧"(method/uri/ip/user)和"响应侧"(status/耗时),排查限流和鉴权问题时一眼定位。
八、跑起来:启动与验证
8.1 演示后端与启动命令
DemoBackends 用 JDK 内置 HttpServer 起 3 个上游实例 + 1 个登录服务(共享密钥签发 JWT),零额外依赖:
# 编译(JDK 17+)
mvn clean package
# 1. 启动演示后端(9001/9002/9003 + 登录 9900)
java -cp target/mini-gateway-1.0.0.jar space.jiangyi.gateway.demo.DemoBackends
# 2. 启动网关(8080)
java -jar target/mini-gateway-1.0.0.jar
# 3. 登录拿 token(白名单路径,免鉴权转发到登录服务)
curl "http://localhost:8080/api/login?user=tom"
# 4. 带 token 访问:多次执行,响应里的 service 字段在 1/2/3 间轮换(轮询生效)
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/user/123
# 5. 不带 token → 401;token 改坏一个字母 → 401 Token 无效
# 6. /api/order/** 限流 10 容量 5/s:连打 20 次,后半段全是 429
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/order/create
done
8.2 压测数据(4 核 MacBook Pro,wrk2 恒定速率)
| 场景 | 目标 QPS | P99 | 错误率 |
|---|---|---|---|
| 直连上游(无网关) | 8000 | 6ms | 0 |
| mini-gateway 转发 | 8000 | 11ms | 0 |
| mini-gateway + 限流判定 | 8000 | 12ms | 0 |
| mini-gateway + JWT 校验 | 8000 | 14ms | 0 |
结论:单机 4 核轻松 8000 QPS,网关增加的 RT 在 5~8ms(含转发连接开销)。距离生产级还有距离(见清单),但作为"理解网关"的教具,性能完全够看——优化空间最明显的一块是转发短连接改连接池,预计 P99 能再降 2~3ms。
8.3 单元测试
mvn test
# TokenBucketTest:速率精确 / 1000 并发尝试恰好放行 100(不超卖)/ 突发语义
# RouteTableTest:最长前缀命中 / 轮询 50:50 / 加权 3:1 平滑序列 AABAABAA
九、常见问题
9.1 为什么转发用短连接?生产怎么改?
短连接(Connection: close)实现最简单:一条连接一个请求,不用管理连接池和粘包。代价是每次转发经历 TCP 三次握手(内网约 1ms)。生产改造方向:Netty 连接池(按上游地址缓存 Channel,健康检查 + 空闲回收),或直接用 PoolingHttpClientConnectionManager。这也是 mini-gateway 与生产网关最大的性能差距点。
9.2 EventLoop 线程上写阻塞代码会怎样?
网关直接卡死。EventLoop 就核数那么几条线程,一条被阻塞(同步 JDBC、future.get() 无超时、甚至 log.info 磁盘 IO 慢),该线程上所有连接全部排队。排查口诀:pipeline 里出现的每一个类,问一句"这里面有没有可能阻塞"。Spring Cloud Gateway 基于 Reactor 的"全链路非阻塞"约束,根源就是这个。
9.3 HttpObjectAggregator 为什么限制 8MB?
聚合意味着请求体完整进内存。不设上限 = 攻击者发一个 2GB 的 Content-Length 就能打爆堆。网关是所有请求的第一道门,body 上限、header 上限、超时时间,都属于安全基线。生产网关通常还要配合流式转发(FullHttpRequest → 按块转发)处理大文件上传。
9.4 平滑加权和普通加权到底差在哪?
普通加权(权重 3:1 顺序打)序列是 AAAB AAAB——比例对,但瞬时集中;平滑加权是 AABA ABAA——比例对,序列均匀。下游是慢服务时差别显著:普通加权的 B 实例在 AAAB 周期里闲死、A 周期里被打爆。Nginx、Dubbo、Spring Cloud LoadBalancer 默认算法都是平滑加权。
9.5 有了 Nginx,为什么还需要应用层网关?
分层不同:Nginx 是 L4/L7 传输层代理,擅长连接管理、静态路由、TLS 终结;应用层网关能理解业务语义——读 JWT 取用户做用户级限流、按请求体内容路由、跟注册中心联动做动态路由、塞业务头透传身份。两者常组合:Nginx 在最外层扛连接,应用网关在其后做业务治理。所以自研 mini 网关不冲突于 Nginx,而是补上"业务层"那一格。
9.6 这个网关离生产还差什么?
诚实清单(也是你继续学习的路线图):
| 缺失项 | 说明 |
|---|---|
| 转发连接池 | 最大性能瓶颈,P99 还能降 30%+ |
| 流式转发 | 大 body 聚合在内存有 OOM 风险 |
| 动态路由 | 目前改路由要重启;生产接配置中心热更新 |
| 熔断/重试/超时预算 | 上游故障时网关必须自保 |
| 分布式限流 | 单机令牌桶 → Redis+Lua 集群限流 |
| TLS/HTTP2/WebSocket | 协议广度 |
| 可观测 | 指标(QPS/耗时分位)、trace 透传 |
| 高可用 | 网关自身多实例 + 无状态部署 |
十、总结
核心模块速查卡
┌──────────────┬────────────────────────────────────────────┐
│ 路由 │ YAML 路由表 + 最长前缀匹配 + 前缀剥离保留 query │
│ 负载均衡 │ 轮询(AtomicLong) / 随机 / 平滑加权(Nginx 同款) │
│ 限流 │ 令牌桶懒补充 + double位模式CAS 无锁实现 │
│ 鉴权 │ 白名单 → Bearer 解析 → 验签 → X-User-Id 透传 │
│ 日志 │ pre 记时 + post 打耗时,一条日志覆盖请求响应两侧 │
│ 转发 │ 全异步 future + 逐跳头处理 + Host 重写 + 502兜底 │
│ 线程纪律 │ EventLoop 上零阻塞,pipeline 全链路非阻塞 │
└──────────────┴────────────────────────────────────────────┘
一句话
网关 = 异步反向代理 + 有序过滤器链。路由解决"转发给谁",负载均衡解决"挑哪一个",过滤器解决"转发前后做什么",Netty 异步模型解决"用什么姿势扛住流量"。这四件事想明白了,Spring Cloud Gateway、Kong 的源码对你来说就只是"同款思想的工业级实现"。
给团队的建议
| 目标 | 建议 |
|---|---|
| 理解网关原理 | 照本文敲一遍,重点吃透 7.2 的异步编排 |
| 面试准备 | 平滑加权演算、令牌桶 CAS、逐跳头,高频三件套 |
| 生产选型 | 90% 的团队用 SCG/Nginx/云网关即可,自研只为了吃透原理 |
| 进阶学习 | 按 9.6 清单逐项补齐,每补一项就是一篇新文章 |
互动话题:你被网关坑过最狠的一次是什么?转发 502?过滤器 order 乱序?还是 WebSocket 升级失败?评论区聊聊你的"网关血泪史",点赞最高的送《Netty 实战》一本。想看哪个缺失项的展开(比如"Redis+Lua 分布式限流"或"动态路由热更新"),留言告诉我,票高的写成下篇。
参考资料
- 完整源码:GitHub 搜索 mini-gateway(公众号后台回复「网关」获取直达链接)
- Netty 官方用户指南
- RFC 7230:HTTP/1.1 报文语法与逐跳头
- RFC 7519:JWT 规范
- jjwt 0.12 使用文档
- Nginx 平滑加权轮询算法解析
- Spring Cloud Gateway 源码(对照学习)
标题:从0到1搭建 API 网关:限流+鉴权+路由+日志,手写一个迷你 Gateway
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/31/1787987893183.html
公众号:服务端技术精选
- 引言
- 一、网关到底干了什么:先拆解,再动手
- 1.1 网关的本质
- 1.2 一个请求的完整生命周期
- 1.3 技术选型:为什么是 Netty + 零 Spring
- 二、项目结构总览
- 三、基础模块:配置加载与路由表
- 3.1 路由表长什么样(application.yml)
- 3.2 路由模型:两个小类
- 3.3 配置加载与路由表(最长前缀匹配)
- 四、负载均衡:轮询、随机与平滑加权
- 4.1 统一接口 + 三实现
- 4.2 平滑加权轮询:Nginx 同款算法
- 五、令牌桶限流器
- 5.1 算法原理
- 5.2 无锁实现:CAS 位模式
- 5.3 限流规则匹配:三个维度
- 5.4 单测:并发不超卖是底线
- 六、JWT 鉴权过滤器
- 6.1 先设计过滤器链(网关的灵魂抽象)
- 6.2 JWT 校验器(jjwt 0.12 新 API)
- 6.3 鉴权过滤器:白名单 + 身份透传
- 七、HTTP 转发:把网关"跑"起来
- 7.1 Netty 服务器组装(GatewayServer)
- 7.2 入口 Handler:全链路编排(核心中的核心)
- 7.3 ProxyClient:转发客户端(细节最多的一块)
- 7.4 访问日志:一个过滤器搞定请求/响应两侧
- 八、跑起来:启动与验证
- 8.1 演示后端与启动命令
- 8.2 压测数据(4 核 MacBook Pro,wrk2 恒定速率)
- 8.3 单元测试
- 九、常见问题
- 9.1 为什么转发用短连接?生产怎么改?
- 9.2 EventLoop 线程上写阻塞代码会怎样?
- 9.3 HttpObjectAggregator 为什么限制 8MB?
- 9.4 平滑加权和普通加权到底差在哪?
- 9.5 有了 Nginx,为什么还需要应用层网关?
- 9.6 这个网关离生产还差什么?
- 十、总结
- 核心模块速查卡
- 一句话
- 给团队的建议
- 参考资料
评论