《Netty 高性能网络编程》15.2 限流、超时与降级

15.1 的网关能转发,但下游变慢时会拖垮自己。本节用纯 JDK 给 linkd-v2 补三层保护:每 IP 与每连接的令牌桶限流返回 429 与 Retry-After,连接、读与总预算分层超时,连续失败打开、半开探测的熔断返回 503,并给出内存上限、清理策略与压测验证命令。

本节目标:让网关在下游变慢或被刷时仍然活着,并且把「拒绝」写成客户端能理解的状态码、响应头和重试时间。
适用版本: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一个锁,所有请求竞争
每 IPIpRateLimiter 的 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 = 1000Bootstrap.connect()502,连接未建立
读ReadTimeoutHandler(1500 ms)最后一次读到数据504,关闭后端连接
总预算CompletableFuture.orTimeout(2000 ms)请求进入 GatewayHandler504,关闭后端连接

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同一连接上出现 429ab 报告
Retry-Aftercurl -sS -D - -o /dev/null http://127.0.0.1:9090/books/7429 响应带整数秒 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 验收与复盘 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.1 网关路由与转发
  3. 《Netty 高性能网络编程》14.3 调优案例与回归