QPACK在HTTP/3中负责头部压缩,其核心之一是动态表的容量管理。编码器可以调整动态表容量,但必须等待解码器确认后才能真正释放表项,否则会让双方对同一张表的认知不一致,进而导致头部块解压失败。本文聚焦确认机制的实现细节,结合Ruby代码展示如何安全地处理容量更新与确认指令。

一、为什么不确认容量更新会引发解压失败
QPACK协议的动态表由编码器和解码器各自维护一份。编码器每插入一个新表项,总数就增加1;解码器在成功解析插入指令后也会复制这个表项,让双方保持同步。但这只是理想情况。HTTP/3的多路复用允许不同流的头部块乱序到达,解码器可能尚未处理完所有插入,编码器却因为本地压力想缩小容量。如果编码器直接丢弃尚未被解码器确认的旧表项,后续到达的头部块若引用了这些表项,解码器就会因为找不到条目而报错。
确认机制要解决的就是这个时序问题。通过Insert Count Increment指令,解码器定期告知编码器它已经处理到哪个插入序列号。编码器据此判断哪些表项可以安全淘汰,哪些还必须保留。换句话说,容量缩减指令只负责声明新的容量上限,真正的驱逐动作必须由确认计数来驱动。
在这个机制中,被跟踪的数字有两个:一是编码器本地维护的TotalNumberOfInserts,表示所有已插入条目的总数;二是编码器从解码器收到的KnownReceivedCount,即解码器确认已处理的插入数量。只有被确认的插入条目才允许在容量缩小时被移除。
二、QPACK容量更新涉及的指令与状态
RFC 9204为动态表容量管理定义了两个关键指令。第一个是Set Dynamic Table Capacity,由编码器发出,作用是告知解码器新的最大容量值。该容量值经过HPACK整数编码后放入前缀为001的字节中。解码器收到该指令后会调整自己的表容量,但如果新容量小于当前已用空间,解码器只负责标记可淘汰项,实际的删除还要参考插入确认。
第二个指令是Insert Count Increment,由解码器发出,作用是对编码器的KnownReceivedCount进行增量更新。它携带一个整数增量值,编码器将其与现有的已知接收数相加。这个指令通常出现在解码器完成一个或多个头部块解析之后,也可以由解码器根据策略批量发送以节省流量。
除了这两个指令,确认机制还隐含一个前提:编码器在发送容量缩减之前,必须评估是否存在尚未确认但可能被引用的表项。常见的保守策略是,只有当KnownReceivedCount大于等于因缩减而需要淘汰的最小编号时,编码器才真正执行删除。这个策略实现简单,且能保证兼容性。
三、用Ruby搭建可用的状态跟踪结构
在Ruby中实现QPACK动态表确认逻辑,不一定要依赖完整的HTTP/3库。可以先构造一个纯Ruby类,封装容量、条目列表、插入总数和已确认计数。下面是一个简化但可扩展的实现骨架。
class QpackDynamicTable
attr_reader :capacity, :total_inserts, :entries
attr_accessor :known_received_count
def initialize(initial_capacity = 4096)
@capacity = initial_capacity
@entries = []
@total_inserts = 0
@known_received_count = 0
end
def max_entries
@capacity / 32
end
def insert(name, value)
size = name.bytesize + value.bytesize + 32
@entries << { id: @total_inserts, name: name, value: value, size: size }
@total_inserts += 1
end
def can_evict?(target_capacity)
target_max = target_capacity / 32
return false if @entries.empty?
@entries.first[:id] <= @known_received_count && @entries.size > target_max
end
end
上面的类把已确认计数known_received_count作为核心状态。实际编码器发送容量缩减时,会先调用can_evict?判断是否满足淘汰条件。但注意can_evict?只是一个粗略判断,生产实现还需要结合每个表项的具体大小和头部块引用状态。
编码器需要处理的另一个状态是Set Dynamic Table Capacity指令的发送时机。如果发送缩减指令后不等待确认就删除条目,对方可能还在引用旧表项。因此稳妥的做法是:先发送容量指令,但仅当后续收到对应的插入计数增量,才真正删除超出的部分。
四、确认指令的编解码与处理逻辑
QPACK指令使用HPACK整数编码。对Set Dynamic Table Capacity来说,第一个字节的高三位是001,低五位开始编码容量值。对Insert Count Increment来说,第一个字节的高两位是00,后面六位编码增量。下面给出简化的编码和解码方法。
def encode_integer(value, prefix_bits)
max = (1 << prefix_bits) - 1
return [value] if value < max
bytes = [max]
value -= max
while value >= 128
bytes << (value % 128) + 128
value /= 128
end
bytes << value
bytes
end
def encode_set_capacity(capacity)
prefix = 0b001
first = prefix << 5
[first | encode_integer(capacity, 5)[0]] + encode_integer(capacity, 5)[1..-1]
end
def decode_integer(data, prefix_bits)
max = (1 << prefix_bits) - 1
value = data[0] & max
return [value, 1] if value < max
shift = 0
idx = 1
loop do
byte = data[idx]
value += (byte & 127) << shift
shift += 7
idx += 1
break if byte & 128 == 0
end
[value, idx]
end
def parse_insert_count_increment(data)
increment, consumed = decode_integer(data, 6)
increment
end
解析Insert Count Increment时,先取出低六位作为第一个值,再调用整数解码器读取后续字节。编码器收到增量后,将known_received_count加上这个值。如果增量使确认计数超过了总插入数,说明协议出错或者本地状态损坏,应立即触发连接错误。
实际工程中,QPACK解码器会在多个事件点发送Insert Count Increment:例如每个头部块处理完成时、内部缓冲区达到阈值时,或者收到编码器发来的容量缩减后立即响应。为了降低确认频率,多数实现会累计增量,在必要时批量发送。
下面展示一个处理循环,模拟编码器收到数据帧后的完整动作。
class QpackEncoder
def on_insert_count_increment(data)
inc = parse_insert_count_increment(data)
table.known_received_count += inc
if table.known_received_count > table.total_inserts
raise ConnectionError, "Invalid insert count increment"
end
apply_pending_eviction
end
def apply_pending_eviction
return unless @pending_capacity
if table.entries.size > table.max_entries_for(@pending_capacity) &&
table.entries.first[:id] <= table.known_received_count
table.evict_to_capacity(@pending_capacity)
@pending_capacity = nil
end
end
end
五、多流并发场景下确认延迟与批量发送策略
HTTP/3的一条连接上同时存在多个请求流,每个流都可能携带QPACK头部块。假设流A先到达并引用了表项编号3,流B后到达但先完成解析,解码器可能先为流B发送插入计数增量,而流A仍未被处理。如果编码器只依赖全局确认计数,可能误以为表项3已安全,从而在容量缩减时把它删除。这种乱序导致确认计数只能反映已完成的头部块数,而不能反映所有流都已经处理到某个插入号。
为了应对这种情况,QPACK规范要求解码器在发送确认时,必须保证所有较小插入编号所依赖的头部块都已经处理完毕。也就是说,增量不能超过真正完成解析的最小流边界。实际实现需要维护每个流的状态,并在所有引用低编号的流完成后再提高确认计数。
Ruby实现中可以使用一个哈希表记录每个流当前处理的插入号,然后在计算全局确认时取所有活跃流的最小值。以下是一个示例代码。
class QpackDecoder
def initialize
@stream_inserts = Hash.new { |h, k| h[k] = 0 }
end
def on_header_block(stream_id, block)
max_insert = process_instructions(block)
@stream_inserts[stream_id] = max_insert
end
def safe_confirm_count
@stream_inserts.values.min || 0
end
def maybe_send_increment
latest = safe_confirm_count
if latest > @last_sent
increment = latest - @last_sent
send_insert_count_increment(increment)
@last_sent = latest
end
end
end
六、用单元测试验证容量缩减的确认逻辑
确认机制的正确性很难靠人工阅读代码保证,必须用测试覆盖关键路径。Ruby的minitest即可完成这项工作。测试要点包括:未收到确认前不得淘汰表项;收到增量后按照新容量删除最旧项;过大的增量应当抛错。
以下测试片段模拟编码器先插入三个表项,然后发送容量缩减到64字节。由于known_received_count为0,can_evict?应返回假;当收到解码器确认增量2后,最旧的两个表项被淘汰,留下最后一个。
require 'minitest/autorun'
class QpackDynamicTableTest < Minitest::Test
def setup
@table = QpackDynamicTable.new(128)
@table.insert('content-type', 'text/html')
@table.insert('accept', 'application/json')
@table.insert('user-agent', 'ruby-qpack')
end
def test_no_eviction_before_ack
refute @table.can_evict?(64)
assert_equal 3, @table.entries.size
end
def test_eviction_after_ack
@table.known_received_count = 2
assert @table.can_evict?(64)
@table.evict_to_capacity(64)
assert_equal 1, @table.entries.size
assert_equal 'user-agent', @table.entries.last[:name]
end
def test_invalid_increment_raises
assert_raises(ConnectionError) do
@table.known_received_count += 10
raise ConnectionError if @table.known_received_count > @table.total_inserts
end
end
end
测试用例中evict_to_capacity方法需要根据容量计算能保留多少表项,并从最旧的开始删除。它的实现应该先按条目大小累计,直到满足目标容量。注意容量限制是针对所有表项总大小,而不仅仅是数量,因此max_entries只是一个估算,生产实现必须精确计算。
除此之外,还应测试解析器对畸形数据的处理。例如整数编码以128结尾但没有后续字节时,解码器应报错而不是无限循环。这些边界测试能避免线上出现死循环或越界读取。