前言
做了几年 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 Created和Location: /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) --------→|
|←-------- 连接关闭 ---------------|
| |
这个模式的问题:
- TCP 三次握手开销:每次请求都要 1 个 RTT(Round-Trip Time,往返时间)。假设你和服务器之间的 RTT 是 100ms,一个页面加载 10 个资源,光握手就用了 1 秒。
- 慢启动:TCP 连接建立后并不是全速传输的,而是从一个小窗口慢慢扩大。每次新建连接,慢启动都要重新开始。
- 并发限制:浏览器通常限制同一域名并发连接数(不同浏览器 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 处理慢(比如需要查询数据库),请求 2 和请求 3 就算再快也得等着。
- 实现复杂度高:很多代理服务器和中间件不支持,或者有 bug。
- 被更好的方案替代了: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. Cookie 与 Session
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: 用户资料 ------|
关键属性:
Domain和Path:限定 Cookie 的作用范围Max-Age/Expires:过期时间HttpOnly:JavaScript 无法读取,防止 XSS 偷取 CookieSecure:仅通过 HTTPS 传输SameSite:防止 CSRF 攻击(SameSite=Lax/Strict/None)
5.2 Session:服务端的状态
Cookie 只是"钥匙",Session 才是"保险柜"。
服务端 Session 的工作原理:
- 用户登录成功
- 服务端创建 Session 对象(存用户 ID、权限等),存在内存或 Redis 里
- 服务端把 Session ID 通过
Set-Cookie发给客户端 - 客户端后续请求带上这个 Session ID
- 服务端根据 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.1 和 Host: 头都是明文。这虽然方便了开发和调试(可以 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 压缩头部:
- 静态表 :61 个预定义的常用头部(如
:method: GET、:path: /、content-type: text/html),用索引号代替完整字符串 - 动态表:连接建立后,双方维护一个动态表,首次出现的头部存入表中,后续用索引号引用
- 霍夫曼编码:对头部值进行霍夫曼编码压缩
压缩效果 :首次请求压缩约 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。