4.7 应用层:HTTP、DNS 与 TLS
HTTP 报文到底长什么样,为什么它只是「一段有格式的文本」?一次网页访问里 DNS 和 TLS 各占多少时间?HTTP/1.1 的队头阻塞和 TCP 的队头阻塞是一回事吗?TLS 1.3 为什么只要一个 RTT,证书链又是怎么被骗过去的?curl -v 的输出该从哪一行看起?
用 C 手写一个 HTTP 请求并读出响应;用 dig +trace 跟完一次递归解析;用 curl -v 和 openssl s_client 把一次 HTTPS 连接拆成 TCP、TLS、HTTP 三段计时;解释 SNI、证书链、ALPN 各自解决什么问题。
运输层给了我们一条可靠的字节流,但字节流没有「消息」的概念。应用层要在这条流上定义边界:一个请求从哪开始、到哪结束,响应怎么区分成功和失败。HTTP 用「首部 + 空行 + 正文」解决边界,DNS 用「长度前缀」解决,TLS 用「记录层长度」解决——三种方案,同一个问题。
这一章选三个协议,是因为它们构成了今天绝大多数网络请求的完整路径:先用 DNS 把域名变成 IP,再用 TCP 连上去,再用 TLS 建立加密通道,最后用 HTTP 传数据。这条路径上的每一段都可能成为瓶颈,而排障的第一步永远是分清慢在哪一段。
这一章也会补上《计算机网络》第 7–8 章的内容:网络安全的三类威胁、TLS 在协议栈里的位置、实时媒体为什么偏爱 UDP。这些内容在本书里只作背景,但足以让你在读到「HTTPS 优化」「视频卡顿」时知道该往哪想。
4.7.1 HTTP 报文:文本协议的边界
HTTP/1.1 的报文是纯文本,用 CRLF(\r\n)分隔行,用空行分隔首部和正文。
请求报文 响应报文 ┌────────────────────────────┐ ┌────────────────────────────┐ │ GET /index.html HTTP/1.1 │ │ HTTP/1.1 200 OK │ ← 状态行 │ Host: example.com │ │ Content-Type: text/html │ │ User-Agent: curl/8.5.0 │ │ Content-Length: 1256 │ │ Accept: */* │ │ Connection: keep-alive │ │ │ │ │ ← 空行 │ (GET 一般没有正文) │ │ ... │ ← 正文 └────────────────────────────┘ └────────────────────────────┘ 请求行 + 首部 + 空行 + 正文 状态行 + 首部 + 空行 + 正文
读这张图要抓住三件事。第一,边界靠空行:首部一直到空行结束,之后全是正文。第二,正文长度靠 Content-Length 或 Transfer-Encoding: chunked:没有这两个,接收方不知道读到哪算完。第三,首部是大小写不敏感的键值对,顺序无关,可以重复(比如多个 Set-Cookie)。
Host 首部是 HTTP/1.1 的强制字段。它的作用是让同一个 IP 上的多个网站共存——服务器根据 Host 决定返回哪个站点。没有它,虚拟主机就无法工作。这也是为什么 HTTP/1.1 的请求必须带 Host,而 HTTP/1.0 可以不带。
下面这段程序用 socket 手写一个 HTTP 请求,证明「HTTP 就是在 TCP 上发一段有格式的文本」:
/* http_get.c:用 socket 手写一个最小 HTTP/1.1 请求 */
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
int main(int argc, char **argv)
{
const char *host = "127.0.0.1";
int port = 8080;
if (argc >= 2) host = argv[1];
if (argc >= 3) port = atoi(argv[2]);
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); return 1; }
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons((uint16_t)port) };
if (inet_pton(AF_INET, host, &a.sin_addr) != 1) {
fprintf(stderr, "地址格式错误: %s\n", host);
return 2;
}
if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) { perror("connect"); return 1; }
char req[512];
int n = snprintf(req, sizeof req,
"GET / HTTP/1.1\r\n"
"Host: %s:%d\r\n"
"Connection: close\r\n"
"User-Agent: http_get/1.0\r\n"
"\r\n", host, port);
if (write(fd, req, (size_t)n) != n) { perror("write"); return 1; }
char buf[4096];
ssize_t r;
long total = 0;
while ((r = read(fd, buf, sizeof buf)) > 0) {
total += r;
fwrite(buf, 1, (size_t)r, stdout); /* 原样打印响应 */
}
fprintf(stderr, "\n--- 共收到 %ld 字节 ---\n", total);
close(fd);
return 0;
}
编译并对着本机的静态服务器跑一次:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 http_get.c -o http_get
python3 -m http.server 8080 --bind 127.0.0.1 &
./http_get 127.0.0.1 8080 | head -8
输出:
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.13.13
Date: Sat, 17 Oct 2026 18:11:34 GMT
Content-type: text/html; charset=utf-8
Content-Length: 11369
<!DOCTYPE HTML>
...
注意响应是 HTTP/1.0,不是 HTTP/1.1。Python 的 http.server 默认用 1.0 协议,每条连接只处理一个请求就关闭。服务端返回的版本号决定了连接能否复用,这就是为什么抓包时会看到「明明请求写的 1.1,响应却是 1.0 并断开」。
还有一个容易忽略的细节:read 循环直到返回 0。因为 HTTP 响应可能跨多个 TCP 段,单次 read 拿不全。这段程序能跑,正是因为它老老实实循环读到 EOF——而 Connection: close 保证了服务端会关闭连接,让 EOF 出现。
4.7.2 方法与状态码:语义比实现重要
HTTP 方法定义「对资源做什么」,状态码定义「结果如何」。两者都是语义约定,不是传输机制。
| 方法 | 幂等 | 安全 | 典型用途 |
|---|---|---|---|
| GET | 是 | 是 | 读资源,可缓存 |
| HEAD | 是 | 是 | 只要首部,用于探测 |
| POST | 否 | 否 | 创建资源、提交数据 |
| PUT | 是 | 否 | 整体替换资源 |
| PATCH | 否 | 否 | 部分更新 |
| DELETE | 是 | 否 | 删除资源 |
「幂等」的意思是「执行一次和执行多次效果相同」。它决定了能不能安全重试:GET/PUT/DELETE 可以自动重试,POST 不行(可能重复下单)。这是分布式系统里重试策略的基础。
状态码分五类,记住首位数字就够定位问题:
| 类别 | 含义 | 常见码与排障方向 |
|---|---|---|
| 1xx | 信息 | 101 协议切换(WebSocket) |
| 2xx | 成功 | 200、201、204 |
| 3xx | 重定向 | 301/302(缓存策略不同)、304(协商缓存命中) |
| 4xx | 客户端错误 | 400 参数错、401 未认证、403 无权限、404 不存在、429 限流 |
| 5xx | 服务端错误 | 500 代码异常、502 上游返回坏响应、503 过载、504 上游超时 |
502 和 504 值得单独说:它们是网关产生的,不是源站产生的。502 表示网关收到了上游的无效响应(连接被重置、响应格式错),504 表示网关等上游超时。看到这两个码,问题在网关到上游这一段,不在客户端。
# 只看状态码和耗时
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' http://127.0.0.1:8080/
# 跟随重定向,显示每一跳
curl -sSL -o /dev/null -w '%{url_effective} %{http_code}\n' http://127.0.0.1:8080/
-w 的格式化变量是排障利器:time_namelookup、time_connect、time_appconnect(TLS 完成)、time_starttransfer(首字节)、time_total。这五个时间点把一次请求切成四段,哪段大就查哪段。
4.7.3 连接复用与队头阻塞
HTTP/1.1 默认开启持久连接(Connection: keep-alive),一个 TCP 连接可以串行处理多个请求。这省掉了重复握手,但带来一个问题:响应必须按请求顺序返回。
假设一个页面需要 10 个资源,浏览器在一条连接上依次请求。第 1 个请求的响应很慢(比如一个慢查询),后面 9 个都得等——这就是 HTTP/1.1 的应用层队头阻塞(head-of-line blocking)。
浏览器的对策是开多条连接(每个域名 6–8 条)。这缓解了问题,但带来新的开销:每条连接都要握手、都要独立的拥塞控制、都占内存。域名分片(domain sharding)就是利用这一点,把资源分散到多个子域,突破单域名的连接数限制。
注意区分两种队头阻塞:
| 类型 | 层次 | 原因 | 解法 |
|---|---|---|---|
| HTTP 队头阻塞 | 应用层 | 响应必须按序返回 | 多连接、HTTP/2 多路复用 |
| TCP 队头阻塞 | 运输层 | 丢包后后续字节要等重传 | QUIC(HTTP/3) |
HTTP/2 解决了第一种,没解决第二种;HTTP/3 用 QUIC 把两者都解决了。这个区分是理解「为什么还要发明 HTTP/3」的关键。
4.7.4 HTTP/2 与 HTTP/3:从多路复用到 QUIC
HTTP/2 的核心是二进制分帧 + 多路复用。它把每个请求/响应切成帧(HEADERS、DATA 等),给每个流一个 stream id,在一条 TCP 连接上交错发送。接收端按 stream id 重组。
| 特性 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 传输格式 | 文本 | 二进制帧 | 二进制帧 |
| 多路复用 | 靠多连接 | 单连接多流 | 单连接多流 |
| 首部压缩 | 无 | HPACK | QPACK |
| 传输层 | TCP | TCP | QUIC(UDP) |
| TCP 队头阻塞 | 有 | 有 | 无 |
| 握手 | TCP + TLS | TCP + TLS + ALPN | QUIC 内建 TLS 1.3 |
HTTP/2 的 HPACK 用静态表 + 动态表压缩首部。首部里有大量重复字段(User-Agent、Accept),压缩后能省很多字节。代价是动态表有状态,丢一个帧可能导致后续解码全错——所以 HTTP/2 对丢包很敏感。
HTTP/3 把传输层换成 QUIC。QUIC 在 UDP 上实现了可靠传输、拥塞控制、加密和流控,并且每条流独立排序:一条流丢包不影响其他流。它还支持 0-RTT 重连(用上次的会话票据),进一步省掉握手。
代价是 UDP 在很多网络里被限速或封禁,所以 HTTP/3 通常与 HTTP/2 并存,靠 Alt-Svc 首部或 DNS HTTPS 记录告诉客户端「我也支持 h3」。
# 看服务端支持哪些协议(需要外网)
curl -sSI --http2 https://example.com | head -3
# 强制用 HTTP/3(curl 需要编译支持)
curl -sSI --http3 https://example.com | head -3
# 看 ALPN 协商结果
openssl s_client -connect example.com:443 -alpn h2,http/1.1 </dev/null 2>/dev/null | grep ALPN
ALPN(Application-Layer Protocol Negotiation)是 TLS 的扩展,让客户端在握手时就说「我支持 h2 和 http/1.1」,服务端选一个。它在 TLS 握手内完成,不增加往返。
4.7.5 DNS:把名字变成地址
DNS 是一个分布式的、层级式的数据库。它的查询分两种模式:
- 递归查询:客户端问本地解析器「example.com 的 IP 是多少」,解析器负责跑完全程,只回最终答案。
- 迭代查询:解析器问根服务器,根说「去问 .com 的服务器」;再问 .com,它说「去问 example.com 的权威服务器」;最后拿到答案。
客户端 本地解析器 根服务器 .com 服务器 权威服务器 │ │ │ │ │ │ 递归:A? example.com │ │ │ ├─────────────────▶│ │ │ │ │ │ 迭代:A? ────────▶│ │ │ │ │◀── 去问 .com ─────┤ │ │ │ │ 迭代:A? ────────────────────────▶│ │ │ │◀── 去问 ns.example.com ───────────┤ │ │ │ 迭代:A? ──────────────────────────────────────▶│ │ │◀── A 93.184.216.34 ──────────────────────────────┤ │◀── A 93.184.216.34(带 TTL) │ │ │ │ │ │ │ │ │ 缓存 TTL 秒 │ 缓存 TTL 秒 │ │ │
读这张图要抓住两点。第一,递归和迭代的分界在本地解析器:客户端只发一次递归查询,后面的迭代由解析器代劳。第二,每一层都有缓存,缓存时长由 TTL 决定。TTL 是 DNS 的「一致性 vs 性能」旋钮:TTL 长,解析快但切换 IP 慢;TTL 短,切换快但解析压力大。
常见记录类型:
| 类型 | 含义 | 例子 |
|---|---|---|
| A | IPv4 地址 | example.com. 300 IN A 93.184.216.34 |
| AAAA | IPv6 地址 | example.com. 300 IN AAAA 2606:2800::1 |
| CNAME | 别名 | www.example.com. 300 IN CNAME example.com. |
| MX | 邮件服务器 | example.com. 300 IN MX 10 mail.example.com. |
| TXT | 文本(SPF、验证) | example.com. 300 IN TXT "v=spf1 -all" |
| NS | 权威服务器 | example.com. 86400 IN NS a.iana-servers.net. |
| SOA | 区域起始授权 | 包含序列号、刷新间隔等 |
CNAME 有一个限制:它不能和其他记录共存(除了 DNSSEC 相关记录)。所以根域名通常不能设 CNAME,只能用 A/AAAA——这就是为什么很多 CDN 要求「把 www 指过去,根域名用 A 记录」。
# 基本查询
dig example.com A +short
# 跟着解析器走完全程(需要外网)
dig +trace example.com A
# 指定 DNS 服务器,看它怎么答
dig @8.8.8.8 example.com A
# 看 TTL 与权威段
dig example.com A +noall +answer +authority
+trace 是理解 DNS 层级最好的工具。它会从根开始,逐级打印查询和响应,你能亲眼看到「根返回 .com 的 NS,.com 返回权威 NS,权威返回 A 记录」。
4.7.6 DNS 缓存、TTL 与常见故障
DNS 的故障大多表现为「解析慢」或「解析到错的 IP」。按缓存层级排查最快:
| 缓存位置 | 查看方式 | 典型问题 |
|---|---|---|
| 浏览器/应用 | 重启应用、清缓存 | 应用自己缓存了旧 IP |
本机 systemd-resolved | resolvectl statistics | 缓存命中率低、上游不可达 |
本机 nscd/dnsmasq | nscd -g、看进程 | 缓存过期策略不一致 |
| 本地递归解析器 | dig @<resolver> | 上游超时、被污染 |
| 权威服务器 | dig @<ns> | 记录配错、TTL 太短 |
# 看解析器状态与缓存
resolvectl status | head -20
resolvectl statistics
# 清缓存
sudo resolvectl flush-caches
# 看一个域名走的哪条链路
resolvectl query example.com
/etc/resolv.conf 里的 options timeout:2 attempts:3 决定了「解析器多久放弃」。默认值在不同发行版差异很大,容器里经常被设成 ndots:5,导致「短域名先补全再查」,多出好几次查询。Kubernetes 里 DNS 慢,很多时候是 ndots:5 造成的,把 dnsConfig.options 调成 ndots:2 就能显著改善。
4.7.7 TLS:在明文通道上建一个加密通道
TLS 解决三个问题:机密性(别人看不到内容)、完整性(别人改不了内容)、身份认证(连的是真服务器)。它位于应用层和运输层之间,HTTP 跑在它上面就成了 HTTPS。
TLS 不是一种加密算法,而是几类原语的组合:
| 原语 | 典型算法 | 在 TLS 里的角色 |
|---|---|---|
| 对称加密 | AES-128-GCM、ChaCha20-Poly1305 | 加密应用数据;快,但密钥必须双方共享 |
| 公钥/密钥交换 | X25519、ECDHE、RSA | 握手时协商会话密钥;慢,解决「怎么安全地共享密钥」 |
| 哈希 | SHA-256 | 完整性、HMAC、证书指纹 |
| 数字签名 | ECDSA、RSA-PSS | 证书签名与 CertificateVerify,证明对方持有私钥 |
套件名 TLS_AES_128_GCM_SHA256 就是「对称算法 + 哈希」的组合;会话密钥由 ECDHE 临时协商,所以具备前向安全;证书把公钥与域名绑定,CA 的签名提供信任锚。把这四类分清楚,再看下面的握手图就不会混。
TLS 1.3 的握手只要一个 RTT:
客户端 服务端
│ ── ClientHello ─────────────────────────────────▶ │
│ version, random, cipher_suites, │
│ key_share (DH 公钥), SNI, ALPN │
│ │
│ ◀─────────────────────── ServerHello ─────────── │
│ key_share (DH 公钥), 选定套件 │
│ ◀── {EncryptedExtensions, Certificate, │
│ CertificateVerify, Finished} │
│ ↑ 从这里开始全部加密 │
│ │
│ ── {Finished} ──────────────────────────────────▶ │
│ │
│ ◀════════ 应用数据(HTTP)══════════════════════▶ │
│ │
说明:{} 表示用握手密钥加密;TLS 1.2 需要两个 RTT
读这张图要抓住三点。第一,key_share 里直接带了 DH 公钥,所以双方算完共享密钥就能加密后续消息,省掉了一个往返。第二,证书和 Finished 一起加密发送,中间人看不到证书内容(除了 SNI)。第三,TLS 1.2 需要两个 RTT,因为要先用明文协商再切换加密。这就是为什么 TLS 1.3 对短连接收益明显。
TLS 1.3 还砍掉了一批不安全的算法(RSA 密钥交换、CBC 模式、RC4、SHA-1),只保留前向安全的(EC)DHE。前向安全的意思是:即使服务器私钥将来泄露,过去的流量也解不开,因为每次会话的密钥是临时生成的。
4.7.8 证书链与 SNI
TLS 的身份认证靠证书链:
根证书(CA,预装在系统/浏览器里)
└── 中间证书(由根签发)
└── 服务器证书(由中间签发,绑定域名)
验证过程是:服务器发来「服务器证书 + 中间证书」,客户端用中间证书的公钥验证服务器证书的签名,再用根证书验证中间证书的签名,一直追到信任锚。任何一环缺失或过期,验证就失败。
三个常见错误:
| 错误 | 原因 | 排查 |
|---|---|---|
unable to get local issuer certificate | 服务端没发中间证书 | openssl s_client -showcerts 看链是否完整 |
certificate has expired | 证书过期 | openssl x509 -enddate |
certificate subject name does not match | 域名不在 SAN 里 | openssl x509 -text 看 Subject Alternative Name |
SNI(Server Name Indication) 解决「一个 IP 多个 HTTPS 站点」的问题。TLS 握手时客户端在 ClientHello 里明文带上域名,服务端据此选证书。没有 SNI,服务端不知道客户端要访问哪个站点,只能返回默认证书。
SNI 是明文的,这带来两个后果:中间人可以知道你访问了哪个域名(即使内容加密);基于 SNI 的阻断成为可能。ECH(Encrypted Client Hello) 就是为了加密 SNI 而设计的,目前部署还不普遍。
# 看服务端发来的完整证书链
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates
# 看证书里的 SAN(域名列表)
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -text | grep -A1 'Subject Alternative Name'
-servername 必须显式指定,否则 openssl 不带 SNI,服务端可能返回默认证书,导致「明明浏览器能开,命令行却报证书错」。
4.7.9 用 curl 和 openssl 拆解一次 HTTPS
curl -v 是应用层排障的第一工具。它的输出按时间顺序分成几段:
curl -v -o /dev/null https://example.com/ 2>&1 | head -20
* Trying 93.184.216.34:443...
* Connected to example.com (93.184.216.34) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS certificate (11):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519
* ALPN: server accepted h2
> GET / HTTP/2
> Host: example.com
> user-agent: curl/8.5.0
> accept: */*
>
< HTTP/2 200
< content-type: text/html; charset=UTF-8
逐段读:
Trying ...到Connected:DNS 解析 + TCP 握手。这两步慢,查 DNS 和网络。TLSv1.3 ... Client hello到SSL connection using:TLS 握手。慢通常是因为 RTT 大或证书链大。ALPN: server accepted h2:协议协商结果,决定后面用 HTTP/2 还是 1.1。> GET之后是请求首部,<之后是响应首部。
把时间量化,用 -w:
curl -sS -o /dev/null -w \
'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://example.com/
四个差值分别对应四段耗时:tcp-dns 是 TCP 握手,tls-tcp 是 TLS 握手,ttfb-tls 是等待首字节(含服务端处理),total-ttfb 是传完正文。这四段是最靠谱的性能分解,比任何猜测都准。
4.7.10 网络安全:三类威胁与 TLS 的位置
《计算机网络》第 7 章把网络安全威胁分成三类,这个分类今天仍然适用:
| 威胁 | 含义 | 对策 |
|---|---|---|
| 被动攻击 | 窃听流量,不修改 | 加密(TLS/IPsec) |
| 主动攻击 | 篡改、重放、伪造 | 完整性校验 + 认证 + 序号 |
| 拒绝服务 | 耗尽资源 | 限流、清洗、anycast |
TLS 同时对付前两类:加密解决窃听,AEAD(带关联数据的认证加密)解决篡改,证书解决身份伪造,序号/随机数解决重放。但它对付不了 DDoS——攻击发生在建立连接之前,TLS 还没开始。
这也是为什么生产架构里安全是分层的:网络层有 ACL 和防火墙,传输层有 TLS,应用层有鉴权、限流、WAF。指望一个协议解决所有问题,是把不同层面的威胁混为一谈。
4.7.11 实时媒体:为什么音视频偏爱 UDP
《计算机网络》第 8 章讲音频/视频服务。核心矛盾是:实时性要求「准时」,可靠性要求「完整」,两者冲突时选准时。
一段 20 ms 的语音帧,如果丢了,重传要等至少一个 RTT(几十毫秒),到达时已经晚了,播放器只能丢弃。所以实时媒体宁愿丢帧,也不愿等重传。这就是 RTP 跑在 UDP 上的原因。
| 协议 | 传输层 | 用途 | 特点 |
|---|---|---|---|
| RTP | UDP | 音视频数据 | 带序号和时间戳,不保证送达 |
| RTCP | UDP | 质量控制 | 报告丢包率、抖动,用于调整码率 |
| RTMP | TCP | 推流 | 可靠但延迟高,适合上行 |
| HLS/DASH | HTTP | 点播/直播 | 分片 + HTTP CDN,延迟秒级 |
| WebRTC | UDP(SRTP) | 实时通话 | 低延迟,配合 ICE 打洞 |
现代直播的架构通常是「RTMP 推流 → 转码 → HLS/DASH 分发」,端到端延迟 5–30 秒;实时通话用 WebRTC,延迟 100–300 ms。延迟和成本是一对矛盾:HLS 能复用 HTTP CDN,便宜但慢;WebRTC 快但要专门的媒体服务器。
4.7.12 缓存与代理:HTTP 的另一个半边
HTTP 不只是「客户端和服务端对话」,中间还常有缓存和代理。它们靠首部协作,理解这几个首部就能解释大多数「为什么改了代码没生效」。
| 首部 | 方向 | 作用 |
|---|---|---|
Cache-Control: max-age=60 | 响应 | 60 秒内可直接用缓存,不问服务端 |
Cache-Control: no-cache | 响应 | 可以缓存,但每次用前必须校验 |
Cache-Control: no-store | 响应 | 完全不缓存 |
ETag / If-None-Match | 响应/请求 | 内容指纹,用于协商缓存 |
Last-Modified / If-Modified-Since | 响应/请求 | 时间戳版本,精度只到秒 |
Vary: Accept-Encoding | 响应 | 缓存键要包含这个首部 |
Age | 响应 | 这份缓存已经存活了多少秒 |
协商缓存的工作方式是:客户端带着 If-None-Match: "abc123" 请求,服务端比对 ETag,没变就回 304 Not Modified(无正文),变了就回 200 和新内容。304 省的是正文流量,不是往返,所以它对延迟的改善有限,对带宽的改善明显。
Vary 是最容易出错的一个。CDN 默认按 URL 做缓存键,如果响应内容随 Accept-Encoding 或 User-Agent 变化而没写 Vary,就会出现「A 用户拿到 B 用户的压缩版本」这类串味问题。
代理相关的是 X-Forwarded-For、X-Real-IP、Via。它们不是标准规定的必选首部,但事实上通用:
# 看经过了几层代理、原始客户端 IP 是什么
curl -sS -D - -o /dev/null http://127.0.0.1:8080/ | grep -iE 'via|x-forwarded|server'
X-Forwarded-For 是一个逗号分隔的列表,每经过一层代理追加一个 IP。它可以被客户端伪造,所以服务端只能信任「最后一跳代理追加的那个」,不能拿整个列表的第一个当真实 IP。这是很多「IP 白名单被绕过」漏洞的来源。
4.7.13 HTTP/1.1 的线格式与三个长度来源
4.7.1 说 HTTP 是文本协议,但「文本」不等于「随便读」。HTTP/1.1 的消息边界由三种机制之一确定,解析器必须先判断用哪一种,否则会把两个响应粘成一个。
| 机制 | 出现位置 | 边界规则 | 风险 |
|---|---|---|---|
Content-Length | 响应/请求头 | 精确读 N 字节 | 与实际长度不符会截断或越界 |
Transfer-Encoding: chunked | 响应/请求头 | 每个 chunk 前有十六进制长度 | 与 CL 同时出现时优先级冲突 |
| 连接关闭 | HTTP/1.0 或无以上两者 | 读到 EOF | 无法复用连接,易受慢速攻击 |
一个请求行的结构是 method SP request-target SP HTTP-version CRLF,头部是 field-name ":" OWS field-value OWS CRLF,空行结束。字段名大小写不敏感,值的大小写敏感;Host 是 HTTP/1.1 唯一强制字段。
常被忽略的字段:
| 字段 | 作用 | 解析要点 |
|---|---|---|
Host | 虚拟主机路由 | 可与 URL 的 authority 不同,反向代理据此选后端 |
Connection | 控制复用(keep-alive/close) | 逐跳字段,代理必须消费掉,不能透传 |
Transfer-Encoding | 分块编码 | 只认最后一个编码;chunked 必须最后 |
Content-Length | 实体长度 | 与 TE 同时出现时按 RFC 7230 应拒绝 |
TE | 可接受的传输编码 | 与 Transfer-Encoding 不是一回事 |
Expect: 100-continue | 客户端先问再发体 | 服务端不回 100 时客户端可能一直等 |
ETag/If-None-Match | 条件请求 | 弱比较 W/ 前缀的语义不同 |
Vary | 缓存键的额外维度 | 漏配会导致压缩/语言版本串味 |
分块编码的线格式:
HTTP/1.1 200 OK\r\n
Transfer-Encoding: chunked\r\n
\r\n
5\r\nhello\r\n
6\r\n world\r\n
0\r\n
\r\n
每个 chunk 是「十六进制长度 CRLF 数据 CRLF」,最后以 0 CRLF CRLF 结束(可带 trailer)。长度是十六进制,不是十进制;A 表示 10 字节。解析器要处理 chunk 扩展(5;name=value)和大写十六进制。
HTTP/2 把文本换成二进制帧,帧头固定 9 字节:
| 字段 | 长度 | 含义 |
|---|---|---|
| Length | 3 字节 | 帧负载长度(不含 9 字节头) |
| Type | 1 字节 | HEADERS=1、DATA=0、SETTINGS=4、RST_STREAM=3、GOAWAY=7 |
| Flags | 1 字节 | END_HEADERS、END_STREAM、PADDED 等 |
| R + Stream Identifier | 4 字节 | 最高位保留,低 31 位流 ID |
HTTP/2 的头部用 HPACK 压缩,动态表在连接内共享;HTTP/3 把传输换成 QUIC,头部用 QPACK,帧头与流 ID 的语义又变了一次。三者共同的约束是:消息边界由协议帧显式给出,不再靠读 EOF,这是它们能多路复用的前提。
- 同时接受
Content-Length与Transfer-Encoding:不同中间件选不同来源,就是请求走私(request smuggling)的入口。 - 把
Connection头透传给下一跳:它是逐跳字段,透传会让后端误关连接。 - 按
\n\n找头部结束:合法分隔是\r\n\r\n;只认\n\n会把 body 里的空行当结束。 - 认为 HTTP/1.1 一定长连接:是否复用由
Connection与两端实现共同决定,收到close必须关。
4.7.14 DNS 报文:12 字节头 + 四个区
DNS 的报文结构比 HTTP 简单,但压缩指针和 EDNS0 让解析器容易写错。头部固定 12 字节:
| 字段 | 位宽 | 含义 |
|---|---|---|
| ID | 16 | 事务 ID,响应必须回显 |
| QR | 1 | 0=查询,1=响应 |
| Opcode | 4 | 0=标准查询,5=动态更新 |
| AA | 1 | 权威回答 |
| TC | 1 | 截断(UDP 放不下,需走 TCP) |
| RD | 1 | 期望递归 |
| RA | 1 | 递归可用 |
| Z | 3 | 保留(DNSSEC 用其中 AD/CD 两位) |
| RCODE | 4 | 0=NOERROR,3=NXDOMAIN,2=SERVFAIL |
随后是四个区:Question、Answer、Authority、Additional,每区以 16 位计数开头。资源记录(RR)的格式:
NAME 域名(标签序列,可用压缩指针)
TYPE 16 位(A=1、NS=2、CNAME=5、SOA=6、AAAA=28、OPT=41)
CLASS 16 位(IN=1)
TTL 32 位(秒)
RDLENGTH 16 位
RDATA 变长,内容由 TYPE 决定
压缩指针是 DNS 最容易被忽略的设计:当 NAME 的高两位是 11 时,剩下的 14 位是「从报文开头算起的偏移」,指向另一个域名。解析器必须循环跟随并防环(偏移只能向前,且限制跳数),否则恶意报文能让解析器死循环。一个指针占 2 字节,代替最多 255 字节的域名,这是 DNS 报文能塞进 512 字节的关键。
getaddrinfo() 的行为由三处配置共同决定:
| 位置 | 影响 | 常见问题 |
|---|---|---|
/etc/nsswitch.conf 的 hosts: | 先查 files 还是 dns | 容器里缺少 files 会导致 localhost 解析失败 |
/etc/resolv.conf 的 nameserver/search/options | 递归服务器、搜索域、ndots/timeout/attempts | ndots:5 让 api 先尝试 api.<域> 四次 |
AI_ADDRCONFIG/AI_V4MAPPED 等 hint | 返回哪些地址族 | 只有 IPv6 地址的容器里 AI_ADDRCONFIG 会过滤掉 A 记录 |
ndots 的代价可以用一次抓包量出来:查询 api(无点)时,解析器会依次尝试 api.<search1>、api.<search2>……直到 ndots 次失败才查 api. 本身。Kubernetes 里 ndots:5 配上多个 search 域,一次短名字解析可能产生 5–10 个 UDP 查询,这是 DNS 延迟和 UdpRcvbufErrors 的常见来源。
DNS 默认走 UDP,响应超过 512 字节时:
- 服务端置
TC=1,只返回能放下的部分; - 客户端改用 TCP 重查(
dig会显示Truncated, retrying in TCP mode); - EDNS0 的 OPT 记录把 UDP 上限抬到 1232–4096 字节,但路径 MTU 不够时仍会分片丢失。
OPT 记录本身也是 RR:NAME 为根(0x00),TYPE=41,CLASS 字段被复用为 UDP 载荷大小,TTL 字段被复用为扩展 RCODE 与标志位。把 OPT 当普通 RR 解析会读出荒谬的 TTL,这是手写解析器的经典 bug。
NXDOMAIN 也会被缓存,缓存时长取 SOA 的 MINIMUM 与 SOA TTL 的较小值(RFC 2308)。所以「域名刚配好却解析不到」可能不是没生效,而是上一次 NXDOMAIN 还在负缓存里。排查时用 dig +norecurse 直接问权威,或换一个递归服务器对比。
4.7.15 TLS 1.3:记录层、握手与内核 TLS
TLS 把「明文应用数据」包进记录层。TLS 1.3 的记录头只有 5 字节:
| 字段 | 长度 | 值 | 说明 |
|---|---|---|---|
| ContentType | 1 | 20/21/22/23 | 变更密码规格/警报/握手/应用数据 |
| legacy_version | 2 | 0x0303 | 固定为 TLS 1.2,兼容中间盒 |
| length | 2 | ≤ 16384+256 | 密文长度 |
握手消息头是 type(1) + length(3)。TLS 1.3 的完整 1-RTT 握手:
ClientHello
→ ServerHello
← {EncryptedExtensions, Certificate, CertificateVerify, Finished}
→ {Finished}
→ 应用数据
与 TLS 1.2 相比,TLS 1.3 把密钥交换统一到 key_share 扩展,删掉了 RSA 密钥传输、CBC 模式、压缩和重协商,ServerHello 之后的所有握手消息都已加密。这带来一个观测上的变化:抓包只能看到 ClientHello 和 ServerHello 的明文,证书链在加密的 EncryptedExtensions 之后,tcpdump 里看不到证书。
ClientHello 里必须会读的扩展:
| 扩展 | type | 作用 | 排障价值 |
|---|---|---|---|
server_name (SNI) | 0 | 告诉服务端访问哪个域名 | 抓包可见,判断有没有被中间盒改写 |
supported_groups | 10 | 支持的椭圆曲线 | 不匹配会导致握手失败 |
signature_algorithms | 13 | 支持的签名算法 | 老客户端 + 新服务端常见失败点 |
ALPN | 16 | 协商应用协议(h2/http/1.1) | 决定后续走 HTTP/2 还是 1.1 |
supported_versions | 43 | 支持的 TLS 版本 | 缺失则退回 1.2 |
key_share | 51 | 实际的 DH 公钥 | 缺失会触发 HelloRetryRequest,多一个 RTT |
openssl s_client -tlsextdebug -msg 能把 ClientHello 的扩展逐项打出来,比抓包解析快。
内核侧的 TLS 有两条路:
- 用户态库(OpenSSL/BoringSSL):
SSL_write在用户态加密,再write()到 socket。每次都有一次用户/内核拷贝,且加解密占 CPU。 - kTLS:把密钥交给内核,由内核在发送路径上加密、接收路径上解密。启用方式是先
setsockopt(SOL_TCP, TCP_ULP, "tls"),再用setsockopt(SOL_TLS, TLS_TX/TLS_RX, &crypto_info)传密钥与序列号。
kTLS 的内核路径:
| 阶段 | 函数 | 文件 |
|---|---|---|
| 注册 ULP | tls_init | net/tls/tls_main.c |
| 发送 | tls_sw_sendmsg / tls_device_sendmsg | net/tls/tls_sw.c / tls_device.c |
| 组记录 | tls_push_record → tls_encrypt_skb | net/tls/tls_sw.c |
| 接收 | tls_sw_recvmsg → tls_decrypt_skb | 同上 |
| 卸载 | tls_dev_add(网卡支持时) | net/tls/tls_device.c |
kTLS 的边界很明确:它只支持 TLS 1.2/1.3 的 AEAD 套件,不处理握手。握手仍由用户态库完成,密钥导出后交给内核。所以「启用 kTLS」不是「不用 OpenSSL」,而是「握手用 OpenSSL,数据面用内核」。另外,sendfile() 在 kTLS 下仍要解密/加密,不能真正零拷贝;只有网卡支持 TLS offload 时,才能把加解密也卸到硬件。
- 「抓包能看到证书」:TLS 1.3 的证书在加密握手消息里,只有 TLS 1.2 及以下才可见。
- 「SNI 一定等于 Host」:SNI 是 TLS 层的域名,Host 是 HTTP 层的;反向代理可能按其中一个路由。
- 「ALPN 协商失败会回退到 HTTP/1.1」:服务端可能直接拒绝握手(
no_application_protocol),不是静默回退。 - 「kTLS 能加速所有 TLS」:它只接管记录层,握手、会话恢复、证书校验仍在用户态;且部分网卡/内核组合不支持 RX 卸载。
4.7.16 程序一:支持 chunked 的最小 HTTP 客户端
这段程序发一个 HTTP/1.1 请求,自己解析状态行、头部和两种 body 编码。它验证一个事实:HTTP 客户端的一半工作量在确定消息边界,而不是在收发字节。
/* http_get.c:最小 HTTP/1.1 客户端,支持 Content-Length 与 chunked */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <ctype.h>
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <unistd.h>
static int dial(const char *host, const char *port)
{
struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM };
struct addrinfo *res = NULL;
int fd = -1;
if (getaddrinfo(host, port, &hints, &res) != 0)
return -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0)
continue;
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0)
break;
close(fd);
fd = -1;
}
freeaddrinfo(res);
return fd;
}
/* 读到 EOF,返回 malloc 的缓冲区(HTTP/1.1 请求里带 Connection: close) */
static char *read_all(int fd, size_t *out_len)
{
size_t cap = 8192, len = 0;
char *buf = malloc(cap);
if (buf == NULL)
return NULL;
for (;;) {
if (len + 4096 + 1 > cap) {
cap *= 2;
char *nb = realloc(buf, cap);
if (nb == NULL) { free(buf); return NULL; }
buf = nb;
}
ssize_t n = read(fd, buf + len, 4096);
if (n < 0) {
if (errno == EINTR)
continue;
free(buf);
return NULL;
}
if (n == 0)
break;
len += (size_t)n;
}
buf[len] = '\0';
*out_len = len;
return buf;
}
static const char *find_header(const char *hdrs, const char *name, size_t *vlen)
{
size_t nlen = strlen(name);
const char *p = hdrs;
while (*p != '\0') {
const char *eol = strstr(p, "\r\n");
if (eol == NULL || eol == p)
break;
if ((size_t)(eol - p) > nlen && strncasecmp(p, name, nlen) == 0 && p[nlen] == ':') {
const char *v = p + nlen + 1;
while (*v == ' ' || *v == '\t')
v++;
*vlen = (size_t)(eol - v);
return v;
}
p = eol + 2;
}
return NULL;
}
/* 解析 chunked body,返回解码后的长度;失败返回 -1 */
static ssize_t parse_chunked(const char *p, size_t avail)
{
size_t total = 0;
for (;;) {
char *end = NULL;
unsigned long sz = strtoul(p, &end, 16);
if (end == p || (size_t)(end - p) > avail)
return -1;
if (sz == 0)
return (ssize_t)total;
const char *data = strstr(p, "\r\n");
if (data == NULL || data + 2 + sz + 2 > p + avail)
return -1;
data += 2;
total += sz;
p = data + sz + 2; /* 跳过数据和结尾 CRLF */
avail -= (size_t)(p - (data + sz));
}
}
int main(int argc, char **argv)
{
if (argc != 3) {
fprintf(stderr, "用法: %s <host> <port>\n", argv[0]);
return 2;
}
int fd = dial(argv[1], argv[2]);
if (fd < 0) {
perror("connect");
return 1;
}
char req[512];
int n = snprintf(req, sizeof req,
"GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n"
"Accept: */*\r\n\r\n", argv[1]);
if (n < 0 || write(fd, req, (size_t)n) != n) {
perror("write");
close(fd);
return 1;
}
size_t len = 0;
char *buf = read_all(fd, &len);
close(fd);
if (buf == NULL) {
perror("read");
return 1;
}
char *hend = strstr(buf, "\r\n\r\n");
if (hend == NULL) {
fprintf(stderr, "没有找到头部结束标记\n");
free(buf);
return 1;
}
*hend = '\0';
printf("状态行: %.*s\n", (int)(strstr(buf, "\r\n") - buf), buf);
size_t vlen = 0, body_len = 0;
const char *te = find_header(buf, "Transfer-Encoding", &vlen);
const char *cl = find_header(buf, "Content-Length", &vlen);
const char *body = hend + 4;
size_t avail = len - (size_t)(body - buf);
if (te != NULL && vlen >= 7 && strncasecmp(te, "chunked", 7) == 0) {
ssize_t d = parse_chunked(body, avail);
printf("chunked 解码后 %zd 字节(原始 %zu)\n", d, avail);
body_len = d < 0 ? 0 : (size_t)d;
} else if (cl != NULL) {
char tmp[32];
size_t c = vlen < sizeof tmp - 1 ? vlen : sizeof tmp - 1;
memcpy(tmp, cl, c);
tmp[c] = '\0';
body_len = strtoul(tmp, NULL, 10);
printf("Content-Length=%zu,实际收到 %zu 字节\n", body_len, avail);
} else {
body_len = avail;
printf("无长度字段,读到 EOF:%zu 字节\n", body_len);
}
printf("body 前 40 字节: %.*s\n", (int)(body_len < 40 ? body_len : 40), body);
free(buf);
return 0;
}
运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 http_get.c -o http_get
python3 -m http.server 18083 --bind 127.0.0.1 &
./http_get 127.0.0.1 18083
关键行是 parse_chunked() 的三步:strtoul(p, &end, 16) 读十六进制长度、跳过 \r\n、再跳过数据和尾部 \r\n。不要用 atoi:它不认十六进制,遇到 a 会返回 0,被误判为结束块。
4.7.17 程序二:手写 DNS 查询并解析压缩指针
这段程序构造一个 A 记录查询,发给指定 DNS 服务器,然后跳过可能带压缩指针的 NAME 字段,打印所有 A/AAAA 记录。它验证 DNS 报文的对齐方式与压缩指针的读法。
/* dnsq.c:手写 DNS 查询,解析 A/AAAA 记录 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <poll.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
static size_t encode_name(const char *name, unsigned char *out, size_t cap)
{
size_t o = 0;
const char *p = name;
while (*p != '\0') {
const char *dot = strchr(p, '.');
size_t len = dot ? (size_t)(dot - p) : strlen(p);
if (len == 0 || len > 63 || o + 1 + len >= cap)
return 0;
out[o++] = (unsigned char)len;
memcpy(out + o, p, len);
o += len;
p += len;
if (*p == '.')
p++;
}
out[o++] = 0;
return o;
}
/* 跳过 NAME:处理 0xC0 压缩指针 */
static size_t skip_name(const unsigned char *p, size_t len, size_t off)
{
while (off < len) {
unsigned char c = p[off];
if (c == 0)
return off + 1;
if ((c & 0xC0) == 0xC0)
return off + 2;
off += 1 + c;
}
return 0;
}
int main(int argc, char **argv)
{
const char *name = argc > 1 ? argv[1] : "example.com";
const char *server = argc > 2 ? argv[2] : "1.1.1.1";
unsigned char q[512];
size_t qlen = 12;
memset(q, 0, sizeof q);
uint16_t id = (uint16_t)(getpid() & 0xffff);
q[0] = (unsigned char)(id >> 8); q[1] = (unsigned char)id;
q[2] = 0x01; q[3] = 0x00; /* RD=1 */
q[4] = 0; q[5] = 1; /* QDCOUNT=1 */
size_t n = encode_name(name, q + qlen, sizeof q - qlen - 4);
if (n == 0) {
fprintf(stderr, "非法域名: %s\n", name);
return 2;
}
qlen += n;
q[qlen++] = 0; q[qlen++] = 1; /* QTYPE=A */
q[qlen++] = 0; q[qlen++] = 1; /* QCLASS=IN */
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(53) };
if (inet_pton(AF_INET, server, &a.sin_addr) != 1) {
fprintf(stderr, "非法服务器地址: %s\n", server);
return 2;
}
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}
if (sendto(fd, q, qlen, 0, (struct sockaddr *)&a, sizeof a) < 0) {
perror("sendto");
close(fd);
return 1;
}
struct pollfd pfd = { .fd = fd, .events = POLLIN };
if (poll(&pfd, 1, 3000) <= 0) {
fprintf(stderr, "超时:服务器无响应(UDP 可能被拦截)\n");
close(fd);
return 1;
}
unsigned char r[2048];
ssize_t rlen = recv(fd, r, sizeof r, 0);
close(fd);
if (rlen < 12) {
fprintf(stderr, "响应过短\n");
return 1;
}
unsigned flags = (unsigned)r[2] << 8 | r[3];
unsigned qd = (unsigned)r[4] << 8 | r[5];
unsigned an = (unsigned)r[6] << 8 | r[7];
printf("id=%u rcode=%u tc=%u answers=%u\n", (unsigned)r[0] << 8 | r[1],
flags & 0xF, (flags >> 9) & 1, an);
if (((flags >> 9) & 1) != 0)
printf("TC=1:响应被截断,需要改用 TCP 重查\n");
size_t off = 12;
for (unsigned i = 0; i < qd; i++) {
off = skip_name(r, (size_t)rlen, off);
if (off == 0 || off + 4 > (size_t)rlen)
return 1;
off += 4; /* QTYPE + QCLASS */
}
for (unsigned i = 0; i < an && off + 10 <= (size_t)rlen; i++) {
off = skip_name(r, (size_t)rlen, off);
if (off == 0 || off + 10 > (size_t)rlen)
break;
unsigned type = (unsigned)r[off] << 8 | r[off + 1];
unsigned ttl = (unsigned)r[off + 4] << 24 | (unsigned)r[off + 5] << 16 |
(unsigned)r[off + 6] << 8 | r[off + 7];
unsigned rdlen = (unsigned)r[off + 8] << 8 | r[off + 9];
off += 10;
if (off + rdlen > (size_t)rlen)
break;
if (type == 1 && rdlen == 4) {
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, r + off, ip, sizeof ip);
printf("A %s ttl=%u\n", ip, ttl);
} else if (type == 28 && rdlen == 16) {
char ip[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, r + off, ip, sizeof ip);
printf("AAAA %s ttl=%u\n", ip, ttl);
} else {
printf("type=%u rdlen=%u ttl=%u(跳过)\n", type, rdlen, ttl);
}
off += rdlen;
}
return 0;
}
运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 dnsq.c -o dnsq
./dnsq example.com 1.1.1.1
# 换成只有 AAAA 的域名,观察 type=28 分支
./dnsq ipv6.google.com 8.8.8.8
关键行是 skip_name():遇到 (c & 0xC0) == 0xC0 就只前进 2 字节,绝不跟随指针。查询响应里 NAME 常是压缩指针,如果按「标签长度 + 1」一路走,会把偏移带到报文中间,读出垃圾类型和长度。生产级解析器还要跟随指针取域名、检测环、限制跳数;这里只做跳过,已经足够打印 A 记录。
4.7.18 实验:把一次 HTTPS 请求拆成四段时间
实验一用 curl -w 与 ss 把延迟分段,实验二用 tcpdump + openssl 看 TLS 1.3 的明文边界。
实验一:DNS、连接、TLS、首字节。
curl -w '\ndns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
-o /dev/null -s https://example.com/
# 传输过程中看这条连接的窗口与 RTT(另开终端)
ss -tinm dst :443
# DNS 自身耗时
dig +stats example.com | grep -E 'Query time|SERVER'
读法:
| 指标 | 包含 | 异常指向 |
|---|---|---|
time_namelookup | DNS 解析 | ndots/search 域、递归服务器慢 |
time_connect | TCP 握手 | RTT、SYN 重传、队列 |
time_appconnect | TLS 握手 | 证书链、OCSP、密钥交换 |
time_starttransfer | 首字节 | 服务端处理、后端依赖 |
如果 time_namelookup 占了大头,先查 /etc/resolv.conf 的 options ndots 和 search 域数量,而不是换 TLS 库。如果 time_appconnect - time_connect 很大,用 openssl s_client 看握手里哪一步慢(OCSP stapling 缺失会让客户端自己去取 CRL)。
实验二:TLS 1.3 的明文与密文分界。
# 终端 1:抓 443 的包,-X 打十六进制
sudo tcpdump -i any -nn -X -c 20 'tcp port 443 and host example.com'
# 终端 2:只看握手摘要
openssl s_client -connect example.com:443 -servername example.com -tls1_3 -brief </dev/null
# 看证书链与 ALPN
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates
在 tcpdump -X 的输出里:
- 第一个包是 ClientHello:记录头
16 03 01(handshake,legacy 1.0),内部01开头,SNI 明文可见。 - 第二个包是 ServerHello:
16 03 03,之后立刻出现17 03 03(application data)——TLS 1.3 的证书已经在加密记录里,所以看不到0b(certificate)明文。 - 之后的记录全是
17 03 03,长度字段是密文长度,比明文多 16 字节 AEAD tag 和 1 字节内容类型。
openssl s_client -brief 会打印协商结果,重点看三行:协议版本、加密套件、ALPN。如果 ALPN 是 http/1.1 而不是 h2,说明客户端或服务端有一端没开 HTTP/2,后续的多路复用优化全部失效。
4.7.19 本章陷阱清单
- 把
Content-Length与chunked都当合法:同时出现应拒绝,否则就是请求走私的入口。 - 用
atoi解析 chunk 长度:长度是十六进制,必须strtoul(..., 16)。 - 按
\n\n切头部:合法分隔是\r\n\r\n,宽容解析会被 body 内容欺骗。 - 透传
Connection/TE等逐跳头:代理必须消费,不能转发。 - 认为 HTTP/2 一定比 1.1 快:队头阻塞消失了,但 TCP 层丢包仍会阻塞所有流;HTTP/3 才把传输也换掉。
- 忽略 DNS 压缩指针:按标签走会读错偏移,必须识别
0xC0并限制跳数。 - 把 OPT 记录当普通 RR:CLASS 字段是 UDP 大小,TTL 字段是扩展 RCODE,语义完全不同。
- 只看
ndots不看 search 域数量:Kubernetes 里 5 个 search 域 ×ndots:5能把一次解析放大到 10 个查询。 - 认为 NXDOMAIN 不缓存:负缓存按 SOA MINIMUM 生效,刚配好的域名可能仍然解析失败。
- 在 TLS 1.3 抓包里找证书:证书已加密,只能看 SNI/ALPN 与记录长度。
- 把 SNI 当 Host:两者可能不同,反向代理按哪个路由取决于配置。
- 认为 kTLS 会接管握手:握手仍在用户态,kTLS 只加速记录层;且
sendfile在 kTLS 下仍需加密。
4.7.20 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出 HTTP 报文结构 | 能标出空行与正文边界 | 4.7.1 |
| 区分两种队头阻塞 | 知道 HTTP/2 解决了哪个 | 4.7.3、4.7.4 |
| 判断 HTTP 消息边界 | 能区分 CL、chunked、EOF 三种 | 4.7.13 |
| 解析 DNS 压缩指针 | 知道 0xC0 的含义与防环 | 4.7.14 |
| 说出 TLS 1.3 握手的加密边界 | 知道证书为何抓不到 | 4.7.15 |
用 curl -w 拆分四段耗时 | 能把慢定位到 DNS/TCP/TLS/应用 | 4.7.18 |
| 跟完一次 DNS 递归 | 能说出根/TLD/权威的分工 | 4.7.5 |
| 说清 TLS 1.3 为什么是 1 RTT | 知道 key_share 的作用 | 4.7.7 |
用 openssl s_client 查证书链 | 能判断链是否完整 | 4.7.8 |
用 curl -w 分解四段耗时 | 能算出 TCP/TLS/TTFB 各占多少 | 4.7.9 |
Q1 一个 HTTP 响应没有 Content-Length 也没有 Transfer-Encoding: chunked,接收方怎么知道读到哪结束?
Q2 页面加载慢,curl -w 显示 dns=0.001 tcp=0.002 tls=0.003 ttfb=2.5 total=2.6。瓶颈在哪一段?该查什么?
Q3 HTTP/2 已经多路复用了,为什么还会被 TCP 队头阻塞影响?
Q4 服务端只配了服务器证书,没发中间证书。浏览器可能正常,curl 却报错,为什么?
Q5 为什么根域名(如 example.com)通常不能设 CNAME 记录?
Q6 视频会议为什么不用 TCP?如果用 TCP,卡顿会以什么形式出现?
答案
A1 只能靠连接关闭来判断(读到 EOF)。HTTP/1.0 的默认行为就是这样:服务端发完响应就关连接。HTTP/1.1 里如果两者都没有且不是 204/304,按 RFC 7230 应该用「读到连接关闭」作为边界,但这样连接无法复用。
A2 瓶颈在 ttfb - tls = 2.497s,也就是服务端处理时间(或等待上游)。DNS、TCP、TLS 都只有几毫秒,可以排除网络。该查服务端日志、上游依赖、数据库慢查询。
A3 因为 HTTP/2 的多路复用是在一条 TCP 连接上做的。TCP 保证字节按序交付,一旦某个段丢失,后面的所有流都要等它重传——这就是 TCP 层队头阻塞。HTTP/2 只能解决「应用层必须按序响应」的问题。
A4 浏览器会自动补全中间证书(通过 AIA 扩展去下载),curl/openssl 不会。所以浏览器能开、命令行报 unable to get local issuer certificate,是服务端证书链配置不完整的典型症状。
A5 因为 CNAME 不能与 SOA、NS 等其他记录共存,而根域名必须有 SOA 和 NS。CNAME 是「整条记录集的别名」,根域名有多个必需记录,无法整体别名。实践中用 A/AAAA 记录或 CNAME flattening 解决。
A6 TCP 的重传和按序交付会让延迟不可控:丢一个包,后续所有帧都要等,表现为「画面卡住然后快进」。视频会议要的是「丢一帧就丢,继续播下一帧」,所以用 UDP + 应用层 FEC/PLC 来掩盖丢包。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| W(谢希仁) | 第 6 章 应用层(HTTP、DNS、FTP、邮件、DHCP) | 4.7.1–4.7.6 | 完整:报文、方法、DNS 层级与缓存 |
| W(谢希仁) | 第 7 章 网络安全(威胁分类、对称/公钥密码、数字签名、TLS) | 4.7.7、4.7.8、4.7.10 | 完整:证书链、握手、三类威胁 |
| W(谢希仁) | 第 8 章 互联网上的音频/视频服务(RTP、RTCP、流式) | 4.7.11 | 背景:只取实时性与可靠性的取舍 |
| C(CSAPP) | 第 11 章 11.5(Web 服务器) | 4.7.1、4.7.9 | 精选:HTTP 报文与 TINY 服务器的实现视角 |
| K(Kerrisk) | 第 59 章 59.8(DNS 与 getaddrinfo,PDF p1027–1060) | 4.7.14、4.7.17 | 补充:解析器行为 |
| W(谢希仁) | 第 6 章(应用层:HTTP、DNS,PDF p271–344) | 4.7.13–4.7.17 | 补充:报文格式 |
| W(谢希仁) | 第 7 章(网络安全:TLS 位置,PDF p345–380) | 4.7.15 | 背景:安全威胁模型 |
| K(Kerrisk) | 第 59 章 59.8–59.10(DNS、getaddrinfo) | 4.7.5、4.7.6 | 完整:解析器 API 与 /etc/services |
| N(深入理解 Linux 网络) | 第 9 章 9.1(网络请求优化) | 4.7.9 | 前置:请求耗时的分解方法 |
下一站:协议讲完了,接下来进入内核。一个包从网卡到 socket 到底经过哪些函数?进入 4.8 内核收包路径,从 DMA、硬中断、NAPI 一路走到
tcp_v4_rcv。