HTTP/3把传输层从TCP换成了QUIC,头部压缩协议也随之从HPACK演进为QPACK。QPACK最大的变化在于编码器和解码器各自维护一份动态表,而解码器是否已经收到某个条目,编码器并不能直接感知,必须依赖一套更新确认机制来同步状态。这套机制的核心是KNOWN_RECEIVED计数器以及两类反馈帧:Section Acknowledgment和Stream Cancellation。理解并用Ruby实现它,是深入掌握HTTP/3协议栈的好办法。

QPACK动态表更新确认的基本原理
QPACK的动态表是一个环形缓冲区,编码器通过插入指令(Insert With Name Reference、Insert With Literal Name等)往Encoder流里写入新条目,解码器收到后将其加入自己的动态表。问题在于编码流(Encoder Stream)和请求流是两条独立的QUIC流,到达顺序没有保证。如果编码器在请求流的头部块里引用了动态表第N个条目,而解码器那边还没处理完插入指令,解码就会卡住,这就是所谓的阻塞流(Blocked Stream)。
为了量化这个问题,协议引入了两个关键状态:一是Insert Count,表示编码器已插入的条目总数;二是Known Received Count,表示编码器确认解码器已经收到的条目数量。解码器每成功解码一个头部块,就在Decoder流上发一个Section Acknowledgment帧,携带该头部块所依赖的最大插入序号;编码器据此推进自己的Known Received Count,从而知道哪些条目可以安全地被"无阻塞引用"。
此外还有一个Max Table Capacity参数,由解码器通过SET_MAX_CAPACITY指令设定,编码器插入条目前必须先检查动态表总字节数是否超限,必要时可以发送SetCapacity指令主动缩小或扩大表的逻辑容量。整个确认链条可以概括为:编码器插入条目 → 解码器复制进表 → 解码器回传确认 → 编码器更新Known Received Count → 后续头部块可放心引用。
用Ruby实现编码器端的插入与状态跟踪
下面实现一个简化版的编码器。它维护动态表条目数组、insert_count和known_received_count两个计数器,并能生成插入指令和SetCapacity指令。为了聚焦确认机制,这里对整数编码采用了QPACK的Varint前缀格式,即6.2节定义的整数表示法。
class QpackEncoder
MAX_NAME_LEN = 127
def initialize(max_capacity)
@capacity = 0
@max_capacity = max_capacity
@entries = [] # 动态表条目,按插入顺序排列
@insert_count = 0 # 编码器已插入条目总数
@known_received = 0 # 已被解码器确认收到的条目数量
@blocked_streams = {} # stream_id => 该流依赖的最大插入序号
end
# QPACK整数编码:prefix_bits位前缀 + 可选续字节
def encode_int(value, prefix_bits, first_byte_flags = 0)
max_prefix = (1 << prefix_bits) - 1
if value < max_prefix
return [first_byte_flags | value].pack('C')
end
out = [first_byte_flags | max_prefix].pack('C')
value -= max_prefix
while value >= 128
out << [(value % 128) | 0x80].pack('C')
value /= 128
end
out << [value].pack('C')
end
# SetCapacity指令:T标志位为1
def set_capacity_instruction(new_capacity)
raise 'exceeds decoder max' if new_capacity > @max_capacity
@capacity = new_capacity
encode_int(new_capacity, 5, 0x20)
end
# 以字面量名字插入:T=0,NameLen=H为0
def insert_literal(name, value)
instr = encode_int(name.bytesize, 6, 0x00) << name
instr += encode_int(value.bytesize, 7, 0x00) << value
@entries.unshift([name, value])
@insert_count += 1
instr
end
# 引用动态表条目时记录该流的依赖,用于阻塞计算
def reference_entry(stream_id, index)
required_insert_count = @insert_count - index
current = @blocked_streams[stream_id] || 0
@blocked_streams[stream_id] = [current, required_insert_count].max
end
attr_reader :insert_count, :known_received, :blocked_streams
end这段代码的关键点在于blocked_streams哈希:每个请求流在引用动态表条目时,都会记录它所依赖的最大插入序号。只有当编码器的Known Received Count追平这个序号之后,该流才算解除阻塞。而Known Received Count的推进,完全依赖解码器回传的确认帧。
解码器端的确认帧生成与编码器处理
解码器一侧的工作分两步:一是处理Encoder流上的插入指令,把条目放进自己的动态表;二是在成功解码某个请求流的头部块之后,立即在Decoder流上发送Section Acknowledgment。如果某个流被主动取消(比如客户端中断了请求),解码器则发送Stream Cancellation,告诉编码器不必再为该流的确认等待。
class QpackDecoder
def initialize(max_capacity)
@max_capacity = max_capacity
@entries = []
@insert_count = 0
end
# 解析头部块前缀中的Required Insert Count
def parse_required_insert_count(prefix_bytes)
# 简化:假定单字节即可表示,实际需完整Varint解码
encoded = prefix_bytes.getbyte(0)
return 0 if encoded == 0
# 按RFC 9204 4.5.1.1公式还原真实值
max_entries = @max_capacity / 32
if encoded > max_entries
wrapped = encoded % max_entries
full_range = 2 * max_entries
req = wrapped + full_range * ((max_entries - wrapped + 1) / full_range)
return req >= 0 ? req : 0
end
encoded
end
# Section Acknowledgment:T=0,负载为Stream ID
def section_acknowledgment(stream_id)
[0x00].pack('C') + [stream_id].pack('C*')
end
# Stream Cancellation:T=1
def stream_cancellation(stream_id)
[0x40].pack('C') + [stream_id].pack('C*')
end
end
# 编码器侧处理反馈
class QpackEncoder
def handle_section_ack(stream_id)
required = @blocked_streams.delete(stream_id)
return unless required
# 多个流的确认取最大值推进Known Received Count
@known_received = [@known_received, required].max
end
def handle_stream_cancel(stream_id)
# 取消的流不再等待确认,直接移除依赖记录
@blocked_streams.delete(stream_id)
end
def stream_blocked?(stream_id)
req = @blocked_streams[stream_id]
req && req > @known_received
end
end注意handle_section_ack中用max而非直接赋值推进计数器:因为不同流依赖的插入序号不同,Known Received Count的定义是所有已确认流依赖序号的最大值,回退是不允许的。另外,如果解码器确认的是同一个流上多个头部块中的某一个,协议规定以该流最近一次发送的头部块的Required Insert Count为准。
阻塞流的判定与工程实践建议
有了上面的基础,阻塞判定就非常直观:流处于阻塞状态当且仅当它的Required Insert Count大于编码器当前的Known Received Count。真实的QUIC实现里,编码器通常还有一个Blocked Streams计数上限(由SETTINGS_QPACK_BLOCKED_STREAMS配置),超过这个数量就应该停止在头部块中引用动态表,退回到字面量编码,用压缩率换取零阻塞延迟。
在Ruby工程实践中还有几点值得注意。第一,Ruby的字符串默认可变,插入动态表的头部名值对最好调用freeze,既节省内存又便于作为Hash key做查重。第二,整数Varint编码务必写单元测试,边界值如127、128、16383是高频出错点,一旦编错整个确认链条都会错位。第三,如果只是做协议实验,可以直接在两个Ruby进程间用UNIXSocket模拟Encoder流和Decoder流,观察确认帧的交互时序,比抓包调试QUIC简单得多。
最后要强调一点常见误区:Known Received Count并不等于解码器的Insert Count。解码器可能已经处理了很多插入指令,但如果对应的请求流尚未被解码、确认帧还没发出来,编码器就必须继续把那些条目视为"未确认"。所以在写测试时,应该分别构造"插入完成但头部块未确认"和"确认帧已到达"两种场景,验证编码器状态机的输出差异,这才是QPACK更新确认机制真正考验实现者的地方。