HTTP/2 Server Push允许服务器在客户端请求主文档之前主动推送关联资源,理论上能减少一次RTT。但实际部署中,静态配置http2_push往往导致同一资源在短时间内被反复推送,尤其是在AJAX请求或单页应用路由切换场景下。下文将分析这一问题的根源,并利用LRU最近最少使用算法构建一个有限容量的推送日记来缓解重复推送。

HTTP/2 Server Push在Nginx中的工作方式
启用HTTP/2后,Nginx可以通过http2_push指令在响应主文档时主动向客户端发送PUSH_PROMISE帧。这个帧告知客户端服务器即将推送哪些资源,客户端可以选择接受或拒绝。如果客户端已经缓存过该资源,通常会发送RST_STREAM帧取消推送,从而节省流量。但取消操作发生在服务器已经开始读取并准备发送数据之后,因此服务器端的磁盘IO、内存拷贝和带宽分配仍然发生了。
最基本的配置方式是在location上下文中直接列出要推送的资源,例如下面的静态配置。
server {
listen 443 ssl http2;
server_name ippipp.com;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
location / {
http2_push /css/main.css;
http2_push /js/app.js;
}
}
这种方式的缺点是推送列表是写死的,服务器无法判断客户端是否已经拥有这些资源。只要进入该location,推送就会发生。对于访问频繁的接口或动态请求,每次都会触发相同的推送动作。Nginx也支持通过Link响应头配合http2_push_preload指令实现动态推送,但依然无法记录哪些资源在最近一段时间内已经被推送过。
推送日记的意义与LRU算法引入
推送日记可以理解为一张记录近期已推送URI的表,用来回答一个问题:这个资源在最近的某个时间窗口内是否已经推送过?如果已经推送过,就跳过本次推送,避免重复PUSH_PROMISE帧带来的开销。由于服务器内存有限,不可能无限期保存所有推送记录,必须使用一种淘汰策略来管理这张表。LRU最近最少使用算法恰好符合这个场景:每次访问某个URI时把它移动到队首,当容量达到上限时淘汰队尾最久没有被访问的记录。
LRU的实现可以基于双向链表加哈希表,也可以借助现成的共享内存字典。OpenResty的lua_shared_dict提供了类似memcached的接口,并在内存不足时自动执行LRU淘汰。这个特性非常契合推送日记的需求:我们只需要关心最近的推送历史,久远的记录即使被淘汰也不会影响当前用户体验。
local push_diary = ngx.shared.push_diary
local uri = ngx.var.uri
if not push_diary:get(uri) then
push_diary:set(uri, true, 60)
return true
end
return false
上面的代码展示了用共享字典记录URI的基本逻辑,ttl设置为60秒。超过60秒后记录自动过期,即使没有触发LRU淘汰也会被清理。这种方式既控制了内存占用,又能避免短时间内同一连接的重复推送。需要注意的是,共享字典的LRU淘汰是针对整个字典的,不同location之间会竞争同一块内存空间。
基于OpenResty的动态推送与LRU实现
要让Nginx在运行时根据推送日记动态决定是否推送,需要把逻辑放在请求处理阶段。OpenResty允许在access_by_lua_block中检查共享字典,并根据结果设置Link响应头。配置http2_push_preload on之后,Nginx会解析响应头中的Link字段,对带有rel=preload的资源执行HTTP/2 Server Push。这样推送决策就完全由Lua脚本控制。
http {
lua_shared_dict push_diary 10m;
server {
listen 443 ssl http2;
server_name ippipp.com;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
http2_push_preload on;
location /app {
access_by_lua_block {
local diary = ngx.shared.push_diary
local uri = ngx.var.uri
local push_key = "app_" .. uri
if not diary:get(push_key) then
ngx.header["Link"] = "</assets/app.js>; rel=preload; as=script"
diary:set(push_key, true, 30)
end
}
}
}
}
这段配置中,共享字典容量设置为10MB,每个worker进程共享同一块内存区域。access_by_lua_block会在请求进入location后、上游服务响应前执行。如果push_key不存在,就设置Link头并写入字典,ttl为30秒。30秒内相同URI的请求再次到来时,字典中存在记录,就不会重复推送。当字典容量写满时,lua_shared_dict会自动淘汰最久未访问的条目,保证内存不会无限增长。
这种实现的一个明显好处是推送策略可以按业务逻辑细分。例如可以为不同类型的资源设置不同的ttl,或者根据URI路径前缀决定是否推送。也可以把推送资源列表本身也放入共享字典,避免在Lua代码中硬编码。不过要注意,access阶段设置响应头后,后续的header_filter阶段不能覆盖Link头,否则推送不会生效。
参数调优与常见问题
共享字典的大小直接影响LRU淘汰的频率。设置过小会导致有效的推送记录被过早淘汰,重复推送问题重新出现;设置过大则会占用更多内存,而且在多worker进程下共享内存的锁竞争会变得更明显。通常可以从5MB开始测试,观察共享字典的命中率和内存占用,再逐步调整。ttl的选择需要结合静态资源的更新频率,如果资源更新频繁,过长的ttl会导致客户端错过新版本推送。
多进程环境下,所有worker访问同一块共享内存字典,内部通过互斥锁保证原子性。虽然锁的粒度较细,但在高并发场景下仍然可能成为瓶颈。如果推送日记的读写操作非常频繁,可以考虑使用多个字典分片,降低锁竞争。另外,lua_shared_dict的set操作是原子的,但get和set之间存在竞态窗口,高并发时可能有两个请求同时判断为未推送并同时设置Link头。对于推送场景,这种重复并不是致命问题,因为HTTP/2层会处理重复推送的取消逻辑,但若想严格避免,可以使用add方法替代get加set的组合。
还有一种替代方案是使用纯Nginx的map指令配合日志记录,在log阶段把推送信息写入文件,再通过外部程序定期解析并生成新的配置。但这种方式引入了文件IO和配置重载,实时性较差。相比之下,OpenResty的共享字典方案在实时性、代码复杂度和性能之间取得了更好的平衡。如果无法使用OpenResty,也可以考虑用Nginx的proxy_cache机制模拟LRU行为,但需要额外的缓存规则设计。
最终需要强调的是,HTTP/2 Server Push并不是越多越好。过度的推送会消耗客户端带宽,甚至影响主文档的加载。结合LRU最近最少使用策略后,推送日记能够帮助服务器做出更精准的推送决策,让HTTP/2的多路复用优势真正转化为首屏性能的提升。
Nginx HTTP/2 PushLRU缓存Server Push优化修改时间:2026-08-23 06:05:54