当我们打开一个网页,浏览器到底经历了什么

首先我们看一次完整的网络请求(经典面试题):

  1. DNS 解析(应用层):浏览器要访问 example.com,先问 DNS:“它的 IP 是多少?” DNS 返回如 93.184.216.34。(DNS 查询本身通常走 UDP。)
  2. 建立 TCP 连接(传输层):浏览器和服务器的 443 端口做 TCP 三次握手,建立可靠连接。
  3. TLS 握手(应用层/安全):HTTPS 还要再做 TLS 握手,协商加密密钥(后面 TLS 篇细讲)。
  4. 发送 HTTP 请求(应用层):浏览器构造 HTTP 请求报文,交给 TCP。
  5. 一路封装下行:HTTP 报文 → 加 TCP 头 → 加 IP 头 → 加以太网帧头 → 变比特流,从网卡发出。
  6. 跨互联网路由(网络层):数据包经过家里的路由器、运营商、一个个骨干路由器接力转发,每一跳链路层的 MAC 地址都在变(下一跳变了),但 IP 头里的最终目标 IP 始终不变——这是网络层和链路层分工的绝佳体现。
  7. 服务器解封装上行:服务器网卡收到比特流 → 链路层拆帧 → 网络层确认目标 IP 是自己 → 传输层按端口 443 交给 web 服务 → 应用层拿到 HTTP 请求。
  8. 响应原路返回,浏览器渲染页面。

当我们在浏览器敲一个网址、Web 服务收到一个请求、区块链节点之间同步一个区块——这些看起来完全不同的事,底层走的是同一套分层协议。理解"数据是怎么一层层被包起来、发出去、再一层层拆开"的,是理解一切网络问题(为什么慢、为什么断、为什么要握手)的前提。今天我们要理解的是网络分层。


为什么要分层

网络通信极其复杂:要把数据切成小块、要在茫茫互联网里找到对方、要保证不丢不乱、要处理不同硬件……如果把这些揉成一坨代码,没人维护得了。

分层的核心思想是"分而治之 + 各司其职": 把复杂问题拆成若干层,每一层只解决一个明确的子问题,并且只依赖下一层提供的服务、只给上一层提供服务。层与层之间通过约定好的接口交互,内部怎么实现互不干涉。

这带来两个关键好处:

  • 解耦:传输层只管"可靠地把数据送到对面的某个程序",它不关心底层是光纤还是 WiFi;网络层换了 IPv4 还是 IPv6,传输层的 TCP 代码一行都不用改。
  • 可替换:同一层可以有多种协议并存(传输层有 TCP 也有 UDP),上层按需选用。在物理层面,有个很经典的例子就是“光进铜退”。

一个绝佳的类比是寄快递:你(应用层)只管写好信、填好收件人;快递公司(下面各层)负责分拣、运输、投递。你不需要懂货车怎么开、航线怎么规划——这就是分层。


两套模型:OSI 七层 vs TCP/IP 四层

历史上有两套分层模型。OSI 七层是理论上的"标准参考模型",分得很细,主要用于教学和讨论;TCP/IP 四层是实际互联网真正跑的模型。两者对照如下:

   OSI 七层(理论参考)          TCP/IP 四层(实际使用)        典型协议/数据单元
 ┌──────────────────┐        ┌──────────────────┐
 │ 7 应用层          │        │                  │       HTTP/HTTPS、DNS、
 │ 6 表示层          │───────▶│   应用层          │       WebSocket、gRPC
 │ 5 会话层          │        │                  │       (数据单元:报文 message)
 ├──────────────────┤        ├──────────────────┤
 │ 4 传输层          │───────▶│   传输层          │       TCP / UDP
 │                  │        │                  │       (段 segment / 数据报 datagram)
 ├──────────────────┤        ├──────────────────┤
 │ 3 网络层          │───────▶│   网络层(网际层)   │       IP、ICMP、路由
 │                  │        │                  │       (数据单元:包/分组 packet)
 ├──────────────────┤        ├──────────────────┤
 │ 2 数据链路层       │        │                  │       以太网、WiFi、ARP
 │ 1 物理层          │───────▶│   网络接口层       │       (帧 frame → 比特 bit)
 └──────────────────┘        └──────────────────┘

记住这个对应关系: TCP/IP 把 OSI 的会话/表示/应用三层合并成"应用层",把物理/数据链路合并成"网络接口层"。日常工程里,大家说话常常混用——有时用 OSI 的"七层"“四层"术语(如"四层负载均衡"指传输层、“七层负载均衡"指应用层),有时用 TCP/IP 的划分。两套都要能听懂。

有些教材用 TCP/IP 五层模型(把网络接口层再拆回"数据链路层 + 物理层”),更便于教学。本质一样,不必纠结层数,关键是理解每一层干什么。


每一层到底负责什么

自底向上过一遍,建立"这件事归哪一层管"的直觉:

网络接口层(链路层 + 物理层)

  • 物理层:最底层,负责把 0/1 比特变成实际的电信号、光信号、电磁波,在网线/光纤/空气里传输。它只关心"怎么把一个 bit 发出去”。
  • 数据链路层:负责在同一个局域网内、相邻两个设备之间传输数据帧。它用 MAC 地址(网卡的物理地址,如 a4:83:e7:...)标识设备。以太网、WiFi 都是这一层的协议。ARP 协议在这层负责"已知对方 IP,查出它的 MAC 地址"。

关键点:链路层只管"这一跳"——从你的电脑到路由器、从这个路由器到下一个路由器。它不知道全局路线。

网络层(网际层)——互联网的核心

负责把数据包从源主机跨越整个互联网送到目标主机,核心是 IP 协议。它解决两个问题:

  • 寻址:用 IP 地址(如 192.168.1.10、2001:db8::1)在全球范围唯一标识一台主机。
  • 路由:数据包经过一个个路由器接力转发,每个路由器根据目标 IP 查路由表,决定"下一跳"往哪送。

IP 协议有个重要特性:它是"尽力而为(best-effort)、不可靠、无连接"的。

用寄信来理解:IP 相当于最便宜的平邮——邮局尽力送,但不挂号、不签收、不告诉你到没到。你连着寄三封信,可能第二封丢了,也可能第三封比第一封先到(走了不同的路线)。

所以 IP 不保证送到、不保证顺序、不保证不重复。包可能丢、可能乱序、可能重复。

那为什么不干脆把 IP 设计得可靠一点?因为可靠是有代价的(要确认、要重传、要缓存、要排序),而不是所有场景都需要——视频通话丢一帧无所谓,你为它做重传反而更卡。IP 的设计哲学是把"可靠"这个可选项留给上层自己决定:要可靠就用 TCP,不要就用 UDP。

“让不可靠的 IP 变得可靠"这件事,正是 TCP 存在的全部理由。

传输层——为"进程"服务

网络层把包送到了目标主机,但一台主机上跑着几十个程序(浏览器、微信、你的 Go 服务),数据到底给哪个?

这就像快递送到了一栋写字楼——楼是找对了,可楼里有几十家公司,这个包裹给谁?光有楼的地址不够,还得有房间号。

传输层用**端口号(port)**充当这个房间号,解决这"最后一公里”:

  • 端口是一个 16 位的数(0~65535),标识主机上的一个具体程序。HTTP 默认 80、HTTPS 443、你的服务可能监听 8080。IP 地址 + 端口 唯一确定"哪台机器上的哪个程序",这就是 socket。

传输层有两个协议,是重点:

  • TCP:面向连接、可靠、有序、有流量/拥塞控制。要先"握手"建连接,之后保证数据不丢不乱。代价是慢一点、开销大一点。适合网页、文件传输、大多数场景。
  • UDP:无连接、不可靠、但快、开销小。发了就不管。适合实时音视频、游戏、DNS 查询——偶尔丢一两个包也没关系,但不能忍受延迟。

应用层——直接面向用户和程序

最上层,规定应用程序之间怎么交换数据的"语言"(协议格式)。你写业务代码打交道的就是这一层:

  • HTTP/HTTPS:网页、REST API、大部分后端服务。
  • DNS:把域名(google.com)翻译成 IP 地址。
  • WebSocket:全双工长连接,做实时推送(区块链节点订阅事件常用)。
  • gRPC:基于 HTTP/2 的 RPC 框架,微服务间通信。

应用层不关心数据怎么可靠传输——那是 TCP 的活。它只定义"报文长什么样、字段什么含义"。


数据是怎么一层层被包起来的

这是整套模型最核心的机制,理解了它,前面讲的"分层"就从抽象概念变成了具体动作。

还是用寄快递来想:你写好一封信(应用层数据),装进信封写上收件人(加一层头),信封放进快递袋贴上运单(再加一层头),快递袋装进货车的集装箱贴上路线标签(又一层头)。每一层都在原来的东西外面再套一层、贴上自己需要的信息,而里面那层的内容它压根不看。

对方收到时反过来:卸集装箱、拆快递袋、拆信封、读信。每一层只拆自己贴的那张标签,拆完把里面的东西原样交给上一层。

术语上,数据从应用层往下走,每经过一层就被加上该层的"头部(header)"(有的还加尾部),这叫封装(encapsulation);到了对面从底层往上,每层拆掉自己那层的头部,这叫解封装(decapsulation)。

以你的 Go 程序发一个 HTTP 请求为例,数据下行时:

应用层:   [ HTTP 报文 ]                                    ← 你写的请求内容
              │  加上 TCP 头(源端口、目标端口、序号、确认号…)
传输层:   [ TCP头 | HTTP 报文 ]                    ← 叫"段 segment"
              │  加上 IP 头(源 IP、目标 IP、TTL…)
网络层:   [ IP头 | TCP头 | HTTP 报文 ]             ← 叫"包 packet"
              │  加上以太网头(源 MAC、目标 MAC)+ 尾(校验 FCS)
链路层:   [ 帧头 | IP头 | TCP头 | HTTP 报文 | 帧尾 ]  ← 叫"帧 frame"
              │  转成比特流
物理层:   0101110100101...                                 ← 在网线上跑

对方收到后反过来,一层层拆头、校验、上交:链路层拆掉帧头交给网络层,网络层看 IP 头确认是给自己的、拆掉交给传输层,传输层看端口交给对应程序,应用层最终拿到原始 HTTP 报文。

理解这个过程,很多问题瞬间通透:

  • 每层的"头部"就是该层协议工作所需的控制信息。想知道 TCP 怎么保证可靠?去看 TCP 头里有什么字段(序号、确认号、窗口)——这正是下一篇的内容。
  • 为什么每层数据单元有不同名字(段/包/帧)?因为它们是同一份数据在不同层被包裹后的不同形态。
  • 抓包工具(Wireshark)展开一个数据包时,你看到的正是这一层层嵌套的头部。

MTU 与分片

链路层一帧能装的数据有上限,叫 MTU(最大传输单元),以太网通常是 1500 字节。这就像隧道限高:货再多,一车也只能装这么高,超了过不去。

如果一个 IP 包太大装不下,网络层会把它**分片(fragmentation)**成多个小包分别传输,到目的地再重组。

分片听着像个贴心的兜底,实际上是能避则避的事,原因在于:分片后的若干片必须全部到齐才能重组,丢了任意一片,整个原始包就废了,得整个重传。也就是说,分片把"丢一个包"的风险放大成了"丢一片就全丢"。所以 TCP 会主动探测路径 MTU、控制每段大小(MSS)来尽量避免分片——与其让下层帮你切,不如自己按合适的尺寸发。

封装可以"递归":隧道与 VPN

这里点破一个很多人漏掉、但极其重要的延伸:上面讲的封装是"每层加自己的头",但封装是可以嵌套的——你完全可以把"一个已经封装好的完整 IP 包",整个当作数据,再塞进另一个 IP 包里发出去。这个操作叫隧道(tunneling),是 VPN、云数据中心网络(VXLAN)、区块链 P2P 覆盖网络、Tor 匿名网络的共同底层原理。


网络字节序:大小端问题(工程必踩)

这个问题的本质,和日期写法不统一一模一样:同样是 08/09,美国人读成 8 月 9 日,欧洲人读成 9 月 8 日。数字本身没错,错的是双方对"哪一位在前"的约定不一致。

多字节数据(比如一个 4 字节的整数、端口号)在内存里也有两种存放顺序:

  • 大端(Big-Endian):高位字节放在低地址(“高位在前”,和我们写数字的习惯一致)。
  • 小端(Little-Endian):低位字节放在低地址(x86/ARM 常用,反着来)。

问题来了:如果发送方是小端机、接收方是大端机,同一串字节解释出来的数就完全不是一回事了。为避免混乱,网络协议规定统一用大端序传输,称为"网络字节序(network byte order)"。所以往网络里写多字节数值前,要从"主机字节序"转成"网络字节序"(C 语言的 htons/htonl 就干这个)。

Go 里用 encoding/binary 显式指定字节序,收发协议数据时务必用 BigEndian:

import "encoding/binary"

buf := make([]byte, 2)
binary.BigEndian.PutUint16(buf, 8080) // 端口 8080 按网络字节序写入
port := binary.BigEndian.Uint16(buf)  // 读回

为什么单独强调它? 因为这是自己实现协议、解析数据包、以及读区块链链上数据(交易、区块的二进制编码)时最容易踩的坑——字节序搞反,解析出来的数值完全错误且不报错。你路线图里"大小端 Endianness"那一项,根源就在这里。NEAR 的 Borsh、Solana 的 Borsh使用的就是小端。

谁用大端、谁用小端:真实世界点名

“网络字节序 = 大端"只是网络协议这一家的约定,出了网络协议的地盘,大小端各占半壁江山,而且区块链里两种都有——读链上数据前必须先查清楚这条链用哪种:

阵营例子
大端TCP/IP 全家(IP 头、端口、DNS、TLS、WebSocket 长度字段);以太坊执行层(RLP 编码、EVM 的 256 位字、Solidity ABI);PNG/JPEG;Java class 文件;Git pack 文件;CBOR、MessagePack
小端x86/ARM CPU 本身;NEAR 的 Borsh(规范原文:“integers are little endian”);以太坊共识层的 SSZ;Solana(Bincode/Borsh);Bitcoin 线格式(金额、版本号、区块头字段);Protobuf(fixed32/64,varint 也是低位组在前);WASM 内存;ZIP/GIF/BMP

三个值得记住的细节:

  • 同一条以太坊,两种字节序并存:执行层 RLP 是大端,共识层 SSZ 却是小端。SSZ 选小端是故意的——现代 CPU 全是小端,哈希和反序列化时不用翻字节,直接 memcpy,快。
  • Bitcoin 的显示陷阱:线格式里整数是小端,而区块浏览器上看到的交易哈希/区块哈希是把字节反序显示的。拿浏览器上的 txid 去比对原始字节对不上,就是这个原因,初学者解析比特币裸数据的头号大坑。
  • 规律:诞生于网络协议传统的格式用大端(高位在前,和人写数字的习惯一致,抓包肉眼可读);性能优先、为现代 CPU 设计的新格式偏小端(序列化零翻转)。区块链里的分水岭大致是:老一代/以太坊执行层 = 大端,新一代高性能链 = 小端。

从 Go 的视角看这些层

写 Go 网络程序时,标准库 net 包帮你屏蔽了大部分底层。理解它对应到哪一层,能让你知道"操作系统帮你做了什么”:

// 传输层:建立一个 TCP 连接(三次握手由操作系统内核完成)
conn, err := net.Dial("tcp", "example.com:80")
// 你往 conn 写字节 → 内核 TCP 栈负责分段、加序号、可靠传输
// 再往下的 IP 封装、路由、以太网帧,全由操作系统和网卡处理,你看不见

// 应用层:net/http 在 TCP 之上实现了 HTTP 协议
resp, err := http.Get("https://example.com") // 内部完成 DNS + TCP + TLS + HTTP

分层的价值在这里体现得淋漓尽致: 你调用 http.Get,只在应用层写了一行,底下的 DNS 解析、TCP 握手、IP 路由、字节序转换,全被下面各层封装好了。你只有理解了每层在干什么,才能在出问题时(连不上?超时?慢?)判断是哪一层的锅。

  • 连接建立慢 → 可能是 DNS(应用层)或 TCP 握手(传输层)或网络路由(网络层)。
  • 数据传到一半断了 → 传输层连接问题。
  • 请求发出去了但对方 500 → 应用层(HTTP)问题。