强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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 扩展阅读


第 04 章 - 头部压缩 HPACK | 第 06 章 - 流量控制