Netty对HTTP2流控的支持

前言

流量控制是HTTP/2的一项重要功能,它允许发送方根据接收方的处理能力来控制数据的传输速率。通过合理的流控机制,可以确保服务器和客户端之间的通信不会出现拥塞或资源浪费。

HTTP/2中的流控通过两个机制实现:

  1. 流量控制窗口(Flow Control Window):每个HTTP/2连接都会有一个流量控制窗口,用于控制接收方可以接受的数据量。发送方在发送数据之前必须检查接收方的流量控制窗口大小,并确保发送的数据不会超过接收方的处理能力。接收方可以通过发送WINDOW_UPDATEFrame来调整窗口的大小。
  2. 流量控制体系(Stream Prioritization and Flow Control):HTTP/2中的多个流(Stream)可以同时在一个连接上进行传输。每个流都有自己的流量控制窗口,用于控制该流的数据传输速率。发送方必须根据每个流的窗口大小来确定哪些流应该优先传输数据。通过优化流的优先级和流量控制窗口的调整,可以实现更有效的数据传输。

其中,流量控制窗口的实现较为简单,流优先级的控制则需要一套复杂的算法来支撑,好在Netty已经帮我们实现了。

Http2FlowController

io.netty.handler.codec.http2.Http2FlowController是Netty抽象出来的流控接口,下面有两个分支Http2LocalFlowControllerHttp2RemoteFlowController,分别针对inbound和outbound数据处理。

HTTP2的流控采用滑动窗口设计,Http2FlowController主要是定义了对Stream窗口的读写。

java 复制代码
public interface Http2FlowController {
	// 设置上下文
    void channelHandlerContext(ChannelHandlerContext ctx) throws Http2Exception;

    // 初始窗口大小
    void initialWindowSize(int newWindowSize) throws Http2Exception;

    // 获取初试窗口
    int initialWindowSize();

    // 获取流的窗口大小
    int windowSize(Http2Stream stream);

    // 调整流的窗口大小
    void incrementWindowSize(Http2Stream stream, int delta) throws Http2Exception;
}

Http2LocalFlowController是针对入站数据的流控,主要职责有:

  • 收到被流控的Frame时,递减窗口,窗口小于下限时抛异常
  • 消费被流控的Frame后,在适当时机给对方发送WINDOW_UPDATEFrame,调整窗口大小
java 复制代码
public interface Http2LocalFlowController extends Http2FlowController {
    // 设置FrameWrite,用于发送WINDOW_UPDATE Frame
    Http2LocalFlowController frameWriter(Http2FrameWriter frameWriter);

    // 接收流控Frame 递减窗口大小
    void receiveFlowControlledFrame(Http2Stream stream, ByteBuf data, int padding,
                                    boolean endOfStream) throws Http2Exception;

    // 已消费数据 适当实际发送WINDOW_UPDATE Frame
    boolean consumeBytes(Http2Stream stream, int numBytes) throws Http2Exception;

    // 获取流还未消费的数据大小
    int unconsumedBytes(Http2Stream stream);

    // 获取流的初始窗口
    int initialWindowSize(Http2Stream stream);
}

Http2RemoteFlowController是针对出站数据的流控,主要职责有:

  • 发送被流控的Frame前,确保对方有可用的窗口大小
  • 对方窗口大小为0时,Frame排队等待
  • Stream可写状态发生变更时,触发监听器
  • 维护Stream的优先级和依赖关系
java 复制代码
public interface Http2RemoteFlowController extends Http2FlowController {
    // 获取Channel上下文
    ChannelHandlerContext channelHandlerContext();

    // 对流控Frame排队
    void addFlowControlled(Http2Stream stream, FlowControlled payload);

    // 流是否有排队的Frame
    boolean hasFlowControlled(Http2Stream stream);

    // 写入挂起字节 即排队的Frame
    void writePendingBytes() throws Http2Exception;

    // 设置监听器
    void listener(Listener listener);

    // 流是否可写 即窗口>挂起字节
    boolean isWritable(Http2Stream stream);

    // 可写状态变更
    void channelWritabilityChanged() throws Http2Exception;

    // 更新流的依赖关系树
    void updateDependencyTree(int childStreamId, int parentStreamId, short weight, boolean exclusive);
}

初始窗口

HTTP2连接建立后,发送的第一个Frame一定是SETTINGSFrame,用来对连接进行配置。其中一个属性SETTINGS_INITIAL_WINDOW_SIZE用来设置Stream的初始窗口大小,如果没指定则使用默认值65535个字节。

io.netty.handler.codec.http2.Http2FrameCodec是Netty提供的HTTP2 Frame编解码器,构建该组件时可以配置默认Settings,如下示例将初始窗口大小设为10。

java 复制代码
Http2FrameCodec http2FrameCodec = Http2FrameCodecBuilder.forServer()
        .initialSettings(Http2Settings.defaultSettings().initialWindowSize(10))
        .build();

当有新连接建立时,会触发ChannelInboundHandler#channelActive()事件,Http2FrameCodec此时会基于配置的Settings来发送第一个SETTINGSFrame,方法是Http2ConnectionHandler.PrefaceDecoder#sendPreface()

java 复制代码
private void sendPreface(ChannelHandlerContext ctx) throws Exception {
    if (prefaceSent || !ctx.channel().isActive()) {
        return;
    }
    prefaceSent = true;
    final boolean isClient = !connection().isServer();
    if (isClient) {
        // 客户端发送的第一个消息是一段魔法字符串 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
        ctx.write(connectionPrefaceBuf()).addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
    }
    // 发送SETTINGS Frame
    encoder.writeSettings(ctx, initialSettings, ctx.newPromise()).addListener(
            ChannelFutureListener.CLOSE_ON_FAILURE);
    if (isClient) {
        userEventTriggered(ctx, Http2ConnectionPrefaceAndSettingsFrameWrittenEvent.INSTANCE);
    }
}

对方在接收到SETTINGSFrame后,就会对流控进行配置。

读取SETTINGSFrame的代码在DefaultHttp2FrameReader#readSettingsFrame(),每个Setting占用6个字节,包含2字节的Key和4字节的Value。

java 复制代码
private void readSettingsFrame(ChannelHandlerContext ctx, ByteBuf payload,
        Http2FrameListener listener) throws Http2Exception {
    if (flags.ack()) {// 对方回复的ack
        listener.onSettingsAckRead(ctx);
    } else {
        // 长度除以6 就是Setting个数
        int numSettings = payloadLength / SETTING_ENTRY_LENGTH;
        Http2Settings settings = new Http2Settings();
        for (int index = 0; index < numSettings; ++index) {
            char id = (char) payload.readUnsignedShort();
            long value = payload.readUnsignedInt();
            try {
                // key value存储到Map
                settings.put(id, Long.valueOf(value));
            } catch (IllegalArgumentException e) {
                if (id == SETTINGS_INITIAL_WINDOW_SIZE) {
                    throw connectionError(FLOW_CONTROL_ERROR, e,
                            "Failed setting initial window size: %s", e.getMessage());
                }
                throw connectionError(PROTOCOL_ERROR, e, "Protocol error: %s", e.getMessage());
            }
        }
        // 触发监听
        listener.onSettingsRead(ctx, settings);
    }
}

SETTINGS Frame读取完毕后会触发监听器,此时会对出站流控配置初始窗口大小,因为初始窗口大小针对的是连接中的所有Stream,所以要对连接中所有活跃的Stream进行配置,方法是DefaultHttp2RemoteFlowController.WritabilityMonitor#initialWindowSize()

java 复制代码
void initialWindowSize(int newWindowSize) throws Http2Exception {
    checkPositiveOrZero(newWindowSize, "newWindowSize");
    // 窗口差值
    final int delta = newWindowSize - initialWindowSize;
    initialWindowSize = newWindowSize;
    // 对连接下所有活跃的流配置
    connection.forEachActiveStream(new Http2StreamVisitor() {
        @Override
        public boolean visit(Http2Stream stream) throws Http2Exception {
            // 每个流都有一个自己的FlowState管理窗口
            state(stream).incrementStreamWindow(delta);
            return true;
        }
    });
    // 窗口变大且Channel可写 则写入挂起字节
    if (delta > 0 && isChannelWritable()) {
        writePendingBytes();
    }
}

每个Stream都有一个属于自己的FlowState用来处理流控,字段window记录当前Stream的可用窗口大小,一开始window值是0,接收到初始窗口其实就是递增窗口大小。

java 复制代码
int incrementStreamWindow(int delta) throws Http2Exception {
    if (delta > 0 && Integer.MAX_VALUE - delta < window) {
        throw streamError(stream.id(), FLOW_CONTROL_ERROR,
                "Window size overflow for stream: %d", stream.id());
    }
    // 窗口递增
    window += delta;
    streamByteDistributor.updateStreamableBytes(this);
    return window;
}

SETTINGS Frame接收以后,Stream就有可用窗口了,因为还没有发送DATA Frame,所以窗口还不会减小。

出站流控

目前HTTP2协议规范里规定了,只有DATA Frame的Payload部分是被流量控制的,其它Frame以及DATA Frame的头部都是不受限于流控的,不排除未来还会有更多类型的Frame会被流控。

Netty通过DefaultHttp2ConnectionEncoder#writeData()发送DATA Frame,因为受限于流控,所以不能无脑直接发送,要先进行排队,确保有可用的窗口。

java 复制代码
public ChannelFuture writeData(final ChannelHandlerContext ctx, final int streamId, ByteBuf data, int padding,
        final boolean endOfStream, ChannelPromise promise) {
    promise = promise.unvoid();
    final Http2Stream stream;
    try {
        // 确保Stream存在
        stream = requireStream(streamId);
        // 校验Stream状态
        switch (stream.state()) {
            case OPEN:
            case HALF_CLOSED_REMOTE:
                // Allowed sending DATA frames in these states.
                break;
            default:
                throw new IllegalStateException("Stream " + stream.id() + " in unexpected state " + stream.state());
        }
    } catch (Throwable e) {
        data.release();
        return promise.setFailure(e);
    }

    // 封住成FlowControlledData 排队
    flowController().addFlowControlled(stream,
            new FlowControlledData(stream, data, padding, endOfStream, promise));
    return promise;
}

每个Stream都有一个FlowState管理流控,排队就是把Frame写入到队列。
DefaultHttp2RemoteFlowController.FlowState#enqueueFrame()

java 复制代码
void enqueueFrame(FlowControlled frame) {
    FlowControlled last = pendingWriteQueue.peekLast();
    if (last == null) {
        // 队列是空的 直接入队 并递增挂起字节数
        enqueueFrameWithoutMerge(frame);
        return;
    }
    // 队列不为空 尝试将多个小Frame合并
    int lastSize = last.size();
    if (last.merge(ctx, frame)) {
        incrementPendingBytes(last.size() - lastSize, true);
        return;
    }
    enqueueFrameWithoutMerge(frame);
}

到此为止,Frame仅仅是入队,且递增了FlowState的挂起字节数。要想将数据发送出去,还需调用flush()

Http2ConnectionHandler重写了flush方法,在传播flush事件前,尝试发送挂起的frame。

java 复制代码
public void flush(ChannelHandlerContext ctx) {
    try {
        // 写入被流控的挂起字节
        encoder.flowController().writePendingBytes();
        ctx.flush();
    } catch (Http2Exception e) {
        onError(ctx, true, e);
    } catch (Throwable cause) {
        onError(ctx, true, connectionError(INTERNAL_ERROR, cause, "Error flushing"));
    }
}

发送Frame的方法是DefaultHttp2RemoteFlowController.FlowState#writeAllocatedBytes(),主要做了以下几件事:

  1. 循环取出排队的Frame,准备发送。
  2. 判断可写的字节数N,受限于窗口大小和流的权重优先级,从分配的字节数和窗口大小中取最小值。
  3. 发送N个字节,受限于可写字节数限制,一个Frame不一定能完整发送完,如果Frame发送完了,则从队列中移除。
  4. 因为是循环发的,可能发送了多次Frame,统计好最终发送的字节数M。
  5. 将挂起的字节数和Stream的窗口大小减去M。
java 复制代码
int writeAllocatedBytes(int allocated) {
    final int initialAllocated = allocated;
    int writtenBytes;
    Throwable cause = null;
    FlowControlled frame;
    try {
        assert !writing;
        writing = true;
        boolean writeOccurred = false;
        while (!cancelled && (frame = peek()) != null) {
            // 分配的字节 可写窗口 取最小值
            int maxBytes = min(allocated, writableWindow());
            if (maxBytes <= 0 && frame.size() > 0) {
                // 有Frame 但是不可写 不能写空的Frame
                break;
            }
            writeOccurred = true;
            // 记录一下Frame的原始大小 受限于窗口限制 一个Frame不一定能完整发送
            int initialFrameSize = frame.size();
            try {
                // 开始写 最多maxBytes字节
                frame.write(ctx, max(0, maxBytes));
                if (frame.size() == 0) {
                    // Frame写完了,队列移除Frame
                    pendingWriteQueue.remove();
                    frame.writeComplete();
                }
            } finally {
                // 递减已发送的字节数
                allocated -= initialFrameSize - frame.size();
            }
        }
        if (!writeOccurred) {
            return -1;
        }
    } catch (Throwable t) {
        cancelled = true;
        cause = t;
    } finally {
        writing = false;
        // 已写入的字节数
        writtenBytes = initialAllocated - allocated;
        // 递减挂起字节数
        decrementPendingBytes(writtenBytes, false);
        // 递减窗口大小
        decrementFlowControlWindow(writtenBytes);
        if (cancelled) {
            cancel(INTERNAL_ERROR, cause);
        }
    }
    return writtenBytes;
}

到此为止,只要窗口大小足够,DATA Frame就能发送出去。如果对方窗口太小,来不及消费掉这些Frame,发送方将停止发送,等待对方发送WINDOW_UPDATEFrame递增窗口后再次尝试发送剩下的数据。

入站流控

出站流控要考虑到窗口大小,流的优先级关系等,不能发送过载的数据给到对方,防止把对方给打垮,实现起来非常复杂。

相比之下,入站流控就简单的多,核心就是在适当的时机给对方发送WINDOW_UPDATEFrame。
为什么发送WINDOW_UPDATE Frame如此重要???

发送WINDOW_UPDATE Frame是告诉对方你消耗了一些数据,现在有能力处理更多的数据了,请对方继续发送数据。如果你忘记发送WINDOW_UPDATE Frame了,导致的结果就是,对方一段时间之后将不会再给你发数据了。

HTTP2 Frame的读取代码在DefaultHttp2FrameReader#readDataFrame(),因为DATA Frame格式简单,解析也就很简单了。

java 复制代码
private void readDataFrame(ChannelHandlerContext ctx, ByteBuf payload, int payloadEndIndex,
        Http2FrameListener listener) throws Http2Exception {
    // DATA Frame 由data和padding组成
    int padding = readPadding(payload);
    verifyPadding(padding);
    
    // 剔除padding后的有效数据长度
    int dataLength = lengthWithoutTrailingPadding(payloadEndIndex - payload.readerIndex(), padding);
    ByteBuf data = payload.readSlice(dataLength);
    // 触发监听器
    listener.onDataRead(ctx, streamId, data, padding, flags.endOfStream());
}

触发DefaultHttp2ConnectionDecoder.FrameReadListener#onDataRead()监听时,会递减窗口大小。

  1. 接收到被流控的Frame,递减窗口大小,包含连接级和Stream级别的。
  2. DATA Frame解析完并触发监听器,就意味着Frame被消费掉了,可以递增窗口大小了,但是一般不是这里触发。
java 复制代码
public int onDataRead(final ChannelHandlerContext ctx, int streamId, ByteBuf data, int padding,
                      boolean endOfStream) throws Http2Exception {
    // 获取流
    Http2Stream stream = connection.stream(streamId);
    // 入站流控
    Http2LocalFlowController flowController = flowController();
    int readable = data.readableBytes();
    int bytesToReturn = readable + padding;
    ......
    // 流中还未消费的字节数
    int unconsumedBytes = unconsumedBytes(stream);
    try {
        // 接收被流控的Frame 递减窗口大小
        flowController.receiveFlowControlledFrame(stream, data, padding, endOfStream);
        unconsumedBytes = unconsumedBytes(stream);
        if (error != null) {
            throw error;
        }
        verifyContentLength(stream, readable, endOfStream);
        bytesToReturn = listener.onDataRead(ctx, streamId, data, padding, endOfStream);
        if (endOfStream) {
            lifecycleManager.closeStreamRemote(stream, ctx.newSucceededFuture());
        }
        return bytesToReturn;
    } catch (Http2Exception e) {
        int delta = unconsumedBytes - unconsumedBytes(stream);
        bytesToReturn -= delta;
        throw e;
    } catch (RuntimeException e) {
        int delta = unconsumedBytes - unconsumedBytes(stream);
        bytesToReturn -= delta;
        throw e;
    } finally {
        // 必要时消费这些字节 发送WINDOW_UPDATE Frame的时机 一般不是这里
        flowController.consumeBytes(stream, bytesToReturn);
    }
}

经过调试发现,Netty并没有在解码完DATA Frame后直接发送WINDOW_UPDATE Frame,而是等待ChannelInboundHandler#channelReadComplete()事件被触发时执行。

数据读取完毕时,Netty会传播该事件,然后在Http2ChannelUnsafe#beginRead()中处理本地窗口。

java 复制代码
public void beginRead() {
    if (!isActive()) {
        return;
    }
    // 更新本地窗口 如果有需要
    updateLocalWindowIfNeeded();
    switch (readStatus) {
        case IDLE:
            readStatus = ReadStatus.IN_PROGRESS;
            doBeginRead();
            break;
        case IN_PROGRESS:
            readStatus = ReadStatus.REQUESTED;
            break;
        default:
            break;
    }
}

此时会将已消费的字节数构建成DefaultHttp2WindowUpdateFrame,发送给对方。

java 复制代码
private void updateLocalWindowIfNeeded() {
    if (flowControlledBytes != 0) {
        int bytes = flowControlledBytes;
        flowControlledBytes = 0;
        ChannelFuture future = write0(parentContext(), new DefaultHttp2WindowUpdateFrame(bytes).stream(stream));
        // window update frames are commonly swallowed by the Http2FrameCodec and the promise is synchronously
        // completed but the flow controller _may_ have generated a wire level WINDOW_UPDATE. Therefore we need,
        // to assume there was a write done that needs to be flushed or we risk flow control starvation.
        writeDoneAndNoFlush = true;
        // Add a listener which will notify and teardown the stream
        // when a window update fails if needed or check the result of the future directly if it was completed
        // already.
        // See https://github.com/netty/netty/issues/9663
        if (future.isDone()) {
            windowUpdateFrameWriteComplete(future, AbstractHttp2StreamChannel.this);
        } else {
            future.addListener(windowUpdateFrameWriteListener);
        }
    }
}

WINDOW_UPDATE Frame何时发送?

是每次消费掉一个Frame就立马发送吗?这样子太频繁了,发送WINDOW_UPDATE Frame本身也会占用资源。

Netty的做法是,给窗口设置了一个0.5的比例,当窗口消耗掉一半以后就会开始发送WINDOW_UPDATE Frame。

WINDOW_UPDATE

如果窗口用完了,发送方的Frame会排队等待。接收方消费掉这些Frame后,会在适当的时机发送WINDOW_UPDATE Frame,以提醒发送方,我已经准备好了接收更多的数据,请发送给我吧。

通过这样一套机制,双方可以更好的利用资源,避免接收方被大流量压垮。

发送方是如何处理WINDOW_UPDATE Frame的呢???

解析Frame的代码是DefaultHttp2FrameReader#readWindowUpdateFrame(),WINDOW_UPDATE Frame格式非常简单,Payload只包含递增的窗口大小值。

java 复制代码
private void readWindowUpdateFrame(ChannelHandlerContext ctx, ByteBuf payload,
        Http2FrameListener listener) throws Http2Exception {
    // Payload读取窗口大小值
    int windowSizeIncrement = readUnsignedInt(payload);
    if (windowSizeIncrement == 0) {
        throw streamError(streamId, PROTOCOL_ERROR,
                "Received WINDOW_UPDATE with delta 0 for stream: %d", streamId);
    }
    // 触发监听器
    listener.onWindowUpdateRead(ctx, streamId, windowSizeIncrement);
}

DefaultHttp2ConnectionDecoder.FrameReadListener#onWindowUpdateRead()监听被触发,调用流控的incrementWindowSize()递增窗口值。

java 复制代码
public void onWindowUpdateRead(ChannelHandlerContext ctx, int streamId, int windowSizeIncrement)
        throws Http2Exception {
    Http2Stream stream = connection.stream(streamId);
    if (stream == null || stream.state() == CLOSED || streamCreatedAfterGoAwaySent(streamId)) {
        verifyStreamMayHaveExisted(streamId);
        return;
    }
    // 更新出站流控的窗口大小
    encoder.flowController().incrementWindowSize(stream, windowSizeIncrement);
    listener.onWindowUpdateRead(ctx, streamId, windowSizeIncrement);
}

递增窗口值,判断是否有排队的Frame,有的话要继续发送。

java 复制代码
int incrementStreamWindow(int delta) throws Http2Exception {
    if (delta > 0 && Integer.MAX_VALUE - delta < window) {
        throw streamError(stream.id(), FLOW_CONTROL_ERROR,
                "Window size overflow for stream: %d", stream.id());
    }
    // 递增窗口大小
    window += delta;
    // 更新可流式传输的字节数
    streamByteDistributor.updateStreamableBytes(this);
    return window;
}

尾巴

流控是HTTP/2中至关重要的机制,它能够有效管理和优化网络流量,提升系统的性能和可靠性。

通过合理控制流量的发送和接收,我们可以确保在高负载和延迟网络环境下依然能够提供快速和可靠的数据传输。

随着HTTP/2的普及和发展,流控的重要性将会更加凸显。

相关推荐
梦境之冢10 小时前
axios 常见的content-type、responseType有哪些?
前端·javascript·http
DashVector10 小时前
如何通过HTTP API检索Doc
数据库·人工智能·http·阿里云·数据库开发·向量检索
哈利巴多先生10 小时前
HTTP,续~
网络·网络协议·http
白了个白i10 小时前
http的访问过程或者访问页面会发生什么
网络·网络协议·http
qq_3720068611 小时前
浏览器http缓存问题
网络协议·http·缓存
唐 城1 天前
curl 放弃对 Hyper Rust HTTP 后端的支持
开发语言·http·rust
DevilHeart灬1 天前
使用Grafana中按钮插件实现收发HTTP请求
http·grafana
总是学不会.1 天前
第五篇:前后端如何“扯皮”——HTTP 在开发中的应用
java·网络·网络协议·http·开发
火狮1 天前
鸿蒙Next API 12开发,使用@ohos/axios进行HTTP请求
http·华为·harmonyos
m0_748238271 天前
WebClient HTTP 请求问题处理模板(泛型响应、忽略 SSL 证书等)
网络协议·http·ssl