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

27 阅读14分钟

Android 网络深度系列 · 第 1 篇

系列导航:第1篇:HTTP 协议全解 | 第2篇:HTTPS 与网络安全 | 第3篇:OkHttp 架构剖析 | 第4篇:Retrofit 原理与实战 | 第5篇:WebSocket 与长连接 | 第6篇:网络实战场景

前言

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

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

你一定遇到过这些问题:

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

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

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

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


1. HTTP 报文结构

1.1 请求报文

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

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

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 响应报文

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

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

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 抓到的日志可能是这样的:

--> 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/jsonJSON 格式99% 的 API 调用,Retrofit 默认处理
application/x-www-form-urlencoded表单格式(键值对)登录页、传统表单提交
multipart/form-data多部分混合(总是和文件上传一起出现)上传头像、发朋友圈带图片
application/octet-stream二进制流("我不知道这是什么,反正是一堆字节")下载 APK、文件下载
text/plain纯文本某些日志上报接口
text/htmlHTML 文档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

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 幂等性为什么重要?

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

假设你在支付:

客户端 → 服务器: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 实践

// 这是 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 有个"编辑个人信息"页面,用户只改了昵称——

// ❌ 不该这样——把整个用户对象发回去
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 状态码按百位分五大类:

范围类别含义典型场景
1xxInformational信息性响应,协议层面的信号100 Continue、101 Switching Protocols(WebSocket 升级用)
2xxSuccess成功,请求已收到并正确处理200 最常用
3xxRedirection重定向,需要客户端做额外操作302 临时跳转
4xxClient Error客户端请求有问题401 未登录、404 不存在
5xxServer 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。如果这是你不想要的行为,需要处理:

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

401 vs 403

这是面试高频混淆题。

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

用一个场景你就懂了:

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

Android 开发中的处理

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 中状态码处理策略

// 一个比较完善的 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 连接

客户端                             服务器
  |                                  |
  |----- 三次握手 (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 连接可以被多个请求复用

客户端                             服务器
  |                                  |
  |----- 三次握手 (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 虽然减少连接数,但依旧存在一个问题——请求是串行的。上一个请求的响应没收到之前,不能发下一个请求。

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

客户端                             服务器
  |                                  |
  |------ 请求 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 连接上,多个请求必须排队。

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

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

Android 开发体感

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

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


5. Cookie 与 Session

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

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

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

Set-Cookie 的过程:

客户端(你)                    服务器(网站)
​
第一次请求:
  |------ 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 数据
客户端 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 长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6IuW8oOS4iSIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxMjM0NTY3OH0.abc123...

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

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

对比一下两者的区别

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

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

5.4 Android 中 OkHttp CookieJar 的使用

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

// 一个简单的内存 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 引入)

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 产物)

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(基于时间)

第一次请求:
客户端  服务器
         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(基于内容哈希)

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

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

6.4 完整的缓存决策流程

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

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

6.5 Android 中 OkHttp 缓存实战

// 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 配置):需要服务端返回缓存头支持
  • 网络拦截器中的缓存:可以在发给服务器之前或收到响应之后修改行为

实际开发体感

// 比如 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 把一切都变成了二进制。

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 帧的结构:

+-----------------------------------------------+
|                 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 的问题:

HTTP/1.1 的困境(单连接):
  请求 A 发出 → 等 A 响应 → 请求 B 发出 → 等 B 响应 → 请求 C ...
  
  如果 A 的响应很慢(服务端处理 3 秒),BC 必须排队等。
  这就是「队头阻塞」(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 连接上并行多个流

一个 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 的一个隐性浪费:每次请求都带一堆重复的头部。

// 同一个用户对同一个 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,且默认开启

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 连接上复用,减少连接数。

// 验证当前使用的协议
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 包丢失时:

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 的队头阻塞?

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 的连接建立:

客户端 → 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 首次连接:

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

QUIC 恢复连接(0-RTT):

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

8.4 连接迁移

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

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

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:

// 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/SessionCookie 是钥匙、Session 是保险柜、JWT 是自带信息的通行证
缓存强缓存(Cache-Control)不问直接用,协商缓存(ETag)问了再用
HTTP/2二进制分帧 + 多路复用 + HPACK 头部压缩,一个连接并行所有请求
HTTP/3QUIC 基于 UDP 解决 TCP 队头阻塞,0-RTT 建连 + 连接迁移

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