HTTP/2 与 RPC 精讲教程 / 05 - 服务器推送
第 05 章:服务器推送
未请求,先送达——HTTP/2 Server Push 的原理、实践与争议
5.1 服务器推送概述
服务器推送(Server Push)是 HTTP/2 引入的一项特性,允许服务器在客户端请求之前,主动向客户端推送资源。其核心目标是减少页面加载的往返次数。
5.1.1 问题背景
传统模型(客户端发现资源):
1. 客户端请求 index.html
2. 服务器返回 index.html
3. 客户端解析 HTML,发现需要 style.css
4. 客户端请求 style.css(又一次往返)
5. 客户端解析 CSS,发现需要 font.woff2
6. 客户端请求 font.woff2(又一次往返)
总往返次数:至少 3 次
服务器推送模型(服务器预测资源):
1. 客户端请求 index.html
2. 服务器返回 index.html
同时推送 style.css 和 font.woff2
3. 客户端已有所有资源,无需额外请求
总往返次数:1 次!
5.2 服务器推送工作原理
5.2.1 PUSH_PROMISE 帧
服务器推送通过 PUSH_PROMISE 帧实现。服务器在发送响应之前,先告知客户端它即将推送的资源。
时序图:
客户端 (C) 服务器 (S)
| |
|--- HEADERS (stream 1, GET /index.html) ----->|
| |
|<-- PUSH_PROMISE (stream 2, GET /style.css) --| (预告)
|<-- HEADERS (stream 1, 200 OK) --------------|
|<-- DATA (stream 1, <html>...) --------------|
|<-- HEADERS (stream 2, 200 OK) --------------| (推送响应)
|<-- DATA (stream 2, body of style.css) ------|
|<-- DATA (stream 1, END_STREAM) -------------|
5.2.2 PUSH_PROMISE 帧格式
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length (24) |
+---------------+---------------+-------------------------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+-+-------------------------------------------------------------+
|R| Promised Stream ID (31) |
+-+-------------------------------------------------------------+
| Header Block Fragment (*) |
+---------------------------------------------------------------+
| 字段 | 说明 |
|---|---|
| Stream Identifier | 发起推送的请求流 ID(必须为奇数) |
| Promised Stream ID | 推送响应的流 ID(必须为偶数) |
| Header Block Fragment | 推送资源的请求头部(伪头部) |
5.2.3 推送流的标识符规则
| 类型 | 流 ID 来源 | 示例 |
|---|---|---|
| 客户端请求 | 奇数 | 1, 3, 5, 7 |
| 服务器推送 | 偶数 | 2, 4, 6, 8 |
5.3 服务器推送实现
5.3.1 Go 实现
package main
import (
"fmt"
"io"
"log"
"net/http"
"strings"
"golang.org/x/net/http2"
)
func main() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
// 尝试推送关键资源
pusher, ok := w.(http.Pusher)
if ok {
// 推送 CSS
err := pusher.Push("/static/style.css", &http.PushOptions{
Method: "GET",
Header: http.Header{
"Content-Type": {"text/css"},
},
})
if err != nil {
log.Printf("推送 CSS 失败: %v", err)
}
// 推送 JavaScript
err = pusher.Push("/static/app.js", &http.PushOptions{
Method: "GET",
Header: http.Header{
"Content-Type": {"application/javascript"},
},
})
if err != nil {
log.Printf("推送 JS 失败: %v", err)
}
}
// 返回 HTML
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js"></script>
</head>
<body>Hello, HTTP/2 Server Push!</body>
</html>`)
return
}
// 处理静态资源
if strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("Cache-Control", "public, max-age=31536000")
switch {
case strings.HasSuffix(r.URL.Path, ".css"):
w.Header().Set("Content-Type", "text/css")
fmt.Fprint(w, "body { font-family: sans-serif; }")
case strings.HasSuffix(r.URL.Path, ".js"):
w.Header().Set("Content-Type", "application/javascript")
fmt.Fprint(w, "console.log('Hello');")
}
}
})
server := &http.Server{
Addr: ":8443",
Handler: handler,
}
// 配置 HTTP/2
http2.ConfigureServer(server, &http2.Server{
MaxConcurrentStreams: 100,
})
log.Println("服务器启动于 :8443")
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}
5.3.2 Node.js 实现
const http2 = require('http2');
const fs = require('fs');
const path = require('path');
const server = http2.createSecureServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
});
server.on('stream', (stream, headers) => {
const reqPath = headers[':path'];
if (reqPath === '/' || reqPath === '/index.html') {
// 推送关键资源
const pushHeaders = {
':path': '/static/style.css',
':method': 'GET',
'content-type': 'text/css',
};
stream.pushStream(pushHeaders, (err, pushStream) => {
if (err) {
console.error('推送失败:', err);
return;
}
pushStream.respond({ ':status': 200, 'content-type': 'text/css' });
pushStream.end('body { font-family: sans-serif; }');
});
// 返回主页面
stream.respond({ ':status': 200, 'content-type': 'text/html' });
stream.end(`
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>Hello, HTTP/2 Push!</body>
</html>
`);
} else {
stream.respond({ ':status': 404 });
stream.end('Not Found');
}
});
server.listen(8443, () => {
console.log('HTTP/2 服务器运行于 https://localhost:8443');
});
5.3.3 Python (hyper-h2) 实现
import h2.connection
import h2.events
import h2.settings
import socket
import ssl
def handle_push(h2_conn, client_stream_id, push_path, push_body):
"""发送 PUSH_PROMISE 并推送资源"""
push_headers = [
(':method', 'GET'),
(':path', push_path),
(':authority', 'localhost'),
(':scheme', 'https'),
]
# 发送 PUSH_PROMISE
promised_stream_id = h2_conn.push_stream(
stream_id=client_stream_id,
promised_stream_id=client_stream_id + 2, # 偶数
request_headers=push_headers
)
# 发送推送响应
response_headers = [
(':status', '200'),
('content-type', 'text/css'),
('cache-control', 'public, max-age=3600'),
]
h2_conn.send_headers(promised_stream_id, response_headers)
h2_conn.send_data(promised_stream_id, push_body, end_stream=True)
def main():
# 设置 SSL 上下文
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_ctx.load_cert_chain('cert.pem', 'key.pem')
ssl_ctx.set_alpn_protocols(['h2', 'http/1.1'])
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 8443))
sock.listen(5)
while True:
client_sock, addr = sock.accept()
conn_sock = ssl_ctx.wrap_socket(client_sock, server_side=True)
h2_conn = h2.connection.H2Connection()
h2_conn.initiate_connection()
conn_sock.sendall(h2_conn.data_to_send())
# 处理连接...
5.4 缓存关联(Cache Digest)
5.4.1 避免重复推送
服务器推送的一个关键问题是:如何避免推送客户端已缓存的资源?
问题场景:
第一次访问:
服务器推送 style.css → 客户端缓存 ✓
第二次访问:
服务器推送 style.css → 客户端已有缓存!
浪费了带宽!
5.4.2 Cache Digest 方案
Cache Digest(RFC 草案,未正式发布):
原理:
1. 客户端将缓存的资源 URL 列表用 Golomb 编码压缩
2. 在 SETTINGS 帧或单独请求中发送给服务器
3. 服务器根据 digest 判断哪些资源需要推送
优点:
- 体积小(每个 URL 约 1-2 字节)
- 服务器可精确决策
缺点:
- 草案状态,未标准化
- 实现复杂
- 浏览器支持有限
5.4.3 实用替代方案
// 使用 Cookie 或自定义头部传递缓存信息
func shouldPush(r *http.Request, resource string) bool {
// 方案 1:检查 If-None-Match
if r.Header.Get("If-None-Match") != "" {
return false
}
// 方案 2:自定义缓存头部
cached := r.Header.Get("X-Cached-Resources")
if cached != "" {
cachedList := strings.Split(cached, ",")
for _, c := range cachedList {
if strings.TrimSpace(c) == resource {
return false
}
}
}
return true
}
func handler(w http.ResponseWriter, r *http.Request) {
pusher, ok := w.(http.Pusher)
if ok && shouldPush(r, "/static/style.css") {
pusher.Push("/static/style.css", nil)
}
}
5.5 服务器推送的典型场景
5.5.1 适用场景
| 场景 | 推送内容 | 效果 |
|---|---|---|
| 首页加载 | 关键 CSS、JS | 减少首次渲染时间 |
| API 响应 | 关联资源(如用户头像) | 减少后续请求 |
| 表单页面 | 表单验证脚本 | 提升交互体验 |
| SPA 路由切换 | 下一页数据 | 减少页面切换延迟 |
5.5.2 不适用场景
| 场景 | 原因 |
|---|---|
| 已缓存资源 | 重复推送浪费带宽 |
| 大文件 | 可能阻塞其他流 |
| 不确定的资源 | 推送的资源可能不会被使用 |
| 长轮询/SSE | 语义不匹配 |
5.6 服务器推送的弃用争议
5.6.1 Chrome 移除 Server Push
⚠️ 重要变化:Chrome 从 106 版本(2022 年)开始移除 HTTP/2 Server Push 支持。
移除原因:
| 原因 | 说明 |
|---|---|
| 复杂度高 | 实现正确很难,容易推送错误资源 |
| 缓存冲突 | 难以判断客户端是否已缓存 |
| 竞争问题 | 推送可能与客户端请求竞争带宽 |
| 103 Early Hints | 更好的替代方案 |
| 使用率低 | 实际采用率不足 0.05% |
5.6.2 103 Early Hints:更好的替代
103 Early Hints 工作原理:
客户端 服务器
|--- GET /index.html -->|
| |
|<-- 103 Early Hints ----| (仅告知资源线索)
| Link: </style.css>; rel=preload
| |
| | (服务器继续处理)
| |
|<-- 200 OK -------------| (正式响应)
| <html>...
与 Server Push 的对比:
┌──────────────────┬─────────────────┬────────────────────┐
│ 特性 │ Server Push │ 103 Early Hints │
├──────────────────┼─────────────────┼────────────────────┤
│ 决策方 │ 服务器强制推送 │ 客户端自主请求 │
│ 缓存兼容 │ 困难 │ 自然兼容 │
│ 带宽浪费风险 │ 高 │ 低 │
│ 实现复杂度 │ 高 │ 低 │
│ 浏览器支持 │ 已被移除 │ Chrome/Safari 支持 │
└──────────────────┴─────────────────┴────────────────────┘
// 103 Early Hints 实现
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
// 发送 103 Early Hints
hints := http.Header{}
hints.Set("Link", "</static/style.css>; rel=preload; as=style")
hints.Set("Link", "</static/app.js>; rel=preload; as=script")
// 使用 ResponseController 发送 103
rc := http.NewResponseController(w)
err := rc.Flush() // 确保 103 先发送
if err != nil {
// 某些实现不支持 103
}
// 正常响应
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html>
<head>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js"></script>
</head>
<body>Content</body>
</html>`)
}
5.7 业务场景:CDN 加速优化
场景:CDN 节点向客户端推送预热资源
策略:
1. 首次访问:CDN 推送关键资源
- /static/critical.css (4KB)
- /static/vendor.js (120KB)
2. 后续访问:不推送,依赖客户端缓存
- 通过 Cache-Control 和 ETag 管理缓存
3. 资源更新:版本化 URL
- /static/critical.v2.css
- 推送新版本,旧版本自然过期
5.8 注意事项
⚠️ 推送资源数量限制:
- 浏览器通常限制并发推送流数(如 Chrome 限制 100)
- 推送过多资源可能阻塞客户端请求
- 建议仅推送首屏关键资源
⚠️ 带宽竞争:
- 推送的资源与客户端请求共享带宽
- 大文件推送可能延迟关键请求
- 使用流优先级管理资源顺序
⚠️ 依赖关系:
- 推送资源必须是客户端尚未请求的
- PUSH_PROMISE 必须在响应 HEADERS 之前发送
- 推送资源的请求头部应尽量简化
💡 最佳实践:
- 优先使用
<link rel="preload">替代 Server Push - 考虑 103 Early Hints 作为替代方案
- 如果使用 Server Push,实现缓存关联避免重复推送
- 监控推送命中率,及时调整策略
5.9 扩展阅读
- 📖 RFC 7540 Section 8.2 - Server Push
- 📖 RFC 8297 - 103 Early Hints
- 📖 Chrome 移除 Server Push 的说明
- 📖 Smashing Magazine - HTTP/2 Server Push