OSI Model简述
OSI七层模型概述
首先我们来对整个OSI七层模型做一个整体的介绍.
数据的发送和接收过程如上图所示. 实际上, 虽然每一层和它的上下层都有直接的交互, 但是交互仅限于收数据和发数据. 在传输过程中, 每一层仅对自己应该负责的Header做处理. 所以实际上, 每次传输过程对于七层模型的每一层来说都是点对点的传输.
通过上面的图, 我们应该对七层模型的工作方式有了一定的了解. 接下来我们逐个去看每一层的职责.
物理层
物理层, 顾名思义, 负责物理意义上数据的收发. 物理层的功能有三个:
位同步(Bit Synchronization): 实际上应该是码元同步(Symbol Synchronization). 我们知道, 物理信道的负载–数字信号, 实际上是对模拟信号的采样处理. 描述一个模拟信号的数字信号单元, 叫做码元. 根据描述方式的不同, 码元的大小也不同. 通常情况下, 一个码元的大小(我们称为波特)是一个比特. 当然也有一波特等于两个比特或者更多比特的情况, 这完全取决于描述方式. 发送方按照自己的频率将模拟信号转换成数字信号, 接收方也必须要按照同样的频率将数字信号再转成模拟信号, 这就需要双方的频率相同, 这就是码元同步.
比特率控制(Bit Rate Control): 实际上应该是波特率控制(Baud Rate Control). 只同步工作频率是不够的, 收发双方的工作性能可能也有差异, 所以传输速率也要控制一下, 性能好的一方要照顾一下性能差的一方.
规定传输模式: 决定数据在信道中的流向(单工, 半双工, 全双工)
链路层
物理层的位同步和比特率控制只是提供了最简单的传输可靠性保证. 链路层是在物理层的基础上, 对数据的点对点传输做了更进一步的管理, 它的功能如下:
提供帧(Frame)的封装和拆封. 物理层的数字信号是无意义的, 只有数字信号序列才有真正的意义. 帧(Frame)的意义就是将明确一段有意义的数字信号序列的边界, 保证数字信号可以被正确分割组合. 在一条传输线路上, 每个节点处理数据的能力并不相同, 所以需要规定一帧的大小, 防止一些节点无法处理过大的帧. 这个规定的大小叫做最大传输单元(Maximum Transmission Unit). 不同的链路层协议, MTU的值也不同.
物理寻址. 计算机网络中, 根据不同的物理拓扑, 一个节点可能和多个节点相连接. 链路层需要确定数据要发个具体哪个相连的节点, 这个过程就是物理寻址. 物理地址, 就是我们所熟知的MAC地址了. 通常来说, ARP协议是物理寻址常用的协议. 它的思想也很简单. 每个主机维护一个IP地址-MAC地址的映射表. 每次寻址时先查表, 查到了话就转发; 没查到话就在局域网内广播问一下这个MAC地址是谁, 收到回答后再缓存到自己的映射表里.
差错管理. 既然有意义的数据被封装成了帧, 那就有必要保证每一帧的数据都是被正确传输的, 如果有错误的数据, 还需要重传. 通常来说, 确定帧的内容是否正确, 链路层是通过帧的校验码来检查的.
访问控制. 当一条信道同时被多个节点使用时(通常出现在总线拓扑中), 链路层还需要参与信道的复用调度流程, 明确自己什么时候可以使用信道, 什么时候不能使用信道.
我们知道, 链路层实际上分为逻辑链路层(Logical Link Control)和媒介访问控制层(Medium Access Control)层. 链路层的功能1和3是由更上一层的逻辑链路层负责的, 2和4是由相对底层的媒介访问控制层负责的.
网络层
网络层是一个过渡层, 它一方面负责软件层到硬件层的数据处理, 一方面还负责硬件层的传输规划. 网络层的功能如下:
分包和组包. 网络层需要根据使用的协议所规定的MTU, 将上层下发的报文进行分割和组装.
逻辑寻址. 网络层需要根据接收方的逻辑地址(通常就是IP地址), 决定下一步要转发给谁, 然后将目标地址下发给链路层进行物理寻址.
路由选择. 网络层有一系列的路由发现协议(比如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链接的建立和释放, 就是老生常谈的三次握手和四次挥手了.
上图是三次挥手的流程:
客户端先发送SYN包, Seq是一个随机的数字A
服务端回复一个SYN-ACK包, Seq是一个随机的数字B, Ack是A+1
客户端再回复一个ACK包, Seq是A+1, Ack是B+1
服务端在启动服务时创建了两个队列: SYN队列和ACCEPT队列. 当服务端收到来自客户端的SYN时, 将SYN放入SYN队列中, 同时回复SYN-ACK. 当收到客户端的ACK时, 将对应的SYN包从SYN队列中取出并放入ACCEPT队列中.
为什么要设计这两个队列呢? 第一个原因是, 通过指定队列长度, 可以限制服务端建立的连接数, 起到一定的管理作用. 第二个原因是, 队列存放二次握手和三次握手的结果, 在一些边界场景下会用到. 比如:
客户端在发送SYN后停止. 服务端发送了SYN-ACK, 但是没有收到ACK, 这时会触发重连机制: 服务端每隔1, 2, 4, 8, 16, 32秒会重发一次SYN-ACK, 如果都没有回应, 那么服务端就会断开这个连接, 避免浪费资源.
当收到客户端的ACK时, 服务端正在阻塞中, 不能即使处理. 当服务端从阻塞中恢复后, 可以从ACCEPT队列中找到没有来得及处理的请求来弥补.
上面是四次挥手的流程. 因为TCP链接是全双工的, 所以每一侧的数据传输有需要一对SYN-ACK来关闭. 链接关闭后有一定的冷却时间, 这个时间内链接所使用的端口是不能被重新启用的, 这个时间被叫做最长报文段寿命(Maximum Segment Lifetime), 我们可以类比成IP报文的TTL.
传输可靠性保证
TCP通过三大类功能来提供传输的可靠性保证: 重传, 流量控制和拥塞控制.
重传分为两种情况: 超时重传和累计确认重传.
超时重传指发送方发送了一个TCP包, 在规定的时间内没有收到ACK时会重发. 这个规定时间被叫做超时重传时间(Retransmission Timeout). RTO不是一个固定的值, 它会根据网络状态实时计算更新.
累计确认重传的实现基础是Ack字段. 当发送方连续收到三个ACK中Ack的值都是一样的时候(连续收到三个才触发是为了避免网络延迟, 接收方其实收到了但是比较晚的情况), 就认为Ack后面的包丢包了, 这时会重新发送后面的包.
累计确认重传的效率很低, 比如接收方只丢了第3个包, 后面到包都收到了, 按照这个逻辑, 发送方会重发从第三个包开始所有的包. 所以累计确认重传的升级版: 选择确认重传(SACK)诞生了. SACK实际上是一个可选的字段, 目的是告诉发送方已经收到了的序号. 这样发送方就可以只重发没有收到的包了.
TCP的流量控制策略, 就是老生常谈的滑动窗口了. 关于滑动窗口的具体实现, 这里不多做介绍. 这里只用几句话来简单说明滑动窗口的思路:
滑动窗口的基础仍然是Seq和Ack字段
所谓的滑动窗口, 实际上是双方约定好的接收缓冲区
一个具体的例子. 双方约定的接收缓冲区大小是10个字节. 发送方发送了两个负载为4个字节的包, Seq分别是1和5, 但是只收到了Ack为5的包. 这说明第二个包可能还在缓冲区里(当然也有可能丢了), 那么现在缓冲区一定会剩下的空间就是10-4=6字节. 那么下一个包的负载不能超过6个字节, 不然缓冲区可能就溢出了.
通过3中举的例子, 当缓冲区剩余空间可能为0时, 发送方就不能再发包了. 这时发送方会定时询问接收方现在缓冲区空出来没有, 如果空出来的话就根据最新的剩余空间继续发包
TCP的拥塞控制主要有三个策略: 慢启动, 拥塞避免和快速恢复.
慢启动, 指发送方的拥塞窗口(可以简单理解为发送窗口的最大值)是从小到大缓慢增长的. 最开始窗口的大小是1, 每成功发送一次, 窗口的大小就翻倍. 当窗口超过一个值(这个值被叫做ssthresh)时, 进入拥塞避免策略.
拥塞避免, 指每成功发送一次, 拥塞窗口大小加一, 直到发送失败. 失败时更新ssthresh为当前拥塞窗口大小的一半, 同时将拥塞窗口大小置为1, 进入慢启动策略.
上面提到的发送成功定义为: 在规定的时间内收到ACK. 这个规定的时间被叫做往返时间(Round Trip Time). RTT是根据网络状态动态采样计算的, 它是RTO计算的重要参考.
当发送失败时, 超时重传机制就会触发. 如果在规定的时间内, 重传数据得到了ACK, 那么拥塞窗口会恢复成发送失败时的大小, 这个策略就是快速恢复.
会话层
会话层的职责是管理应用层视角的链接. 它的作用如下:
创建链接. 包括将会话层的地址映射成传输层的地址, 选择和协商链接使用的一些参数, 标识和管理各个链接等
数据传输. 即将应用层的数据–我们称为用户数据单元(Session Service Data Unit)转换成链接使用的协议所支持的数据–我们称为协议数据单元(Session Protocol Data Unit), 并进行传输. 当然, 还要提供SPDU回转成SSDU的能力.
释放链接. 完成一些收尾工作
远程程序调用(Remote Procedure Call)是会话层的一个典型应用, 它的工作流程如下:
客户端调用Client Stub, 传递参数
Client Stub将参数转成特定格式(XML, JSON, 二进制序列等)
通过系统调用, 发送至服务端(通过TCP, UDP或者像gRPC一样使用HTTP)
服务端将收到的参数传递给Server Stub
Server Stub解析参数
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机构, 加密密钥上的编号就是数字证书, 签名就是数字签名. 我们再把这个流程细化一下:
首先客户端给服务端发送了一个Client Hello消息, 这个消息包括了客户端支持的所有SSL协议版本和加密算法, 同时还包括了一个随机生成的字符串Random1.
服务端返回一个Server Hello消息, 消息包括服务端选择的SSL协议版本, 加密算法, 数字证书和随机生成的字符串Random2.
客户端验证数字证书, 然后使用证书提供的公钥Key1加密一个随机生成的字符串Random3. 将密文发送给服务端.
经过前三步后, 客户端和服务端同时拥有了Random1, Random2和Random3. 用这三个字符串生成对称加密的密钥Key2.
客户端发送用Key2加密的消息:Finished
服务端收到后回应同样的消息, 加密信道成功建立
步骤3中提到了客户端会验证数字证书. 那么如何保证数字证书的正确性呢?
每个数字证书都会标明颁发的机构, 证书使用的公钥(即加密密钥)和唯一的证书编号. 为了防止证书被篡改, 每个证书还会有对应的数字签名. 数字签名的制作和验证步骤如下:
对数字证书做哈希, 得到一个哈希串
用证书使用的解密密钥对哈希串进行非对称加密, 这样数字签名就做好了
因为证书使用的解密密钥是不公开的, 所以这个签名是不能被伪造的
校验证书时, 先用证书公开的加密密钥对签名做解密, 得到了正确的哈希串
然后对证书内容再做哈希, 得到实际的哈希串
比较两个哈希串, 如果相同, 那么意味着证书没有被修改过
应用层
应用层是对直接面向应用程序的层次, 提供了各种各样的网络服务. 比较有代表性的协议有DNS, HTTP, RTP等. DNS过于老生常谈, 这里就不介绍了. RTP协议, 严格来说是一个协议簇, 其具体实现非常复杂, 后面有机会我们单独写一篇文章来学习, 这里就只介绍一些最常见的HTTP协议.
HTTP协议请求方式
HTTP提供了多种种请求方式, 这里简单介绍几种常用的:
Get方式. 最古老的请求方式. Get方式的请求中, 不能携带参数. 如果要提供参数, 只能放在请求的Url末尾. 因此, Get是一种不太安全的请求方式
Head方式. Head请求的响应只有头部, 没有内容. 这种请求通常用来获取资源的元信息. 比如在下载一个资源前显示资源的大小, 通常就使用Head来完成.
Post方式. 最常见的请求方式. 请求拥有请求体, 可以携带参数. 参数的类型通过ContentType来标识, 常见的有text/plain, application/json, application/octet-stream, multipart/form-data等.
Put方式. 与Post方式基本相同. HTTP规范里对二者的区分是: 如果一个操作是幂等的(即不管操作多少次, 作用都是相同的)就是用Put, 否则就使用Post. 比如创建一个用户. 如果使用两次Post, 那么就应该创建两个相同的用户; 如果使用Put, 那么就只会有一个用户被创建(当然规范是规范, 目前我看到的都是无脑Post的).
Patch方式. 用来更新资源的请求. 虽然Put和也可以可以用来更新资源, 但是Patch方式更节约带宽. Patch方式只需要传需要更新的字段就可以, Put必须要传更新后所有字段的信息, 如果有字段留空, 那就相当于将该字段清空.
Delete方式. 顾名思义, 用来删除url对应的资源的.
HTTP响应码
每个HTTP响应的第一行都是状态行, 写有HTTP协议版本, 响应状态码和对应的响应信息. 一般来说, 响应码分为五大类:
1xx: 请求已被服务器接收, 正在处理. 一般情况下, 1xx响应码是不会被返回的.
2xx: 请求已被服务器接收, 理解和处理.
3xx: 需要后续操作才能完成请求.
4xx: 请求发生错误.
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提供了更多的请求方式, 但是它的性能不高. 原因有两点:
HTTP/1.0把自己定义为无状态和无连接的传输协议, 即每一次请求结束后都要断开TCP连接. 这使得TCP连接的握手和挥手流程频繁出现, 浪费了很多时间.
HTTP/1.0规定, 下一个请求必须要等前一个请求响应到达后才能发送. 如果响应一直不到, 那整个请求队列就会被阻塞.
HTTP/1.1针对HTTP/1.0的缺点做了优化:
设置Connection字段, 可以将其设置成keep-alive来保持TCP连接. 当确定关闭连接时, 在请求头中将Connection设置为close来关闭连接.
允许同时创建多个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有了下面的特点:
彻底解决了复用连接的问题(毕竟已经没有连接了).
性能进一步提升(毕竟已经没有连接了).
舍弃TCP四元组标识会话的机制(毕竟已经没有连接了), 使用64位随机数来作为会话标识, 这样的好处在于, 服务端可以随时迁移(这个机器不想干了, 直接扔给别的机器干).
为了实现可靠传输, TCP的协议头非常复杂, 但是并没有进行任何加密和认证机制. HTTP/3.0在UDP的基础上实现了可靠传输, 因为是在应用层做的, 所以相关字段都可以被加密, 所以安全性更好.
从这四个特点来看, HTTP/3.0还值得期待的. 因为HTTP/3.0目前还没有真正落地, 其实现可能还有变化, 所以不太好做介绍, 这里就先不多着墨了.