HTTP/2引入了多路复用之后,一个TCP连接上可以同时跑几十上百个流,但带宽终归是有限的,谁先谁后就成了问题。浏览器其实很清楚自己想要的资源有多急迫:渲染阻塞的CSS必须最先到,首屏图片其次,埋点脚本可以慢慢来。这套"急迫程度"在HTTP/2里通过流优先级机制表达,而服务端要做的事情,就是正确接收并利用这些信号。这篇文章用Ruby来实现一套完整的优先级处理方案,重点讨论如何把浏览器的资源加载意图映射到服务端的调度逻辑上。

理解HTTP/2的优先级模型:依赖树与权重
HTTP/2的优先级(RFC 7540定义)由两部分组成:流依赖和权重。每个流可以声明自己依赖于另一个流,所有流这样串起来就形成了一棵依赖树;权重取值1到256,表示同一父节点下各子流之间资源分配的比例。举个直观的例子,如果流A权重为200,流B权重为100,且二者互不依赖,那么理论上A能分到约三分之二的带宽。
这套模型的关键在于:优先级不是一个简单的数字排序,而是一棵动态变化的树。浏览器可以在任意时刻发送PRIORITY帧来调整某个流的位置,比如一张图片从"懒加载状态"升级为"即将进入视口",浏览器就会发PRIORITY帧把它挂到更紧急的分支下。服务端如果忽略这些更新,调度就会和浏览器预期脱节,表现为页面渲染延迟增大。
还需要注意RFC 9218(Extensible Prioritization Scheme)的出现。Chrome和Firefox从某个版本起默认改用了这种基于HTTP头和增量优先级参数的新方案,不再发送PRIORITY帧。所以一个完善的实现要同时兼容两种信号来源。下面我们用Ruby先把依赖树的计算逻辑写出来:
class PriorityTree
Node = Struct.new(:stream_id, :weight, :children, :exclusive_dep)
def initialize
# HTTP/2规定流0是根节点
@root = Node.new(0, 16, [], false)
@nodes = { 0 => @root }
end
def insert(stream_id, parent_id, weight, exclusive: false)
node = @nodes[stream_id] || Node.new(stream_id, weight, [], false)
node.weight = weight
old_parent = find_parent(stream_id)
old_parent&.children&.delete(node)
parent = @nodes[parent_id] || @root
if exclusive
# 独占依赖:原父节点的所有子流挂到新流下面
reparented = parent.children.dup
parent.children = [node]
reparented.each { |c| node.children << c }
else
parent.children << node
end
@nodes[stream_id] = node
end
def find_parent(stream_id)
return nil unless (node = @nodes[stream_id])
@nodes.each_value do |n|
return n if n.children.include?(node)
end
nil
end
# 计算某个流应分得的带宽比例
def bandwidth_share(stream_id, total: nil)
node = @nodes[stream_id] or return 0
siblings = find_parent(stream_id)&.children || [@root]
sum = siblings.sum(&:weight)
node.weight.to_f / sum
end
end这段代码实现了PRIORITY帧到达时的核心处理:插入节点、处理独占依赖(exclusive依赖会导致原兄弟流整体下挂)、以及计算带宽份额。实际生产中还会涉及循环依赖检测——浏览器发来的PRIORITY帧理论上可能让依赖树成环,遇到这种情况必须把该流重新挂回根节点,否则调度器会死循环。
把浏览器的资源加载优先级映射到流上
浏览器的加载优先级模型其实相对固定。以Chrome为例,资源被分成Highest、High、Medium、Low、Lowest几档:HTML文档本身是Highest,CSS是Highest或High,同步脚本High,字体High(尤其是渲染阻塞的字体),图片根据位置是Low到Medium,预加载的资源会提升一档,fetch请求默认是High但用priority提示可以显式降级。
映射到HTTP/2的依赖树,业界通行做法(也是Firefox曾经的标准行为)是建立几条虚拟的优先级分支:排他性分支放最高优先级资源,普通分支放交互性资源,背景分支放图片等低优先级资源,垃圾分支放那些被推迟的请求。用Ruby定义这个映射可以这样写:
class BrowserPriorityMapper
# 虚拟流ID,不能与真实客户端流(奇数)冲突,这里用服务端可识别的常量
EXCLUSIVE_LANE = :exclusive
NORMAL_LANE = :normal
BACKGROUND_LANE = :background
IDLE_LANE = :idle
LANE_WEIGHTS = {
EXCLUSIVE_LANE => 256,
NORMAL_LANE => 150,
BACKGROUND_LANE => 100,
IDLE_LANE => 1
}
# 浏览器优先级字符串到虚拟分支的映射表
PRIORITY_MAP = {
'highest' => EXCLUSIVE_LANE,
'high' => NORMAL_LANE,
'medium' => NORMAL_LANE,
'low' => BACKGROUND_LANE,
'lowest' => IDLE_LANE
}
def self.lane_for(browser_priority, resource_type = nil)
lane = PRIORITY_MAP[browser_priority.to_s.downcase] || BACKGROUND_LANE
# 字体和CSS在浏览器模型里天然高人一等,做一次修正
lane = EXCLUSIVE_LANE if resource_type == :css || resource_type == :font
lane
end
def self.weight_in_lane(browser_priority)
case browser_priority.to_s.downcase
when 'highest' then 220
when 'high' then 180
when 'medium' then 140
when 'low' then 100
else 40
end
end
end这里的映射逻辑分两层:第一层决定资源挂到哪条分支(决定大方向),第二层在同一条分支内用权重区分细腻程度。比如CSS即使浏览器只报了High,也应该挂到排他分支,因为样式表不到位页面完全白屏;而图片即使浏览器标了Medium,放在背景分支也更符合整体渲染节奏——首屏图片可以例外,通过判断响应头或请求路径单独提升。
兼容RFC 9218的场景则更简单一些。新的优先级方案通过 priority 请求头传递,值形如 u=2, i,其中u是0到7的紧迫度(0最高),i表示请求是增量式的。解析这个头比维护依赖树轻量得多:
require 'rack/request'
class ExtensiblePriorityParser
URGENCY_DEFAULT = 3
def self.parse(env)
header = env['HTTP_PRIORITY'].to_s
return { urgency: URGENCY_DEFAULT, incremental: false } if header.empty?
params = header.split(',').map(&:strip)
urgency = URGENCY_DEFAULT
incremental = false
params.each do |p|
key, value = p.split('=', 2)
case key
when 'u' then urgency = value.to_i.clamp(0, 7)
when 'i' then incremental = true
end
end
{ urgency: urgency, incremental: incremental }
end
# 把urgency值转成内部调度队列
def self.to_queue(urgency)
# u=0最急迫,映射到最高的调度档位
7 - urgency
end
end在真实服务端落地:基于流优先级的发送调度
光有优先级数据没有意义,关键是在写数据帧的时候按优先级排队。HTTP/2的DATA帧发送是服务端完全可控的,一个典型的加权调度器维护若干就绪队列,每轮按权重比例从各队列取数据。用ruby-http-2这个库搭建服务端时,可以在发送循环中嵌入这套调度逻辑:
require 'http/2'
require 'socket'
class PriorityAwareServer
MAX_FRAME_SIZE = 16384
def initialize
@lanes = Hash.new { |h, k| h[k] = [] }
# lane结构: { stream_id: Integer, data: String, weight: Integer }
end
def enqueue(stream_id, body, weight)
lane_key = lane_for_weight(weight)
@lanes[lane_key] << { stream_id: stream_id, data: body, weight: weight }
end
def lane_for_weight(weight)
return :high if weight >= 200
return :medium if weight >= 100
:low
end
# 加权轮转:每轮各lane按比例发送
def run_once(conn)
frame_budget = 8 # 每轮最多发送的帧数,防止低优先级饿死的上限控制
[:high, :medium, :low].cycle do |lane|
break if frame_budget.zero?
task = @lanes[lane].shift or next
chunk = task[:data].slice!(0, MAX_FRAME_SIZE)
@lanes[lane].unshift(task) unless task[:data].empty?
conn.stream(task[:stream_id]) do |s|
s.data(chunk, end_stream: task[:data].empty?)
end
frame_budget -= 1
end
end
end这个调度器有两个细节值得展开。第一是防饿死问题:如果高优先级分支一直有数据,纯权重调度会让低优先级流永远发不出去,上面的帧预算机制保证每轮循环里低优先级至少有机会被调度,更精细的做法是给每个lane设置最低保障带宽。第二是TCP层面的现实约束:HTTP/2跑在TCP上,应用层的优先级无法穿透到拥塞控制的丢包重传逻辑,一个低优先级流丢包同样会阻塞整个连接——这正是HTTP/3选择QUCK/UDP的动机之一,不过对HTTP/2服务端来说,控制单连接的并发流数量、避免队头阻塞加剧,已经是能做的极限了。
另一个实践建议是做好可观测性。调度器每轮统计各lane的实际发送字节数,暴露成指标,就能直观看到"浏览器认为很重要的CSS是否真的被优先发送了"。不少线上案例显示,正确实现优先级后,LCP指标能改善百分之十几,因为关键的CSS和首屏图片不再和几十张缩略图抢带宽。反过来,如果Nginx或中间代理没配好 grpc_http2_priority 类似的透传选项,服务端再精心的调度也是白费,所以部署时务必确认整条链路都尊重优先级信号。
总结一下这套方案的骨架:用依赖树与权重承接RFC 7540的PRIORITY帧,用简单的头解析承接RFC 9218的 priority 头,两边统一归一到内部的加权调度队列,最后在DATA帧发送循环中按比例消费。Ruby虽然不是高性能HTTP/2服务的主流选择,但其表达力非常适合验证调度算法的正确性,等逻辑跑通后再迁移到C扩展或Go实现也不迟。