前言
作为一个写了多年代码的程序员,我一直以为自己"懂网络"。
我知道 HTTP、知道 TCP 三次握手、知道 IP 地址。但如果有人问我:当你调用 socket.send() 之后,数据是怎么从内存跑到网线上的?
我可能会支支吾吾:"呃......操作系统会处理吧?"
是的,操作系统会处理。但怎么处理的?
这正是《网络是怎样连接的》第二章告诉我们的故事--从应用程序委托协议栈发送数据开始,一路追踪到网卡把电信号发出去为止。
读完这章,我感觉自己终于不再是那个"只知道调用 API"的程序员了。
一、协议栈:你代码和数据之间的"中间商"
1.1 什么是协议栈?
简单说,协议栈就是操作系统里负责网络通信的那部分代码。
当你在代码里写下:
send(socket, "Hello World", 11, 0);
你以为你直接把数据发到网上了?天真。你只是把数据交给了协议栈,真正干活的是它。
协议栈就像一个快递公司:
- 你:寄件人,只管把包裹交给快递员
- 协议栈:快递公司,负责打包、贴单、分拣、派送
- 网卡:快递车,真正把包裹运出去
1.2 协议栈里都有谁?
协议栈内部是分层的,每层负责不同的事:
┌─────────────────────────────────┐
│ 应用程序(你的代码) │
├─────────────────────────────────┤
│ TCP/UDP(传输层)- 可靠传输 │
├─────────────────────────────────┤
│ IP(网络层)- 地址和路由 │
├─────────────────────────────────┤
│ 以太网(链路层)- 局域网传输 │
├─────────────────────────────────┤
│ 网卡驱动 + 网卡(物理层) │
└─────────────────────────────────┘
每一层只跟自己的上下层打交道,就像公司里的层级汇报。
二、TCP vs UDP:可靠传输 vs 极速传输
这是这章最重要的概念之一:传输层有两个协议,性格完全相反。
2.1 TCP:操心的大叔
特点: 可靠、有序、有状态
TCP 就像一个操心的快递员:
- 发货前先打电话确认:"您在家吗?我准备送包裹了"(三次握手)
- 每送一个包裹都要对方签收确认(ACK 机制)
- 如果包裹丢了,重新送(重传)
- 如果对方没签收,就等着(超时重传)
- 如果包裹太多,就慢点送(流量控制)
优点: 你只管发,TCP 保证对方一定能收到,而且顺序正确。
缺点: 慢!每发一个包都要确认,开销大。
适用场景:
- 网页(HTTP)
- 文件传输(FTP)
- 邮件(SMTP)
- 任何不能丢数据的场景
2.2 UDP:潇洒的少年
特点: 不可靠、无连接、无状态
UDP 就像一个潇洒的快递员:
- 不管你在不在家,直接把包裹扔门口
- 扔完就走,不等你签收
- 丢了?不管,反正我扔了
- 顺序乱了?不管,反正到了
优点: 快!没有握手、没有确认、没有重传。
缺点: 丢包、乱序都不管,你自己处理。
适用场景:
- DNS 查询(就问一句话,丢了重问就是)
- 视频直播(丢几帧没事,快最重要)
- 语音通话(稍微卡一下比延迟好)
- 游戏(王者荣耀里你卡了一下,比等你 2 秒重连好)
2.3 什么时候用 TCP,什么时候用 UDP?
| 场景 | 选择 | 原因 |
|---|---|---|
| 网页浏览 | TCP | 页面不能丢字 |
| 文件下载 | TCP | 文件必须完整 |
| 视频直播 | UDP | 丢几帧没关系,延迟要低 |
| 语音通话 | UDP | 稍微模糊比卡顿好 |
| DNS 查询 | UDP | 问一句话的事,丢了重问 |
| 游戏实时同步 | UDP | 宁可丢数据,不能卡 |
书里有个关键点:DNS 查询用 UDP,因为数据量很小,丢了重发就行,没必要建立连接。
但 HTTP 请求用 TCP,因为网页内容可能很大,必须保证完整。
2.4 TCP 的连接建立与断开:三次握手与四次挥手
这是 TCP 最经典的知识点,面试必问,但你知道为什么要这样设计吗?
三次握手:建立连接
客户端 服务端
│ │
│ ─── SYN (seq=100) ─────────────────────────> │
│ "我想建立连接,我的初始序号是 100" │
│ │
│ <─── SYN+ACK (seq=300, ack=101) ───────────── │
│ "好的,我也想建立连接,我的初始序号是 300" │
│ "我确认收到你的 SYN,期待你发 101 开始的数据" │
│ │
│ ─── ACK (ack=301) ─────────────────────────> │
│ "收到,我确认你的 SYN,期待你发 301 开始的数据" │
│ │
│ 连接建立成功! │
为什么是三次,不是两次?
关键在于:双方都要确认"我能收到你的消息,你也能收到我的消息"。
-
第一次握手:客户端 → 服务端
- 服务端确认:客户端能发消息,服务端能收消息
- 但客户端还不知道服务端能不能收到
-
第二次握手:服务端 → 客户端
- 客户端确认:服务端能收消息(因为收到了 SYN),服务端能发消息
- 但服务端还不知道客户端能不能收到自己的消息
-
第三次握手:客户端 → 服务端
- 服务端确认:客户端能收到服务端的消息
- 现在双方都确认了双向通信正常!
如果是两次握手会怎样?
假设客户端发了一个 SYN,但在网络中延迟了很久。客户端以为丢了,重发了一个 SYN,成功建立连接,传完数据,断开连接。
然后,那个"迷路"的旧 SYN 终于到了服务端。如果是两次握手:
- 服务端立即建立连接,等待客户端发数据
- 但客户端根本没想连接,不会发数据
- 服务端傻傻地等着,浪费资源
三次握手可以避免这种"幽灵连接"。
四次挥手:断开连接
客户端 服务端
│ │
│ ─── FIN ──────────────────────────────────> │
│ "我发完数据了,想断开连接" │
│ │
│ <─── ACK ─────────────────────────────────── │
│ "收到,但我可能还有数据要发" │
│ │
│ ... 服务端继续发送剩余数据 ... │
│ │
│ <─── FIN ─────────────────────────────────── │
│ "我也发完了,可以断开了" │
│ │
│ ─── ACK ──────────────────────────────────> │
│ "好的,断开连接" │
│ │
│ 连接断开! │
为什么是四次,不是三次?
关键在于:TCP 是全双工的,两个方向独立关闭。
- 客户端说"我不发了" → 服务端说"好的"(第一次、第二次挥手)
- 但服务端可能还有数据要发!
- 等服务端发完了 → 服务端说"我也不发了" → 客户端说"好的"(第三次、第四次挥手)
不能合并成三次吗?
不能,因为服务端的 ACK 和 FIN 通常不能同时发:
- ACK 是立即回应"收到你的 FIN"
- FIN 是"我也发完了"
- 但服务端可能还有数据在发,FIN 要等数据发完才能发
为什么客户端最后要等 2MSL (TIME_WAIT 状态)?
客户端 服务端
│ │
│ ─── ACK (最后一次) ────────────────────────> │
│ │
│ 等待 2MSL... │
│ (MSL = Maximum Segment Lifetime,报文最大生存时间)
│ │
│ 如果 ACK 丢了,服务端会重发 FIN │
│ 客户端还能收到并重发 ACK │
│ │
│ 2MSL 后,确认服务端收到了 ACK │
│ 客户端才真正关闭连接 │
如果不等会怎样?
- 客户端发了最后一个 ACK,立即关闭
- 但 ACK 丢了!
- 服务端没收到 ACK,重发 FIN
- 客户端已经关闭了,不会回应
- 服务端一直重发,最后报错
TIME_WAIT 是为了确保连接可靠关闭。
三次握手 vs 四次挥手:为什么次数不同?
| 三次握手 | 四次挥手 | |
|---|---|---|
| 目的 | 建立连接 | 断开连接 |
| 谁先发起 | 客户端 | 任一方 |
| 能否合并 | SYN+ACK 可以合并(服务端同时确认和发起) | ACK 和 FIN 通常不能合并(服务端可能还有数据) |
| 最后一步 | 客户端发 ACK | 主动关闭方等待 TIME_WAIT |
核心区别:
- 建立连接时,双方都没有数据要发,SYN+ACK 可以一起发
- 断开连接时,一方不发不代表另一方也发完了,ACK 和 FIN 要分开
三、TCP 的核心机制:滑动窗口
这是我看这章时最困惑的概念,看了好几遍才明白。
3.1 为什么需要滑动窗口?
假设没有滑动窗口,发数据是这样的:
发送方 接收方
│ │
│ ─── 包1 ─────────────────> │
│ │
│ <────── ACK 1 ──────────── │ 等待...
│ │
│ ─── 包2 ─────────────────> │
│ │
│ <────── ACK 2 ──────────── │ 等待...
│ │
│ ─── 包3 ─────────────────> │
... ...
发 100 个包,要等 100 次!网络延迟假设是 50ms,那发完这批数据就要 5 秒!
效率太低了。
3.2 滑动窗口:接收方告诉发送方“我还能收多少”
核心思想: 接收方告诉发送方“我还能收多少”,发送方就连续发多少,不用等 ACK。
但这里有个关键问题:接收方怎么把窗口大小告诉发送方?
答案在 TCP 头部 里。
TCP 头部结构(简化版)
┌──────────────────────────────────────────────────────────┐
│ 源端口号(16位) │ 目标端口号(16位) │
├──────────────────────────────────────────────────────────┤
│ 序号(32位) │
├──────────────────────────────────────────────────────────┤
│ 确认号(32位) │
├────────────┬─────────────┬───────────────────────────────┤
│ 头部长度 │ 标志位 │ 窗口大小(16位) ← 就是它! │
├────────────┴─────────────┴───────────────────────────────┤
│ 校验和 │
└──────────────────────────────────────────────────────────┘
窗口大小(Window Size) 字段,16 位,最大值 65535(字节)。
每次接收方发 ACK 的时候,都会在 TCP 头部带上这个字段,告诉发送方:“我现在还能收这么多字节。”
3.3 用代码来理解
假设我们写一个简化版的 TCP 实现:
// 接收方的缓冲区
typedef struct {
char buffer[65535]; // 接收缓冲区
int buffer_size; // 缓冲区总大小
int unread_bytes; // 已收到但未被应用读取的字节数
} ReceiveBuffer;
// 计算当前窗口大小(还能收多少字节)
int calculate_window_size(ReceiveBuffer* buf) {
return buf->buffer_size - buf->unread_bytes;
}
// 接收方收到数据后,发送 ACK
void send_ack(int socket, int ack_num, ReceiveBuffer* buf) {
TCPHeader header;
header.ack_number = ack_num; // 确认号
header.window_size = calculate_window_size(buf); // 窗口大小!
send(socket, &header, sizeof(header), 0);
}
// 应用层读取数据后,窗口变大
int app_read(ReceiveBuffer* buf, char* data, int len) {
int read_len = min(len, buf->unread_bytes);
memcpy(data, buf->buffer, read_len);
buf->unread_bytes -= read_len; // 未读字节减少了
// 注意:这里窗口变大了,下次发 ACK 时会告诉发送方
return read_len;
}
发送方的逻辑:
// 发送方维护的状态
typedef struct {
int last_sent; // 最后发送的字节序号
int last_acked; // 最后确认的字节序号
int window_size; // 对方告诉我的窗口大小
} SendState;
// 发送数据前,先检查窗口
void send_data(int socket, char* data, int len, SendState* state) {
// 计算还能发多少
int in_flight = state->last_sent - state->last_acked; // 已发但未确认
int can_send = state->window_size - in_flight; // 还能发多少
if (can_send <= 0) {
// 窗口满了,等待对方 ACK
printf("窗口满了,等待...\n");
return;
}
// 发送 can_send 字节的数据
int send_len = min(len, can_send);
send(socket, data, send_len, 0);
state->last_sent += send_len;
}
// 收到 ACK 时更新窗口大小
void on_ack_received(SendState* state, int ack_num, int new_window_size) {
state->last_acked = ack_num;
state->window_size = new_window_size; // 更新窗口大小!
printf("收到 ACK %d,窗口大小更新为 %d\n", ack_num, new_window_size);
}
3.4 完整的交互流程
发送方 接收方
│ │
│ 初始窗口 = 1000 │
│ │
│ ─── 发送 300 字节 ─────────────────────────> │
│ (in_flight = 300) │
│ │
│ ─── 发送 400 字节 ─────────────────────────> │
│ (in_flight = 700) │
│ │
│ ─── 发送 300 字节 ─────────────────────────> │
│ (in_flight = 1000,窗口满了,暂停发送) │
│ │
│ 应用读取了 500 字节
│ (窗口变大:1000 - 500 = 500)
│ │
│ <─── ACK = 1000, Window = 500 ────────────── │
│ (收到确认,更新窗口) │
│ │
│ ─── 发送 500 字节 ─────────────────────────> │
│ (继续发送!) │
3.5 为什么叫“滑动”窗口?
想象接收方的缓冲区:
初始状态(窗口大小 4):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 1 │ 2 │ 3 │ 4 │ │ │ │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
└──── 窗口 ────┘
收到包 1,确认后,窗口向右“滑动”:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ ✓ │ 2 │ 3 │ 4 │ 5 │ │ │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
└──── 窗口 ────┘
收到包 2、3,窗口继续滑动:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ ✓ │ ✓ │ ✓ │ 4 │ 5 │ 6 │ │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
└──── 窗口 ────┘
窗口“滑动”了!这就是名字的由来。
3.6 如果接收方处理不过来怎么办?
窗口大小会动态调整!
- 接收方处理快 → 窗口变大 → 发送方发更快
- 接收方处理慢 → 窗口变小 → 发送方发慢点
- 接收方缓冲区满了 → 窗口变成 0 → 发送方暂停发送
这就是流量控制(Flow Control):接收方通过窗口大小,告诉发送方“慢点”或“快点”。
3.7 关键点总结
- 窗口大小在 TCP 头部:16 位字段,每个 ACK 都带
- 接收方动态计算:
窗口大小 = 缓冲区大小 - 未读字节数 - 应用读取数据后,窗口变大:因为未读字节减少了
- 发送方根据窗口控制发送速率:
还能发多少 = 窗口大小 - 已发未确认 - 窗口为 0 时暂停发送:等对方 ACK,窗口变大后再继续
3.8 滑动窗口的本质
用一句话总结:
滑动窗口 = 接收方通过 TCP 头部的 Window Size 字段告诉发送方“我还能收多少”,发送方就发多少,不用每发一个包都等确认。
四、IP 层和 ARP 协议:数据包如何找到目的地
TCP 解决了"可靠传输"的问题,但数据包是怎么从我的电脑跑到目标服务器的呢?
4.1 IP 地址:网络世界的门牌号
每个设备都有一个 IP 地址,就像现实中的门牌号。IP 协议负责:
- 给数据包贴上"收件地址"(目标 IP)
- 给数据包贴上"寄件地址"(源 IP)
- 决定下一步该往哪发(路由)
4.2 IP 和 MAC 的分工
这里有个关键概念:IP 地址是逻辑地址,MAC 地址是物理地址。两者分工不同:
IP(路由器)负责将包发送给通信对象这一整体过程,而其中将包传输到下一个路由器的过程则是由以太网(交换机)来负责的。
用寄快递来理解:
- IP 地址:收件人的完整地址(北京市朝阳区xxx小区)
- MAC 地址:下一站快递点的地址(先送到xx分拣中心)
每到一个中转站,MAC 地址就换成"下一站"的地址,但 IP 地址(最终目的地)始终不变。
4.3 问题来了:已知 IP,如何找到 MAC?
现实问题: 当你要把数据包发往某个 IP 时,网卡需要知道目标的 MAC 地址。但问题是--IP 是软件层面的地址,MAC 是硬件层面的地址,两者之间没有自动映射关系。
你只知道"收件人是北京市朝阳区",但快递员需要知道"具体的 MAC 地址是 xx:xx:xx:xx:xx:xx"。怎么办?
解决方案:ARP(Address Resolution Protocol,地址解析协议)
4.4 ARP 协议的工作流程
主机 A(IP: 192.168.1.10)想给主机 B(IP: 192.168.1.20)发数据
步骤 1:查 ARP 表
┌─────────────────────────────────┐
│ IP 地址 │ MAC 地址 │
├─────────────────────────────────┤
│ 192.168.1.1 │ AA:BB:CC:DD:EE:FF│ ← 网关的 MAC
│ 192.168.1.20 │ ??? │ ← 不知道!
└─────────────────────────────────┘
步骤 2:广播 ARP 请求
"谁是 192.168.1.20?请告诉我你的 MAC 地址!"
(发给局域网内所有设备)
步骤 3:主机 B 回应
"我是 192.168.1.20,我的 MAC 是 11:22:33:44:55:66"
步骤 4:更新 ARP 表,继续发数据
┌─────────────────────────────────┐
│ IP 地址 │ MAC 地址 │
├─────────────────────────────────┤
│ 192.168.1.20 │ 11:22:33:44:55:66│ ← 记住了!
└─────────────────────────────────┘
关键点:
- ARP 请求是广播的(发给所有人)
- ARP 回复是单播的(只回复给请求者)
- ARP 表会缓存结果(下次不用再问)
- ARP 表有有效期(过期要重新问)
4.5 ARP 的脆弱性
ARP 协议设计简单,但也埋下了隐患:
问题 1:ARP 欺骗 恶意设备不断发送伪造的 ARP 回复:"我是 192.168.1.1(网关)!" → 其他设备的 ARP 表被污染 → 流量被劫持到恶意设备(中间人攻击)
问题 2:IP 冲突导致 ARP 混乱 两个设备有相同的 IP → ARP 请求时两个都回应 → ARP 表可能记错 → 网络一片混乱
这就是我遇到的那个 502 问题的根因。
五、实战案例:一次 IP 冲突引发的 502 血案
5.1 问题现象
公司的测试环境搭建在阿里云 ECS 上:
- 阿里云负载均衡器(SLB)做域名转发
- 所有应用部署在 Docker 容器里
- 容器通过 docker-compose 自动创建网络,没有指定固定 IP
当时要部署 12 个应用:
- 前 2 个应用:一切正常
- 第 3 个应用部署后:新应用报 502,旧应用也开始报 502
- 容器都在正常运行,负载均衡配置也没动过
为什么突然集体 502?
5.2 根因分析:IP 冲突导致 ARP 混乱
咨询阿里云客服后,定位到问题:IP 地址冲突。
负载均衡器有个 IP 是 172.13.2.25,而第 3 个 Docker 容器自动分配的 IP 恰好也是 172.13.2.25。
现在用 ARP 协议来解释为什么会 502:
场景:请求要发到负载均衡器(172.13.2.25)
步骤 1:服务器查 ARP 表
"谁是 172.13.2.25?告诉我你的 MAC 地址!"
步骤 2:两个设备同时回应
负载均衡器:"我是!MAC 是 AA:BB:CC:DD:EE:FF"
容器: "我是!MAC 是 11:22:33:44:55:66"
步骤 3:ARP 表记录了容器的 MAC
┌──────────────────────────────────────┐
│ IP 地址 │ MAC 地址 │
├──────────────────────────────────────┤
│ 172.13.2.25 │ 11:22:33:44:55:66 │ ← 记了容器的!
└──────────────────────────────────────┘
步骤 4:请求被发到错误的设备
数据包 → 容器(不是负载均衡器)→ 502 Bad Gateway
5.3 追问:为什么前 2 个应用"后来也中招"?
因为 ARP 表是共享的。
一旦 ARP 表记录了错误的 MAC 地址,所有发往 172.13.2.25 的请求都会被劫持到那个容器--包括之前正常的 2 个应用的请求。
网络问题是会"传染"的!
5.4 解决方案
核心思路:避免 IP 冲突。
# 1. 删除冲突的容器
docker-compose down
# 2. 创建指定网段的网络(避开 SLB 的网段)
docker network create --subnet=172.20.0.0/16 my-network
# 3. docker-compose.yml 中指定网络
networks:
default:
external:
name: my-network
这样容器 IP 会在 172.20.x.x 范围内,与 SLB 的 172.13.x.x 互不干扰。
5.5 这个案例教给我什么
- Docker 默认网络虽方便,但有隐患:自动分配的 IP 可能和其他服务撞车
- 理解 ARP,才能快速定位网络问题:否则可能花大量时间在负载均衡配置、Docker 配置上绕圈
- 网络问题是会"传染"的:ARP 表、路由表是全局共享的,一个错误可能影响全局
- IP 地址规划很重要:生产环境尽量提前规划网段,不要依赖自动分配
六、整个流程:一张图总结
当你调用 send() 发送 "Hello World" 之后:
应用程序
│
│ send("Hello World")
▼
TCP 层:添加 TCP 头部(端口号、序号、ACK号等)
│ - 如果用 UDP,就没有这些
▼
IP 层:添加 IP 头部(源IP、目标IP)
│ - 查路由表,确定下一跳
▼
ARP:查询下一跳的 MAC 地址
│ - 如果 ARP 表没有,广播 ARP 请求
▼
以太网层:添加以太网头部(源MAC、目标MAC)
│
▼
网卡驱动:将数据包写入网卡缓冲区
│
▼
网卡:数字信号 → 电信号,发射!
│
▼
网线/光纤/空气:信号传输
七、说点心里话
读这章之前,我一直是那种"遇到网络问题就重启"的人。
说实话,刚开始在公司遇到那个 502 错误时,我心里是慌的。负载均衡查了、Docker 看了、配置对了......但还是 502。
最后发现是 IP 冲突的时候,我第一反应是: "就这?"
但仔细一想,这事真不怪我——
- Docker 自动分配 IP,我哪知道会和 SLB 撞上?
- ARP 表记错了,我都不知道有这东西
说白了,就是"知其然,不知其所以然"。
用了这么多年网络编程,调了这么多年接口,但数据包到底是怎么从我写的 send() 跑出去的?我从来没认真想过。
读这章最大的收获不是记住了 TCP 三次握手、滑动窗口,而是:
当你真正理解底层发生了什么,很多"玄学问题"就不玄了。
IP 冲突、ARP 欺骗、滑动窗口......这些东西以前只在面试题里见过,读完书才发现,它们每天都在你的代码背后发生着。
怎么读这本书
这本书我比较推荐按顺序读,尤其是第二章。整章其实就是一件事:跟着一个 HTTP 请求,看它怎么从浏览器跑到服务器。
这种"追踪式"的读法有个好处——知识点不再是孤立的:
- 讲 TCP,就会提到它和 UDP 的区别
- 讲 IP,就会接上 ARP 和 MAC
- 讲三次握手,就会解释为什么不能两次
串起来之后,理解就深多了。
如果你也想从"调 API 的"变成"懂原理的",这本书值得啃。
本文是对《网络是怎样连接的》(户根勤 著)第二章的读书总结,结合了个人踩坑经验。