OSI Model简述

OSI七层模型概述

首先我们来对整个OSI七层模型做一个整体的介绍.

数据的发送和接收过程如上图所示. 实际上, 虽然每一层和它的上下层都有直接的交互, 但是交互仅限于收数据和发数据. 在传输过程中, 每一层仅对自己应该负责的Header做处理. 所以实际上, 每次传输过程对于七层模型的每一层来说都是点对点的传输.

通过上面的图, 我们应该对七层模型的工作方式有了一定的了解. 接下来我们逐个去看每一层的职责.

物理层

物理层, 顾名思义, 负责物理意义上数据的收发. 物理层的功能有三个:

  1. 位同步(Bit Synchronization): 实际上应该是码元同步(Symbol Synchronization). 我们知道, 物理信道的负载–数字信号, 实际上是对模拟信号的采样处理. 描述一个模拟信号的数字信号单元, 叫做码元. 根据描述方式的不同, 码元的大小也不同. 通常情况下, 一个码元的大小(我们称为波特)是一个比特. 当然也有一波特等于两个比特或者更多比特的情况, 这完全取决于描述方式. 发送方按照自己的频率将模拟信号转换成数字信号, 接收方也必须要按照同样的频率将数字信号再转成模拟信号, 这就需要双方的频率相同, 这就是码元同步.

  2. 比特率控制(Bit Rate Control): 实际上应该是波特率控制(Baud Rate Control). 只同步工作频率是不够的, 收发双方的工作性能可能也有差异, 所以传输速率也要控制一下, 性能好的一方要照顾一下性能差的一方.

  3. 规定传输模式: 决定数据在信道中的流向(单工, 半双工, 全双工)

链路层

物理层的位同步和比特率控制只是提供了最简单的传输可靠性保证. 链路层是在物理层的基础上, 对数据的点对点传输做了更进一步的管理, 它的功能如下:

  1. 提供帧(Frame)的封装和拆封. 物理层的数字信号是无意义的, 只有数字信号序列才有真正的意义. 帧(Frame)的意义就是将明确一段有意义的数字信号序列的边界, 保证数字信号可以被正确分割组合. 在一条传输线路上, 每个节点处理数据的能力并不相同, 所以需要规定一帧的大小, 防止一些节点无法处理过大的帧. 这个规定的大小叫做最大传输单元(Maximum Transmission Unit). 不同的链路层协议, MTU的值也不同.

  2. 物理寻址. 计算机网络中, 根据不同的物理拓扑, 一个节点可能和多个节点相连接. 链路层需要确定数据要发个具体哪个相连的节点, 这个过程就是物理寻址. 物理地址, 就是我们所熟知的MAC地址了. 通常来说, ARP协议是物理寻址常用的协议. 它的思想也很简单. 每个主机维护一个IP地址-MAC地址的映射表. 每次寻址时先查表, 查到了话就转发; 没查到话就在局域网内广播问一下这个MAC地址是谁, 收到回答后再缓存到自己的映射表里.

  3. 差错管理. 既然有意义的数据被封装成了帧, 那就有必要保证每一帧的数据都是被正确传输的, 如果有错误的数据, 还需要重传. 通常来说, 确定帧的内容是否正确, 链路层是通过帧的校验码来检查的.

  4. 访问控制. 当一条信道同时被多个节点使用时(通常出现在总线拓扑中), 链路层还需要参与信道的复用调度流程, 明确自己什么时候可以使用信道, 什么时候不能使用信道.

我们知道, 链路层实际上分为逻辑链路层(Logical Link Control)和媒介访问控制层(Medium Access Control)层. 链路层的功能1和3是由更上一层的逻辑链路层负责的, 2和4是由相对底层的媒介访问控制层负责的.

网络层

网络层是一个过渡层, 它一方面负责软件层到硬件层的数据处理, 一方面还负责硬件层的传输规划. 网络层的功能如下:

  1. 分包和组包. 网络层需要根据使用的协议所规定的MTU, 将上层下发的报文进行分割和组装.

  2. 逻辑寻址. 网络层需要根据接收方的逻辑地址(通常就是IP地址), 决定下一步要转发给谁, 然后将目标地址下发给链路层进行物理寻址.

  3. 路由选择. 网络层有一系列的路由发现协议(比如RIP协议), 能够规划出一条通信链路, 按照这个链路进行逻辑寻址. 同时, 网络层还提供了控制协议(比如ICMP协议), 对通信链路进行诊断和分析.

传输层

传输层屏蔽了硬件层的实现, 直接对软件层负责, 确保软件层的数据能够正确发送(也即确保数据的端到端传输). 因为软件层的服务有很多, 为了辨识应该将数据呈递给哪个上层服务, 传输层引入了端口(Port)的概念.

实际上, 传输层的功能就只有一个: 提供端到端的传输服务. 端到端的传输服务又可以分为两大类: 无连接的传输和面向连接的传输, 各自的代表就是UDP和TCP了.

UDP协议

UDP协议(或者说它代表的无连接传输协议)非常简单, 只标识了端口号, 负载长度和必要的校验码. 因为简单, 所以它不能保证传输的可靠性(它没有重传机制, 网络层也没有重传机制); 同样因为简单, 它不用做什么复杂的处理, 所以它的延迟很低.

TCP协议

TCP协议(或者说它代表的面向链接的传输协议)提供了链接的特性(实际上就是高可靠性). 为了实现这个特性, 它做了很多额外的工作, 传输质量提高的同时, 传输效率也有所下降.

因为TCP是常见的面向连接的传输协议, 所以我们就详细介绍一下TCP是怎么保证可靠性的.

Seq和Ack

与UDP报文不同, TCP报文除了增加了传输层引入的端口号和传输层使用的校验码外, 还引入了各种标识字段. 最基础和最重要的两个字段是Seq和Ack.

我们知道, 网络层会将过长的数据根据MTU进行分包. 对于无连接不重传的UDP来说, 数据怎么分包都无所谓, 所以它自己干脆就不分包, 交给网络层去做了. 对于要提供可靠传输的TCP来说, 它是不希望网络层做分包的, 因为网络层没有重传功能, 分包后如果丢包了不知道丢的是哪个包. 所以为了不让网络层插手, TCP自己把数据做了切割, 一个TCP报文的负载不能超过MTU-IP报头长度-TCP报文长度. 这个限制长度叫做最大分段长度(Maximum Segment Size). 所以TCP报文实际上是不会触发IP分包的.

既然自己做了数据切割, 那么只要对每个包做一个标识, 丢包的时候就可以知道是哪个包丢了,根据自己的切割策略就可以知道要重传哪段数据了. 这个标识就是Seq.它的含义是报文负载的第一个字节在整个报文中的偏移(Seq超过字段能描述的最大值2^32-1时会从0开始). Ack则是自己收到对方上一个包的Seq+1. 通过Ack, 自己就知道对方有没有丢包, 丢的是哪个包了.

根据Seq的意义, 理论上第一个数据包的Seq应该是0. 这样做会有很大的安全隐患: 所有TCP流的第一个包都从0开始, 那每段所对应的Seq都是可以计算的, 这样TCP包很容易被伪造(也就是TCP序号预测攻击). 所以为了安全, 第一个数据包的Seq是一个随机的值.

链接的建立和释放

TCP链接的建立和释放, 就是老生常谈的三次握手和四次挥手了.

上图是三次挥手的流程:

  1. 客户端先发送SYN包, Seq是一个随机的数字A

  2. 服务端回复一个SYN-ACK包, Seq是一个随机的数字B, Ack是A+1

  3. 客户端再回复一个ACK包, Seq是A+1, Ack是B+1

服务端在启动服务时创建了两个队列: SYN队列和ACCEPT队列. 当服务端收到来自客户端的SYN时, 将SYN放入SYN队列中, 同时回复SYN-ACK. 当收到客户端的ACK时, 将对应的SYN包从SYN队列中取出并放入ACCEPT队列中.

为什么要设计这两个队列呢? 第一个原因是, 通过指定队列长度, 可以限制服务端建立的连接数, 起到一定的管理作用. 第二个原因是, 队列存放二次握手和三次握手的结果, 在一些边界场景下会用到. 比如:

  1. 客户端在发送SYN后停止. 服务端发送了SYN-ACK, 但是没有收到ACK, 这时会触发重连机制: 服务端每隔1, 2, 4, 8, 16, 32秒会重发一次SYN-ACK, 如果都没有回应, 那么服务端就会断开这个连接, 避免浪费资源.

  2. 当收到客户端的ACK时, 服务端正在阻塞中, 不能即使处理. 当服务端从阻塞中恢复后, 可以从ACCEPT队列中找到没有来得及处理的请求来弥补.

上面是四次挥手的流程. 因为TCP链接是全双工的, 所以每一侧的数据传输有需要一对SYN-ACK来关闭. 链接关闭后有一定的冷却时间, 这个时间内链接所使用的端口是不能被重新启用的, 这个时间被叫做最长报文段寿命(Maximum Segment Lifetime), 我们可以类比成IP报文的TTL.

传输可靠性保证

TCP通过三大类功能来提供传输的可靠性保证: 重传, 流量控制和拥塞控制.

重传分为两种情况: 超时重传和累计确认重传.

超时重传指发送方发送了一个TCP包, 在规定的时间内没有收到ACK时会重发. 这个规定时间被叫做超时重传时间(Retransmission Timeout). RTO不是一个固定的值, 它会根据网络状态实时计算更新.

累计确认重传的实现基础是Ack字段. 当发送方连续收到三个ACK中Ack的值都是一样的时候(连续收到三个才触发是为了避免网络延迟, 接收方其实收到了但是比较晚的情况), 就认为Ack后面的包丢包了, 这时会重新发送后面的包.

累计确认重传的效率很低, 比如接收方只丢了第3个包, 后面到包都收到了, 按照这个逻辑, 发送方会重发从第三个包开始所有的包. 所以累计确认重传的升级版: 选择确认重传(SACK)诞生了. SACK实际上是一个可选的字段, 目的是告诉发送方已经收到了的序号. 这样发送方就可以只重发没有收到的包了.

TCP的流量控制策略, 就是老生常谈的滑动窗口了. 关于滑动窗口的具体实现, 这里不多做介绍. 这里只用几句话来简单说明滑动窗口的思路:

  1. 滑动窗口的基础仍然是Seq和Ack字段

  2. 所谓的滑动窗口, 实际上是双方约定好的接收缓冲区

  3. 一个具体的例子. 双方约定的接收缓冲区大小是10个字节. 发送方发送了两个负载为4个字节的包, Seq分别是1和5, 但是只收到了Ack为5的包. 这说明第二个包可能还在缓冲区里(当然也有可能丢了), 那么现在缓冲区一定会剩下的空间就是10-4=6字节. 那么下一个包的负载不能超过6个字节, 不然缓冲区可能就溢出了.

  4. 通过3中举的例子, 当缓冲区剩余空间可能为0时, 发送方就不能再发包了. 这时发送方会定时询问接收方现在缓冲区空出来没有, 如果空出来的话就根据最新的剩余空间继续发包

TCP的拥塞控制主要有三个策略: 慢启动, 拥塞避免和快速恢复.

慢启动, 指发送方的拥塞窗口(可以简单理解为发送窗口的最大值)是从小到大缓慢增长的. 最开始窗口的大小是1, 每成功发送一次, 窗口的大小就翻倍. 当窗口超过一个值(这个值被叫做ssthresh)时, 进入拥塞避免策略.

拥塞避免, 指每成功发送一次, 拥塞窗口大小加一, 直到发送失败. 失败时更新ssthresh为当前拥塞窗口大小的一半, 同时将拥塞窗口大小置为1, 进入慢启动策略.

上面提到的发送成功定义为: 在规定的时间内收到ACK. 这个规定的时间被叫做往返时间(Round Trip Time). RTT是根据网络状态动态采样计算的, 它是RTO计算的重要参考.

当发送失败时, 超时重传机制就会触发. 如果在规定的时间内, 重传数据得到了ACK, 那么拥塞窗口会恢复成发送失败时的大小, 这个策略就是快速恢复.

会话层

会话层的职责是管理应用层视角的链接. 它的作用如下:

  1. 创建链接. 包括将会话层的地址映射成传输层的地址, 选择和协商链接使用的一些参数, 标识和管理各个链接等

  2. 数据传输. 即将应用层的数据–我们称为用户数据单元(Session Service Data Unit)转换成链接使用的协议所支持的数据–我们称为协议数据单元(Session Protocol Data Unit), 并进行传输. 当然, 还要提供SPDU回转成SSDU的能力.

  3. 释放链接. 完成一些收尾工作

远程程序调用(Remote Procedure Call)是会话层的一个典型应用, 它的工作流程如下:

  1. 客户端调用Client Stub, 传递参数

  2. Client Stub将参数转成特定格式(XML, JSON, 二进制序列等)

  3. 通过系统调用, 发送至服务端(通过TCP, UDP或者像gRPC一样使用HTTP)

  4. 服务端将收到的参数传递给Server Stub

  5. Server Stub解析参数

  6. Server Stub调用对应的程序, 返回运行结果

展示层

展示层功能是对应用层数据进行编(解)码, 加(解)密和压缩(解压缩).

加密和压缩我们都不难理解, 这里解释一下编码. 所谓的编码, 指的是将数据转化成计算机通用的公共数据. 比如Windows使用的ANSI编码, Mac使用的是UTF-8编码. Windows和Mac通信, 就需要采用一种公共的语言, 双方将自己使用的编码翻译成公共语言, 再让对方根据公共语言翻译成对方使用的编码. 所以, 我们可以把展示层理解为一种计算机之间使用的编译器.

SSL(TLS)协议

在介绍SSL(TLS)协议之前, 要说明的是, 关于SSL协议的归属, 有很多种不同的观点. 这里将SSL协议划分到展示层只是我个人的观点.

SSL协议(或者叫做TLS协议)是一种数据加密协议, 我们熟知的HTTPS就是HTTP+SSL.

在介绍SSL协议之前, 我们先了解一下对称加密和非对称加密这两个概念.

我们知道, 明文消息的安全性是非常差的, 一旦被别人拦截到, 消息的内容就一览无余了. 为了提高消息的安全性, 我们常常会对消息内容做一次映射, 这个映射过程就叫做加密, 映射的结果我们叫做密文. 密文可以经过映射再转成明文, 这个映射我们叫做解密. 加密和解密都需要提供参数(如果没有参数, 即只要密文和解密方法对得上就可以解密, 那只要暴力枚举解密映射就可以了, 这显然也是不安全的), 这个参数我们叫做加密密钥和解密密钥. 一些加密算法, 加密和解密密钥都是同一个, 这样的加密我们叫做对称加密; 相反, 加密密钥和解密密钥不是同一个的, 我们叫做非对称加密.

在明确了对称加密和非对称加密的概念后, 我们来思考. 如果通信过程中双方只使用对称加密, 那么就会面临一个难题: 通信开始时, 双方需要交换密钥, 但是因为此时双方又没有彼此的密钥, 所以密钥这条消息本身无法加密. 而使用非对称加密时, 这个难题就不存在了: 双方可以只交换加密密钥, 自己留着解密密钥. 这样即使加密密钥被拦截了, 也不会影响消息的安全性, 因为解密密钥始终只有自己知道.

既然非对称加密这么好, 那对称加密还有存在的必要吗? 答案是有的. 因为为了实现加密和解密密钥不相同的效果, 非对称加密的算法非常复杂, 解密的时间很长. 所以为了性能, 最好的方式是二者结合用. 即使用非对称加密来交换对称加密的密钥, 完成密钥交换后, 使用对称加密来加密消息. 像下图这样:

但是, 这种模式也有不安全的地方. 像下图这样, 当B给A发送加密密钥时, 中间人C可以将其拦截, 然后告诉A自己的加密密钥, 同时扮演A给B发送自己的对称加密密钥. 这样A和B就都以为自己正在和对方通信了.

为了解决这个问题, B想了一个办法. 如下图, 它找了一个大家都认可的好人D, 从D那里要了一个加密密钥和一个解密密钥. 为了防止D的密钥被别人伪造, D给加密密钥上印了自己给密钥的编号和自己的签名, 相当于防伪标志, 这样就没有人能伪造了. A收到了这个密钥后, 看到这个密钥上刻着D的名字, 因为D是大家公认的好人, 所以A就可以放心使用这个密钥来加密了.

这就是SSL的工作原理. 好人D就是CA机构, 加密密钥上的编号就是数字证书, 签名就是数字签名. 我们再把这个流程细化一下:

  1. 首先客户端给服务端发送了一个Client Hello消息, 这个消息包括了客户端支持的所有SSL协议版本和加密算法, 同时还包括了一个随机生成的字符串Random1.

  2. 服务端返回一个Server Hello消息, 消息包括服务端选择的SSL协议版本, 加密算法, 数字证书和随机生成的字符串Random2.

  3. 客户端验证数字证书, 然后使用证书提供的公钥Key1加密一个随机生成的字符串Random3. 将密文发送给服务端.

  4. 经过前三步后, 客户端和服务端同时拥有了Random1, Random2和Random3. 用这三个字符串生成对称加密的密钥Key2.

  5. 客户端发送用Key2加密的消息:Finished

  6. 服务端收到后回应同样的消息, 加密信道成功建立

步骤3中提到了客户端会验证数字证书. 那么如何保证数字证书的正确性呢?

每个数字证书都会标明颁发的机构, 证书使用的公钥(即加密密钥)和唯一的证书编号. 为了防止证书被篡改, 每个证书还会有对应的数字签名. 数字签名的制作和验证步骤如下:

  1. 对数字证书做哈希, 得到一个哈希串

  2. 证书使用的解密密钥对哈希串进行非对称加密, 这样数字签名就做好了

  3. 因为证书使用的解密密钥是不公开的, 所以这个签名是不能被伪造的

  4. 校验证书时, 先用证书公开的加密密钥对签名做解密, 得到了正确的哈希串

  5. 然后对证书内容再做哈希, 得到实际的哈希串

  6. 比较两个哈希串, 如果相同, 那么意味着证书没有被修改过

应用层

应用层是对直接面向应用程序的层次, 提供了各种各样的网络服务. 比较有代表性的协议有DNS, HTTP, RTP等. DNS过于老生常谈, 这里就不介绍了. RTP协议, 严格来说是一个协议簇, 其具体实现非常复杂, 后面有机会我们单独写一篇文章来学习, 这里就只介绍一些最常见的HTTP协议.

HTTP协议请求方式

HTTP提供了多种种请求方式, 这里简单介绍几种常用的:

  1. Get方式. 最古老的请求方式. Get方式的请求中, 不能携带参数. 如果要提供参数, 只能放在请求的Url末尾. 因此, Get是一种不太安全的请求方式

  2. Head方式. Head请求的响应只有头部, 没有内容. 这种请求通常用来获取资源的元信息. 比如在下载一个资源前显示资源的大小, 通常就使用Head来完成.

  3. Post方式. 最常见的请求方式. 请求拥有请求体, 可以携带参数. 参数的类型通过ContentType来标识, 常见的有text/plain, application/json, application/octet-stream, multipart/form-data等.

  4. Put方式. 与Post方式基本相同. HTTP规范里对二者的区分是: 如果一个操作是幂等的(即不管操作多少次, 作用都是相同的)就是用Put, 否则就使用Post. 比如创建一个用户. 如果使用两次Post, 那么就应该创建两个相同的用户; 如果使用Put, 那么就只会有一个用户被创建(当然规范是规范, 目前我看到的都是无脑Post的).

  5. Patch方式. 用来更新资源的请求. 虽然Put和也可以可以用来更新资源, 但是Patch方式更节约带宽. Patch方式只需要传需要更新的字段就可以, Put必须要传更新后所有字段的信息, 如果有字段留空, 那就相当于将该字段清空.

  6. Delete方式. 顾名思义, 用来删除url对应的资源的.

HTTP响应码

每个HTTP响应的第一行都是状态行, 写有HTTP协议版本, 响应状态码和对应的响应信息. 一般来说, 响应码分为五大类:

  1. 1xx: 请求已被服务器接收, 正在处理. 一般情况下, 1xx响应码是不会被返回的.

  2. 2xx: 请求已被服务器接收, 理解和处理.

  3. 3xx: 需要后续操作才能完成请求.

  4. 4xx: 请求发生错误.

  5. 5xx: 服务器错误

鉴于1xx消息基本不会出现, 我们可以简单记成: 响应码越大, 事态越严重.

HTTP的演进

到现在为止, HTTP经过了0.9, 1.0, 1.1和2.0四个版本, HTTP的3.0版本也即将推出.

HTTP/0.9是最古老的一个版本, 现在基本废弃了. HTTP/0.9中没有版本号的概念(毕竟那时候只有它一个), 它只能支持Get请求一种.

HTTP/1.0提供了更多的请求方式, 但是它的性能不高. 原因有两点:

  1. HTTP/1.0把自己定义为无状态和无连接的传输协议, 即每一次请求结束后都要断开TCP连接. 这使得TCP连接的握手和挥手流程频繁出现, 浪费了很多时间.

  2. HTTP/1.0规定, 下一个请求必须要等前一个请求响应到达后才能发送. 如果响应一直不到, 那整个请求队列就会被阻塞.

HTTP/1.1针对HTTP/1.0的缺点做了优化:

  1. 设置Connection字段, 可以将其设置成keep-alive来保持TCP连接. 当确定关闭连接时, 在请求头中将Connection设置为close来关闭连接.

  2. 允许同时创建多个TCP连接. 这样就可以给每个HTTP请求都开一个TCP连接了(只要没有队伍, 就不会排队).

除了上述优化外, HTTP/1.1还支持了缓存和断点续传. 同时还新增了Host字段, 使得一个服务器可以创建多个Web站点(一个IP可以对应多个域名, HTTP通过指定域名来区分使用服务器上的哪个Web站点).

HTTP/2.0最大的特性就是流传输. 在HTTP/2.0中, 一个TCP连接里可以存在若干个流. 每个流可以传输若干个消息. 每个消息由若干个帧组成. 可以简单理解为, HTTP/2.0就是在应用层又造了一个传输层, 网络层和链路层. 一个流对应一个TCP连接, 一个消息对应一个IP包, 一个帧对应链路层的帧.

流传输带来的特性有三个: TCP连接可以真正做到一个连接被多个请求复用, 所以性能比HTTP/1.1要好(但是同样因为复用了TCP连接, 阻塞的情况又有可能出现了); TCP中的帧是以二进制而并非HTTP/1.1中的明文形式传输的, 安全性更好; 由于流是可以一直存在的(因为TCP连接可以一直保持), 流的传输模式又是双工的, 服务端可以主动向客户端推送数据, 不再需要被动等待客户端发送请求了.

HTTP/3.0已经在2018年得到了实验性的验证, 但是鉴于目前连HTTP/2.0都没有被普遍支持, HTTP/3.0的正式落地可能还要等一些时间.

与以前的版本相比, HTTP/3.0最大的特色是: 它使用的是UDP而非TCP. 这似乎与我们的固有印象大相径庭: UDP是不可靠的传输, 怎么能保证可靠的会话呢? 答案是: TCP的事情UDP没做, 那就让HTTP/3.0来做.

基于UDP的特色, 让HTTP/3.0有了下面的特点:

  1. 彻底解决了复用连接的问题(毕竟已经没有连接了).

  2. 性能进一步提升(毕竟已经没有连接了).

  3. 舍弃TCP四元组标识会话的机制(毕竟已经没有连接了), 使用64位随机数来作为会话标识, 这样的好处在于, 服务端可以随时迁移(这个机器不想干了, 直接扔给别的机器干).

  4. 为了实现可靠传输, TCP的协议头非常复杂, 但是并没有进行任何加密和认证机制. HTTP/3.0在UDP的基础上实现了可靠传输, 因为是在应用层做的, 所以相关字段都可以被加密, 所以安全性更好.

从这四个特点来看, HTTP/3.0还值得期待的. 因为HTTP/3.0目前还没有真正落地, 其实现可能还有变化, 所以不太好做介绍, 这里就先不多着墨了.