本节目标:让网关在下游变慢或被刷时仍然活着,并且把「拒绝」写成客户端能理解的状态码、响应头和重试时间。
适用版本:JDK 25 + Netty 4.2.19.Final;不引入 Resilience4j 或任何外部限流库。
承接的问题
15.1 的 GatewayHandler 已经能把 /books/{id} 异步转发到 libraryd,超时返回 504,池满返回 503。但它只保护了「单个请求」,没有保护「整个进程」:
- 同一个 IP 一秒发 5000 个请求,每个都合法,池会被瞬间借空,其他用户排队到超时。
- 一条连接上客户端连续 pipelining,单个连接就能占满一个 EventLoop 的读事件。
libraryd已经挂了,网关还在为每个请求建连、等 1 秒、再返回 502,把线程和 fd 浪费在必然失败的调用上。- 15.1 设了建连、读和总预算三层超时,但超时触发后只是返回 504,没有区分「偶发慢」和「下游已经坏了」,也没有在入口拒绝持续超限的流量。
三层保护的顺序是:限流决定「要不要接这个请求」,超时决定「接了就等多久」,熔断决定「下游已经坏了,还要不要试」。三层的状态都必须在内存里有上限,否则保护机制自己会变成内存泄漏。
第一层:令牌桶限流与 429
令牌桶比固定窗口简单,也不会在窗口边界放行两倍流量:桶按固定速率补充令牌,容量决定允许多大的突发,每个请求取一个令牌,取不到就拒绝。
package com.plume.linkd.v2.server;
import java.util.concurrent.ConcurrentHashMap;
final class TokenBucket {
private final long capacity;
private final double tokensPerNano;
private double tokens;
private long lastNanos;
TokenBucket(long capacity, double permitsPerSecond, long nowNanos) {
this.capacity = capacity;
this.tokensPerNano = permitsPerSecond / 1_000_000_000d;
this.tokens = capacity;
this.lastNanos = nowNanos;
}
synchronized boolean tryAcquire(long nowNanos, long permits) {
refill(nowNanos);
if (tokens < permits) {
return false;
}
tokens -= permits;
return true;
}
synchronized long nanosUntil(long nowNanos, long permits) {
refill(nowNanos);
double missing = permits - tokens;
return missing <= 0 ? 0 : (long) Math.ceil(missing / tokensPerNano);
}
synchronized long idleNanos(long nowNanos) {
return Math.max(0, nowNanos - lastNanos);
}
private void refill(long nowNanos) {
long elapsed = Math.max(0, nowNanos - lastNanos);
lastNanos = nowNanos;
tokens = Math.min(capacity, tokens + elapsed * tokensPerNano);
}
}
public final class IpRateLimiter {
private final ConcurrentHashMap<String, TokenBucket> buckets = new ConcurrentHashMap<>();
private final int maxEntries;
private final long capacity;
private final double permitsPerSecond;
public IpRateLimiter(int maxEntries, long capacity, double permitsPerSecond) {
this.maxEntries = maxEntries;
this.capacity = capacity;
this.permitsPerSecond = permitsPerSecond;
}
public boolean tryAcquire(String ip, long nowNanos) {
TokenBucket existing = buckets.get(ip);
if (existing != null) {
return existing.tryAcquire(nowNanos, 1);
}
if (buckets.size() >= maxEntries && !evictIdle(nowNanos)) {
return false;
}
return buckets.computeIfAbsent(ip,
key -> new TokenBucket(capacity, permitsPerSecond, nowNanos))
.tryAcquire(nowNanos, 1);
}
public long nanosUntil(String ip, long nowNanos) {
TokenBucket bucket = buckets.get(ip);
return bucket == null ? 0 : bucket.nanosUntil(nowNanos, 1);
}
private boolean evictIdle(long nowNanos) {
int before = buckets.size();
buckets.entrySet().removeIf(entry ->
entry.getValue().idleNanos(nowNanos) > 300_000_000_000L);
return buckets.size() < before || buckets.size() < maxEntries;
}
}
TokenBucket 全部方法都 synchronized,因为同一个 IP 的请求会落在不同 EventLoop 上;锁的粒度是单个桶,不同 IP 之间不竞争。refill 用 System.nanoTime() 而不是 currentTimeMillis:NTP 回拨会让 elapsed 变成负数,令牌不是冻结就是暴增。nanoTime 没有绝对时间语义,但差值稳定。
限流有三个作用域,不能只做一个:
| 作用域 | 状态放在哪 | 拦截什么 | 代价 |
|---|---|---|---|
| 全局 | 一个固定 TokenBucket | 整个进程的入口 QPS | 一个锁,所有请求竞争 |
| 每 IP | IpRateLimiter 的 map | 单个来源的突发 | 每个 IP 一个桶,内存随 IP 数增长 |
| 每连接 | handler 实例字段 | 单条连接上的 pipelining | 连接关闭即回收,无全局状态 |
RateLimitHandler 串起三层,顺序是先全局、再连接、最后 IP:
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
long now = System.nanoTime();
if (!global.tryAcquire(now, 1)
|| !perConnection.tryAcquire(now, 1)
|| !perIp.tryAcquire(clientIp, now)) {
reject(ctx, request, now);
return;
}
ctx.fireChannelRead(request.retain());
}
private void reject(ChannelHandlerContext ctx, FullHttpRequest request, long now) {
long waitNanos = Math.max(perConnection.nanosUntil(now, 1),
perIp.nanosUntil(clientIp, now));
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.TOO_MANY_REQUESTS,
Unpooled.copiedBuffer("rate limited\n", CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8");
response.headers().set(HttpHeaderNames.RETRY_AFTER, Long.toString(
Math.max(1, (waitNanos + 999_999_999L) / 1_000_000_000L)));
HttpUtil.setContentLength(response, response.content().readableBytes());
ChannelFuture write = ctx.writeAndFlush(response);
if (!HttpUtil.isKeepAlive(request)) {
write.addListener(ChannelFutureListener.CLOSE);
}
}
ctx.fireChannelRead(request.retain()) 是这段代码里最容易漏的一行:SimpleChannelInboundHandler 会在 channelRead0 返回后自动释放入站消息,往下传之前必须 retain(),否则下一个 handler 读到的是已释放的 ByteBuf。
Retry-After 用整数秒,按最慢的那个桶算等待时间;客户端看到 429 至少知道「什么时候可以再试」,而不是盲目重试。429 默认保持连接,只有客户端要求关闭时才 CLOSE;但如果一个 IP 持续超限,更稳妥的做法是直接关闭连接,让攻击者付 TCP 重连的成本。
内存上限。 每个 TokenBucket 是对象头加两个 long/double 字段,按字段估约 40 字节;ConcurrentHashMap 的节点、key 字符串和数组槽位再加 80–120 字节。50,000 个 IP 大约是 6–8 MiB(按对象布局估算,不是 JOL 实测)。maxEntries 必须设,evictIdle 在表满时清掉 5 分钟没访问的桶,另外用 GlobalEventExecutor.INSTANCE.scheduleAtFixedRate 每 60 秒扫一次,避免「不新增 IP 就永不清理」。表满且没有可回收项时,对未登记 IP 返回 429,是失败关闭;如果更看重可用性,可以改为放行并打日志,但要明确这是策略选择。
第二层:连接、读与整体预算
超时不是「设一个数」,而是三层 deadline 的嵌套,外层必须大于内层,否则外层先触发,内层的错误信息永远看不到:
| 层 | 配置 | 计时起点 | 触发后的动作 |
|---|---|---|---|
| 建连 | ChannelOption.CONNECT_TIMEOUT_MILLIS = 1000 | Bootstrap.connect() | 502,连接未建立 |
| 读 | ReadTimeoutHandler(1500 ms) | 最后一次读到数据 | 504,关闭后端连接 |
| 总预算 | CompletableFuture.orTimeout(2000 ms) | 请求进入 GatewayHandler | 504,关闭后端连接 |
ReadTimeoutHandler 是「空闲读超时」,每读到一次数据就重置。它对「上游每秒吐 1 字节」这种慢速攻击无效,所以还需要总预算;反过来,只有总预算时,一个卡住的连接会占满 2 秒,池的并发被慢慢吃掉。两层都要有。
建连超时是 Bootstrap 的 ChannelOption,不是 ReadTimeoutHandler 能替代的:TCP 握手阶段还没有「读事件」,读超时根本不会启动。15.1 的 HttpBackendPools 把 CONNECT_TIMEOUT_MILLIS 设成 1000,就是这个位置。
总预算的计时必须从「请求进入网关」开始,而不是从「连接借到」开始。池等待时间也是用户体验的一部分;如果池排队 1.8 秒、上游再花 0.5 秒,总预算 2 秒应该在 0.2 秒后就结束,而不是等到上游返回。15.1 的 result.orTimeout 紧跟在 pool.acquire() 之后注册,借连接还没完成时计时已经开始,池等待因此被算进预算。
第三层:熔断与半开探测
限流保护自己,熔断保护下游。下游已经连续失败时,继续为每个请求建连、等超时,只会让恢复更慢;熔断器在失败达到阈值后直接返回 503,过一段时间只放一个探测请求,成功就恢复,失败就继续打开。
package com.plume.linkd.v2.server;
import java.time.Duration;
public final class CircuitBreaker {
public enum State { CLOSED, OPEN, HALF_OPEN }
private final int failureThreshold;
private final long openNanos;
private State state = State.CLOSED;
private int failures;
private long openedAt;
private boolean probeInFlight;
public CircuitBreaker(int failureThreshold, Duration openFor) {
this.failureThreshold = failureThreshold;
this.openNanos = openFor.toNanos();
}
public synchronized boolean tryAcquire(long nowNanos) {
if (state == State.OPEN) {
if (nowNanos - openedAt < openNanos) {
return false;
}
state = State.HALF_OPEN;
}
if (state == State.HALF_OPEN) {
if (probeInFlight) {
return false;
}
probeInFlight = true;
}
return true;
}
public synchronized void onSuccess() {
if (state == State.OPEN) {
return; // 熔断打开前放行的旧请求,迟到成功不能直接恢复
}
state = State.CLOSED;
failures = 0;
probeInFlight = false;
}
public synchronized void onFailure(long nowNanos) {
if (state == State.OPEN) {
return; // 已经打开,旧请求的失败不重复延长打开时间
}
probeInFlight = false;
if (state == State.HALF_OPEN || ++failures >= failureThreshold) {
state = State.OPEN;
openedAt = nowNanos;
failures = 0;
}
}
public synchronized State state() {
return state;
}
}
状态机只有三条边:
| 当前 | 条件 | 下一个 | 行为 |
|---|---|---|---|
CLOSED | 连续失败达到阈值(基线 5 次) | OPEN | 记录打开时间,清零计数 |
OPEN | 打开满 openFor(基线 5 秒) | HALF_OPEN | 只放一个探测请求 |
HALF_OPEN | 探测成功 | CLOSED | 恢复正常放行 |
HALF_OPEN | 探测失败 | OPEN | 重新计时,继续拒绝 |
阈值和打开时长是基线值,不是实测最优:下游重启通常要几秒,5 秒能让它缓一口气;如果下游是数据库主从切换,可能要 30 秒。参数要按恢复时间调,不要照抄。
tryAcquire 在 HALF_OPEN 只放一个探测,其他请求继续 503。探测请求本身也要受 15.1 的总预算约束:如果它卡住不返回,whenComplete 不会执行,probeInFlight 永远为 true,熔断器就卡在半开。所以熔断器的 onFailure 必须挂在「请求最终完成」的回调上,而不是只挂在写失败上。
接入 GatewayHandler 只需要三处:
if (!breaker.tryAcquire(System.nanoTime())) {
write(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE, "circuit open");
return;
}
// ... 15.1 的 forward ...
result.whenComplete((response, error) -> {
if (error == null) {
breaker.onSuccess();
} else {
breaker.onFailure(System.nanoTime());
}
});
熔断器要按上游实例建,不能全局一个。libraryd 有两个实例时,一个挂掉不应该把另一个也熔断;用 ConcurrentHashMap<InetSocketAddress, CircuitBreaker>,key 和 8.2 的连接池保持一致。全局熔断器在单上游的 linkd-v2 里够用,但扩到多实例就是故障放大器。
在 linkd-v2 里落地:三层如何排序
9090 的 pipeline 顺序是:
HttpServerCodec -> HttpObjectAggregator -> RateLimitHandler
-> LinkdHttpHandler -> GatewayHandler
LinkdHttpHandler 只处理 /healthz 等本地路由;未命中的请求要 retain() 后 fireChannelRead 给后面的 handler。如果它像 11.2 的 h2 精简版那样对非 /healthz 直接 404,/books/{id} 永远到不了网关,15.3 的“上游挂了健康检查仍 200”也就无从验证。
- 限流放在聚合器之后,因为它要读
request.uri()和HttpUtil.isKeepAlive(request)。放在聚合器之前也能按连接限流,但需要处理HttpRequest与HttpContent的转发和丢弃,代码复杂度换来的只是「大 body 还没读完就拒绝」。真正要防大 body,应该限制HttpObjectAggregator的最大长度和HttpServerCodec的初始行/头长度。 - 熔断放在限流之后,被限流拒绝的请求不应该计入下游失败,否则攻击者可以用 429 把熔断器打开;
linkd-v2不单独建CircuitBreakerHandler,而是把breaker.tryAcquire放在GatewayHandler.forward入口,因为它要知道 route 对应的上游才能分桶。 - 超时不单独做一个 handler,它是
Bootstrap的建连参数、后端 pipeline 的ReadTimeoutHandler和GatewayHandler的orTimeout三处配置;单独加一个ReadTimeoutHandler在网关侧只能限制客户端读,不限制上游。
三层都返回 503/429 时,响应体要短、要有 Content-Type,并且不要回显内部地址。15.1 的 write 方法已经做了长度和 Content-Type,这里复用即可。
压测验证:怎么证明三层生效
不要用「感觉变稳了」验收。每一项都给命令、观察点和证据,数字按 14 章的口径记录。
| 验证项 | 命令 | 观察点 | 证据 |
|---|---|---|---|
| 全局限流 | ab -k -n 600 -c 30 http://127.0.0.1:9090/books/7 | 出现 429,且 429 集中在桶耗尽之后 | ab 的 Non-2xx 计数 |
| 单连接限流 | ab -k -n 200 -c 1 http://127.0.0.1:9090/books/7 | 同一连接上出现 429 | ab 报告 |
Retry-After | curl -sS -D - -o /dev/null http://127.0.0.1:9090/books/7 | 429 响应带整数秒 Retry-After | 响应头 |
| 读超时 | kill -STOP $(pgrep -f 'libraryd.*jar'),再 time curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/books/7 | 约 1.5–2 秒返回 504 | 状态码与耗时 |
| 熔断打开 | 连续请求 12 次,保持 libraryd 停止 | 前几次 504,达到阈值后变 503 | 状态码序列 |
| 熔断不建连 | 熔断打开后 lsof -nP -iTCP:8080 | 没有新的 8080 连接 | lsof 输出 |
| 半开恢复 | kill -CONT,等 5 秒后请求一次 | 探测 200,随后恢复 200 | 状态码与日志 |
| 状态内存 | jcmd <pid> GC.class_histogram | grep TokenBucket | 桶数量不超过 maxEntries | 直方图 |
ab 的 Non-2xx 包含 429,不要把 429 当成「请求失败」写进可用性;限流命中是保护生效,不是故障。反过来,如果压测里一个 429 都没有,要么速率没打上去,要么限流没接进 pipeline。
熔断验证要注意 kill -STOP 和 kill -TERM 的区别:STOP 让进程暂停但 TCP 连接还在,网关能建连、能写请求,读超时后 504,这是「慢失败」;TERM 让进程退出,连接被拒,网关拿到 ConnectException,这是「快失败」。两种都要测,因为熔断器对它们的计数路径不同。
常见错误与边界
错误一:用 System.currentTimeMillis 补充令牌。 NTP 回拨 1 秒,elapsed 变成负数,令牌可能被减掉或冻结;用 nanoTime 的差值,不受墙上时钟调整影响。
错误二:限流 map 没有上限。 每个新 IP 建一个桶,攻击者用大量源地址就能把堆撑爆。maxEntries、空闲回收、定时扫描三件套缺一不可;只做 removeIf 不做上限,仍然会在扫描间隔内被灌满。
错误三:熔断器全局一个。 多上游时,一个实例挂掉会让所有实例都被 503;按 InetSocketAddress 分桶,和连接池的 key 对齐。
错误四:半开放行所有请求。 HALF_OPEN 如果直接 return true,等于没有熔断;必须只放一个探测,其他请求继续 503,直到探测有结果。
错误五:429 不带 Retry-After。 客户端只能盲目重试,放大的流量会把刚恢复的桶再次打满。Retry-After 至少给 1 秒,按最慢的桶计算。
边界:令牌桶不是精确的 QPS 计量。 它允许容量大小的突发,capacity=100、permitsPerSecond=100 时,一个空闲很久的桶可以瞬间放行 100 个请求。要严格平滑,用漏桶或排队;要防突发,调小 capacity。
边界:连续失败计数不识别间歇性故障。 50% 失败率但成功与失败交替时,计数器会被成功清零,熔断器永远不打开。需要按滑动窗口统计失败率时,状态会复杂得多;linkd-v2 先用连续失败,是因为它可预测、易解释,代价是对间歇性故障不敏感。
边界:限流不替代容量规划。 限流只决定「拒绝谁」,不增加下游容量;把全局 QPS 设得比 14 章压测出的安全值高,只是把雪崩推迟几秒。
小结
- 限流分全局、每 IP、每连接三层,令牌桶用
nanoTime补充,容量决定突发上限。 - 429 必须带整数秒
Retry-After;限流状态要有maxEntries、空闲回收和定时扫描。 - 超时分建连、读、总预算三层,外层大于内层;
ReadTimeoutHandler是空闲读超时,不是总预算。 - 熔断按上游实例建,连续失败打开、半开只放一个探测;探测也必须受总预算约束。
- 排序是限流 → 熔断 → 转发,被限流的请求不计入下游失败。
- 压测要分别验证 429、504、503 和半开恢复;限流命中不是故障。
阅读导航:上一节:15.1 网关路由与转发 · 下一节:15.3 linkd-v2 验收与复盘 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。