你在浏览器里敲下 example.com,不到一百毫秒,一台可能远在另一个大洲的机器就把网页送了回来。中间隔着你家路由器、运营商的机房、海底光缆,没有任何一条线是从你这里直接连到它那里的。这篇回答一个问题:一个包凭什么能一跳一跳地找到路?答案会从”两台机器用一根线连起来”开始,每次规模变大、原来的办法失效,就引出一层新的设计。
前置:信息的表示(二进制、字节序)
分层:每一层只解决一个问题
先想最朴素的情形:两台电脑用一根网线直连。要通信,只需要约定电信号怎么表示 0 和 1,再约定一段比特从哪里开始、到哪里结束。这就是物理层和链路层的工作。
现在把一栋楼的几十台电脑连起来。每个包得写明”给谁”,于是有了链路层地址(MAC)。再往上,把全世界几十亿台设备连起来,MAC 就不够用了(下文会讲为什么),于是有了网络层的 IP 地址和路由。IP 只负责”尽力把包送到那台机器”,可能丢、可能乱序;一台机器上又同时跑着浏览器、微信、ssh,于是传输层用端口区分进程,TCP 还负责把丢掉的包补回来。最上面是应用层,HTTP、DNS 这些协议定义”内容是什么意思”。
| 层 | 解决的问题 | 地址 | 典型协议 / 设备 |
|---|---|---|---|
| 应用层 | 内容的含义 | 域名、URL | HTTP、DNS、SSH |
| 传输层 | 交给哪个进程;可靠性 | 端口(16 位) | TCP、UDP |
| 网络层 | 跨网络送到哪台机器 | IP 地址(IPv4 32 位) | IP、ICMP;路由器 |
| 链路层 | 同一段链路上交给谁 | MAC 地址(48 位) | 以太网、Wi-Fi;交换机 |
| 物理层 | 比特怎么变成信号 | — | 网线、光纤、无线电 |
这就是常说的 TCP/IP 五层模型。分层的价值在于每层只依赖下一层提供的服务,而不关心它怎么实现:TCP 不在乎下面是 Wi-Fi 还是光纤,以太网也不在乎上面装的是 IP 还是别的东西。
分层在数据上的体现叫封装。应用把数据交给 TCP,TCP 在前面加上自己的头(端口、序号),交给 IP;IP 再加上源和目的 IP 地址,交给以太网;以太网再套上 MAC 地址和校验,变成线上的一帧:
| 以太网头 14B | IP 头 20B | TCP 头 20B | HTTP 数据 ... | FCS 4B |
dst MAC, src MAC, src IP, dst IP, src port, dst port,
EtherType=0x0800 TTL, protocol=6 seq, ack, flags每个头里都有一个字段说明”里面装的是什么”:以太网的 EtherType 0x0800 表示 IPv4,IP 头的 protocol 6 表示 TCP、17 表示 UDP,TCP 的端口号说明交给哪个应用。接收方按这些字段一层层拆开,就像拆快递时外箱上写着”内有小盒”。
以太网一帧的载荷上限默认是 1500 字节,叫 MTU。去掉 IP 头和 TCP 头各 20 字节,一个 TCP 段最多带 1460 字节应用数据。这个数字后面讲 TCP 时还会出现。
路由器转发一个 HTTP 请求包时,通常会读取并改写哪些头部?
路由器工作在网络层:按目的 IP 查路由表,TTL 减 1,然后为下一段链路重新封装以太网帧(新的源、目的 MAC)。
按 URL 或端口决定方向是应用层代理、负载均衡器才做的事,普通路由器不看这些层。原样转发是交换机在同一链路内的行为,而且即便如此路由器也必须改 TTL。
链路层:在一根线上找到对方
早期以太网所有电脑挂在同一根同轴电缆上,后来是集线器(hub):一个口进来的信号原样复制到所有其他口。这意味着所有人共享一条”走廊”,两台机器同时说话信号就会叠在一起。解决办法是 CSMA/CD:先听线上有没有人说话,没人再发;发的同时继续听,发现冲突就停下,随机等一会儿重来,冲突次数越多等待的随机范围越大(二进制指数退避)。
共享介质的问题是规模一大冲突就失控,而且谁都能听到别人的包。交换机(switch)改变了这一点。它也有很多口,但它会记下”哪个 MAC 地址出现在哪个口”:
- 收到一帧,把(源 MAC,入口)记进 MAC 表。这一步叫自学习,不需要任何配置。
- 查目的 MAC:表里有就只从那个口发出;没有就向除入口外的所有口泛洪。
- 目的地址是广播
ff:ff:ff:ff:ff:ff的帧,一律泛洪。
每个口独立通信,冲突基本消失。现代有线以太网都是全双工点对点链路,CSMA/CD 实际上已经不再触发;Wi-Fi 因为还是共享空气,用的是避免冲突的变体 CSMA/CA。
这里的 MAC 地址是 48 位,写成 a4:83:e7:12:34:56 这样的形式,前 24 位是厂商编号,原则上出厂即固定(如今手机为隐私会随机化)。它是”身份”而不是”位置”:一个 MAC 地址不会告诉你这台设备在世界的哪个角落。这正是 MAC 不能用来做全球寻址的原因。如果路由器要按 MAC 转发,它就得记住全世界每一块网卡在哪个方向,表项数以百亿计,而且设备一移动就全部失效。
那么同一个局域网里,我知道对方的 IP(比如网关 192.168.1.1),怎么知道它的 MAC?用 ARP:
我 → 广播 ff:ff:ff:ff:ff:ff: 谁是 192.168.1.1?请告诉 192.168.1.23
网关 → 单播给我: 192.168.1.1 在 a4:83:e7:xx:xx:xx结果缓存在本机的 ARP 表里(arp -a 可以看),过几分钟过期。ARP 完全基于信任,谁回答都信,同一局域网的攻击者可以冒充网关(ARP 欺骗)。
交换机刚上电、MAC 表为空时,主机 A 给主机 B 发了一帧。会发生什么?
自学习只看源地址:A 的位置在这一刻被学到。目的未知时交换机用泛洪保证送达,B 回复时交换机又学到 B 的位置,之后两者之间就只走单播。
ARP 是主机用来"由 IP 求 MAC"的协议,交换机不发 ARP,也不看 IP。交换机也不需要路由器帮忙,链路层的转发完全在本地完成。
IP 地址:把”身份”换成”位置”
MAC 失败的教训是:全球寻址需要可聚合的地址,让一个表项代表一大片机器,就像邮编 100 开头的都往北京送,邮局不需要认识北京每一户。IPv4 地址是 32 位,写成四个十进制字节 192.168.1.77,并被切成两段:前缀表示”哪个网络”,剩下的表示”网络里的哪台主机”。
前缀有多长,用 CIDR 记法 /n 或者等价的子网掩码说明。192.168.1.0/24 表示前 24 位是网络号,掩码 255.255.255.0,这个网络有 2^8 = 256 个地址,其中全 0 的 .0 是网络地址、全 1 的 .255 是广播地址,剩下 254 个给主机。判断一个地址是否在某网络内,就是把它和掩码按位与,看结果是否等于网络号。Python 标准库的 ipaddress 可以直接验证:
import ipaddress as ip
net = ip.ip_network("192.168.1.0/24")
print(net.netmask, net.num_addresses, net.broadcast_address, ip.ip_address("192.168.1.77") in net)
print(ip.ip_address("192.168.1.77").is_private, ip.ip_address("8.8.8.8").is_private)255.255.255.0 256 192.168.1.255 True
True False早年地址按 A/B/C 类固定切成 /8、/16、/24,浪费严重,1993 年起 CIDR 才允许任意长度的前缀。
一台主机发包前要做的第一个判断就基于掩码:目的 IP 和我在不在同一个子网?在,就 ARP 问对方的 MAC 直接发;不在,就把包交给默认网关(通常是家里的路由器),ARP 问的是网关的 MAC。注意这时帧上的目的 MAC 是网关,而 IP 头里的目的地址仍然是最终服务器,这两个地址从此分道扬镳:IP 地址端到端不变(NAT 除外,见后文),MAC 地址每过一跳就换一次。
8.8.8.8 输出 is_private=False,因为 RFC 1918 留出了三段私有地址:10.0.0.0/8、172.16.0.0/12、192.168.0.0/16。它们可以在任何内网重复使用,但不能出现在公网上。为什么要这样?因为 32 位只有约 43 亿个地址,早已分完。私有地址加上后面的 NAT 是 IPv4 续命的主要手段,真正的解决方案是 128 位地址的 IPv6,它的地址空间大到可以给每台设备分配全局唯一地址,但迁移至今仍未完成。
主机 10.1.2.7/24 要给 10.1.3.9 发包,以太网帧的目的 MAC 应该填谁?
/24 下本机网络是 10.1.2.0,而 10.1.3.9 的网络号是 10.1.3.0,不在同一子网,所以要交给网关,帧的目的 MAC 是网关的;IP 头的目的地址仍是 10.1.3.9。
ARP 广播只在本子网内传播,问不到别的子网的主机。用广播 MAC 发数据会让整个局域网都收到,不是正常转发方式。链路层永远需要 MAC,IP 包总要装在某种帧里。
路由:每一跳只做一次查表
包到了路由器,路由器怎么决定往哪个口发?它有一张路由表,每一项是”前缀 → 下一跳”。目的地址可能同时匹配好几项,规则是最长前缀匹配:选前缀最长、也就是最具体的那一项。0.0.0.0/0 匹配一切,所以它就是默认路由,兜底用。
用 ipaddress 写一个四行的路由器:
import ipaddress as ip
table = [("0.0.0.0/0","默认网关"),("10.0.0.0/8","公司 VPN"),("10.1.0.0/16","机房 A"),("10.1.2.0/24","GPU 集群")]
def route(dst):
d = ip.ip_address(dst)
hits = [(ip.ip_network(n), h) for n, h in table if d in ip.ip_network(n)]
return max(hits, key=lambda x: x[0].prefixlen) # 越具体越优先
for dst in ["10.1.2.7","10.1.9.9","10.200.0.1","8.8.8.8"]:
n,h = route(dst); print(f"{dst:<12} -> {str(n):<12} {h}")10.1.2.7 -> 10.1.2.0/24 GPU 集群
10.1.9.9 -> 10.1.0.0/16 机房 A
10.200.0.1 -> 10.0.0.0/8 公司 VPN
8.8.8.8 -> 0.0.0.0/0 默认网关10.1.2.7 同时落在四个前缀里,最长的 /24 胜出。这个规则让聚合和例外可以共存:一个运营商对外宣告一个大前缀,内部某个客户需要特殊路径时再加一条更长的前缀覆盖它。真实路由器面对几十万条公网路由、每秒上亿个包,用的是 trie(前缀树)或专用的 TCAM 硬件,但语义和上面这段代码相同。
每一跳路由器还会把 IP 头里的 TTL(Time To Live)减 1,减到 0 就丢包,并回一个 ICMP “超时”消息给源地址。TTL 的本意是防止路由配置错误时包在环路里永远打转。traceroute 巧妙地利用了它:依次发 TTL = 1、2、3 … 的包,第 n 跳路由器会因为 TTL 归零而回 ICMP,于是源头就知道了路径上每一跳是谁、往返多久。ping 用的则是 ICMP 的回显请求和回显应答。
路由表从哪里来?小网络里手工配置就行。大网络里路由器之间运行路由协议互相告知可达性。一个自治系统(AS,例如一个运营商或一所大学)内部常用 OSPF 这类链路状态协议:每台路由器把自己的邻居和链路代价泛洪给所有人,每台都拼出整张图,再各自用 Dijkstra 算最短路(算法本身见 算法设计)。另一类是距离向量协议(如 RIP):每台只告诉邻居”我到各目的地的距离”,大家迭代做 Bellman-Ford,实现简单但收敛慢。AS 之间则用 BGP,它交换的是”经过哪些 AS 能到这个前缀”,选路主要看商业策略而非最短距离。互联网的连通性最终就是几万个 AS 用 BGP 拼起来的。
路由器只做尽力而为的转发:队列满了就丢,不做补救,可靠性留给端上的 TCP,这就是端到端原则。
关于路由与 TTL,下列哪些说法正确?
最长前缀匹配保证更具体的路由优先,默认路由兜底。TTL 在实践中是跳数计数,每台路由器减 1,归零即丢并回 ICMP。
TTL 名字里有 time,但 IPv4 实现上就是跳数,而且是在途中的路由器上丢包,不是目的主机。IP 是尽力而为的服务,路由器不重传,可靠性由端上的 TCP 负责。
NAT 与 DHCP:一个公网地址,一屋子设备
家里的手机、电脑、电视都是 192.168.x.x 这样的私有地址,可它们都能上网。中间做手脚的是家用路由器上的 NAT(Network Address Translation),更准确地说是 NAPT,因为它同时改端口:
内网包: 192.168.1.23:52011 → 93.184.x.x:443
NAT 改写:203.0.113.5:40001 → 93.184.x.x:443 并记录 40001 ↔ 192.168.1.23:52011
回包: 93.184.x.x:443 → 203.0.113.5:40001
NAT 还原:93.184.x.x:443 → 192.168.1.23:52011(203.0.113.0/24 是 RFC 5737 留给文档用的示例地址。)NAT 把一个公网 IP 的六万多个端口分给内网的连接用,靠一张转换表记住谁是谁。代价是它破坏了”IP 地址端到端不变”的假设:外面的机器无法主动连进来,因为 NAT 表里没有对应条目。这顺带提供了一点安全性,却让 P2P、视频通话这类需要直连的应用很头疼,它们得靠 STUN 之类的”打洞”技术,或者干脆经过中继服务器。运营商为省地址还可能再套一层 NAT(CGNAT)。
新设备接入网络时还没有 IP,它怎么知道自己该用哪个地址、网关和 DNS 是谁?靠 DHCP。设备用源地址 0.0.0.0 广播一个 Discover,DHCP 服务器(一般也是家用路由器)回 Offer,设备 Request,服务器 Ack,四步之后设备拿到 IP、掩码、默认网关、DNS 服务器地址,还有一个租期。租期到了要续约,不续就收回给别人用。
家里一台笔记本(192.168.1.23)在跑一个 Web 服务,朋友在外网直接访问家里的公网 IP 却连不上。最根本的原因是什么?
NAT 的映射是内网主动发起连接时才创建的。外网主动发起时,路由器不知道 80 端口该交给谁,只能丢弃或自己处理;手工配置端口转发就是人为加一条映射。
私有地址确实不能出现在公网上,但 NAT 正是负责改写它的,回包本身不是问题。DHCP 与此无关,笔记本在内网一切正常。公网 IP 被多台设备共享正是 NAT 的设计,并非冲突。
DNS:名字到地址的分布式查表
到这里,只要知道目的 IP,包就能送到。但人记的是 example.com。DNS 负责把名字翻译成 IP。最朴素的做法是一个全球文件记录所有名字,早期的 hosts.txt 正是这样,主机一多就无法维护。DNS 的解法和 IP 地址一样:按层级切分,把管理权一级级下放。
名字从右往左读:根 . 之下是顶级域 com,之下是 example,再之下可以是 www。根服务器只知道各顶级域归谁管,.com 的服务器只知道 example.com 归谁管,最后由 example.com 自己的权威服务器给出答案。你的电脑并不亲自走这条路,它把问题交给一个递归解析器(运营商的、或者 8.8.8.8、1.1.1.1 这类公共的),由解析器去问根、问顶级域、问权威,再把结果和 TTL 一起缓存起来。
这条链路上的每一级都可以查。下面用 Cloudflare 的 DNS-over-HTTPS 接口逐级查询:
curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=com&type=NS'
curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=example.com&type=NS'
curl -s -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=example.com&type=A'com NS a.gtld-servers.net. b.gtld-servers.net. ... TTL 169758
example.com NS hera.ns.cloudflare.com. elliott.ns.cloudflare.com. TTL 79575
example.com A 104.20.23.154 172.66.147.243 TTL 167(输出从 JSON 里摘出了关键字段。)三个 TTL 差了三个数量级:顶级域的 NS 记录几乎不变,缓存两天;具体的 A 记录可能随负载均衡变动,只缓存几分钟。缓存是 DNS 撑住全球查询量的关键,一个热门名字的查询绝大多数在解析器的缓存里就结束了,根服务器很少被打扰。代价是改了记录之后,旧答案要等 TTL 过期才会在各处消失。
常见记录类型有 A、AAAA、CNAME(别名)、NS、MX。DNS 查询通常走 UDP 53 端口,一问一答各一个包,省掉了建立连接的开销;答案太大时才退回 TCP。
DNS 默认是明文的,而且路径上的任何人都能伪造回答。在写这篇的机器上,系统默认解析器的结果是这样的:
$ dig +noall +answer example.com A
example.com. 1 IN A 198.18.0.208198.18.0.0/15 是保留给网络测试的地址段,不属于任何真实网站。这台机器开着代理软件,它在本地截获 DNS 查询,返回一个假地址,再把发往这个假地址的连接转进代理隧道(所谓 fake-IP 模式)。这正好演示了 DNS 的脆弱:谁控制了你的解析路径,谁就能决定 example.com 指向哪里。DNSSEC 给记录加签名来防伪造,DoH/DoT 把查询装进加密通道来防窃听和篡改,上面的 curl 查询就是 DoH。
把一个网站的 A 记录从旧 IP 改到新 IP 后,部分用户几小时内仍然访问旧 IP。最可能的原因是什么?
DNS 是拉取式的:权威服务器不推送更新,各级缓存按记录的 TTL 保存答案,过期后才重新问。所以迁移前常把 TTL 调小。
根服务器只负责顶级域的委派,与某个网站的 A 记录无关。UDP 丢包只会导致那次查询重试,不会让旧答案存活几小时。权威服务器也没有"通知所有解析器"的机制。
串起来:开机到发出第一个包
把这些拼在一起,看笔记本连上 Wi-Fi 后访问 example.com 的前半程:
- DHCP:广播请求,拿到本机 IP
192.168.1.23/24、网关192.168.1.1、DNS 服务器地址。 - ARP:DNS 服务器不在本子网,要经过网关,于是先 ARP 拿到网关的 MAC。
- DNS:把查询
example.com A?装进 UDP,目的 IP 是 DNS 服务器,目的 MAC 是网关,发出去。家用路由器做 NAT 改写源地址,包沿着最长前缀匹配一跳跳到达解析器,解析器从缓存或逐级查询拿到答案返回。 - 发往服务器:现在有了服务器 IP。构造 TCP 段,外面套 IP 头(目的是服务器),再套以太网头(目的 MAC 仍是网关),发出去。从这里开始是传输层的故事。
没有哪一层知道整条路径,路径是每一跳的局部决策拼出来的。至于包丢了怎么办、怎么不把网络挤爆、HTTPS 怎么防窃听,交给 网络:TCP 与应用层。
记住这三件事
- 分层让每层只解决一个问题:MAC 管同一链路内的下一跳,每跳都换;IP 管端到端的目的地,一路不变(NAT 除外)。
- IP 地址可聚合,路由器只按目的地址做最长前缀匹配、TTL 减 1,尽力而为,不保证送达。
- DNS 和路由一样靠层级委派加缓存扩展到全球,代价是更新要等 TTL,而且默认不防伪造。
延伸阅读
- Stanford CS144 Introduction to Computer Networking 课程讲义与视频(cs144.github.io)
- Kurose & Ross, Computer Networking: A Top-Down Approach,第 4–6 章(网络层与链路层)
- RFC 791(IPv4)、RFC 826(ARP)、RFC 1918(私有地址)、RFC 1034/1035(DNS)
- Julia Evans, How DNS Works 漫画与
dig系列文章(jvns.ca) - Cloudflare Learning Center:What is BGP / What is DNS