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

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 章 - 流量控制