HTTP/2 Server Push是Nginx早就支持的特性,通过http2_push和http2_push_preload指令,服务端可以在浏览器请求HTML时主动把CSS、JS等资源推下去,省掉一次往返。但推送不是无条件的:如果客户端已经缓存了资源,或者本次连接里已经推过同样的资源,再推一次就是浪费带宽。Nginx用一个叫push diary(推送日记)的结构记录已经推送过的资源指纹,而遍历这份日记、逐项比对的工具,就是http2_push_diary_iterator。这个迭代器定义在src/http/v2/ngx_http_v2.c中,属于ngx_http_v2_module模块的核心数据结构之一。

推送日记为什么需要专门的迭代器
先看Nginx源码中迭代器的定义:
typedef struct {
ngx_http_v2_push_t *push;
ngx_queue_t *queue;
ngx_queue_t sentinel;
} ngx_http_v2_push_diary_iterator_t;结构很简洁,三个字段:push指向推送资源对象,queue是当前遍历到的队列节点,sentinel是本地构造的哨兵节点。Nginx的push diary本质上是一个循环双向链表,配合一个64位的位图(ngx_http_v2_node_t里的size与桶数组)实现指纹的快速登记。
之所以不直接用下标去遍历数组,是因为diary的桶数量是可变的。Nginx会根据配置项http2_max_concurrent_pushes以及日记容量的对数值动态分配桶,初始容量从16个条目起步,按2的幂倍增。用队列迭代器而非裸指针数组遍历,可以天然兼容扩容过程中节点被重新链接的情况,遍历逻辑不需要关心桶的数量和分布。
另外一点容易被忽视:迭代器内部的sentinel字段是一个栈上的哨兵。当遍历结束时,队列会被接到这个哨兵上,形成一个临时的空队列,这样后续代码可以用ngx_queue_empty判断遍历是否完成,避免再额外维护一个计数器。这是Nginx源码里很典型的链表哨兵技巧。
迭代器的工作流程与指纹比对逻辑
当Nginx决定推送一个资源时,流程大致是:先根据资源URI计算出哈希指纹,然后初始化迭代器扫描diary,检查该指纹是否已存在。相关源码节选如下:
/* 计算32位指纹(简化示意) */
static uint32_t
ngx_http_v2_push_diary_hash(ngx_str_t *path)
{
uint32_t hash;
/* 经典的Murmur-ish字符串哈希 */
for (hash = 0; path->len; path->len--) {
hash = *path->data++ + (hash << 6) + (hash << 16) - hash;
}
return hash;
}
/* 初始化迭代器并逐项比对 */
iterator.push = NULL;
ngx_queue_init(&iterator.sentinel);
for (q = ngx_queue_head(&h2c->push_diary);
q != ngx_queue_sentinel(&h2c->push_diary);
q = ngx_queue_next(q))
{
node = ngx_queue_data(q, ngx_http_v2_node_t, queue);
if (node->id == fingerprint) {
/* 已推送过,命中,停止遍历 */
iterator.push = node;
break;
}
}这段代码体现了迭代器的核心价值:遍历是惰性的。每次调用ngx_http_v2_push_diary_next只前进一个节点,一旦指纹命中就立即停止,不必扫完整个链表。对于一个长连接内推送了几十个资源的场景,这种提前退出能明显减少CPU开销。
指纹的计算方式有两种实现。标准Nginx发行版采用指针按位与的方式取模:hash & (entries - 1),利用容量必然是2的幂这一约束,把除法换成位运算。而Cloudfork等衍生版本以及LiteSpeed的实现里,用的是对质数取模的方式:hash % table_size。位运算版本更快,但要求扩容时必须重新哈希所有条目;取模版本在扩容时可以延迟迁移,代价是每次查找多一次除法指令。Nginx选择了前者,配合迭代器在扩容时重建链表,整体性能更优。
实践配置与常见问题排查
理解了diary的去重逻辑,配置推送时的一些怪现象就好解释了。典型配置如下:
server {
listen 443 ssl http2;
server_name example ipipp.com;
# 显式推送
http2_push /style.css;
http2_push /app.js;
# 或者根据Link头自动推送
http2_push_preload on;
}最常见的疑问是:为什么抓包看到第一次请求推了资源,第二次刷新就不推了?答案正是push diary在起作用。diary记录的是连接级别的指纹,只要这条HTTP/2连接没断,同样的URI指纹会被迭代器检测到并跳过推送。浏览器复用连接时表现尤其明显。要验证推送行为,建议用nghttp工具配合-v参数,可以清楚看到PUSH_PROMISE帧的发出情况。
另一个坑是http2_push参数里路径写成了绝对URL或带了查询字符串。diary的指纹是基于URI字符串逐字节计算的,/style.css和/style.css?v=2会被视为两个不同资源,导致重复推送。此外,如果客户端在SETTINGS帧中把SETTINGS_ENABLE_PUSH设为0,Nginx会在更早的环节直接关闭推送,迭代器根本不会执行,这一点在排查时要先确认,别一上来就怀疑diary逻辑。
从源码角度再补充一点:diary的容量上限由http2_recv_buffer_size等配置间接影响,且Nginx对diary做了64条目的软性约束(超过后会按LRU思路淘汰最老的指纹)。迭代器在淘汰场景下同样适用,因为淘汰本质上是把队头节点摘链,迭代器的queue指针在被摘节点的前一个位置,不会造成悬空访问。理解这套机制后,无论是优化推送策略还是排查推送异常,都能快速定位到正确的环节。
Nginxhttp2_push_diary_iteratorHTTP/2 Server Push修改时间:2026-09-06 04:04:37