
HTTP/2 的流优先级是由 RFC 7540 定义的一套依赖树模型,允许客户端向服务器表达不同资源之间的相对重要性。每个流可以指定一个父流以及一个权重值(1~256 之间的整数),服务器则根据这棵树来分配带宽和调度 DATA 帧的发送顺序。但在实际实现中,仅仅构建一棵静态依赖树远远不够——客户端在页面加载过程中会动态添加、关闭流,甚至需要修改某个流的权重来响应用户交互。这就要求服务器或中间代理必须高效地实现权重调整算法,既能维护树结构的正确性,又能避免每次调整都触发整棵子树的计算,造成不必要的开销。
依赖树权重模型的核心数学关系
理解权重调整算法,首先要明确 HTTP/2 规范中定义的资源分配策略。它不是简单地按照绝对权重数值来分时间片,而是采用一种“按比例切分子树带宽”的递归方式。每个节点从其父节点那里获得一部分带宽,这部分带宽的大小等于该节点自身的权重除以它所有兄弟节点(即同一个父节点的子节点)权重之和。
例如,假设流 A 有两个子流 B(权重 12)和 C(权重 4),那么流 A 分配给子树的带宽中,B 会得到 12/(12+4)=75%,C 得到 25%。如果 B 下面还有子节点 D(权重 8)和 E(权重 2),那么 D 获得的带宽比例则为 B 得到带宽的 8/(8+2)=80%。这种层层比例的模型保证了兄弟节点之间的相对关系是稳定的,但同时也引入了一个问题:当我们要调整某个中间节点的权重时,其实是在改变它相对于兄弟节点的占比,这会影响到它整个子树的所有后代流。
关键点在于,权重的修改仅仅影响局部兄弟组的比例,而不会向上冒泡改变父节点的分配。也就是说,父节点给该子节点所在组的带宽总和并不会因为个别子节点权重变化而改变,除非该子节点本身被重新排入不同的父节点下。这个性质是算法实现的基础,我们可以利用它来将修改范围限制在同一兄弟组内部。
用 Ruby 设计流优先级树的数据结构
在动手写代码之前,我们需要一个能够快速定位节点、查询父子关系、遍历兄弟节点的数据结构。Ruby 的面向对象特性非常适合用节点类来表示流,每个节点保存流 ID、父节点引用、权重,以及一个子节点数组。为了支持 O(1) 的查找,可以用一个哈希表将流 ID 映射到节点对象。
下面是节点类和调度器骨架的基础定义:
class PriorityNode
attr_accessor :stream_id, :weight, :parent, :children
def initialize(stream_id, weight: 16, parent: nil)
@stream_id = stream_id
@weight = weight
@parent = parent
@children = []
end
end
class PriorityTree
attr_reader :nodes
def initialize
@nodes = {} # stream_id => PriorityNode
# 创建根节点,流0,无父节点
root = PriorityNode.new(0, weight: 16)
@nodes[0] = root
end
# 添加一个流,指定父节点
def add_stream(stream_id, parent_id: 0, weight: 16, exclusive: false)
# 实现见正文
end
# 修改流权重
def update_weight(stream_id, new_weight)
# 实现见正文
end
end
其中 exclusive 参数用于处理 HTTP/2 中的独占标志。当客户端设置独占位时,新流会将父节点原有的所有子节点“抢”过来,变成自己的子节点,而父节点原来的子树则整体下沉为这个新节点的子树。这一操作本质上是一种树重构,并不复杂,只需要调整父子引用关系,但需要特别注意不能破坏原来的相对顺序。
权重的默认值设为 16 是遵循规范建议。根节点永远存在于流 0 上,它没有父节点,权重固定为 16 且不参与实际的流量分配,只作为整棵树的逻辑起点。
权重调整算法的实现与局部修正技巧
当收到一个包含权重更新字段的优先级帧时,我们需要更新对应流的权重值,并且保证该流与其兄弟节点之间的比例关系按新权重生效。乍看上去似乎很简单:直接修改 node.weight = new_weight 即可。但这样做的结果会导致整个兄弟组的权重总和发生变化,进而影响所有兄弟节点的相对带宽占比。比如原来 B 和 C 权重分别为 12 和 4,总和 16;如果把 B 改为 6,总和变为 10,那么 C 的占比会从 25% 上升到 40%,这通常不是客户端期望的——它往往希望只改变某个流的优先级,而尽量不影响其他兄弟流。
更符合预期的做法是权重再平衡:仅调整被修改流自己的权重,而保持其他兄弟节点之间的比例不变,从而使得整个组的资源分配仍然均匀过渡。这可以通过对兄弟节点进行等比例缩放来实现。具体步骤如下:
- 计算出该流所有兄弟节点(不包括自身)的当前权重之和 old_siblings_sum。
- 设新权重为 new_weight。我们希望所有兄弟节点继续占用 old_siblings_sum 的总权重,这样整个组的带宽池大小不变,只有被修改流自己的份额发生变化。
- 对每一个兄弟节点,将其权重乘以 (old_siblings_sum / new_siblings_sum) 并取整(需要向上或向下取整以保证总和相等)。但实际上直接缩放可能带来浮点数误差,更稳妥的方法是重新计算目标总权重,然后按原本的权重比例重新分配。
下面给出一个具体的 Ruby 实现,其中包含了处理独占标志和兄弟节点重新分摊的逻辑:
class PriorityTree
# ... 前述代码 ...
def add_stream(stream_id, parent_id: 0, weight: 16, exclusive: false)
raise "Stream #{stream_id} already exists" if @nodes[stream_id]
parent = @nodes[parent_id] || @nodes[0]
node = PriorityNode.new(stream_id, weight: weight, parent: parent)
if exclusive
# 独占模式:父节点的现有子节点全部变成该新节点的子节点
node.children = parent.children.dup
parent.children.each { |child| child.parent = node }
parent.children.clear
end
parent.children << node
@nodes[stream_id] = node
node
end
def update_weight(stream_id, new_weight)
node = @nodes[stream_id]
return unless node
return if node.parent.nil? # 根节点不可修改
parent = node.parent
siblings = parent.children.reject { |c| c == node }
old_total = siblings.sum(&:weight) + node.weight
old_siblings_sum = siblings.sum(&:weight)
# 设置新权重
node.weight = new_weight
new_siblings_sum = old_total - new_weight
# 按比例重新分配兄弟权重,保持原有比例不变
if old_siblings_sum.zero?
# 如果原来兄弟权重都为0(很少见),则平均分配
siblings.each { |s| s.weight = new_siblings_sum / siblings.size }
else
ratio = new_siblings_sum.to_f / old_siblings_sum
siblings.each { |s| s.weight = (s.weight * ratio).round }
# 由于四舍五入可能导致总和细小差异,这里做一个简单的微调
adjust_rounding(parent, node, new_siblings_sum)
end
end
private
# 调整因取整产生的微小偏差
def adjust_rounding(parent, updated_node, target_siblings_sum)
actual_sum = parent.children.reject { |c| c == updated_node }.sum(&:weight)
delta = target_siblings_sum - actual_sum
return if delta.zero?
# 将差值加到第一个兄弟节点上(简单处理)
sibling = parent.children.find { |c| c != updated_node }
sibling.weight += delta if sibling
end
end
上面这段代码的核心在于通过保持兄弟权重总和恒定,来确保该兄弟组的带宽池大小不变。当某个流的权重从 12 改为 6 时,其他兄弟的总和原本可能是 4,现在该组总池依然为 16,于是兄弟节点按比例缩放后总和变为 10,各自占比与原来保持一致(比如原兄弟 C 权重 4,缩放后变为 10*4/4=10?这里需要重新计算:
原兄弟总和 4,新兄弟总和 16 - 6 = 10,缩放因子 10/4 = 2.5,C 的新权重为 4*2.5=10。这样 B 得到 6/16=37.5%,C 得到 10/16=62.5%,与原来的 12:4 比例相比,B 相对 C 的重要性降低了,但 C 的绝对带宽份额因为总池未变反而增加了。这种局部重新分配的策略更符合“调整单一流优先级同时尽量不对其他流产生意外影响”的直觉。
需要注意的是,四舍五入会导致总和略微偏离目标值,因此我们在最后加了微调步骤。这个简单策略在实际产品中可能还需要更精确的整数分配算法,但思路是通用的。
避免实现中的常见陷阱与性能优化
一个很容易犯的错误是直接用权重值作为调度器的绝对时间配比。例如收到权重 12 的流就分配 12 个令牌,权重 4 就分配 4 个令牌。这样做完全忽略了依赖树的层级结构,根节点的所有子节点会被扁平化对待,导致一些低权重高优先级的资源反而得不到及时发送。正确的做法应该是从根节点开始逐层计算比例,下面给出一个简单的递归权重求和与选择函数的示例:
def select_next_stream(node = root)
return nil if node.children.empty?
# 如果节点有子节点,则在该节点的子节点组中进行轮询
total_weight = node.children.sum(&:weight)
pick = rand(total_weight) # 随机选择模拟基于权重的分发
cumulative = 0
node.children.each do |child|
cumulative += child.weight
if pick < cumulative
return select_next_stream(child) if child.children.any?
return child
end
end
end
另一个陷阱是忘记处理流关闭时的子树重组。当一个流被 RST_STREAM 帧关闭,所有以它为父节点的流都需要重新分配到一个合适的父流,通常是这个已关闭流的父节点。如果处理不当,这些流就会变成孤儿或者错误地挂到根节点,导致资源分配完全走样。实现时必须在 remove_stream 方法中将子节点重新插入到其祖父节点下,并根据独占标志做出相应处理。
性能方面,如果服务器的并发流数量很大,权重调整可能会触发大量兄弟节点遍历。一个可行的优化是惰性重算——不立即更新兄弟节点的权重,而是维护一个“脏标记”,在下一次调度计算时再统一重新归一化。或者引入权重归一化缓存,只有当修改涉及组内节点时才使缓存失效。但在小型代理或网关中,直接遍历 siblings 的开销通常微不足道,所以简单实现也完全可用。
最后,Ruby 语言本身的特性使得这类树状算法的实现非常直观,丰富的集合操作和区块语法可以大幅减少代码量。把 PriorityTree 封装成一个独立模块后,可以方便地嵌入到任何使用 HTTP/2 协议的应用中,比如用 http-2 gem 构建的客户端或服务器。完整示例代码和测试套件可以在阅读 RFC 7540 第 5.3 节时一并参考验证,确保实现与协议标准严格吻合。