HTTP 协议全解:从报文到 HTTP/3,Android 开发者需要知道的一切

前言

做了几年 Android 开发,你大概已经和 Retrofit + OkHttp 混得很熟了------定义几个接口、加个注解、调个 enqueue,数据就来了。看起来一切都很简单。

但 HTTP 远不止是"调用 API"这么简单。

你一定遇到过这些问题:

  • 为什么同样的接口,有时快有时慢?
  • 为什么图片列表加载到一半突然卡住?
  • 为什么切换了 WiFi 后请求就断了?
  • 为什么明明设置了缓存,请求还是出去了?
  • HTTP/2 和 HTTP/1.1 到底有什么区别,值得我关注吗?

这些问题的答案,都在 HTTP 协议里。

作为 Android 开发者,OkHttp 和 Retrofit 是你的"交通工具",但 HTTP 协议才是你脚下的路。不懂路况,翻车了都不知道怎么翻的。

本文是这个系列的起点。我们不背八股文,我们把它讲明白。


1. HTTP 报文结构

1.1 请求报文

一个 HTTP 请求长什么样?你可以把它想象成你寄快递时填的单子。

请求报文 = 请求行 + 请求头 + 空行 + 请求体

bash 复制代码
POST /api/v1/users HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
Content-Length: 48
User-Agent: Mozilla/5.0 (Linux; Android 14; Pixel 8)
Accept: application/json
​
{"name": "张三", "email": "zhangsan@example.com"}

我来拆解一下:

请求行(第一行)POST /api/v1/users HTTP/1.1

  • POST --- 方法,告诉服务器我要做什么
  • /api/v1/users --- 路径,告诉服务器我找谁
  • HTTP/1.1 --- 协议版本

请求头(第二行到空行之前) :键值对,每行一个,告诉服务器额外的信息------客户端的身份、期望的响应格式、认证信息等。

空行:这个空行不是用来"好看"的,它是协议规定的分隔符,告诉服务器"头部到此结束,下面开始是消息体"。

请求体(空行之后) :POST、PUT、PATCH 方法通常会携带请求体,就是实际要发送的数据。

1.2 响应报文

服务端收到你的请求后,会返回类似结构的响应。

响应报文 = 状态行 + 响应头 + 空行 + 响应体

yaml 复制代码
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 96
Date: Tue, 29 Apr 2026 06:00:00 GMT
Cache-Control: no-store
​
{"id": 1001, "name": "张三", "email": "zhangsan@example.com", "createdAt": "2026-04-29T06:00:00Z"}

状态行HTTP/1.1 201 Created,包含协议版本、状态码和原因短语。201 表示"资源创建成功"。

1.3 用抓包视角看一个真实的请求

假设你正在调试一个登录接口,用 OkHttp 的 HttpLoggingInterceptor 抓到的日志可能是这样的:

sql 复制代码
--> POST https://api.example.com/auth/login
Content-Type: application/json
Content-Length: 72
User-Agent: okhttp/4.12.0
​
{"phone":"13800138000","password":"******"}
--> END POST (72-byte body)
​
<-- 200 OK https://api.example.com/auth/login (342ms)
Content-Type: application/json
Content-Length: 215
Set-Cookie: sessionId=abc123; Path=/; HttpOnly
X-Response-Time: 150ms
​
{"token":"eyJhbGciOiJIUzI1NiIs...","userId":1001,"expiresIn":7200}
<-- END HTTP (215-byte body)

注意几个细节:

  • 客户端先发送了请求头,然后是空行(日志中不显示),接着是请求体
  • 服务端返回了 200 状态码和响应体
  • 返回头里带了一个 Set-Cookie,这是服务端想设置 Cookie 的信号

1.4 Content-Type:Android 开发中的日常

Content-Type 告诉对方"我这包数据的格式是什么"。在 Android 开发中,你最常遇到的是这些:

Content-Type 说明 Android 场景
application/json JSON 格式 99% 的 API 调用,Retrofit 默认处理
application/x-www-form-urlencoded 表单格式(键值对) 登录页、传统表单提交
multipart/form-data 多部分混合(总是和文件上传一起出现) 上传头像、发朋友圈带图片
application/octet-stream 二进制流("我不知道这是什么,反正是一堆字节") 下载 APK、文件下载
text/plain 纯文本 某些日志上报接口
text/html HTML 文档 WebView 加载网页(但不是 API 场景)
image/jpeg, image/png 图片 图片上传的请求体类型

Android 开发体感 : 用 Retrofit 时,你通常在 @POST 注解上加 @Headers("Content-Type: application/json"),或者更简单------用 @Body 注解一个 Kotlin data class,Retrofit 自动用 GsonConverterFactory 帮你序列化成 JSON 并设置正确的 Content-Type

但当你用 OkHttp 直接上传文件时,别忘了设置 MediaType

ini 复制代码
val mediaType = "image/jpeg".toMediaType()
val requestBody = file.asRequestBody(mediaType)

不设置正确的 Content-Type,服务端可能直接返回 415(Unsupported Media Type)。


2. HTTP 方法语义

2.1 不只是 GET 和 POST

很多人第一次接触 Web 开发时,绕来绕去就是 GET 和 POST 两个方法。面试也爱问"GET 和 POST 有什么区别"。但真实世界里,HTTP 标准定义了九个方法。日常开发最常用的五个是 GET、POST、PUT、PATCH、DELETE。

2.2 各方法的真正语义

方法 语义 幂等 安全 有请求体
GET 获取资源
POST 创建资源 / 提交处理
PUT 全量替换资源
PATCH 部分更新资源
DELETE 删除资源 通常无

解释两个关键词:

  • 安全(Safe):操作不会改变服务器上的资源状态。GET 和 HEAD 是安全的。想象你只是在橱窗外看商品,不管看多少遍,商品都不会变。
  • 幂等(Idempotent):同一个请求执行一次和执行 N 次,结果一样。这听起来像数学概念,但在网络环境下极其重要。

2.3 幂等性为什么重要?

网络是不稳定的。你的请求发出去了,但响应可能丢了------客户端收不到确认,就会重试。

假设你在支付:

bash 复制代码
客户端 → 服务器:POST /api/payment (扣款100元)
                              → 网络超时 ←
客户端 → 服务器:POST /api/payment (扣款100元) ← 再发一次

如果是非幂等的 POST,用户可能被扣了 200 元。

但如果这个接口设计成幂等的------比如带上一个全局唯一的 paymentId------服务器收到第二次请求时检查到 paymentId 已存在,直接返回之前的成功结果,就不会重复扣款。这就是去重

这就是幂等性的实际价值:在不可靠的网络里,给了你安全重试的能力。

2.4 POST vs PUT:经典的困惑

很多团队争论"新增用户用 POST 还是 PUT?"

关键区别不在于"新增"还是"更新",而在于谁来决定资源的 URI

  • POST :服务端决定。你 POST /api/users,服务端返回 201 CreatedLocation: /api/users/1001。ID 是自动生成的。
  • PUT :客户端决定。你 PUT /api/users/1001,说"在 1001 这个位置放一个用户"。如果 1001 不存在,就创建;存在,就全量替换。

所以 RESTful 设计中的最佳实践是:

  • 客户端无法确定 ID → POST /api/users → 服务端分配 ID,返回 201
  • 客户端可以确定 ID → PUT /api/users/1001 → 幂等,可以随便重试
  • 只修改部分字段 → PATCH /api/users/1001 → 发什么改什么,其他字段不变
  • 删除 → DELETE /api/users/1001 → 删掉,再删一次也不报错

2.5 Android 开发中的 RESTful 实践

less 复制代码
// 这是 Retrofit 接口定义
interface UserApiService {
​
    // GET - 安全幂等,列表查询
    @GET("api/v1/users")
    suspend fun getUsers(@Query("page") page: Int): Response<UserListResponse>
​
    // GET - 获取单个资源
    @GET("api/v1/users/{id}")
    suspend fun getUser(@Path("id") userId: Long): Response<UserResponse>
​
    // POST - 创建新用户,客户端不知道最终 ID
    @POST("api/v1/users")
    suspend fun createUser(@Body request: CreateUserRequest): Response<UserResponse>
​
    // PUT - 完整更新,幂等
    @PUT("api/v1/users/{id}")
    suspend fun updateUser(@Path("id") userId: Long, @Body request: UpdateUserRequest): Response<UserResponse>
​
    // PATCH - 部分更新,只修改传入的字段
    @PATCH("api/v1/users/{id}/profile")
    suspend fun patchProfile(@Path("id") userId: Long, @Body patch: JsonObject): Response<UserResponse>
​
    // DELETE - 删除,幂等
    @DELETE("api/v1/users/{id}")
    suspend fun deleteUser(@Path("id") userId: Long): Response<Unit>
}

实际场景:如果你的 APP 有个"编辑个人信息"页面,用户只改了昵称------

ini 复制代码
// ❌ 不该这样------把整个用户对象发回去
val fullUpdate = UpdateUserRequest(
    name = "新昵称",
    email = oldEmail,
    avatar = oldAvatarUrl,
    phone = oldPhone,
    // ...
)
​
// ✅ 应该这样------只发改动的内容
val patch = JsonObject().apply {
    addProperty("name", "新昵称")
}
​
userApiService.patchProfile(userId, patch)

从网络传输的角度看,PATCH 每次至少省了几百字节的冗余数据------对于移动端来说,每一 KB 的流量都值得珍惜。


3. 状态码深度理解

3.1 状态码家族

HTTP 状态码按百位分五大类:

范围 类别 含义 典型场景
1xx Informational 信息性响应,协议层面的信号 100 Continue、101 Switching Protocols(WebSocket 升级用)
2xx Success 成功,请求已收到并正确处理 200 最常用
3xx Redirection 重定向,需要客户端做额外操作 302 临时跳转
4xx Client Error 客户端请求有问题 401 未登录、404 不存在
5xx Server Error 服务端出问题 502 网关挂了

3.2 容易混淆的状态码对比

301 vs 302 vs 307 vs 308

这四个都是重定向,但行为有细微差别:

状态码 含义 缓存性 方法是否可能变化
301 永久重定向 ✅ 浏览器会缓存 ⚠️ 可能把 POST 改成 GET
302 临时重定向 ❌ 不缓存 ⚠️ 可能把 POST 改成 GET
307 临时重定向 ❌ 不缓存 ✅ 保持原方法
308 永久重定向 ✅ 缓存 ✅ 保持原方法

关键点 :301 和 302 很多浏览器/客户端会把 POST 请求的跳转自动变成 GET,这会丢失请求体。如果你的场景需要跨重定向保留请求方法(比如支付回调需要 POST 到新地址),必须用 307 或 308。

Android 体感 :OkHttp 默认会跟随重定向(followRedirects = true),POST 请求的 301/302 跳转会被转为 GET。如果这是你不想要的行为,需要处理:

scss 复制代码
val client = OkHttpClient.Builder()
    .followRedirects(false)  // 关闭自动跟随,自己处理
    .build()

401 vs 403

这是面试高频混淆题。

  • 401 Unauthorized:你没登录(或登录凭证过期),请先登录。潜台词是"我不知道你是谁,你先证明身份"。
  • 403 Forbidden:你已登录,但权限不够。潜台词是"我知道你是谁,但你没权限看这个"。

用一个场景你就懂了:

  • 你到公司门口 → 保安说"请出示工牌" → 你掏出工牌 → "对不起,你是市场部的,研发部 6 楼不能进"
  • 没出示工牌 → 401
  • 出示了但权限不够 → 403

Android 开发中的处理

kotlin 复制代码
override fun onResponse(call: Call<*>, response: Response<*>) {
    when (response.code) {
        401 -> {
            // Token 过期 → 刷新 token 或跳转登录页
            authManager.refreshToken()
            // 如果刷新成功,重新执行原请求
        }
        403 -> {
            // 权限不足 → 给用户展示"无权限"提示
            showToast("您没有权限执行此操作")
        }
    }
}

注意:不要对 403 自动尝试刷新 Token!这是非常常见的 bug------很多人把 401 和 403 都统一"登录失效"处理了。403 刷新 Token 没用的,白费一次网络请求。

404 vs 410

  • 404 Not Found:资源不存在。可能是路径写错了,可能是被删了没及时更新文档。
  • 410 Gone:资源曾存在,但现在被永久删除了,并且服务器明确不再提供。比 404 多了"曾经存在过"这个语义。

410 在实际 API 中比较少见,但当你看到它时,说明服务端是认真设计过的------比如把老版本的 API 下线了,返回 410 比 404 给客户端更多的信息:"别找了,这个接口以前有但现在没了"。

3.3 Android 中状态码处理策略

kotlin 复制代码
// 一个比较完善的 OkHttp 拦截器
class StatusCodeInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())
​
        return when (response.code) {
            in 200..299 -> response  // 正常,直接返回
​
            304 -> {
                // 缓存命中!HTTP 缓存机制
                // 什么都不做,OkHttp 的缓存会处理
                response
            }
​
            in 300..399 -> {
                // 重定向,OkHttp 默认自动跟随
                response
            }
​
            401 -> {
                // 尝试刷新 Token
                response.close()
                val newRequest = chain.request().newBuilder()
                    .header("Authorization", "Bearer ${tokenProvider.refresh()}")
                    .build()
                chain.proceed(newRequest)
            }
​
            429 -> {
                // 请求太频繁,被限流了
                val retryAfter = response.header("Retry-After")?.toIntOrNull() ?: 5
                Thread.sleep(retryAfter * 1000L)
                response.close()
                chain.proceed(chain.request())
            }
​
            in 400..499 -> response  // 其他客户端错误,交给上层处理
​
            in 500..599 -> {
                // 服务器错误,可以尝试重试
                response.close()
                if (retryCount < MAX_RETRIES) {
                    retryCount++
                    chain.proceed(chain.request())
                } else {
                    response
                }
            }
​
            else -> response
        }
    }
}

真实场景:某次线上事故,后端"挂了"但实际上是服务端网关返回 502。客户端如果直接展示 "Network error" 给用户,体验极差。加了重试机制后,用户几乎无感------7 秒的重试窗口后请求自动恢复。


4. HTTP 连接管理

4.1 HTTP/1.0:一次请求一个连接

HTTP/1.0 时代,每次请求都要重新建立一次 TCP 连接

lua 复制代码
客户端                             服务器
  |                                  |
  |----- 三次握手 (SYN/SYN-ACK/ACK)--|
  |←-------- TCP 连接建立 -----------→|
  |                                  |
  |------ HTTP 请求 (GET /) ---------→|
  |←---- HTTP 响应 (200 OK) ---------|
  |                                  |
  |------ TCP 四次挥手 (FIN) --------→|
  |←-------- 连接关闭 ---------------|
  |                                  |
  |  (又要请求一个图片...)              |
  |                                  |
  |----- 三次握手 (又从头开始) -------|
  |←-------- TCP 连接建立 -----------→|
  |                                  |
  |------ HTTP 请求 (GET /logo.png) -→|
  |←---- HTTP 响应 (200 OK) ---------|
  |                                  |
  |------ TCP 四次挥手 (FIN) --------→|
  |←-------- 连接关闭 ---------------|
  |                                  |

这个模式的问题

  1. TCP 三次握手开销:每次请求都要 1 个 RTT(Round-Trip Time,往返时间)。假设你和服务器之间的 RTT 是 100ms,一个页面加载 10 个资源,光握手就用了 1 秒。
  2. 慢启动:TCP 连接建立后并不是全速传输的,而是从一个小窗口慢慢扩大。每次新建连接,慢启动都要重新开始。
  3. 并发限制:浏览器通常限制同一域名并发连接数(不同浏览器 4-8 个不等),超出需要排队。

这就是为什么当年网页第一次加载时会看到一个"白屏"------不是没在下载,是连接还没建立好。

4.2 HTTP/1.1 keep-alive:长连接的救赎

HTTP/1.1 默认启用了持久连接(Persistent Connection,也叫 keep-alive)。同一个 TCP 连接可以被多个请求复用

lua 复制代码
客户端                             服务器
  |                                  |
  |----- 三次握手 (SYN/SYN-ACK/ACK)--|
  |←-------- TCP 连接建立 -----------→|
  |                                  |
  |------ 请求 1: GET /index.html ---→|
  |←---- 响应 1: 200 OK ------------|
  |                                  |
  |------ 请求 2: GET /logo.png -----→|
  |←---- 响应 2: 200 OK ------------|
  |                                  |
  |------ 请求 3: GET /style.css ----→|
  |←---- 响应 3: 200 OK ------------|
  |                                  |
  |------------ 空闲超时后关闭 -------→|

好处显而易见

  • 省掉了 TCP 三次握手的 RTT
  • TCP 慢启动已经完成,数据传输更快
  • 减少了系统资源消耗

就这一个改进,就让网页加载速度提升了一大截。

4.3 管线化(Pipelining):理论上很美

keep-alive 虽然减少连接数,但依旧存在一个问题------请求是串行的。上一个请求的响应没收到之前,不能发下一个请求。

于是有人想到了一种优化:管线化

lua 复制代码
客户端                             服务器
  |                                  |
  |------ 请求 1: GET /1 ------------→|
  |------ 请求 2: GET /2 ------------→|  ← 不等响应就直接发
  |------ 请求 3: GET /3 ------------→|
  |                                  |
  |←---- 响应 1: 200 OK /1 ---------|
  |←---- 响应 2: 200 OK /2 ---------|
  |←---- 响应 3: 200 OK /3 ---------|

看起来很美------不用等待前一个响应,直接连续发送多个请求。

但 Pipelining 为什么死了?

  1. 队头阻塞依然存在:服务器必须按收到请求的顺序返回响应。如果请求 1 处理慢(比如需要查询数据库),请求 2 和请求 3 就算再快也得等着。
  2. 实现复杂度高:很多代理服务器和中间件不支持,或者有 bug。
  3. 被更好的方案替代了:HTTP/2 的多路复用才是终极方案。

所以 Pipelining 现在基本被废弃了。绝大部分客户端(包括 OkHttp)默认禁用 Pipeling。

4.4 队头阻塞(Head-of-Line Blocking)

这是 HTTP/1.1 最根本的性能问题。

什么是队头阻塞?

简单说就是:在一个 TCP 连接上,多个请求必须排队。

ini 复制代码
连接 1: [请求 A 等待响应] → 阻塞中 → 阻塞中 → 响应到达 → [请求 B]
                                                                    ↑
                                                             请求 A 处理很慢
                                                                    ↑
连接 2: [请求 C] → 响应 → [请求 D] → 响应 ... (这条线正常)

因为连接 1 上的请求 A 是队头,它处理得慢,导致后面排队的请求 B 也被堵住了------尽管请求 B 本身可能是个非常快的请求(比如读取缓存)。

Android 开发体感

当一个页面上有多个请求时------比如一个首页同时需要获取用户信息、商品列表、推荐数据------HTTP/1.1 只能在有限的连接数内排队。OkHttp 默认单个主机最多 5 个并发连接。如果 5 个连接都排满了(比如 3 个在下载大图),剩下的请求就得等着。

这正是 HTTP/2 要解决的核心问题。


5.1 Cookie:服务端的"便签纸"

HTTP 协议本身是无状态的。每次请求都是独立的,服务器不知道你之前干过什么。就像一个记忆力很差的人,每次见面都问"你是谁"。

Cookie 就是用来解决这个问题的:服务器在一张"便签纸"上写了一些信息,交给客户端带着。下次客户端再来,把便签纸一贴,服务器就知道你是谁了。

Set-Cookie 的过程:

sql 复制代码
客户端(你)                    服务器(网站)
​
第一次请求:
  |------ GET /login -----------→|
  |                              | 服务器检查:没有 Cookie,不认识你
  |                              | 验证用户名密码正确
  |←---- 200 OK ----------------|
  |     Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Max-Age=86400;
  |                              |
  |  浏览器/客户端收到 Set-Cookie,保存起来
  |                              |
第二次请求(现在带着 Cookie):
  |------ GET /profile ---------→|
  |     Cookie: sessionId=abc123 |  ← 自动携带
  |                              | 服务器看到 Cookie,识别出是你
  |←---- 200 OK: 用户资料 ------|

关键属性:

  • DomainPath:限定 Cookie 的作用范围
  • Max-Age / Expires:过期时间
  • HttpOnly:JavaScript 无法读取,防止 XSS 偷取 Cookie
  • Secure:仅通过 HTTPS 传输
  • SameSite:防止 CSRF 攻击(SameSite=Lax/Strict/None)

5.2 Session:服务端的状态

Cookie 只是"钥匙",Session 才是"保险柜"。

服务端 Session 的工作原理:

  1. 用户登录成功
  2. 服务端创建 Session 对象(存用户 ID、权限等),存在内存或 Redis 里
  3. 服务端把 Session ID 通过 Set-Cookie 发给客户端
  4. 客户端后续请求带上这个 Session ID
  5. 服务端根据 Session ID 找到对应的 Session 数据
ini 复制代码
客户端 Cookie: sessionId=abc123
                ↓
服务端: 查内存/Redis → sessionId=abc123 → {userId: 1001, role: "admin", loginTime: ...}

Session 的优势:敏感数据存在服务端,客户端只持有 ID,安全。

Session 的劣势:在分布式系统中,Session 数据需要共享(比如 Redis)。用户请求落到不同服务器上,都得能查到同一个 Session。

5.3 Token vs Session(JWT)

现在越来越多的应用使用 Token 而非 Session。最常见的 Token 格式是 JWT(JSON Web Token)。

JWT 长这样:

erlang 复制代码
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6IuW8oOS4iSIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxMjM0NTY3OH0.abc123...

看起来是一串乱码,实际上它由三部分组成(用 . 分隔):

  • Header(头部):包含签名算法类型
  • Payload(载荷):包含用户信息、过期时间等
  • Signature(签名):用密钥对整个 JWT 签名的结果,防止篡改

对比一下两者的区别

维度 Session JWT Token
数据存储 服务端(内存/Redis) 客户端(Token 本身包含数据)
状态性 有状态(服务端必须存 Session) 无状态(服务端可以不知道你是谁,验签就行)
扩展性 需要 Redis 共享 Session 天然适合分布式,任何服务器都能验证
登出 直接删 Session,立竿见影 Token 在有效期内一直可用,需要黑名单
安全性 Session ID 泄露问题相对可控 一旦 Token 泄露,拿到的人可以伪造身份到过期

JWT 的致命缺陷:无法主动失效!要强制用户下线,必须维护一个黑名单------这又回到了有状态。

5.4 Android 中 OkHttp CookieJar 的使用

OkHttp 默认不处理 Cookie,你需要自己实现 CookieJar 或者用 PersistentCookieJar 之类的库。

kotlin 复制代码
// 一个简单的内存 CookieJar
class InMemoryCookieJar : CookieJar {
    private val cookieStore = ConcurrentHashMap<HttpUrl, MutableList<Cookie>>()

    override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
        // 每次 Set-Cookie 时,OkHttp 回调这里
        cookies.forEach { cookie ->
            cookieStore.getOrPut(cookie.domain.toHttpUrlOrNull() ?: url) {
                mutableListOf()
            }.add(cookie)
        }
    }

    override fun loadForRequest(url: HttpUrl): List<Cookie> {
        // 每次发起请求时,OkHttp 回调这里拿 Cookie
        return cookieStore.entries
            .filter { (domain, _) -> url.host.endsWith(domain.host) }
            .flatMap { (_, cookies) -> cookies }
            .filter { it.matches(url) }
    }
}

// 配置 OkHttp
val client = OkHttpClient.Builder()
    .cookieJar(InMemoryCookieJar())
    .addInterceptor { chain ->
        val request = chain.request()
        Log.d("HTTP", "Cookie for ${request.url}: ${request.header("Cookie")}")
        chain.proceed(request)
    }
    .build()

实际场景 :如果你的 App 使用 Cookie 做会话管理(比如登录后服务端返回 Set-Cookie),但你没有配置 CookieJar------那么每次请求都不会携带 Cookie,每次都会被当作"未登录"处理。

而这正是很多 App 中"为什么登录成功后,下一页又让我登录"的原因------Cookie 没存住。


6. HTTP 缓存机制

6.1 为什么需要缓存?

每次网络请求都有成本:DNS 查询、TCP 连接、TLS 握手、数据传输......一个 1KB 的小图标可能花费几百毫秒的网络开销,而真正传输时间不到 10ms。

缓存的意义在于:有些数据根本不需要重复获取

6.2 强缓存:不用问直接用

强缓存的意思是:浏览器/客户端判断本地缓存还在有效期内,直接从本地加载,不会发出任何 HTTP 请求

强缓存由两个响应头控制:

Cache-Control: max-age=3600(推荐,HTTP/1.1 引入)

arduino 复制代码
HTTP/1.1 200 OK
Content-Type: image/png
Cache-Control: public, max-age=86400

告诉客户端:这个资源可以在本地缓存 86400 秒(一天),一天之内再来要,我都不问服务器,直接用本地副本。

Expires: Wed, 30 Apr 2026 06:00:00 GMT(HTTP/1.0 产物)

yaml 复制代码
HTTP/1.1 200 OK
Content-Type: image/png
Expires: Wed, 30 Apr 2026 06:00:00 GMT

也是告诉客户端过期时间,但用的是绝对时间 。缺点很明显:客户端时间不准的话,缓存可能失效。所以优先用 Cache-Control,它用的是相对时间

6.3 协商缓存:问一下再用

如果强缓存过期了(max-age 用完了),或者响应头没有 Cache-Control,客户端不会直接删除缓存,而是带上缓存的一些标识去问服务器"我这个缓存还能用吗?"

服务端可能回答:

  • 304 Not Modified:可以,继续用本地缓存
  • 200 OK + 新数据:不行了,给你新的

协商缓存有两种实现方式:

方式一:Last-Modified / If-Modified-Since(基于时间)

yaml 复制代码
第一次请求:
客户端 → 服务器
        ← 200 OK + Last-Modified: 2026-04-28 10:00:00 + 响应体
​
第二次请求:
客户端:GET /resource
         If-Modified-Since: 2026-04-28 10:00:00
        → 服务器检查:文件修改时间没变
        ← 304 Not Modified(空响应体)

问题:精确到秒,同一秒内多次修改无法识别。

方式二:ETag / If-None-Match(基于内容哈希)

sql 复制代码
第一次请求:
客户端 → 服务器
        ← 200 OK + ETag: "abc123" + 响应体
​
第二次请求:
客户端:GET /resource
         If-None-Match: "abc123"
        → 服务器检查:内容没变,哈希还是 abc123
        ← 304 Not Modified(空响应体)

ETag 比较的是内容的哈希值,改没改一清二楚,精度比 Last-Modified 高得多。

6.4 完整的缓存决策流程

下面用文字画出一个完整的缓存判断链,每个请求最先达到的就是这个判断:

sql 复制代码
收到请求 → 检查本地是否有缓存
              ↓
         没有缓存 → 发请求到服务器 → 200 OK + 新数据 + 缓存策略 → 存入缓存 → 用新数据
              ↓
         有缓存 → 检查是否过期(Cache-Control: max-age / Expires)
              ↓                  ↓
          未过期(强缓存)     已过期(弱缓存)
              ↓                  ↓
         直接从缓存取出        携带条件(If-None-Match / If-Modified-Since)
         返回数据                      ↓
                                  GET 到服务器
                                      ↓
                              服务器检查资源状态
                              ↓               ↓
                          304 Not Modified  200 OK + 新数据
                              ↓               ↓
                          缓存继续有效        更新缓存
                              ↓               ↓
                          用本地缓存          用新数据

6.5 Android 中 OkHttp 缓存实战

scss 复制代码
// OkHttp 的缓存配置
val cacheDir = File(context.cacheDir, "http_cache")
val cache = Cache(cacheDir, maxSize = 10 * 1024 * 1024) // 10MB 缓存空间
​
val client = OkHttpClient.Builder()
    .cache(cache)
    .addNetworkInterceptor { chain ->
        val response = chain.proceed(chain.request())
​
        // 修改响应头来控制缓存行为
        response.newBuilder()
            .header("Cache-Control", "public, max-age=${60 * 60}") // 1小时
            .removeHeader("Pragma")  // 移除 HTTP/1.0 的缓存头
            .build()
    }
    .build()
​
// 使用缓存------请求头也可以控制缓存行为
val request = Request.Builder()
    .url("https://api.example.com/user/avatar")
    // 强制使用缓存(即使服务端说不能缓存)
    .header("Cache-Control", "only-if-cached, max-stale=${60 * 60}") 
    .build()

一个关键细节:OkHttp 的缓存有两种缓存级别:

  • 应用层缓存cache 配置):需要服务端返回缓存头支持
  • 网络拦截器中的缓存:可以在发给服务器之前或收到响应之后修改行为

实际开发体感

java 复制代码
// 比如 App 的商品列表,在不刷新时应该缓存
// 你不需要自己写复杂的缓存逻辑
​
// 1. 服务端返回:
//    Cache-Control: public, max-age=300
//    ETag: "product-list-v3"
​
// 2. OkHttp 自动处理:
//    - 前 300 秒内请求 → 强缓存,0 网络请求,瞬间返回
//    - 300 秒后请求 → 带 If-None-Match 去协商
//    - 数据没变 → 304,空响应体,用本地缓存
//    - 数据变了 → 200,新数据,更新缓存
​
// 3. 用户下拉刷新时:
val request = Request.Builder()
    .url("https://api.example.com/products")
    .header("Cache-Control", "no-cache")  // 强制走协商缓存
    .build()

常见坑 :使用 no-cache 不是"不用缓存",而是"每次都要问服务器"。真正的"不用缓存"是 no-store


7. HTTP/2 革命性改进

7.1 从文本到二进制

HTTP/1.1 的报文是纯文本 的------你看到的那些 GET /api HTTP/1.1Host: 头都是明文。这虽然方便了开发和调试(可以 telnet 到端口手动发请求),但对程序来说解析效率低。

HTTP/2 把一切都变成了二进制。

yaml 复制代码
HTTP/1.1: GET /api/v1/users HTTP/1.1\r\nHost: example.com\r\n\r\n
                       ↓
HTTP/2:  二进制帧 → 0001 0011 1010 ... → 解析更高效

这不是随便改改,这是 HTTP/2 所有革命性改进的基础------二进制分帧层

7.2 二进制分帧层

HTTP/2 在应用层和传输层之间引入了一个二进制分帧层

复制代码
┌─────────────────────────────────────┐
│          HTTP/2 应用层               │
│  (请求/响应用 HTTP/2 语法表示)     │
├─────────────────────────────────────┤
│         二进制分帧层                │  ← 新引入
│  HEADERS frame / DATA frame /        │
│  PRIORITY frame / SETTINGS frame ... │
├─────────────────────────────────────┤
│            TCP 传输层               │
└─────────────────────────────────────┘

每个 HTTP/2 帧的结构:

diff 复制代码
+-----------------------------------------------+
|                 Length (24 bit)                |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+------+--------+
|R|         Stream Identifier (31 bit)          |
+=+=============+===============+======+========+
|                 Frame Payload                 |
+-----------------------------------------------+
  • Length:帧负载长度,最大 16384 字节(可通过 SETTINGS 调大)
  • Type:帧类型,如 DATA(0x0)、HEADERS(0x1)、PRIORITY(0x2)、RST_STREAM(0x3)、SETTINGS(0x4)、PING(0x6)等
  • Flags:标志位,比如 END_STREAM、END_HEADERS
  • Stream Identifier:流 ID,标识这个帧属于哪个请求/响应

流(Stream)的概念:HTTP/2 中每个请求/响应是一个独立的「流」,用奇数 ID 标识客户端发起的流,偶数 ID 标识服务端发起的流(如服务端推送)。同一个 TCP 连接上可以同时存在多个流,这就是多路复用的基础。

7.3 多路复用:彻底解决队头阻塞

回顾 HTTP/1.1 的问题:

less 复制代码
HTTP/1.1 的困境(单连接):
  请求 A 发出 → 等 A 响应 → 请求 B 发出 → 等 B 响应 → 请求 C ...
  
  如果 A 的响应很慢(服务端处理 3 秒),B 和 C 必须排队等。
  这就是「队头阻塞」(Head-of-Line Blocking)。
​
HTTP/1.1 的绕行方案:开多个 TCP 连接(浏览器通常 6 个)
  连接1: 请求 A → 响应 A
  连接2: 请求 B → 响应 B
  连接3: 请求 C → 响应 C
  
  但 TCP 连接本身有成本:三次握手 + TLS 握手 + 慢启动。
  6 个连接 = 6 倍握手开销。

HTTP/2 的解法:一个 TCP 连接上并行多个流

arduino 复制代码
一个 TCP 连接上的多路复用:
​
  客户端                               服务端
    |--- HEADERS (Stream 1, GET /a) --→|
    |--- HEADERS (Stream 3, GET /b) --→|  ← 不用等 Stream 1 响应!
    |--- HEADERS (Stream 5, GET /c) --→|  ← 三个请求几乎同时发出
    |                                   |
    |←-- DATA (Stream 3, /b 响应) ------|
    |←-- DATA (Stream 1, /a 响应 part1)-|  ← Stream 1 分批返回
    |←-- DATA (Stream 5, /c 响应) ------|
    |←-- DATA (Stream 1, /a 响应 part2)-|  ← Stream 1 继续

关键优势:

  • 无队头阻塞:每个流独立,A 慢不影响 B 和 C
  • 一个连接:只需一次 TCP + TLS 握手
  • 帧可以交错:大响应可以被切成多个 DATA 帧,和其他流的帧交错传输

面试追问:HTTP/2 真的完全解决了队头阻塞吗?

:没有。HTTP/2 解决了 HTTP 层 的队头阻塞,但 TCP 层的队头阻塞依然存在------TCP 要求字节按序到达,如果一个 TCP 包丢失,后面所有包(即使属于不同流)都必须等重传。这就是 HTTP/3(QUIC)要解决的问题。

7.4 头部压缩(HPACK)

HTTP/1.1 的一个隐性浪费:每次请求都带一堆重复的头部。

makefile 复制代码
// 同一个用户对同一个 API 的连续请求,这些头每次都传:
Host: api.example.com
User-Agent: okhttp/4.12.0
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiI...
Accept-Encoding: gzip
Cookie: sessionId=abc123; _ga=GA1.2.123456
​
// 这些头加起来可能 500-2000 字节,而请求体可能只有几十字节

HTTP/2 用 HPACK 压缩头部:

  1. 静态表 :61 个预定义的常用头部(如 :method: GET:path: /content-type: text/html),用索引号代替完整字符串
  2. 动态表:连接建立后,双方维护一个动态表,首次出现的头部存入表中,后续用索引号引用
  3. 霍夫曼编码:对头部值进行霍夫曼编码压缩

压缩效果 :首次请求压缩约 50-60%,后续请求(大量头部命中动态表)压缩可达 85-95%

7.5 服务端推送

服务端推送(Server Push)允许服务器在客户端请求 HTML 页面时,主动推送它预判客户端会需要的资源(CSS、JS、图片)。

复制代码
客户端请求 index.html
  → 服务端返回 index.html
  → 服务端主动推送 style.css(不等客户端请求)
  → 服务端主动推送 app.js
  
客户端:等等,我还没请求 css 和 js 呢......但既然来了,先缓存着吧。

听起来很美,实际效果不佳

  • 服务器猜错了客户端需要什么?浪费带宽
  • 客户端已经有缓存了?推送的资源白传了
  • CDN 场景下难以配置
  • Chrome 在 2022 年移除了对 HTTP/2 Server Push 的支持

结论:了解原理即可,实际 Android 开发中几乎用不到。

7.6 Android 中 OkHttp 对 HTTP/2 的支持

OkHttp 从 3.x 起就支持 HTTP/2,且默认开启

scss 复制代码
val client = OkHttpClient.Builder()
    // 不需要额外配置,OkHttp 自动通过 ALPN 协商 HTTP/2
    .build()
​
// ALPN(Application-Layer Protocol Negotiation)协商过程:
// 1. TLS 握手时,客户端在 ClientHello 中声明支持 h2(HTTP/2)和 http/1.1
// 2. 服务器如果支持 h2,在 ServerHello 中选择 h2
// 3. 后续通信使用 HTTP/2
// 4. 如果服务器不支持,回退到 HTTP/1.1

连接合并 :如果两个域名解析到同一个 IP 且使用同一个 TLS 证书(如通配符证书 *.example.com),OkHttp 会在同一个 HTTP/2 连接上复用,减少连接数。

kotlin 复制代码
// 验证当前使用的协议
val client = OkHttpClient.Builder()
    .eventListener(object : EventListener() {
        override fun connectionAcquired(call: Call, connection: Connection) {
            val protocol = connection.protocol()
            Log.d("HTTP", "Protocol: $protocol") // 输出 h2 或 http/1.1
        }
    })
    .build()

8. HTTP/3 与 QUIC

8.1 为什么还需要 HTTP/3?

HTTP/2 已经很快了,但有一个根本性问题:它跑在 TCP 上

TCP 要求所有字节严格按序到达。当一个 TCP 包丢失时:

ini 复制代码
HTTP/2 over TCP 的问题:
​
  Stream 1 的数据: [包1] [包2] [包3]
  Stream 3 的数据: [包4] [包5] [包6]
  
  TCP 传输层看到的:[包1] [包2] [包3] [包4] [包5] [包6]
  
  如果 [包2] 丢了:
    TCP 层面:暂停一切,等 [包2] 重传
    HTTP/2 层面:Stream 1 和 Stream 3 都被阻塞!
    
  即使 [包4][包5][包6](属于 Stream 3)已经完整到达,
  TCP 也不会交给上层------因为 TCP 只认序号,[包2] 没到就不放行。

这就是 TCP 层面的队头阻塞。HTTP/2 的多路复用在应用层解决了问题,但在传输层反而更脆弱------所有流共享一个 TCP 连接,一个包丢了所有流都停。

在丢包率 2% 的弱网络下,HTTP/2 的性能甚至不如 HTTP/1.1(多连接方案至少只有一个连接被阻塞)。

8.2 QUIC 协议核心

HTTP/3 = HTTP over QUIC。QUIC 的核心思路:不用 TCP,在 UDP 上自建可靠传输

复制代码
HTTP/1.1 和 HTTP/2 的协议栈:     HTTP/3 的协议栈:

  ┌──────────┐                    ┌──────────┐
  │  HTTP    │                    │  HTTP/3  │
  ├──────────┤                    ├──────────┤
  │ TLS 1.2/1.3 │                │  QUIC    │  ← 合并了 TLS + 传输
  ├──────────┤                    ├──────────┤
  │  TCP     │                    │  UDP     │
  └──────────┘                    └──────────┘

QUIC 如何解决 TCP 的队头阻塞?

ini 复制代码
QUIC 的独立流:

  Stream 1 的数据: [包1] [包2] [包3]
  Stream 3 的数据: [包4] [包5] [包6]
  
  如果 [包2] 丢了:
    QUIC:只阻塞 Stream 1,等 [包2] 重传
    Stream 3 的 [包4][包5][包6] 正常交付给应用层!

每个 QUIC 流有自己独立的字节序号和重传机制。丢包只影响对应的流,不影响其他流。

8.3 0-RTT 连接建立

TCP + TLS 1.3 的连接建立:

scss 复制代码
客户端 → SYN                     (1 RTT - TCP)
客户端 ← SYN-ACK                 
客户端 → ACK + ClientHello       (2 RTT - TLS)
客户端 ← ServerHello + Finished  
客户端 → 数据                    (3 RTT 后才能发数据)
​
总计:1 RTT (TCP) + 1 RTT (TLS 1.3) = 2 RTT

QUIC 首次连接:

scss 复制代码
客户端 → Initial (含 ClientHello)  (1 RTT)
客户端 ← Initial (含 ServerHello) + Handshake
客户端 → 数据
​
总计:1 RTT

QUIC 恢复连接(0-RTT):

scss 复制代码
客户端 → Initial + 0-RTT 数据     (0 RTT!)
客户端 ← 响应数据
​
第一个包就携带应用数据,无需等待握手完成。

8.4 连接迁移

TCP 用四元组(源IP、源端口、目标IP、目标端口)标识连接。当用户从 WiFi 切到 4G 时,IP 地址变了,TCP 连接断开,必须重新建立。

QUIC 用 Connection ID 标识连接,与 IP 无关:

ini 复制代码
WiFi 环境:IP=192.168.1.100, Connection ID=abc123
  ↓ 用户走出 WiFi 范围,切到 4G
4G 环境:IP=10.0.0.50, Connection ID=abc123(同一个!)
​
QUIC 服务端看到同一个 Connection ID → 连接不断,继续传数据。
用户甚至感觉不到网络切换!

这对移动端体验提升巨大------电梯、地铁、WiFi/4G 切换场景下不再掉线重连。

8.5 Android 上的 Cronet

Cronet 是 Google 提供的网络库,支持 QUIC/HTTP/3:

kotlin 复制代码
// build.gradle
implementation("com.google.android.gms:play-services-cronet:18.0.1")
​
// 初始化 Cronet 引擎
val engine = CronetEngine.Builder(context)
    .enableQuic(true)                    // 启用 QUIC
    .enableHttp2(true)                   // 同时支持 HTTP/2 回退
    .setStoragePath(context.cacheDir.absolutePath)
    .build()
​
// 发起请求
val requestBuilder = engine.newUrlRequestBuilder(
    "https://example.com/api/data",
    object : UrlRequest.Callback() {
        override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
            // info.negotiatedProtocol → "quic/1+spdy/3" 或 "h2"
            Log.d("HTTP3", "Protocol: ${info.negotiatedProtocol}")
            request.read(ByteBuffer.allocateDirect(32 * 1024))
        }
        override fun onReadCompleted(
            request: UrlRequest, info: UrlResponseInfo, buffer: ByteBuffer
        ) {
            buffer.flip()
            val bytes = ByteArray(buffer.remaining())
            buffer.get(bytes)
            Log.d("HTTP3", String(bytes))
            request.read(buffer.clear())
        }
        override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
            Log.d("HTTP3", "Request completed")
        }
        override fun onFailed(
            request: UrlRequest, info: UrlResponseInfo?, error: CronetException
        ) {
            Log.e("HTTP3", "Failed: ${error.message}")
        }
    },
    Executors.newSingleThreadExecutor()
)
requestBuilder.build().start()

实际情况:目前大多数 Android App 仍用 OkHttp(不支持 QUIC),只有对性能极致追求的场景(如视频流、大厂 App)才用 Cronet。OkHttp 团队曾讨论 QUIC 支持,但截至目前尚未内置。


9. 总结

本篇要点回顾

章节 一句话总结
HTTP 报文 请求/响应都是「起始行 + 头部 + 空行 + 正文」的文本结构
方法语义 GET 幂等取数据、POST 非幂等提交数据、PUT 幂等全量替换
状态码 301 永久重定向、302 临时重定向、304 缓存可用、401 未认证、403 无权限
连接管理 HTTP/1.1 keep-alive 复用连接,但有队头阻塞
Cookie/Session Cookie 是钥匙、Session 是保险柜、JWT 是自带信息的通行证
缓存 强缓存(Cache-Control)不问直接用,协商缓存(ETag)问了再用
HTTP/2 二进制分帧 + 多路复用 + HPACK 头部压缩,一个连接并行所有请求
HTTP/3 QUIC 基于 UDP 解决 TCP 队头阻塞,0-RTT 建连 + 连接迁移

下一篇HTTPS 与网络安全


本文参考 RFC 7230-7235(HTTP/1.1)、RFC 7540(HTTP/2)、RFC 9000(QUIC)、RFC 9114(HTTP/3)。代码基于 OkHttp 4.x。

相关推荐
lifewange2 小时前
如何设计一个 RESTful API
后端·http·restful
夜瞬11 小时前
HTTP基础教程:请求方法、状态码、JSON、鉴权、超时、重试与流式返回
网络协议·http·json
你觉得脆皮鸡好吃吗1 天前
HTTP (XSS前简单了解)
网络·网络协议·http·网络安全学习
摸鱼仙人~1 天前
HTTP 状态码系统拆解
网络·网络协议·http
学编程就要猛1 天前
JavaEE初阶:网络原理-HTTP(上)
网络·网络协议·http
菱玖1 天前
常见 HTTP 状态码详解
网络·网络协议·http
我不是立达刘宁宇1 天前
CORS(跨原产资源共享)靶场1
python·http
胡图图不糊涂^_^1 天前
网络原理笔记
java·网络·笔记·学习·tcp/ip·http·https