摘要
- HPACK 动态表索引只在某条 HTTP/2 连接的特定方向、特定编解码上下文与特定处理次序中有意义。
- 成功还原字段列表不等于缓存命中;缓存键、响应存储、新鲜度、验证、授权和应用结果必须分别取证。
故障面板上写着“HPACK 缓存命中”。监控的原始事实其实很窄:编码器没有再次发送一串完整字节,而是发了一个索引;解码器按自己当时的状态找到了相应字段。面板却悄悄多说了一件事——仿佛系统已经从缓存取出了一份可以复用的响应。
这两个动作都利用重复,却不是同一件事。HPACK 保留字段名和值的字节,是为了在连接内重建字段列表。HTTP 缓存保留响应,是为了在规则允许时回答将来的请求。前者节省表示成本,后者作出复用决定。
Roberto Peon 是观察这条界线的合适人物。他与 Hervé Ruellan 合著了定义 HPACK 的 RFC 7541。他的 IETF Datatracker 记录还列有最初的 HTTP/2 规范 RFC 7540。一份 2012 年 QCon San Francisco 讲者简介称他当时在 Google 任工程师,并参与创建 SPDY。这是有日期的历史资料,不是对其当前职位的推断。现行 HTTP/2 规范 RFC 9113 也并非由 Peon 撰写。
HPACK 负责还原,不负责解释
RFC 7541 把头字段列表定义为有顺序的“名称—值”集合。完全相同的字段可以重复出现;在压缩层,名称和值只是不可解释的八位组序列;压缩前后的顺序必须一致。
因此,解码器还原出 cache-control: private,只说明这些字节出现在列表中。HPACK 不解释 private,不创建私有缓存,也不裁决共享缓存能否保存响应。语义判断发生在更上层。
HPACK 有静态表,也有动态表。静态表预先定义、只读。动态表起初为空,随着字段块被处理而插入条目,按先进先出的方式管理,并受内存上限约束。同名同值的重复条目是合法状态。
索引因此不是字段的永久身份证。最新条目位于动态区较小的索引位置,随后每次插入都可能改变位置;空间不足会驱逐旧条目。一条新连接从空表开始,同一个数字可能指向别的字节,也可能越界。只记“索引 63”而不记表的代际,就像只记座位号、却丢掉了当天的座位图。
一条连接有两套方向记忆
在双向 HTTP 中,RFC 7541 明确分开一个端点的编码动态表和解码动态表。客户端发送请求时使用的上下文,不是它接收响应时使用的上下文;服务端也维护相反方向的两套状态。
RFC 9113把这一安排写入现行 HTTP/2:每个端点在一条连接上有一个 HPACK 编码上下文和一个解码上下文,各自服务于该方向的全部字段块,动态表是其中最主要的可变状态。
所以,“这条连接见过此字段”仍然不是完整证据。谁见过?在哪个方向?哪个字段块之前?当时的表上限和实际内容是什么?若接收方漏掉一次插入,发送方与接收方从下一块起就可能用同一个索引指代不同内容。
这也解释了一个看似反直觉的要求:即使接收方准备丢弃某条消息,也仍须重组并解压完整字段块。块内指令可能改变后续流依赖的压缩状态。应用层不再关心某条流,并不等于连接可以跳过那段表历史。
表上限是一段时序,不是保留期限
HTTP/2 连接建立时,相关动态表的初始最大值是 4096 字节。解码方用 SETTINGS_HEADER_TABLE_SIZE 宣告自己允许的上限;编码方可选择不超过它的实际容量,并用 HPACK 的动态表大小更新指令表达变化。
4096 只是协议初始值,不是对所有实现和部署的普查结论。缩小上限还要经过 SETTINGS 及其确认所构成的次序。确认生效后,若现有表超过新限制,后续第一个字段块必须按规范带上正确的大小更新。
插入新条目时,旧条目会被逐个驱逐,直至腾出空间。单个条目若大于最大容量,结果是清空动态表而非永久保存它。这里控制的是一个短命、有限的压缩字典。
状态不同步时,影响范围暴露了真实依赖。RFC 9113 要求无法解压字段块的一方以 COMPRESSION_ERROR 终止连接。症状出现在某条流上,但受损的是这一方向上跨流共享的压缩上下文。它证明压缩会话无法可靠继续,却不证明业务请求本身错误。
HTTP 缓存回答的是另一个问题
RFC 9111把 HTTP 缓存定义为响应消息的本地存储,以及控制这些消息存入、取出和删除的子系统。缓存的核心动作,是在条件满足时让先前响应回答当前请求。
这个决定至少需要包含请求方法和目标 URI 的缓存键;Vary 指定的请求字段还可能参与匹配。随后还要判断响应是否新鲜、是否允许陈旧使用、是否经来源重新验证,并执行 no-store、private、no-cache 及与授权请求相关的规则。
HPACK 动态表没有这些权限。它可以保存 etag: "blue" 的字节,却没有对应响应表示;可以压缩 age: 300,却不会更新当前年龄;可以重建 vary: accept-language,却不比较前后两个请求的语言字段。
再看 cache-control: private。编码器可能因为该字段经常重复而把它插入动态表。插入只说明编码器选择利用重复性;它既不会创建私有缓存,也不会单独阻止共享缓存存储。缓存组件必须在解析完整响应后执行语义。HPACK 条目可能已经被驱逐,而响应仍在缓存;也可能条目仍在,响应却从未存入任何缓存。
反方向同样成立:一次真正的缓存命中,可以把字段按字面发送、引用静态表,或经由一条动态表尚为空的新连接交付。压缩命中和响应复用既不互为前提,也不互为证明。
“解得开”不是“信得过”
有效索引能证明一件具体的事:在处理序列的那个时点,解码方具有兼容状态,因而还原了一个字段条目。这足以继续解码,却不能自动验证字段字符是否合法、语义是否成立、来源服务器是否生成了它、途经中间节点是否改写,或应用是否完成动作。
机密性也不能从短编码推出。RFC 7541 警告:若攻击者既能影响待压缩字段,又能观察压缩后的长度,就可能探测动态表状态。TLS 隐藏内容,却没有隐藏全部长度信息。压缩率变化有许多正常原因,不能一概叫攻击;同样,节省字节也不是安全证明。
“永不索引”的字面表示会让该值不进入动态表,并要求中间节点重新编码时保留这一选择。它削减了一个暴露面,却不是保密定理:低熵值仍可能被猜中,长度信号仍可能存在,其他组件仍可能记录它,应用授权更不由它承担。
为两种状态各留一张收据
压缩收据从连接标识和两端角色开始。它应记录方向、流和字段块次序,所宣告的表上限及确认时点,块处理前后的实际容量与代际,插入、驱逐、大小更新、字面或索引表示,以及最终解码结果。若历史是推断出来的,这一点也要保留。
HTTP 收据在字段还原之后开始:方法、URI、状态码、来源路径和解析后的字段语义。涉及缓存时,还要写入缓存键、Vary 输入、存储决定、当前年龄与新鲜度、验证器、授权相关规则、重新验证结果,以及应用最终拿到什么。
抓包可以证明 HPACK 同步而完全看不到缓存内部;缓存日志可以证明响应复用而不呈现线上的压缩方式。缺的一列必须留空。解码器记住过字段字节,永远不能单独推导出系统有权复用响应。
来源
会员简报
档案背景详情
使用相应会员等级登录,即可解锁完整简报与来源注释。
仅限 Strategic Circle
Strategic Circle
所有读者均可浏览。加入并登录后可解锁档案简报。
加入 Strategic Circle仅限 Leadership Alliance
Leadership Alliance
符合条件的 IP 资产所有者和管理层可登录查看 Leadership Alliance 简报。
加入 Leadership Alliance
