踩坑实录|一次 ARP 缓存问题排查,顺便搞懂 TCP/IP 底层原理

4 阅读17分钟

前言

作为一个写了多年代码的程序员,我一直以为自己"懂网络"。

我知道 HTTP、知道 TCP 三次握手、知道 IP 地址。但如果有人问我:当你调用 socket.send() 之后,数据是怎么从内存跑到网线上的?

我可能会支支吾吾:"呃......操作系统会处理吧?"

是的,操作系统会处理。但怎么处理的?

这正是《网络是怎样连接的》第二章告诉我们的故事--从应用程序委托协议栈发送数据开始,一路追踪到网卡把电信号发出去为止。

读完这章,我感觉自己终于不再是那个"只知道调用 API"的程序员了。


一、协议栈:你代码和数据之间的"中间商"

1.1 什么是协议栈?

简单说,协议栈就是操作系统里负责网络通信的那部分代码。

当你在代码里写下:

send(socket"Hello World"110);

你以为你直接把数据发到网上了?天真。你只是把数据交给了协议栈,真正干活的是它。

协议栈就像一个快递公司:

  • 你:寄件人,只管把包裹交给快递员
  • 协议栈:快递公司,负责打包、贴单、分拣、派送
  • 网卡:快递车,真正把包裹运出去

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* stateint 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):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 1234 │   │   │   │   │
└────┴────┴────┴────┴────┴────┴────┴────┘
└──── 窗口 ────┘
​
收到包 1,确认后,窗口向右“滑动”:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ ✓ │ 2345 │   │   │   │
└────┴────┴────┴────┴────┴────┴────┴────┘
    └──── 窗口 ────┘
​
收到包 23,窗口继续滑动:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ ✓ │ ✓ │ ✓ │ 456 │   │   │
└────┴────┴────┴────┴────┴────┴────┴────┘
        └──── 窗口 ────┘

窗口“滑动”了!这就是名字的由来。

3.6 如果接收方处理不过来怎么办?

窗口大小会动态调整!

  • 接收方处理快 → 窗口变大 → 发送方发更快
  • 接收方处理慢 → 窗口变小 → 发送方发慢点
  • 接收方缓冲区满了 → 窗口变成 0 → 发送方暂停发送

这就是流量控制(Flow Control):接收方通过窗口大小,告诉发送方“慢点”或“快点”。

3.7 关键点总结

  1. 窗口大小在 TCP 头部:16 位字段,每个 ACK 都带
  2. 接收方动态计算:窗口大小 = 缓冲区大小 - 未读字节数
  3. 应用读取数据后,窗口变大:因为未读字节减少了
  4. 发送方根据窗口控制发送速率:还能发多少 = 窗口大小 - 已发未确认
  5. 窗口为 0 时暂停发送:等对方 ACK,窗口变大后再继续

3.8 滑动窗口的本质

用一句话总结:

滑动窗口 = 接收方通过 TCP 头部的 Window Size 字段告诉发送方“我还能收多少”,发送方就发多少,不用每发一个包都等确认。


四、IP 层和 ARP 协议:数据包如何找到目的地

TCP 解决了"可靠传输"的问题,但数据包是怎么从我的电脑跑到目标服务器的呢?

4.1 IP 地址:网络世界的门牌号

每个设备都有一个 IP 地址,就像现实中的门牌号。IP 协议负责:

  1. 给数据包贴上"收件地址"(目标 IP)
  2. 给数据包贴上"寄件地址"(源 IP)
  3. 决定下一步该往哪发(路由)

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│ ← 网关的 MAC192.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.2011:22:33:44:55:66│ ← 记住了!
└─────────────────────────────────┘

关键点:

  1. ARP 请求是广播的(发给所有人)
  2. ARP 回复是单播的(只回复给请求者)
  3. ARP 表会缓存结果(下次不用再问)
  4. 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 这个案例教给我什么

  1. Docker 默认网络虽方便,但有隐患:自动分配的 IP 可能和其他服务撞车
  2. 理解 ARP,才能快速定位网络问题:否则可能花大量时间在负载均衡配置、Docker 配置上绕圈
  3. 网络问题是会"传染"的:ARP 表、路由表是全局共享的,一个错误可能影响全局
  4. 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 的"变成"懂原理的",这本书值得啃。


本文是对《网络是怎样连接的》(户根勤 著)第二章的读书总结,结合了个人踩坑经验。