0%

计算机网络

OSI七层模型&TCP/IP五层网络模型

传输层,给应⽤数据前⾯增加了 TCP 头;⽹络层,给 TCP 数据包前⾯增加了 IP 头;⽹络接⼝层,给 IP 数据包前后分别增加了帧头和帧尾;
网络接口层的传输单位是帧(frame),IP 层的传输单位是包(packet),TCP 层的传输单位是段(segment),HTTP 的传输单位则是消息或报文(message)。

输入网址到网页显示,期间发生了什么?

HTTP

解析URL
当没有路径名时,就代表访问根目录下事先设置的默认文件,也就是 /index.html 或者 /default.html

生成HTTP请求

DNS

在这一流程中,需要查询服务器域名对应的 IP 地址,因为委托操作系统发送消息时,必须提供通信对象的 IP 地址。在域名中,越靠右的位置表示其层级越高。

  • 根 DNS 服务器(.)
  • 顶级域 DNS 服务器(.com)
  • 权威 DNS 服务器(server.com)

DNS 域名解析,只指路不带路。浏览器会先看自身有没有对这个域名的缓存,如果有,就直接返回,如果没有,就去问操作系统,操作系统也会去看自己的缓存,如果有,就直接返回,如果没有,再去 hosts 文件看,也没有,才会去问「本地 DNS 服务器」。

流程示例:
客户端->缓存->本地DNS服务器->根域服务器(.)->顶级域名服务器(.com)->权威DNS服务器(server.com)

通过 DNS 获取到 IP 后,就可以把 HTTP 的传输工作交给操作系统中的协议栈。

TCP和UDP

如果 HTTP 请求消息比较长,超过了MSS(TCP 最大报文段长度) ,这时 TCP 就需要把 HTTP 的数据拆解成一块块的数据发送,而不是一次性发送所有数据。这样即使中途有一个分块丢失或损坏了,只需要重新发送这一个分块,而不用重新发送整个数据包。在 TCP 协议中,我们把每个分块称为一个 TCP 段(TCP Segment)

至此,网络报文如图:

IP


网络层负责将数据从一个设备传输到另一个设备,该如何找到对方呢?因此,网络层需要有区分设备的编号。一般用 IP 地址给设备进行编号,对于 IPv4 协议, IP 地址共 32 位,分成了四段(比如,192.168.100.1),每段是 8 位。只有一个单纯的 IP 地址虽然做到了区分设备,但是寻址起来就特别麻烦,全世界那么多台设备,难道一个一个去匹配?这显然不科学。

因此,需要将 IP 地址分成两种意义:

  • 一个是网络号,负责标识该 IP 地址是属于哪个「子网」的;
  • 一个是主机号,负责标识同一「子网」下的不同主机;

那么在寻址的过程中,先匹配到相同的网络号(表示要找到同一个子网),才会去找对应的主机。

因为 HTTP 是经过 TCP 传输的,所以在 IP 包头的协议号,要填写为 06(十六进制),表示协议为 TCP

IP 协议会将传输层的报文作为数据部分,再加上 IP 包头组装成 IP 报文,如果 IP 报文大小超过 MTU(以太网中一般为 1500 字节) 就会再次进行分片,得到一个即将发送到网络的 IP 报文。

至此,网络报文如图:

MAC

生成了 IP 头部之后,接下来要交给网络接口层(Link Layer)在 IP 头部的前面加上 MAC 头部,并封装成数据帧(Data frame)发送到网络上。

电脑上的以太网接口,Wi-Fi接口,以太网交换机、路由器上的千兆,万兆以太网口,还有网线,它们都是以太网的组成部分。所以说,网络接口层主要为网络层提供「链路级别」传输的服务,负责在以太网、WiFi 这样的底层网络上发送原始数据包,工作在网卡这个层次,使用 MAC 地址来标识网络上的设备。

IP 头部中的接收方 IP 地址表示网络包的目的地,通过这个地址我们就可以判断要将包发到哪里,但在以太网的世界中,这个思路是行不通的。以太网在判断网络包目的地时和 IP 的方式不同,因此必须采用相匹配的方式才能在以太网中将包发往目的地,而 MAC 头部就是干这个用的,所以,在以太网进行通讯要用到 MAC 地址。MAC 头部是以太网使用的头部,它包含了接收方和发送方的 MAC 地址等信息,可以通过 ARP 协议获取对方的 MAC 地址。

ARP 协议会在以太网中以广播的形式,对以太网所有的设备喊出:“这个 IP 地址是谁的?请把你的 MAC 地址告诉我”。然后就会有人回答:“这个 IP 地址是我的,我的 MAC 地址是 XXXX”。如果对方和自己处于同一个子网中,那么通过上面的操作就可以得到对方的 MAC 地址。ARP 是借助 ARP 请求与 ARP 响应两种类型的包确定 MAC 地址的。然后,我们将这个 MAC 地址写入 MAC 头部,MAC 头部就完成了。

操作系统会把本次查询结果放到一块叫做 ARP 缓存的内存空间留着以后用,不过缓存的时间就几分钟。在发包时先查询 ARP 缓存,如果其中已经保存了对方的 MAC 地址,就不需要发送 ARP 查询,直接使用 ARP 缓存中的地址。而当 ARP 缓存中不存在对方 MAC 地址时,则发送 ARP 广播查询。

一般在 TCP/IP 通信里,MAC 包头的协议类型只使用:0800 : IP 协议,0806 : ARP 协议

至此,网络报文如图:

网卡

网卡驱动获取网络包之后,会将其复制到网卡内的缓存区中,接着会在其开头加上报头和起始帧分界符,在末尾加上用于检测错误的帧校验序列

交换机

交换机的设计是将网络包原样转发到目的地。交换机工作在 MAC 层,也称为二层网络设备。交换机的端口不具有 MAC 地址。交换机的端口不核对接收方 MAC 地址,而是直接接收所有的包并存放到缓冲区中。

交换机的 MAC 地址表主要包含两个信息:
一个是设备的 MAC 地址,另一个是该设备连接在交换机的哪个端口上。

交换机根据 MAC 地址表查找 MAC 地址,然后将信号发送到相应的端口,如果交换机无法判断应该把包转发到哪个端口,只能将包转发到除了源端口之外的所有端口上,无论该设备连接在哪个端口上都能收到这个包。这样做不会产生什么问题,因为以太网的设计本来就是将包发送到整个网络的,然后只有相应的接收者才接收包,而其他设备则会忽略这个包。

路由器

路由器是基于 IP 设计的,俗称三层网络设备,路由器的各个端口都具有 MAC 地址和 IP 地址
而交换机是基于以太网设计的,俗称二层网络设备,交换机的端口不具有 MAC 地址

路由器的端口具有 MAC 地址,因此它就能够成为以太网的发送方和接收方;同时还具有 IP 地址,从这个意义上来说,它和计算机的网卡是一样的。当转发包时,首先路由器端口会接收发给自己的以太网包,然后路由表查询转发目标,再由相应的端口作为发送方将以太网包发送出去。

完成包接收操作之后,路由器就会去掉包开头的 MAC 头部。MAC 头部的作用就是将包送达路由器,其中的接收方 MAC 地址就是路由器端口的 MAC 地址。因此,当包到达路由器之后,MAC 头部的任务就完成了,于是 MAC 头部就会被丢弃。接下来,路由器会根据 MAC 头部后方的 IP 头部中的内容进行包的转发操作。

根据路由表的网关列判断对方的地址。
如果网关是一个 IP 地址,则这个IP 地址就是我们要转发到的目标地址,还未抵达终点,还需继续需要路由器转发。
如果网关为空,则 IP 头部中的接收方 IP 地址就是要转发到的目标地址,也是就终于找到 IP 包头里的目标地址了,说明已抵达终点。

在网络包传输的过程中,源 IP 和目标 IP 始终是不会变的,一直变化的是 MAC 地址,因为需要 MAC 地址在以太网内进行两个设备之间的包传输

服务器与客户端

远程定位——IP

IP 地址的分类

A、B、C 分类对应的地址范围、最大主机个数:

如何计算最大主机个数,就是要看主机号的位数,C类为 2^8 - 2 = 254,

减去两个:主机号全为 1 指定某个网络下的所有主机,用于广播,主机号全为 0 指定某个网络

缺点:

  1. 同一网络下没有地址层次,比如一个公司里用了 B 类地址,但是可能需要根据生产环境、测试环境、开发环境来划分地址层次,而这种 IP 分类是没有地址层次划分的功能,所以这就缺少地址的灵活性。

  2. 不能很好的与现实网络匹配。C 类地址能包含的最大主机数量实在太少了,只有 254 个,估计一个网吧都不够用。而 B 类地址能包含的最大主机数量又太多了,6 万多台机器放在一个网络下面,一般的企业基本达不到这个规模,闲着的地址就是浪费。

无分类地址CIDR

这种方式不再有分类地址的概念,32 比特的 IP 地址被划分为两部分,前面是网络号,后面是主机号。点击此处跳转到IP地址划分参考

表示形式 a.b.c.d/x,其中 /x 表示前 x 位属于网络号, x 的范围是 0 ~ 32,这就使得 IP 地址更加具有灵活性。比如 10.100.122.2/24,这种地址表示形式就是 CIDR,/24 表示前 24 位是网络号,剩余的 8 位是主机号。

子网掩码VLSM

子网掩码,掩码的意思就是掩盖掉主机号,剩余的就是网络号。
配合子网掩码才能算出 IP 地址 的网络号和主机号。10.100.122.0/24,后面的/24表示就是 255.255.255.0 子网掩码,255.255.255.0 二进制是「11111111-11111111-11111111-00000000」,为了简化子网掩码的表示,用/24代替255.255.255.0。将 10.100.122.2 和 255.255.255.0 进行按位与运算,就可以得到网络号

子网掩码还有一个作用,那就是划分子网。子网划分实际上是将主机地址分为两个部分:子网网络地址和子网主机地址

网络地址 192.168.1.0,使用子网掩码 255.255.255.192 对其进行子网划分。要划分4个子网,且每个子网可容纳55台主机:

  1. 计算子网号:2^n−2≥55,得n = 6 (此时可容纳62台主机,题目只需55台),从 8 位主机号中借用 2 位作为子网号。

  1. 由于子网网络地址被划分成 2 位,那么子网地址就有 4 个,分别是 00、01、10、11,具体划分如下图:
  2. 最终结果如图

IPV6

IPv4 的地址是 32 位的,大约可以提供 42 亿个地址,但是早在 2011 年 IPv4 地址就已经被分配完了。
IPv6 的地址是 128 位的,IPv6 可以保证地球上的每粒沙子都能被分配到一个 IP 地址。

IPv6 相比 IPv4 的首部改进:

  • 取消了首部校验和字段。 因为在数据链路层和传输层都会校验,因此 IPv6 直接取消了 IP 的校验。
  • 取消了分片/重新组装相关字段。 分片与重组是耗时的过程,IPv6 不允许在中间路由器进行分片与重组,这种操作只能在源与目标主机,这将大大提高了路由器转发的速度。
  • 取消选项字段。 选项字段不再是标准 IP 首部的一部分了,但它并没有消失,而是可能出现在 IPv6 首部中的「下一个首部」指出的位置上。删除该选项字段使的 IPv6 的首部成为固定长度的 40 字节。

DNS

点击此处跳转到DNS

ARP

ARP ⽤于根据 IP 地址查询相应的以太⽹ MAC 地址。在传输⼀个 IP 数据报的时候,确定了源 IP 地址和⽬标 IP 地址后,就会通过主机「路由表」确定 IP 数据包下⼀跳。然⽽,⽹络层的下⼀层是数据链路层,所以我们还要知道「下⼀跳」的 MAC 地址,所以可以通过 ARP 协议,求得下⼀跳的 MAC 地址。
点击此处跳转到ARP协议

DHCP

路由器常用

一旦客户端收到 DHCP ACK 后,交互便完成了,并且客户端能够在租用期内使用 DHCP 服务器分配的 IP 地址。可以发现,DHCP 交互中,全程都是使用 UDP 广播通信。

如果租约的 DHCP IP 地址快期后,客户端会向服务器发送 DHCP 请求报文:

  • 服务器如果同意继续租用,则用 DHCP ACK 报文进行应答,客户端就会延长租期。
  • 服务器如果不同意继续租用,则用 DHCP NACK 报文,客户端就要停止使用租约的 IP 地址。

NAT

简单的来说 NAT 就是同个公司、家庭、教室内的主机对外部通信时,把私有 IP 地址转换成公有 IP 地址。
普通的 NAT 转换没什么意义。由于绝大多数的网络应用都是使用传输层协议 TCP 或 UDP 来传输数据的。因此,可以把 IP 地址 + 端口号一起进行转换(网络地址与端口转换 NAPT)。

图中有两个客户端 192.168.1.10 和 192.168.1.11 同时与服务器 183.232.231.172 进行通信,并且这两个客户端的本地端口都是 1025。此时,两个私有 IP 地址都转换 IP 地址为公有地址 120.229.175.121,但是以不同的端口号作为区分。

ICMP

互联⽹控制报⽂协议。ICMP 主要的功能包括:确认 IP 包是否成功送达⽬标地址、报告发送过程中 IP 包被废弃的原因和改善⽹络设置等。

ICMP ⼤致可以分为两⼤类:

  1. ⼀类是⽤于诊断的查询消息,也就是「查询报⽂类型」
  2. 另⼀类是通知出错原因的错误消息,也就是「差错报⽂类型」

在 IP 通信中如果某个 IP 包因为某种原因未能达到目标地址,那么这个具体的原因将由 ICMP 负责通知。

ping的工作原理

ping 命令执⾏的时候,源主机⾸先会构建⼀个 ICMP
ICMP 数据包内包含多个字段,最重要的是两个:

  • 第⼀个是类型,对于回送请求消息⽽⾔该字段为 8 ;
  • 另外⼀个是序号,主要⽤于区分连续 ping 的时候发出的多个数据包。
    每发出⼀个请求数据包,序号会⾃动加 1 。为了能够计算往返时间 RTT ,它会在报⽂的数据部分插⼊发送时间。

可靠传输——TCP和UDP

UDP与TCP比较

特点 TCP UDP
连接 面向连接 不需要连接,即刻传输
服务对象 一对一 一对一、一对多、多对多
可靠性 可靠交付数据,数据可以无差错、不丢失、不重复、按序到达 尽最大努力交付数据,不保证可靠交付数据
拥塞控制、流量控制 没有
首部开销 没有使用「选项」字段时是 20 个字节,较长 8 个字节,较小
传输方式 流式传输,保证顺序和可靠 一个包一个包的发送,有边界,可能丢包和乱序
分片不同 大于MSS大小,在传输层进行分片,丢失了一个分片,只需要传输丢失的这个分片 大于 MTU 大小在IP层进行分片

UDP

UDP头部

  1. ⽬标和源端⼝:主要是告诉 UDP 协议应该把报⽂发给哪个进程。
  2. 包⻓度:该字段保存了 UDP ⾸部的⻓度跟数据的⻓度之和。
  3. 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计,防止收到在网络传输中受损的 UDP 包。

UDP特点

  1. 面向无连接
    首先 UDP 是不需要和 TCP一样在发送数据前进行三次握手建立连接的,想发数据就可以开始发送了。并且也只是数据报文的搬运工,不会对数据报文进行任何拆分和拼接操作。
    在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增加一个 UDP 头标识下是 UDP 协议,然后就传递给网络层了
    在接收端,网络层将数据传递给传输层,UDP 只去除 IP 报文头就传递给应用层,不会任何拼接操作
  2. 有单播,多播,广播的功能
    UDP 不止支持一对一的传输方式,同样支持一对多,多对多,多对一的方式,也就是说 UDP 提供了单播,多播,广播的功能。
  3. UDP是面向报文的
    发送方的UDP对应用程序交下来的报文,在添加首部后就向下交付IP层。UDP对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界。因此,应用程序必须选择合适大小的报文
  4. 不可靠性
    首先不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠。并且收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了。
    再者网络环境时好时坏,但是 UDP 因为没有拥塞控制,一直会以恒定的速度发送数据。即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用 UDP 而不是 TCP。
  5. 头部开销小,传输数据报文时是很高效的
    因此 UDP 的头部开销小,只有八字节,相比 TCP 的至少二十字节要少得多,在传输数据报文时是很高效的

TCP

TCP头部

  1. 序列号:在建⽴连接时由计算机⽣成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送⼀次数据,就「累加」⼀次该「数据字节数」的⼤⼩。⽤来解决⽹络包乱序问题。
  2. 确认应答号:指下⼀次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。⽤来解决丢包的问题。
  3. 头部长度:标识该TCP头部有多少个32bit字(4字节)。4位最大能表示15,所以TCP头部最长是60字节。
  4. 窗口大小:是TCP流量控制的一个手段。它告诉对方本段的TCP接受缓冲区的情况,控制对方的发送的速度。
  5. 校验和:由发送端填充,接收端对TCP报文端执行CRC算法以校验TCP报文段在传输过程是否损坏。(数据和头部全部校验的。)
  6. 紧急指针:发送端向接受端发送紧急数据使用的。
  7. 控制位:
  • URG:表示紧急指针是否有效
  • ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建⽴连接时的 SYN 包之外该位必须设置为 1 。
  • RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接
  • PSH:提示接收端应用程序应该立即从TCP接受缓冲区读走数据,为之后的接受的数据腾出位置。
  • SYN:该位为 1 时,表示希望建⽴连接,并在其「序列号」的字段进⾏序列号初始值的设定。
  • FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双⽅的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。

TCP特点

  1. 面向连接
    面向连接,是指发送数据之前必须在两端建立连接。建立连接的方法是“三次握手”,这样能建立可靠的连接。建立连接,是为数据的可靠传输打下了基础。
  2. 仅支持单播传输
    每条TCP传输连接只能有两个端点,只能进行点对点的数据传输,不支持多播和广播传输方式。
  3. 面向字节流
    TCP不像UDP一样那样一个个报文独立地传输,而是在不保留报文边界的情况下以字节流方式进行传输。
    消息是「没有边界」的,所以⽆论我们消息有多⼤都可以进⾏传输。并且消息是「有序的」,当「前⼀个」消息没有收到的时候,即使它先收到了后⾯的字节,那么也不能扔给应⽤层去处理,同时对「重复」的报⽂会⾃动丢弃。
  4. 可靠传输
    对于可靠传输,判断丢包,误码靠的是TCP的段编号以及确认号。TCP为了保证报文传输的可靠,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的字节发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据(假设丢失了)将会被重传。
  5. 提供拥塞控制
    当网络出现拥塞的时候,TCP能够减小向网络注入数据的速率和数量,缓解拥塞
  6. TCP提供全双工通信
    TCP允许通信双方的应用程序在任何时候都能发送数据,因为TCP连接的两端都设有缓存,用来临时存放双向通信的数据。当然,TCP可以立即发送一个数据段,也可以缓存一段时间以便一次发送更多的数据段(最大的数据段大小取决于MSS)

TCP四元组

TCP 四元组可以唯一的确定一个连接,四元组包括如下:
源地址、源端口、目的地址、目的端口

源地址和目的地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。
源端口和目的端口的字段(16 位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。

TCP三次握手

  1. 服务端主动监听某个端口,处于 LISTEN 状态
  2. 客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。

  1. 服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。

  1. 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。

  1. 服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。

从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的。

TCP 半连接和全连接队列

在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:

半连接队列,也称 SYN 队列;
全连接队列,也称 accept 队列;

服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握⼿的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调⽤ accept 函数时把连接取出来。

全连接队列满了会发生什么
1
ss -lnt

当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。发生 TCP 全连接队溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象。

实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。

1
2
$cat /proc/sys/net/ipv4/tcp_abort_on-overflow
0 #默认值为0

0 :如果全连接队列满了,那么 server 扔掉 client 发过来的 ack ;
1 :如果全连接队列满了,server 发送一个 reset 包给 client,表示废掉这个握手过程和这个连接;

如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。

但是通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。当 TCP 全连接队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 TCP 全连接队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接

TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值

  • somaxconn默认值是 128,Linux 内核参数:/proc/sys/net/core/somaxconn
  • backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度;
半连接队列满了会发生什么
1
netstat -natp | grep SYN_RECV | wc -l

为什么不是两次握手或四次握手?

防止旧的重复连接初始化造成混乱(主要原因)

若客户端向服务端发送的连接请求丢失,客户端等待应答超时后就会再次发送连接请求,此时,上一个连接请求就是『失效的』。
如果是历史连接(序列号过期或超时),则第三次握⼿发送的报⽂是 RST 报⽂,以此中⽌历史连接;
如果不是历史连接,则第三次发送的报⽂是 ACK 报⽂,通信双⽅就会成功建⽴连接;

如果采用两次握手建立 TCP 连接的场景下,服务端在向客户端发送数据前,并没有阻止掉历史连接,导致服务端建立了一个历史连接,又白白发送了数据,妥妥地浪费了服务端的资源。因此,最好就是在服务端发送数据前,也就是建立连接之前,要阻止掉历史连接:

同步双⽅的初始序列号

当客户端发送携带「初始序列号」的 SYN 报⽂的时候,需要服务端回⼀个 ACK 应答报⽂,表示客户端的 SYN 报⽂已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样⼀来⼀回,才能确保双⽅的初始序列号能被可靠的同步。序列号是可靠传输的一个关键因素,它的作用:

  • 接收方可以去除重复的数据;
  • 接收方可以根据数据包的序列号按序接收;
  • 可以标识发送出去的数据包中, 哪些是已经被对方收到的(通过 ACK 报文中的序列号知道);

四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了「三次握手」。而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。

可以避免资源浪费

假设采用“两次握手”,服务端重复接受无用的连接请求 SYN 报文。服务端不清楚客户端是否收到了自己发送的建立连接的 ACK 确认报文,所以每收到一个 SYN 就只能先主动建立一个连接,而造成重复分配资源

第一次握手失败,会发生什么

如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的。

在 Linux 里,客户端的 SYN 报文最大重传次数由 tcp_syn_retries内核参数控制,这个参数是可以自定义的,默认值一般是 5。

1
2
# cat /proc/sys/net/ipv4/tcp_syn_retries
5

通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍。

第二次握手丢失了,会发生什么

第二次握手的 SYN-ACK 报文其实有两个目的 :

  • 第二次握手里的 ACK, 是对第一次握手的确认报文;
  • 第二次握手里的 SYN,是服务端发起建立 TCP 连接的报文;

如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文。
如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边会触发超时重传机制,重传 SYN-ACK 报文。

在 Linux 下,SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定,默认值是 5。

1
2
# cat /proc/sys/net/ipv4/tcp_synack_retries
5

因此,当第二次握手丢失了,客户端和服务端都会重传

第三次握手丢失了,会发生什么

第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。
注意,ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文。

什么是 SYN 攻击?如何避免 SYN 攻击?

假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的半连接队列,使得服务端不能为正常用户服务。

不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,默认情况都会丢弃报文。SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。

避免 SYN 攻击方式:

  1. 无效连接监视释放
    这种方法不停监视系统的半开连接和不活动连接,当达到一定阈值时拆除这些连接,从而释放系统资源。这种方法对于所有的连接一视同仁,而且由于SYN Flood造成的半开连接数量很大,正常连接请求也被淹没在其中被这种方式误释放掉,因此这种方法属于入门级的SYN Flood方法。

  2. 调大 netdev_max_backlog;
    当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000,我们要适当调大该参数的值,比如设置为 10000:

    1
    net.core.netdev_max_backlog = 10000
  3. 增大 TCP 半连接队列;
    要同时增大下面这三个参数:
    增大 net.ipv4.tcp_max_syn_backlog
    增大 listen() 函数中的 backlog
    增大 net.core.somaxconn

  4. 开启 tcp_syncookies,延缓任务控制块(TCB)分配方法
    开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,相当于绕过了 SYN 半连接来建立连接。0 值,表示关闭该功能;1 值,表示仅当 SYN 半连接队列放不下时,再启用它;2 值,表示无条件开启功能;

当 「 SYN 队列」满之后,后续服务端收到 SYN 包,不会丢弃,而是根据算法,计算出一个 cookie 值;将 cookie 值放到第二次握手报文的「序列号」里,然后服务端回第二次握手给客户端;服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,将该连接对象放入到「 Accept 队列」。最后应用程序通过调用 accpet() 接口,从「 Accept 队列」取出的连接。

  1. 减少 SYN+ACK 重传次数
    当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
    可以减少 SYN-ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。

  2. 使用SYN Proxy防火墙
    因此很多防火墙中都提供一种SYN代理的功能,其主要原理是对试图穿越的SYN请求进行验证后才放行。

SYN 报文什么时候情况下会被丢弃?

SYN 报文被丢弃的两种场景:

  1. 开启 tcp_tw_recycle 参数,并且在 NAT 环境下,造成 SYN 报文被丢弃
  • tcp_timestamps 选项开启之后, PAWS 机制会自动开启,它的作用是防止 TCP 包中的序列号发生绕回。点击此处跳转到TCP时间戳和序列号回绕(PAWS)
  • tcp_tw_recycle 在使用了 NAT 的网络下是不安全的!对于服务器来说,如果同时开启了recycle 和 timestamps 选项,则会开启一种称之为「 per-host 的 PAWS 机制」。per-host 是对「对端 IP 做 PAWS 检查」,而非对「IP + 端口」四元组做 PAWS 检查。
  • 如果客户端网络环境是用了 NAT 网关,那么客户端环境的每一台机器通过 NAT 网关后,都会是相同的 IP 地址,在服务端看来,就好像只是在跟一个客户端打交道一样,无法区分出来。当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包。tcp_tw_recycle 在 Linux 4.12 版本后,直接取消了这一参数。
  1. TCP 两个队列满了(半连接队列和全连接队列),造成 SYN 报文被丢弃

已建立连接的TCP,收到SYN会发生什么?

如果客户端掉线了,服务器不知道,客户端再上线的时候发起了SYN握手,服务器如何应对?

  1. 客户端的 SYN 报文里的端口号与历史连接不相同
    此时服务端会认为是新的连接要建立,于是就会通过三次握手来建立新的连接。那旧连接里处于 Established 状态的服务端最后会怎么样呢?
    如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭了,此时客户的内核就会回 RST 报文,服务端收到后就会释放连接。如果服务端一直没有发送数据包给客户端,在超过一段时间后,TCP 保活机制就会启动,检测到客户端没有存活后,接着服务端就会释放掉该连接。

  2. 客户端的 SYN 报文里的端口号与历史连接相同
    处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接
    RFC 793 文档里的第 34 页里,有说到这个例子。

TCP四次挥手

服务端在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被放在已排队等候的其他已接收的数据之后,所以必须要得继续 read 接收缓冲区已接收的数据;

当服务端在 read 数据的时候,最后自然就会读到 EOF,接着 read() 就会返回 0,这时服务端应用程序如果有数据要发送的话,就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,这时服务端就会发一个 FIN 包,这个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;

是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(上图的服务端)的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,

为什么挥手需要四次?

  • 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据
  • 服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
可以变成三次挥手吗?

但是在特定情况下,四次挥手是可以变成三次挥手的,能不能把第二次的 ACK 报文, 放到第三次 FIN 报文一起发送?

「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。

TCP延迟确认
  • 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
  • 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
  • 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK

例如:

1
2
3
4
5
#define TCP_DELACK_MAX	((unsigned)(HZ/5))	/* maximal time to delay before sending an ACK */
static_assert((1 << ATO_BITS) > TCP_DELACK_MAX);

#if HZ >= 100
#define TCP_DELACK_MIN ((unsigned)(HZ/25)) /* minimal time to delay before sending an ACK */

1
2
[lepos@apptest132 boot]$ cat /boot/config-3.10.0-693.el7.x86_64 | grep 'CONFIG_HZ='
CONFIG_HZ=1000

最大延迟确认时间是 200 ms,最短延迟确认时间是 40 ms

如果要关闭 TCP 延迟确认机制,可以在 Socket 设置里启用 TCP_QUICKACK。

1
2
3
// 1 表示开启 TCP_QUICKACK,即关闭 TCP 延迟确认机制
int value = 1;
setsockopt(socketfd, IPPROTO_TCP, TCP_QUICKACK, (char*)& value, sizeof(int));

第一次挥手丢失了,会发生什么?

如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。

  • 当客户端超时重传已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK 报文),那么客户端就会断开连接。

第二次挥手丢失了,会发生什么?

ACK 报文是不会重传的,所以如果服务端的第二次挥手丢失了,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。

  • 当客户端超时重传已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK 报文),那么客户端就会断开连接。

第三次挥手丢失了,会发生什么?

内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。

  • 服务端重传第三次挥手报文的次数达到了重传最大次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK报文),那么服务端就会断开连接。
  • 客户端因为是通过 close 函数关闭连接的,处于 FIN_WAIT_2 状态是有时长限制的,如果 tcp_fin_timeout (默认值是 60 秒)时间内还是没能收到服务端的第三次挥手(FIN 报文),那么客户端就会断开连接。</font>

第四次挥手丢失了,会发生什么?

ACK 报文是不会重传的,如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。

  • 服务端重传第三次挥手报文的次数达到了重传最大次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK报文),那么服务端就会断开连接。
  • 客户端在收到第三次挥手后,就会进入 TIME_WAIT 状态,开启时长为 2MSL 的定时器,如果途中再次收到第三次挥手(FIN 报文)后,就会重置定时器,当等待 2MSL 时长后,客户端就会断开连接

粗暴关闭vs优雅关闭

close函数—粗暴关闭

同时 socket 关闭发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力。如果有多进程/多线程共享同一个 socket,如果有一个进程调用了 close 关闭只是让 socket 引用计数 -1,并不会导致 socket 不可用,同时也不会发出 FIN 报文,其他进程还是可以正常读写该 socket,直到引用计数变为 0,才会发出 FIN 报文。

如果客户端是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的数据,由于客户端已经不再具有发送和接收数据的能力,所以客户端的内核会回 RST 报文给服务端,然后内核会释放连接,这时就不会经历完成的 TCP 四次挥手,所以调用 close 是粗暴的关闭
当服务端收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就能感知到连接已经被释放了:

  • 如果是读操作,则会返回 RST 的报错,也就是我们常见的Connection reset by peer。
  • 如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。

shutdown函数—优雅关闭

可以指定 socket 只关闭发送方向而不关闭读取方向,也就是 socket 不再有发送数据的能力,但是还是具有接收数据的能力。如果有多进程/多线程共享同一个 socket,shutdown 则不管引用计数,直接使得该 socket 不可用,然后发出 FIN 报文,如果有别的进程企图使用该 socket,将会受到影响。
shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手,所以调用 shutdown 是优雅的关闭

TIME_WAIT

MSL(最大分段生存期),指明TCP报文在Internet上最长生存时间,每个具体的TCP实现都必须选择一个确定的MSL值

因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了

1
2
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT 
state, about 60 seconds */

TIME_WAIT的作用
  1. 保证「被动关闭连接」的一方,能被正确的关闭
    如果被动关闭⽅没有收到断开连接的最后的 ACK 报⽂,就会触发超时重发 Fin 报⽂,另⼀⽅接收到 FIN 后,会触发 ACK 给被动关闭⽅, ⼀来⼀去正好 2 个 MSL。
    2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端⼜接收到了服务端重发的 FIN 报⽂,那么 2MSL 时间将重新计时。可以看到 2MSL时长,这其实是相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。
    没有TIME_WAIT可以吗?
    没有数据要发送的话也是可以的,假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSE 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。

  1. 防止历史连接中的数据,被后面相同四元组的连接错误的接收
    在关闭一个TCP连接后,马上又重新建立起一个相同的IP地址和端口之间的TCP连接,后一个连接被称为前一个连接的化身 ,那么有可能出现这种情况,前一个连接的迷途重复分组在前一个连接终止后出现,从而被误解成从属于新的化身。为了避免这个情况,TCP不允许处于TIME_WAIT状态的连接启动一个新的化身,因为TIME_WAIT状态持续2MSL,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
    例如:服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 SEQ = 301 这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。
TIME_WAIT 过多有什么危害?

第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;第二是占用端口资源

如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题?

如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。

net.ipv4.tcp_tw_reuse
1
net.ipv4.tcp_tw_reuse = 1

开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。该选项只适用于连接发起方

举个例子,假设客户端已经与服务器建立了一个 TCP 连接,并且这个状态处于 TIME_WAIT 状态:

1
2
客户端地址:端口           服务端地址:端口         TCP 连接状态
192.168.1.100:2222 172.19.11.21:8888 TIME_WAIT

然后客户端又与该服务器(172.19.11.21:8888)发起了连接,在调用 connect 函数时,内核刚好选择了 2222 端口,接着发现已经被相同四元组的连接占用了:

  • 如果没有开启 net.ipv4.tcp_tw_reuse 内核参数,那么内核就会选择下一个端口,然后继续判断,直到找到一个没有被相同四元组的连接使用的端口, 如果端口资源耗尽还是没找到,那么 connect 函数就会返回错误。
  • 如果开启了 net.ipv4.tcp_tw_reuse 内核参数,就会判断该四元组的连接状态是否处于 TIME_WAIT 状态,如果连接处于 TIME_WAIT 状态并且该状态持续的时间超过了 1 秒,那么就会重用该连接,于是就可以使用 2222 端口了,这时 connect 就会返回成功。

再次提醒一次,开启了 net.ipv4.tcp_tw_reuse 内核参数,是客户端(连接发起方) 在调用 connect() 函数时才起作用,所以在服务端开启这个参数是没有效果的。

net.ipv4.tcp_tw_recycle

如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收;慎用,NAT环境下会导致SYN被丢弃,且不安全

要使得上面两个选项生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1))。

net.ipv4.tcp_max_tw_buckets
1
net.ipv4.tcp_max_tw_buckets

这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置,这个方法比较暴力。

程序中使用 SO_LINGER

设置 socket 选项,来设置调用 close 关闭连接行为。

1
2
3
4
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));

如果l_onoff为非 0, 且l_linger值为 0,那么调用close后,会立该发送一个RST标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。

《UNIX网络编程》:TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它。如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。

服务器出现大量 TIME_WAIT 状态的原因有哪些?

什么场景下服务端会主动断开连接呢?

  1. HTTP 没有使用长连接(Keep-Alive):
    1
    Connection: Keep-Alive
  • 客户端禁用了 HTTP Keep-Alive,服务端开启 HTTP Keep-Alive,谁是主动关闭方?
    当客户端禁用了 HTTP Keep-Alive,这时候 HTTP 请求的 header 就会有 Connection:close 信息,这时服务端在发完 HTTP 响应后,就会主动关闭连接。为什么要这么设计呢?HTTP 是请求-响应模型,发起方一直是客户端,HTTP Keep-Alive 的初衷是为客户端后续的请求重用连接,如果我们在某次 HTTP 请求-响应模型中,请求的 header 定义了 connection:close 信息,那不再重用这个连接的时机就只有在服务端了,所以我们在 HTTP 请求-响应这个周期的「末端」关闭连接是合理的。

  • 客户端开启了 HTTP Keep-Alive,服务端禁用了 HTTP Keep-Alive,谁是主动关闭方?
    当客户端开启了 HTTP Keep-Alive,而服务端禁用了 HTTP Keep-Alive,这时服务端在发完 HTTP 响应后,服务端也会主动关闭连接。为什么要这么设计呢?在服务端主动关闭连接的情况下,只要调用一次 close() 就可以释放连接,剩下的工作由内核 TCP 栈直接进行了处理,整个过程只有一次 syscall;如果是要求客户端关闭,则服务端在写完最后一个 response 之后需要把这个 socket 放入 readable 队列,调用 select / epoll 去等待事件;然后调用一次 read() 才能知道连接已经被关闭,这其中是两次 syscall,多一次用户态程序被激活执行,而且 socket 保持时间也会更长。

所以,根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都会导致服务端在处理完一个 HTTP 请求后,就主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接。都是由服务端主动关闭连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。

  1. HTTP 长连接超时
    为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,如 nginx 提供的 keepalive_timeout 参数。
    假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。

  2. HTTP 长连接的请求数量达到上限
    Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。
    比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
    对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000 , 50000 甚至更高,如果 keepalive_requests 参数值是 100,这时候nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态

服务器出现大量 CLOSE_WAIT 状态的原因有哪些?

当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接。需要排查代码

一个普通的 TCP 服务端的流程:

  1. 创建服务端 socket,bind 绑定端口、listen 监听端口
  2. 将服务端 socket 注册到 epoll
  3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
  4. 将已连接的 socket 注册到 epoll
  5. epoll_wait 等待事件发生
  6. 对方连接关闭时,我方调用 close
  • 第一个原因:第 2 步没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。不过这种原因发生的概率比较小,这种属于明显的代码逻辑 bug,在前期 read view 阶段就能发现的了。
  • 第二个原因: 第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。发生这种情况可能是因为服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。
  • 第三个原因:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。
  • 第四个原因:第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。
如果已经建立了连接,但是客户端突然出现故障了怎么办?

客户端的主机发生了宕机。发生这种情况的时候,如果服务端一直不会发送数据给客户端,那么服务端是永远无法感知到客户端宕机这个事件的,也就是服务端的 TCP 连接将一直处于 ESTABLISH 状态,占用着系统资源。

为了避免这种情况,TCP使用保活机制:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用。每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。

1
2
3
net.ipv4.tcp_keepalive_time=7200 //表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
net.ipv4.tcp_keepalive_intvl=75 //表示每次检测间隔 75 秒
net.ipv4.tcp_keepalive_probes=9 //表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。我们可以自己在应用层实现一个心跳机制
tcp_keepalive_time + (tcp_keepalive_intvl * tcp_keepalive_probes)

如果开启了 TCP 保活,需要考虑以下几种情况:

  1. 对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
  2. 对端主机宕机并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
  3. 是对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
如果已经建立了连接,但是服务端突然出现故障了怎么办?

在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手

四次挥手中收到乱序的 FIN 包会如何处理?

客户端只调用shutdown关闭写的情况下,假如服务端二三次挥手之间发送的数据被阻塞了,导致FIN先到达客户端,会发生什么?

在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就被会加入到「乱序队列」,这个队列的数据结构是红黑树,并不会进入到 TIME_WAIT 状态。

等再次收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这时才会进入 TIME_WAIT 状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int tcp_v4_rcv(struct sk_buff *skb){
...
//根据四元组查找相应连接的socket结构
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
if (!sk)
goto no_tcp_socket;
...
//判断socket是否被user占用,如果没有占用,调用tcp_v4_do_rcv()
if (!sock_owned_by_user(sk)) {
if (!tcp_prequeue(sk, skb))
ret = tcp_v4_do_rcv(sk, skb);
}
//为了避免并发操作socket,将数据包放入backlog队列,放入失败或已满,丢弃数据包
else if (unlikely(sk_add_backlog(sk, skb,
sk->sk_rcvbuf + sk->sk_sndbuf))) {
bh_unlock_sock(sk);
NET_INC_STATS_BH(net, LINUX_MIB_TCPBACKLOGDROP);
goto discard_and_relse;
}
...
}
1
2
3
4
5
6
7
8
9
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb){
...
if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
rsk = sk;
goto reset;
}
return 0;
...
}

shutdown 只关闭了写方向,所以会继续往下调用 tcp_data_queue 函数(因为 case TCP_FIN_WAIT2 代码块里并没有 break 语句,所以会走到该函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
...
case TCP_FIN_WAIT2:
/* RFC 793 says to queue data in these states,
* RFC 1122 says we MUST send a reset.
* BSD 4.4 also does reset.
*/
if (sk->sk_shutdown & RCV_SHUTDOWN) {
if (TCP_SKB_CB(skb)->end_seq != TCP_SKB_CB(skb)->seq &&
after(TCP_SKB_CB(skb)->end_seq - th->fin, tp->rcv_nxt)) {
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPABORTONDATA);
tcp_reset(sk);
return 1;
}
}
/* Fall through */
case TCP_ESTABLISHED:
tcp_data_queue(sk, skb);
queued = 1;
break;
}

/* tcp_data could move socket to TIME-WAIT */
if (sk->sk_state != TCP_CLOSE) {
tcp_data_snd_check(sk);
tcp_ack_snd_check(sk);
}
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static void tcp_data_queue(struct sock *sk, struct sk_buff *skb){
...
/* Queue data for delivery to the user.
* Packets in sequence go to the receive queue.
* Out of sequence packets to the out_of_order_queue.
*/
//如果报文的序列号是有序的
if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
if (tcp_receive_window(tp) == 0)
goto out_of_window;

/* Ok. In sequence. In window. */
if (tp->ucopy.task == current &&
tp->copied_seq == tp->rcv_nxt && tp->ucopy.len &&
sock_owned_by_user(sk) && !tp->urg_data) {
int chunk = min_t(unsigned int, skb->len,
tp->ucopy.len);

__set_current_state(TASK_RUNNING);
...
//如果有fin标识,会调用tcp_fin()
if (TCP_SKB_CB(skb)->tcp_flags & TCPHDR_FIN)
tcp_fin(sk);
//检查乱序队列有没有数据
if (!skb_queue_empty(&tp->out_of_order_queue)) {
//检查乱序队列中是否有数据包可用,即是否在乱序队列中找到与当前数据包保持序列号连续的数据包
tcp_ofo_queue(sk);

/* RFC2581. 4.2. SHOULD send immediate ACK, when
* gap in queue is filled.
*/
if (skb_queue_empty(&tp->out_of_order_queue))
inet_csk(sk)->icsk_ack.pingpong = 0;
}
...
/* If window is closed, drop tail of packet. But after
* remembering D-SACK for its head made in previous line.
*/
if (!tcp_receive_window(tp))
goto out_of_window;
goto queue_and_out;
}
//如果是乱序的,通过此函数加入乱序队列
tcp_data_queue_ofo(sk, skb);
}

tcp_input.c/tcp_data_queue_ofo()

1
2
3
4
static void tcp_data_queue_ofo(struct sock *sk, struct sk_buff *skb)
{
//红黑树结构,有点复杂,自己研究
}

在TIME_WAIT状态的 TCP 连接,收到 SYN 后会发生什么?

在 TCP 正常挥手过程中,处于 TIME_WAIT 状态的连接,收到相同四元组的 SYN 后会发生什么?

针对这个问题,关键是要看 SYN 的「序列号和时间戳」是否合法
合法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要大,并且 SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要大。
非法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要小,或者 SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要小。

点击此处跳转到TCP时间戳和序列号回绕(PAWS)

如果处于 TIME_WAIT 状态的连接收到「合法的 SYN 」后,就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。
如果处于 TIME_WAIT 状态的连接收到「非法的 SYN 」后,就会再回复一个第四次挥手的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号,就回 RST 报文给服务端

那么处于 TIME_WAIT 状态的连接,收到 RST 会断开连接吗?

net.ipv4.tcp_rfc1337 这个内核参数(默认情况是为 0):
如果这个参数设置为 0, 收到 RST 报文会提前结束 TIME_WAIT 状态,释放连接。
如果这个参数设置为 1, 就会丢掉 RST 报文。

TCP保活机制

TCP保活(KeepAlive)功能工作过程中,开启该功能的一端会发现对方处于以下四种状态之一:

  1. 对方主机仍在工作,并且可以到达。此时请求端将保活计时器重置。如果在计时器超时之前应用程序通过该连接传输数据,计时器再次被设定为保活时间值。

  2. 对方主机已经崩溃,包括已经关闭或者正在重新启动。这时对方的TCP将不会响应。请求端不会接收到响应报文,并在经过保活时间间隔指定的时间后超时。超时前,请求端会持续发送探测报文,一共发送保活探测数指定次数的探测报文,如果请求端没有收到任何探测报文的响应,那么它将认为对方主机已经关闭,连接也将被断开。

  3. 客户主机崩溃并且已重启。在这种情况下,请求端会收到一个对其保活探测报文的响应,但这个响应是一个重置报文段RST,请求端将会断开连接。

  4. 对方主机仍在工作,但是由于某些原因不能到达请求端(例如网络无法传输,而且可能使用ICMP通知也可能不通知对方这一事实)。这种情况与状态2相同,因为TCP不能区分状态2与状态4,结果是都没有收到探测报文的响应。

其存在以下两点主要弊端:

  1. 在出现短暂的网络错误的时候,保活机制会使一个好的连接断开;
  2. 保活机制会占用不必要的带宽;

应用层自己实现的心跳包

由应用程序自己发送心跳包来检测连接是否正常,大致的方法是:服务器在一个 Timer事件中定时 向客户端发送一个短小精悍的数据包,然后启动一个低级别的线程,在该线程中不断检测客户端的回应, 如果在一定时间内没有收到客户端的回应,即认为客户端已经掉线;同样,如果客户端在一定时间内没 有收到服务器的心跳包,则认为连接不可用。

对比

  1. TCP自带的KeepAlive使用简单,发送的数据包相比应用层心跳检测包更小,仅提供检测连接功能

  2. 应用层心跳包不依赖于传输层协议,无论传输层协议是TCP还是UDP都可以用

  3. 应用层心跳包可以定制,可以应对更复杂的情况或传输一些额外信息

  4. KeepAlive仅代表连接保持着,而心跳包往往还代表客户端可正常工作

TCP可靠传输

超时重传

当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
精确的测量超时时间 RTO 的值是非常重要的,这可让重传机制更高效。超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。

Linux 是如何计算 RTO 的呢?估计往返时间,通常需要采样以下两个:

  1. 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化。
  2. 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况。

RFC6289 建议使用以下的公式计算 RTO:

其中 SRTT 是计算平滑的RTT ,DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。
在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。别问怎么来的,问就是大量实验中调出来的。
每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

快速重传

快速重传机制,它不以时间为驱动,而是以数据驱动重传。

  • 第一份 Seq1 先送到了,于是就 Ack 回 2;
  • 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;
  • 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
  • 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。
  • 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。

此时会引出一个问题:只丢Seq2和Seq2、Seq3都丢失了,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的, 那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢

SACK(选择性确认)

需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据
发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。

如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。

Duplicate SACK

Duplicate SACK 又称 D-SACK,其主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。

  • 「接收方」发给「发送方」的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499)
  • 于是「接收方」发现数据是重复收到的,于是回了一个 SACK = 3000~3500,告诉「发送方」 3000~3500 的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个 SACK 就代表着 D-SACK。
  • 这样「发送方」就知道了,数据没有丢,是「接收方」的 ACK 确认报文丢了。

  • 数据包(1000~1499) 被网络延迟了,导致「发送方」没有收到 Ack 1500 的确认报文。
  • 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000~1499)又到了「接收方」;
  • 所以「接收方」回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包。
  • 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。

D-SACK 有这么几个好处:

  1. 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
  2. 可以知道是不是「发送方」的数据包被网络延迟了;
  3. 可以知道网络中是不是把「发送方」的数据包给复制了;

在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。

校验和

发送方:在发送数据之前计算检验和,并进行校验和的填充。
接收方:收到数据后,对数据以同样的方式进行计算,求出校验和,与发送方的进行比对。
注意:如果接收方比对校验和与发送方不一致,那么数据一定传输有误。但是如果接收方比对校验和与发送方一致,数据不一定传输成功。

停止等待

停止等待”就是每发送完一个分组就停止发送,等待对方的确认。在收到确认后再发送下一个分组
序列号的作用不仅仅是应答的作用,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据。这也是TCP传输可靠性的保证之一。

确认迟到

  • A收到重复的确认后,直接丢弃。
  • B收到重复的M1后,也直接丢弃重复的M1

流量控制

窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。

这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。通常窗口的大小是由接收方的窗口大小来决定的

累计确认

假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:

滑动窗口协议

发送方:#2 和 #3就是发送方的滑动窗口

1 是已发送并收到 ACK确认的数据

2 是已发送但未收到 ACK确认的数据

3 是未发送但总大小在接收方处理范围内(接收方还有空间)

4 是未发送但总大小超过接收方处理范围(接收方没有空间)

滑动窗口在被连续确认后才进行滑动,当收到之前发送的数据 32~36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动5个字节,接下来 52~56 字节又变成了可用窗口,那么后续也就可以发送这5个字节的数据了。

  • SND.WND:表示发送窗口的大小(大小是由接收方指定的);
  • SND.UNA:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
  • SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。
    那么可用窗口大小的计算就可以是:可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)

接收方:#2就是接收窗口

1 + #2 是已成功接收并确认的数据(等待应用进程读取);

3 是未收到数据但可以接收的数据;

4 未收到数据并不可以接收的数据;

  • RCV.WND:表示接收窗口的大小,它会通告给发送方。
  • RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。

思考丢包的情形?
为了防止丢包的情况产生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。

TCP的”死锁”

当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如不采取措施,这种相互等待的过程,会造成了死锁的现象。

只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。如果持续计时器超时,就会发送窗口探测报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。

糊涂窗口综合症

如果发送端为产生数据很慢的应用程序服务,例如一次产生一个字节。这个应用程序一次将一个字节的数据写入发送端的TCP的缓存。如果发送端的TCP没有特定的指令,它就产生只包括一个字节数据的报文段。结果有很多41字节的IP数据报就在互连网中传来传去。(大车拉少人)

就要同时解决两个问题就可以了:

  1. 让接收方不通告小窗口给发送方
  2. 让发送方避免发送小数据

接收方策略如下:
当「窗口大小」小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0,也就阻止了发送方再发数据过来。

发送方策略如下:
防止发送端的TCP逐个字节地发送数据。必须强迫发送端的TCP收集数据,然后用一个更大的数据块来发送。发送端的TCP要等待多长时间呢?如果它等待过长,它就会使整个的过程产生较长的时延。如果它的等待时间不够长,它就可能发送较小的报文段。

使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才可以发送数据:

条件一:要等到窗口大小 >= MSS 并且 数据大小 >= MSS;
条件二:收到之前发送数据的 ack 回包;

1
2
3
4
5
6
7
8
9
10
11
if 有数据要发送 {
if 可用窗口大小 >= MSS and 可发送的数据 >= MSS {
立刻发送MSS大小的数据
} else {
if 有未确认的数据 {
将数据放入缓存等待接收ACK
} else {
立刻发送数据
}
}
}

注意,如果接收方不能满足「不通告小窗口给发送方」,那么即使开了 Nagle 算法,也无法避免糊涂窗口综合症,因为如果对端 ACK 回复很快的话(达到 Nagle 算法的条件二),Nagle 算法就不会拼接太多的数据包,这种情况下依然会有小数据包的传输,网络总体的利用率依然很低。所以,接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症

拥塞控制

慢开始

拥塞窗口cwnd,发送窗口swnd
慢开始算法的思路就是,不要一开始就发送大量的数据,先探测一下网络的拥塞程度,也就是说由小到大逐渐增加拥塞窗口的大小。拥塞窗口的增长是指数级别的。慢启动的机制只是说明在开始的时候发送的少,发送的慢,但是增长的速度是非常快的。

慢启动门限ssthresh状态变量。
当 cwnd < ssthresh 时,使用慢启动算法。
当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。

拥塞避免

为了控制拥塞窗口的增长,不能使拥塞窗口单纯的加倍,设置一个拥塞窗口的阈值,当拥塞窗口大小超过阈值时,不能再按照指数来增长,而是线性的增长。
在慢启动开始的时候,慢启动的阈值等于窗口的最大值,一旦造成网络拥塞,发生超时重传时,慢启动的阈值会为原来的一半(这里的原来指的是发生网络拥塞时拥塞窗口的大小),同时拥塞窗口重置为 1

快速重传

点击此处跳转到快速重传

快恢复

考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将cwnd设置为ssthresh减半后的值,然后执行拥塞避免算法,使cwnd缓慢增大。

高性能TCP

TCP快速连接

客户端在向服务端发起 HTTP GET 请求时,一个完整的交互过程,需要 2.5 个 RTT 的时延。由于第三次握手是可以携带数据的,这时如果在第三次握手发起 HTTP GET 请求,需要 2 个 RTT 的时延。

在 Linux 3.7 内核版本中,提供了 TCP Fast Open 功能,内核参数:net.ipv4.tcp_fastopen,0-关闭;1-作为客户端使用 Fast Open 功能;2-作为服务端使用 Fast Open 功能;3-无论作为客户端还是服务器,都可以使用 Fast Open 功能这个功能可以减少 TCP 连接建立的时延。

在第一次建立连接的时候,服务端在第二次握手产生一个 Cookie (已加密)并通过 SYN、ACK 包一起发给客户端,在下次请求的时候,客户端在 SYN 包带上 Cookie 发给服务端,就提前可以跳过三次握手的过程,因为 Cookie 中维护了一些信息,服务端可以从 Cookie 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就只需要 1 个 RTT 的时延;

如何理解是 TCP 面向字节流协议

当消息通过 UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。
操作系统在收到 UDP 报文后,会将其插入到队列里,队列里的每一个元素就是一个 UDP 报文,这样当用户调用 recvfrom() 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。

当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。例如:”Hello World”、”Hello” + “ World”、”He”+”llo World”等各种情况,所以,我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。

TCP粘包和拆包

如果客户端连续不断的向服务端发送数据包时,服务端接收的数据会出现两个数据包粘在一起的情况,这时接收方不知道消息的边界的话,是无法读出有效的消息。

TCP发生粘包和拆包原因
  1. 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。
  2. 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。
  3. 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。
  4. 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包
粘包的解决办法

解决问题的关键在于如何给每个数据包添加边界信息,常用的方法有如下几个:

一般有三种方式分包的方式:

  1. 固定长度的消息
    规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。但是这种方式灵活性不高,实际中很少用。
  2. 特殊字符作为边界
    HTTP 是一个非常好的例子。

  1. 自定义消息结构。
    首先 4 个字节大小的变量来表示数据长度,真正的数据则在后面。

    1
    2
    3
    4
    struct { 
    u_int32_t message_length;
    char message_data[];
    } message;
  2. 发送端给每个数据包添加包首部,首部中应该至少包含数据包的长度,这样接收端在接收到数据后,通过读取包首部的长度字段,便知道每一个数据包的实际长度了。

UDP协议是否会发生粘包问题

不会。UDP是基于报文发送的,在UDP首部采用了16bit来指示UDP数据报文的长度,因此在应用层能很好的将不同的数据报文区分开,从而避免粘包和拆包的问题。

有一个 IP 的服务端监听了一个端口,它的 TCP 的最大连接数是多少?

客户端 IP 和端口是可变的,最大TCP连接数 = 客户端的 IP 数 * 客户端的端口数

对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,所以,服务端单机最大 TCP 连接数,约为 2 的 48 次方。

服务端最大并发 TCP 连接数远不能达到理论上限,会受以下因素影响:

  • 文件描述符限制,每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 Too many open files。Linux 对可打开的文件描述符的数量分别作了三个方面的限制:
    系统级:当前系统可打开的最大数量,通过 cat /proc/sys/fs/file-max 查看;
    用户级:指定用户可打开的最大数量,通过 cat /etc/security/limits.conf 查看;
    进程级:单个进程可打开的最大数量,通过 cat /proc/sys/fs/nr_open 查看;
  • 内存限制,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM。

TCP 和 UDP 可以同时绑定相同的端口吗?

可以,在操作系统的协议栈中,TCP和UDP是两个不同的模块,当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。

多个 TCP 服务进程可以绑定同一个端口吗?

如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。

注意,如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。这是因为 0.0.0.0 地址比较特殊,代表任意地址,意味着绑定了 0.0.0.0 地址,相当于把主机上的所有 IP 地址都绑定了。

重启 TCP 服务进程时,为什么会有“Address in use”的报错信息?

TCP四次挥手后,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。

当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。

如何解决:
对 socket 设置 SO_REUSEADDR 属性,可以解决这个问题。

1
2
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

如果当前启动进程绑定的 IP+PORT 与处于TIME_WAIT 状态的连接占用的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功。

客户端的端口可以重复使用吗?

TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。

为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢?

  1. 为了防止历史报文被下一个相同四元组的连接接收(主要方面);
    如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。

    已经有了TIME_WAIT 状态且持续 2 MSL 时长,历史报文不是早就消散了吗?

    思考下面这种情况:

    客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。

如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,如果每次的初始化序列号一样,在对方接收窗口的概率就会变大。而且TCP产生的随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。


但是也不是完全避免:

  • 序列号(SEQ),是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0
  • 初始序列号(ISN),在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时

序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。为了解决这个问题,就需要有 TCP 时间戳。tcp_timestamps 参数是默认开启的,开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)
在开启 tcp_timestamps 选项情况下,一台机器发的所有 TCP 包都会带上发送时的时间戳,PAWS 要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包

  1. 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收;

既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?

如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。

UDP层的分片

  1. 由于UDP是不需要保证可靠性的,那么它就不会保存发送的数据包,TCP之所以保存发送的数据包是因为要进行重传。所以UDP本身是没有像TCP一样的发送缓冲区的。这就导致了对UDP进行write系统调用的时候,实际上应用层的数据是直接传输到IP层,由于IP层本身也不会有缓冲区,数据就会直接写到链路层的输出队列中。
  2. 在这种情况下,IP层会不会对来自UDP的数据进行分片呢?这个取决于UDP数据报的大小。如果UDP数据报的大小大于链路层的MTU,那么IP层就会直接进行分片,然后在发送到链路层的输出队列中,反之,则不会进行分片,直接加上IP头部发送到链路层的输出队列中。

TCP 连接,一端断电和进程崩溃有什么区别?

在没有开启 TCP keepalive,且双方一直没有数据交互的情况下,如果客户端的「主机崩溃」了,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。

进程崩溃,在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手。

有数据传输的场景:

  1. 客户端主机宕机,又迅速重启
    只要有一方重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接。
  2. 客户端主机宕机,一直没有重启
    这种情况,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。
1
2
cat /proc/sys/net/ipv4/tcp_retries2
15

内核会根据 tcp_retries2 设置的值,计算出一个 timeout,如果重传间隔超过这个 timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接。

拔掉网线后, 原本的 TCP 连接还存在吗?

存在,TCP在内核中以struct socket结构体存在,当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变。所以拔网线的动作不会影响TCP的状态
后续:

  1. 拔掉网线后,有数据传输,触发超时重传机制
    如果在服务端重传报文的过程中,客户端刚好把网线插回去了,无事发生。
    如果在服务端重传报文的过程中,客户端一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元祖的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。

  2. 拔掉网线后,没有数据传输
    如果没有开启 TCP keepalive 机制,在客户端拔掉网线后,那么客户端和服务端的 TCP 连接将会一直保持存在。
    如果开启了 TCP keepalive 机制,对端主机宕机,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,没有响应连续几次,达到保活探测次数后,TCP 会报告该连接已经死亡。

为什么 tcp_tw_reuse 默认是关闭的?

问题一:因为快速复用 TIME_WAIT 状态的端口,导致新连接可能被回绕序列号的 RST 报文断开了,而如果不跳过 TIME_WAIT 状态,而是停留 2MSL 时长,那么这个 RST 报文就不会出现下一个新的连接。

前面被网络延迟 RST 报文这时抵达了客户端,而且 RST 报文的序列号在客户端的接收窗口内,由于防回绕序列号算法不会防止过期的 RST,所以 RST 报文会被客户端接受了,于是客户端的连接就断开了。

问题二:如果第四次挥手的 ACK 报文丢失了,服务端会触发超时重传,重传第三次挥手报文,处于 syn_sent 状态的客户端收到服务端重传第三次挥手报文,则会回 RST 给服务端。

如果 TIME_WAIT 状态被快速复用后,刚好第四次挥手的 ACK 报文丢失了,那客户端复用 TIME_WAIT 状态后发送的 SYN 报文被处于 last_ack 状态的服务端收到了会发生什么呢?

处于 last_ack 状态的服务端收到了 SYN 报文后,会回复确认号与服务端上一次发送 ACK 报文一样的 ACK 报文,这个 ACK 报文称为 Challenge ACK ,并不是确认收到 SYN 报文。处于 syn_sent 状态的客户端收到服务端的 Challenge ACK 后,发现不是自己期望收到的确认号,于是就会回复 RST 报文,服务端收到后,就会断开连接。

没有listen,能建立TCP连接吗?

答案,是可以的,客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有listen,就能建立连接。

执行 listen 方法时,会创建半连接队列和全连接队列。但是客户端没有listen方法,是如何做到的?
内核还有个全局 hash 表,可以用于存放 sock 连接的信息。这个全局 hash 表其实还细分为 ehash,bhash和listen_hash等。在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局 hash 表中,然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <sys/types.h> 
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

#define LOCAL_IP_ADDR (0x7F000001) // IP 127.0.0.1
#define LOCAL_TCP_PORT (34567) // 端口

int main(void)
{
struct sockaddr_in local, peer;
int ret;
char buf[128];
int sock = socket(AF_INET, SOCK_STREAM, 0);

memset(&local, 0, sizeof(local));
memset(&peer, 0, sizeof(peer));

local.sin_family = AF_INET;
local.sin_port = htons(LOCAL_TCP_PORT);
local.sin_addr.s_addr = htonl(LOCAL_IP_ADDR);

peer = local;

int flag = 1;
ret = setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
if (ret == -1) {
printf("Fail to setsocket SO_REUSEADDR: %s\n", strerror(errno));
exit(1);
}

ret = bind(sock, (const struct sockaddr *)&local, sizeof(local));
if (ret) {
printf("Fail to bind: %s\n", strerror(errno));
exit(1);
}

ret = connect(sock, (const struct sockaddr *)&peer, sizeof(peer));
if (ret) {
printf("Fail to connect myself: %s\n", strerror(errno));
exit(1);
}

printf("Connect to myself successfully\n");

//发送数据
strcpy(buf, "Hello, myself~");
send(sock, buf, strlen(buf), 0);

memset(buf, 0, sizeof(buf));

//接收数据
recv(sock, buf, sizeof(buf), 0);
printf("Recv the msg: %s\n", buf);

sleep(1000);
close(sock);
return 0;
}

可以看到 TCP socket 成功的“连接”了自己,并发送和接收了数据包,netstat 的输出更证明了 TCP 的两端地址和端口是完全相同的。

没有accept,能建立TCP连接吗?

可以,建立连接的过程中根本不需要accept()参与, 执行accept()只是为了从全连接队列里取出一条连接。

虽然都叫队列,但其实全连接队列icsk_accept_queue是个链表,而半连接队列syn_table是个哈希表。思考为什么这么设计?

补充:前面提到了预防SYN攻击可以开启 syncookies 功能:点击此处跳转到syncookies功能,那么,会有一个cookies队列吗?
不会,如果有这样一个队列的话,碰到SYN攻击也会被打满,它是通过通信双方的IP地址端口、时间戳、MSS等信息进行实时计算的,保存在TCP报头的seq里。

cookies方案为什么不直接取代半连接队列?
  1. 因为cookies方案服务端并不会保存连接信息,所以如果传输过程中数据包丢了,也不会重发第二次握手的信息。

  2. 编码解码cookies,都是比较耗CPU的,利用这一点,如果此时攻击者构造大量的第三次握手包(ACK包),进行ACK攻击(通过构造大量ACK包去消耗服务端资源的攻击),同时带上各种瞎编的cookies信息,服务端收到ACK包后以为是正经cookies,憨憨地跑去解码(耗CPU),最后发现不是正经数据包后才丢弃。

TCP 序列号和确认号是如何变化的?

万能公式
公式一:序列号 = 上一次发送的序列号 + len(数据长度)。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 上一次发送的序列号 + 1。
公式二:确认号 = 上一次收到的报文中的序列号 + len(数据长度)。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1。

TCP的缺陷

升级困难

存在与操作系统内核的协议栈,升级新的TCP需要升级内核,很麻烦

建立连接的延迟

现在大多数网站都是使用 HTTPS 的,这意味着在 TCP 三次握手之后,还需要经过 TLS 四次握手后,才能进行 HTTP 数据的传输,这在一定程序上增加了数据传输的延迟。

存在队头阻塞问题

TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据。

网络迁移需要重新建立 TCP 连接

当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接

TCP一定可靠吗?

数据包的发送流程

  1. 为了发送数据包,两端首先会通过三次握手,建立TCP连接。

  2. 一个数据包,从聊天框里发出,消息会从聊天软件所在的用户空间拷贝到内核空间的发送缓冲区(send buffer),数据包就这样顺着传输层、网络层,进入到数据链路层,在这里数据包会经过流控(qdisc),再通过RingBuffer发到物理层的网卡。数据就这样顺着网卡发到了纷繁复杂的网络世界里。这里头数据会经过n多个路由器和交换机之间的跳转,最后到达目的机器的网卡处

  3. 此时目的机器的网卡会通知DMA将数据包信息放到RingBuffer中,再触发一个硬中断给CPU,CPU触发软中断让ksoftirqd去RingBuffer收包,于是一个数据包就这样顺着物理层,数据链路层,网络层,传输层,最后从内核空间拷贝到用户空间里的聊天软件里

何时会丢包?

建立连接时丢包

半连接队列和全连接队列已满,那新来的包就会被丢弃。

1
2
3
4
5
6
7
# 全连接队列溢出次数
# netstat -s | grep overflowed
4343 times the listen queue of a socket overflowed

# 半连接队列溢出次数
# netstat -s | grep -i "SYNs to LISTEN sockets dropped"
109 times the listen queue of a socket overflowed
流量控制丢包

如果所有数据不加控制一股脑冲入到网卡,网卡会吃不消,让数据按一定的规则排个队依次处理,也就是所谓的qdisc(Queueing Disciplines,排队规则),这也是我们常说的流量控制机制。

1
2
3
4
5
6
7
8
9
# ifconfig eth0
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.21.66.69 netmask 255.255.240.0 broadcast 172.21.79.255
inet6 fe80::216:3eff:fe25:269f prefixlen 64 scopeid 0x20<link>
ether 00:16:3e:25:26:9f txqueuelen 1000 (Ethernet)
RX packets 6962682 bytes 1119047079 (1.0 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 9688919 bytes 2072511384 (1.9 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

txqueuelen后面的数字1000,其实就是流控队列的长度。当发送数据过快,流控队列长度txqueuelen又不够大时,就容易出现丢包现象。查看TX下的dropped字段,当它大于0时,则有可能是发生了流控丢包。

1
# ifconfig eth0 txqueuelen 1500 //流控队列长度从1000提升为1500.
网卡丢包
  1. 网线质量差,接触不良等

  2. RingBuffer过小导致丢包
    上面提到,在接收数据时,会将数据暂存到RingBuffer接收缓冲区中,然后等着内核触发软中断慢慢收走。如果这个缓冲区过小,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生丢包。

    1
    2
    3
    # ifconfig
    eth0: RX errors 0 dropped 0 overruns 0 frame 0
    //查看上面的overruns指标,它记录了由于RingBuffer长度不足导致的溢出次数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
Current hardware settings:
RX: 1024
RX Mini: 0
RX Jumbo: 0
TX: 1024

//RingBuffer最大支持4096的长度,但现在实际只用了1024。想要修改这个长度可以执行ethtool -G eth1 rx 4096 tx 4096将发送和接收RingBuffer的长度都改为4096。
  1. 网卡性能不足
接收缓冲区丢包

使用TCP socket进行网络编程的时候,内核都会分配一个发送缓冲区和一个接收缓冲区。不管是接收缓冲区还是发送缓冲区,都能看到三个数值,分别对应缓冲区的最小值,默认值和最大值 (min、default、max)。缓冲区会在min和max之间动态调整。

1
2
3
4
5
6
7
8
# 查看接收缓冲区
# sysctl net.ipv4.tcp_rmem
net.ipv4.tcp_rmem = 4096 87380 6291456

# 查看发送缓冲区
# sysctl net.ipv4.tcp_wmem
net.ipv4.tcp_wmem = 4096 16384 4194304

  • 当发送缓冲区满了,如果是阻塞调用,那就会等,等到缓冲区有空位可以发数据。如果是非阻塞调用,就会立刻返回一个 EAGAIN 错误信息,意思是 Try again。让应用程序下次再重试。这种情况下一般不会发生丢包。
  • 当接受缓冲区满了,它的TCP接收窗口会变为0,也就是所谓的零窗口,并且会通过数据包里的win=0,告诉发送端。一般这种情况下,发送端就该停止发消息了,但如果这时候确实还有数据发来,就会发生丢包。
两端之间的网络丢包

路由器和交换机还有光缆啥的

灵活应用——HTTP和HTTPS

到目前为止,HTTP 常见到版本有 HTTP/1.1,HTTP/2.0,HTTP/3.0,不同版本的 HTTP 特性是不一样的。

HTTP 是超⽂本传输协议,也就是HyperText Transfer Protocol。HTTP 是⼀个在计算机世界⾥专⻔在「两点」之间「传输」⽂字、图⽚、⾳频、视频等「超⽂本」数据的「约定和规范」。

HTTP请求报文

HTTP 协议通过设置回车符、换行符作为 HTTP header 的边界,通过 Content-Length 字段作为 HTTP body 的边界,这两个方式都是为了解决“粘包”的问题。

Get请求例子,使用Charles抓取的request:

1
2
3
4
5
6
7
GET /562f25980001b1b106000338.jpg HTTP/1.1
Host img.mukewang.com
User-Agent Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
Accept image/webp,image/*,*/*;q=0.8
Referer http://www.imooc.com/
Accept-Encoding gzip, deflate, sdch
Accept-Language zh-CN,zh;q=0.8

请求行

用来说明请求方法,要访问的资源以及所使用的HTTP版本。GET说明请求方法为GET,jpg为要访问的资源,该行的最后一部分说明使用的是HTTP1.1版本。

请求方法

1. GET

传递参数长度受限制,因为传递的参数是直接表示在地址栏中,而特定浏览器和服务器对url的长度是有限制的。因此,GET不适合用来传递私密数据,也不适合拿来传递大量数据。一般的HTTP请求大多都是GET。

2. POST

POST把传递的数据封装在HTTP请求数据中,以名称/值的形式出现,可以传输大量数据,对数据量没有限制,也不会显示在URL中。表单的提交用的是POST。

GET和POST的区别:
  • GET 的语义是从服务器获取指定的资源⽅法,GET 请求的参数位置一般是写在 URL 中,URL 规定只能支持 ASCII,所以 GET 请求的参数只允许 ASCII 字符 ,而且浏览器会对 URL 的长度有限制(HTTP协议本身对 URL长度并没有做任何规定)。
  • GET是安全且幂等的,因为它是「只读」操作,⽆论操作多少次,服务器上的数据都是安全的,且每次的结果都是相同的。可以对 GET 请求的数据做缓存,这个缓存可以做到浏览器本身上(彻底避免浏览器发请求),也可以做到代理上(如nginx),而且在浏览器中 GET 请求可以保存为书签。

  • POST 的语义是根据请求负荷(报文body)对指定的资源做出处理,POST 请求携带数据的位置一般是写在报文 body 中,body 中的数据可以是任意格式的数据,只要客户端与服务端协商好即可,而且浏览器不会对 body 大小做限制。

  • POST 因为是「新增或提交数据」的操作,会修改服务器上的资源,所以是不安全的,且多次提交数据就会创建多个资源,所以不是幂等的。所以,浏览器一般不会缓存 POST 请求,也不能把 POST 请求保存为书签。

3. HEAD
HEAD跟GET相似,不过服务端接收到HEAD请求时只返回响应头,不发送响应内容。所以,如果只需要查看某个页面的状态时,用HEAD更高效,因为省去了传输页面内容的时间。
4. DELETE
删除某一个资源。
5. OPTIONS
用于获取当前URL所支持的方法。若请求成功,会在HTTP头中包含一个名为“Allow”的头,值是所支持的方法,如“GET, POST”。
6. PUT
把一个资源存放在指定的位置上。本质上来讲, PUT和POST极为相似,都是向服务器发送数据,但它们之间有一个重要区别,PUT通常指定了资源的存放位置,而POST则没有,POST的数据存放位置由服务器自己决定。
7. TRACE
回显服务器收到的请求,主要用于测试或诊断。
8. CONNECT
CONNECT方法是HTTP/1.1协议预留的,能够将连接改为管道方式的代理服务器。通常用于SSL加密服务器的链接与非加密的HTTP代理服务器的通信。

请求头部

请求头部由关键字/值对组成,每行一对
User-Agent : 产生请求的浏览器类型
Accept : 客户端希望接受的数据类型,比如 Accept:text/xml(application/json)表示希望接受到的是xml(json)类型
Content-Type:发送端发送的实体数据的数据类型。比如,Content-Type:text/html(application/json)表示发送的是html类型。
Host : 请求的主机名,允许多个域名同处一个IP地址,即虚拟主机
Referer:表示当前请求是从哪个资源发起的;或者是请求的上一步的地址。
Referer是常用于网站的访问统计,比如我在很多地方都做了广告链接到我网站的主页,这时候我就可以通过Referer来查看哪些地方跳转过来的人多,就说广告的效果好。

空行

请求头部后面的空行是必须的,即使第四部分的请求数据为空,也必须有空行。

请求数据

请求数据也叫主体,可以添加任意的其他数据。

HTTP响应报文

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Date: Fri, 22 May 2009 06:07:21 GMT
Content-Type: text/html; charset=UTF-8

<html>
<head></head>
<body>
<!--body goes here-->
</body>
</html>

状态行

由HTTP协议版本号, 状态码, 状态消息 三部分组成。(HTTP/1.1)表明HTTP版本为1.1版本,状态码为200,状态消息为(ok)

状态码

1xx 类状态码

属于提示信息,是协议处理中的一种中间状态,实际用到的比较少。

2xx 类状态码

表示服务器成功处理了客户端的请求,也是我们最愿意看到的状态。

  • 「200 OK」是最常见的成功状态码,表示一切正常。如果是非 HEAD 请求,服务器返回的响应头都会有 body 数据。
  • 「204 No Content」也是常见的成功状态码,与 200 OK 基本相同,但响应头没有 body 数据。
  • 「206 Partial Content」是应用于 HTTP 分块下载或断点续传,表示响应返回的 body 数据并不是资源的全部,而是其中的一部分,也是服务器处理成功的状态。
3xx 类状态码

表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向。

  • 「301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。
  • 「302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。

    301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。

  • 「304 Not Modified」不具有跳转的含义,表示资源未修改,重定向已存在的缓冲文件,也称缓存重定向,也就是告诉客户端可以继续使用缓存资源,用于缓存控制。

4xx 类状态码

表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。

  • 「400 Bad Request」表示客户端请求的报文有错误,但只是个笼统的错误。
  • 「403 Forbidden」表示服务器禁止访问资源,并不是客户端的请求出错。
  • 「404 Not Found」表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端。
5xx 类状态码

表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。

  • 「500 Internal Server Error」与 400 类型,是个笼统通用的错误码,服务器发生了什么错误,我们并不知道。
  • 「501 Not Implemented」表示客户端请求的功能还不支持,类似“即将开业,敬请期待”的意思。
  • 「502 Bad Gateway」通常是服务器作为网关或代理时返回的错误码,表示服务器自身工作正常,访问后端服务器发生了错误。
  • 「503 Service Unavailable」表示服务器当前很忙,暂时无法响应客户端,类似“网络服务正忙,请稍后重试”的意思。

响应头

用来说明客户端要使用的一些附加信息
Date:生成响应的日期和时间;Content-Type:指定了MIME类型的HTML(text/html),编码类型是UTF-8

空行

第三部分:空行,消息报头后面的空行是必须的

响应体

服务器返回给客户端的文本信息。空行后面的html部分为响应正文。

HTTP缓存技术

强制缓存

强缓存指的是只要浏览器判断缓存没有过期,则直接使用浏览器的本地缓存,决定是否使用缓存的主动性在于浏览器这边。

  • Cache-Control, 是一个相对时间;
  • Expires,是一个绝对时间;

如果 HTTP 响应头部同时有 Cache-Control 和 Expires 字段的话,Cache-Control 的优先级高于 Expires 。Cache-control 选项更多一些,设置更加精细,所以建议使用 Cache-Control 来实现强缓存。具体的实现流程如下:

  • 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 Cache-Control,Cache-Control 中设置了过期时间大小;
  • 浏览器再次请求访问服务器中的该资源时,会先通过请求资源的时间与 Cache-Control 中设置的过期时间大小,来计算出该资源是否过期,如果没有,则使用该缓存,否则重新请求服务器;
  • 服务器再次收到请求后,会再次更新 Response 头部的 Cache-Control。

协商缓存

某些请求的响应码是 304,这个是告诉浏览器可以使用本地缓存的资源,通常这种通过服务端告知客户端是否可以使用缓存的方式被称为协商缓存。

协商缓存就是与服务端协商之后,通过协商结果来判断是否使用本地缓存,协商缓存可以基于两种头部来实现。

第一种:

  • 响应头部中的 Last-Modified:标示这个响应资源的最后修改时间;
  • 请求头部中的 If-Modified-Since:当资源过期了,发现响应头中具有 Last-Modified 声明,则再次发起请求的时候带上 Last-Modified 的时间,服务器收到请求后发现有 If-Modified-Since 则与被请求资源的最后修改时间进行对比(Last-Modified),如果最后修改时间较新(大),说明资源又被改过,则返回最新资源,HTTP 200 OK;如果最后修改时间较旧(小),说明资源无新修改,响应 HTTP 304 走缓存。

第二种:

  • 响应头部中 Etag:唯一标识响应资源;
  • 请求头部中的 If-None-Match:当资源过期时,浏览器发现响应头里有 Etag,则再次向服务器发起请求时,会将请求头 If-None-Match 值设置为 Etag 的值。服务器收到请求后进行比对,如果资源没有变化返回 304,如果资源变化了返回 200。

第一种实现方式是基于时间实现的,第二种实现方式是基于一个唯一标识实现的,相对来说后者可以更加准确地判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题。

HTTP/1.1

HTTP/1.1优点

  1. 简单
    HTTP 基本的报⽂格式就是 header + body ,头部信息也是 key-value 简单⽂本的形式,易于理解
  2. 灵活和易于扩展
    HTTP协议⾥的各类请求⽅法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发⼈员⾃定义和扩充。同时 HTTP 由于是⼯作在应⽤层( OSI 第七层),则它下层可以随意变化。
  3. 应⽤⼴泛和跨平台

HTTP/1.1缺点

  1. ⽆状态
  • 好处:因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存⽤来对外提供服务。
  • 坏处:既然服务器没有记忆能⼒,它在完成有关联性的操作时会⾮常麻烦。
  1. 不安全
  • 通信使用明文(不加密),内容可能会被窃听
  • 不验证通信方的身份,因此有可能遭遇伪装。
  • 无法证明报文的完整性,所以有可能已遭篡改。

HTTP/1.1特点

  1. ⻓连接
  • 早期 HTTP/1.0 性能上的⼀个很⼤的问题,那就是每发起⼀个请求,都要新建⼀次 TCP 连接(三次握⼿),⽽且是串⾏请求,做了⽆谓的 TCP 连接建⽴和断开,增加了通信开销。
  • 为了解决上述 TCP 连接问题,HTTP/1.1 提出了⻓连接的通信⽅式,也叫持久连接。这种⽅式的好处在于减少了TCP 连接的复建⽴和断开所造成的额外开销,减轻了服务器端的负载。持久连接的特点是,只要任意⼀端没有明确提出断开连接,则保持 TCP 连接状态。
  1. 管道⽹络传输
  • HTTP/1.1 采⽤了⻓连接的⽅式,这使得管道⽹络传输成为了可能。即可在同⼀个 TCP 连接⾥⾯,客户端可以发起多个请求,只要第⼀个请求发出去了,不必等其回来,就可以发第⼆个请求出去,可以减少整体的响应时间。
  • 管道机制则是允许浏览器同时发出 A 请求和 B 请求。但是服务器必须按照接收请求的顺序发送对这些管道化请求的响应。
  1. 队头阻塞
  • 但是服务器还是按照顺序,先回应 A 请求,完成后再回应 B 请求。要是前⾯的回应特别慢,后⾯就会有许多请求排队等着。这称为「队头堵塞」。
  • 「请求 - 应答」的模式加剧了 HTTP 的性能问题。因为当顺序发送的请求序列中的⼀个请求因为某种原因被阻塞时,在后⾯排队的所有请求也⼀同被阻塞了,会招致客户端⼀直请求不到数据,这也就是「队头阻塞」。HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞

HTTP/1.1 相⽐ HTTP/1.0 性能上的改进:

  1. 使⽤ TCP ⻓连接的⽅式改善了 HTTP/1.0 短连接造成的性能开销。
  2. ⽀持管道⽹络传输,只要第⼀个请求发出去了,不必等其回来,就可以发第⼆个请求出去,可以减少整体的响应时间。

HTTP/1.1性能瓶颈

  1. 请求 / 响应头部(Header)未经压缩就发送,⾸部信息越多延迟越⼤。只能压缩 Body 的部分;
  2. 发送冗⻓的⾸部。每次互相发送相同的⾸部造成的浪费较多;
  3. 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端⼀直请求不到数据,也就是队头阻塞;
  4. 没有请求优先级控制;
  5. 请求只能从客户端开始,服务器只能被动响应。

HTTP/2

HTTP/2 协议是基于 HTTPS 的,所以 HTTP/2 的安全性也是有保障的。

HTTP/2做了哪些优化

  1. 头部压缩
    HTTP/2 会压缩头(Header)如果你同时发出多个请求,他们的头是⼀样的或是相似的,那么,协议会帮你消除重复的部分。这就是所谓的 HPACK 算法:在客户端和服务器同时维护⼀张头信息表,所有字段都会存⼊这个表,⽣成⼀个索引号,以后就不发送同样字段了,只发送索引号,这样就提⾼速度了。
    为高频出现在头部的字符串和字段建立了一张静态表:

  1. ⼆进制格式
    HTTP/2 不再像 HTTP/1.1 ⾥的纯⽂本形式的报⽂,⽽是全⾯采⽤了⼆进制格式,头信息和数据体都是⼆进制,并且统称为帧:头信息帧和数据帧。增加了传输效率

  2. 并发传输
    HTTP/2引出了 Stream 概念,多个 Stream 复用在一条 TCP 连接。

针对不同的 HTTP 请求用独一无二的 Stream ID 来区分,接收端可以通过 Stream ID 有序组装成 HTTP 消息,不同 Stream 的帧是可以乱序发送的,因此可以并发不同的 Stream ,也就是 HTTP/2 可以并行交错地发送请求和响应。

  1. 服务器主动推送
  • HTTP/2 还在⼀定程度上改善了传统的「请求 - 应答」⼯作模式,服务不再是被动地响应,也可以主动向客户端发送消息。举例来说,在浏览器刚请求 HTML 的时候,就提前把可能会⽤到的 JS、CSS ⽂件等静态资源主动发给客户端,减少延时的等待,也就是服务器推送。
  • 客户端和服务器双方都可以建立 Stream, Stream ID 也是有区别的,客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。

HTTP/2存在的问题

TCP的队头阻塞:HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。

所以,一旦发生了丢包现象,就会触发 TCP 的重传机制,这样在一个 TCP 连接中的所有的 HTTP 请求都必须等待这个丢了的包被重传回来。

HTTP/3

HTTP/3做了哪些优化

HTTP/3 把 HTTP 下层的 TCP 协议改成了 UDP,基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠性传输。

  • QUIC 有⾃⼰的⼀套机制可以保证传输的可靠性的。当某个流发⽣丢包时,只会阻塞这个流,其他流不会受到影响。
  • TLS3 升级成了最新的 1.3 版本,头部压缩算法也升级成了 QPack 。
  • HTTPS 要建⽴⼀个连接,要花费 6 次交互,先是建⽴三次握⼿,然后是 TLS/1.3 的三次握⼿。QUIC 直接把以往的 TCP 和 TLS/1.3 的 6 次交互合并成了 3 次,减少了交互次数。

QUIC 是一个在 UDP 之上的伪 TCP + TLS + HTTP/2 的多路复用的协议。

HTTPS

HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。很好的解决了窃听⻛险,篡改⻛险,冒充⻛险

  1. 混合加密的⽅式实现信息的机密性,解决了窃听的⻛险。
  2. 摘要算法的⽅式来实现完整性,它能够为数据⽣成独⼀⽆⼆的「指纹」,指纹⽤于校验数据的完整性,解决了篡改的⻛险。
  3. 将服务器公钥放⼊到数字证书中,解决了冒充的⻛险。

HTTPS的特点

摘要算法

客户端在发送明⽂之前会通过摘要算法(哈希函数)算出内容的哈希值,这个哈希值是唯一的,且无法通过哈希值推导出内容。发送的时候把「哈希值 + 明⽂」⼀同加密成密⽂后,发送给服务器,服务器解密后,⽤相同的摘要算法算出发送过来的明⽂,通过⽐较客户端携带的「哈希值」和当前算出的「哈希值」做⽐较,若「哈希值」相同,说明数据是完整的。

通过哈希算法可以确保内容不会被篡改,但是并不能保证「内容 + 哈希值」不会被中间人替换,因为这里缺少对客户端收到的消息是否来源于服务端的证明。为了避免这种情况,用非对称加密算法来解决

混合加密

HTTPS 采⽤的是对称加密和⾮对称加密结合的「混合加密」⽅式:

  • 在通信建⽴前采⽤⾮对称加密的⽅式交换「会话秘钥」,后续就不再使⽤⾮对称加密。
  • 在通信过程中全部使⽤对称加密的「会话秘钥」的⽅式加密明⽂数据。

采⽤「混合加密」方式的原因:

  • 对称加密只使⽤⼀个密钥,运算速度快,密钥必须保密,⽆法做到安全的密钥交换。
  • ⾮对称加密使⽤两个密钥:公钥和私钥,公钥可以任意分发⽽私钥保密,解决了密钥交换问题但速度慢。

流程的不同,意味着目的也不相同:
公钥加密,私钥解密。这个目的是为了保证内容传输的安全,因为被公钥加密的内容,其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容;
私钥加密,公钥解密。这个目的是为了保证消息不会被冒充,因为私钥是不可泄露的,如果公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的。

但是一般不会用非对称加密来加密实际的传输内容,因为非对称加密的计算比较耗费性能的。而是对内容的哈希值加密。

数字证书

需要借助第三⽅权威机构 CA (数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。一个数字证书通常包含了:

  1. 公钥;
  2. 持有者信息;
  3. 证书认证机构(CA)的信息;
  4. CA 对这份文件的数字签名及使用的算法;
  5. 证书有效期;
  6. 还有一些其他额外信息;

SSL/TLS 协议基本流程

TLS 的「握手阶段」涉及四次通信,一般位于TCP和HTTP之间,在三次握手之后

  1. 首先,由客户端向服务器发起加密通信请求,也就是 ClientHello 请求。涉及以下信息:

    ①TLS 协议版本
    ②随机数(Client Random),后面用于生成「会话秘钥」条件之一
    ③支持的密码套件列表,如 RSA 加密算法

  1. 服务器收到客户端请求后,向客户端发出响应,也就是 SeverHello + Server Certificate + Server Hello Done。回应的内容:

    ①确认 TLS 协议版本,如果浏览器不支持,则关闭加密通信
    ②随机数(Server Random),也是后面用于生产「会话秘钥」条件之一
    ③确认的密码套件列表,如 RSA 加密算法。
    ④服务器的数字证书

Server Hello

Server Certificate

Server Hello Done

  1. 客户端收到服务器的回应之后,发送响应:Client Key Exchange + Change Cipher Spec + Encrypted Handshake Message(Finishd)
    首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。如果证书没有问题,客户端会从数字证书中取出服务器的公钥,然后使用它加密报文,向服务器发送如下信息:

    ①一个随机数(pre-master key)。该随机数会被服务器公钥加密
    ②加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信
    ③客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验

Client Key Exchange

上面第一项的随机数是整个握手阶段的第三个随机数,会发给服务端,所以这个随机数客户端和服务端都是一样的。服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。

生成完「会话密钥」后,然后客户端发一个「Change Cipher Spec」,告诉服务端开始使用加密方式发送消息。

Change Cipher Spec

然后,客户端再发一个「Encrypted Handshake Message(Finishd)」消息,把之前所有发送的数据做个摘要,再用会话密钥(master secret)加密一下,让服务器做个验证,验证加密通信「是否可用」和「之前握手信息是否有被中途篡改过」。

Encrypted Handshake Message

  1. 服务器收到客户端的第三个随机数(pre-master key)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。然后,向客户端发送最后的信息:

    ①加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信
    ②服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验

至此,整个 TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。

HTTPS一定安全?

HTTPS本身是安全的

中间人服务器与客户端在 TLS 握手过程中,实际上发送了自己伪造的证书给浏览器,而这个伪造的证书是能被浏览器(客户端)识别出是非法的,于是就会提醒用户该证书存在问题。如果你无视风险,继续访问,那就是不安全的

密匙交换算法

使⽤⾮对称加密的⽅式来保护对称加密密钥的协商,这个⼯作就是密钥交换算法负责的。

RSA 算法

  • 传统的 TLS 握⼿基本都是使⽤ RSA 算法来实现密钥交换的,在将 TLS 证书部署服务端时,证书⽂件中包含⼀对公私钥,其中公钥会在 TLS 握⼿阶段传递给客户端,私钥则⼀直留在服务端,⼀定要确保私钥不能被窃取。
  • 在 RSA 密钥协商算法中,客户端会⽣成随机密钥,并使⽤服务端的公钥加密后再传给服务端。根据⾮对称加密算法,公钥加密的消息仅能通过私钥解密,这样服务端解密后,双⽅就得到了相同的密钥,再⽤它加密应⽤消息。

缺陷:
不⽀持前向保密。因为客户端传递随机数(⽤于⽣成对称加密密钥的条件之⼀)给服务端时使⽤的是公钥加密的,服务端收到到后,会⽤私钥解密得到随机数。所以⼀旦服务端的私钥泄漏了,过去被第三⽅截获的所有 TLS 通讯密⽂都会被破解。

ECDHE 算法

ECDHE 密钥协商算法是 DH 算法演进过来的,先从 DH 算法说起。

DH 算法

该算法的核心数学思想是离散对数。离散对数是在对数运算的基础上加了「模运算」,也就说取余数,也可以用 mod 表示。

底数 a 和模数 p 是离散对数的公共参数,也就说是公开的,b 是真数,i 是对数。知道了对数,就可以用上面的公式计算出真数。但反过来,知道真数却很难推算出对数。特别是当模数 p 是一个很大的质数,即使知道底数 a 和真数 b ,在现有的计算机的计算水平是几乎无法算出离散对数的,这就是 DH 算法的数学基础。

现假设⼩红和⼩明约定使⽤ DH 算法来交换密钥,那么基于离散对数,需要先确定模数和底数作为算法的参数,这两个参数是公开的,⽤ P 和 G 来代称。然后⼩红和⼩明各⾃⽣成⼀个随机整数作为私钥,⼩红的私钥⽤ a 代称,⼩明的私钥⽤ b 代称。

现在⼩红和⼩明双⽅都有了 P 和 G 以及各⾃的私钥,于是就可以计算出公钥:
⼩红的公钥记作 A,A = G ^ a ( mod P );
⼩明的公钥记作 B,B = G ^ b ( mod P );

  • A 和 B 也是公开的,因为根据离散对数的原理,从真数(A 和 B)反向计算对数 a 和 b 是⾮常困难的
  • 双⽅交换各⾃ DH 公钥后,⼩红⼿上共有 5 个数:P、G、a、A、B,⼩明⼿上也同样共有 5 个数:P、G、b、B、A。
  • ⼩红执⾏运算: B ^ a ( mod P ),其结果为 K,因为离散对数的幂运算有交换律,所以⼩明执⾏运算: A ^ b (mod P ),得到的结果也是 K。
  • 这个 K 就是⼩红和⼩明之间⽤的对称加密密钥,可以作为会话密钥使⽤。

可以看到,整个密钥协商过程中,⼩红和⼩明公开了 4 个信息:P、G、A、B,其中 P、G 是算法的参数,A 和 B是公钥,⽽ a、b 是双⽅各⾃保管的私钥,⿊客⽆法获取这 2 个私钥,因此⿊客只能从公开的 P、G、A、B ⼊⼿,计算出离散对数(私钥)。如果 P 是⼀个⼤数,在现有的计算机的计算能⼒是很难破解出 私钥 a、b的,破解不出私钥,也就⽆法计算出会话密钥,因此 DH 密钥交换是安全的。

缺陷:

  • 不具备前向安全性。DH 算法里有一方的私钥是静态的,也就说每次密钥协商的时候有一方的私钥都是一样的,一般是服务器方固定,即小红的私钥 a 不变,客户端的私钥则是随机生成的。于是,DH 交换密钥时就只有客户端(小明)的公钥是变化,而服务端(小红)公钥是不变的,
  • 那么随着时间延长,因为密钥协商的过程有些数据是公开的,黑客就会截获海量的密钥协商过程的数据,暴力破解出服务器的私钥,然后就可以计算出会话密钥了,于是之前截获的加密数据会被破解
DHE算法

让双方的私钥在每次密钥交换通信时,都是随机生成的、临时的,这个方式也就是 DHE 算法,E 全称是 ephemeral(临时性的)。

缺陷:
计算性能不佳,因为需要做大量的乘法

ECDHE算法

ECDHE 算法是在 DHE 算法的基础上利用了 ECC 椭圆曲线特性,可以用更少的计算量计算出公钥,以及最终的会话密钥。

椭圆曲线

什么是椭圆曲线?椭圆曲线有两个特点:

  • 如果你在上方随便画一个点,那么下方也一定有一个对称的点,上下方距离水平线X轴是相同的。
  • 随便在图形上画两个点,让这两个点连成线然后延长会经过第三个点,当然除了垂直线以外。
  1. 如果有A,B两个点,延长以后会经过第三个点,而这第三个点以X轴为中心是会有一个点与其对称,把这个对称的点称为C点。A和B得出C,把运算过程称为 “点运算”,A点B得到C,这个 “点运算”其实就是椭园曲线上的加法运算。
  2. 现在把A和C进行连线,同样经过了第三个点,第三个点也有一个对称的点,这里称为D点,也就是A点C得到D,
  3. 再把A和D连线,也经过了第三个点,第三个点也有一个对称的点,这里称为E点,也就是A点D得到E。

问题:已知起点是A,终点是E,请问起点A经过多少次点运算得到E?很难知道经过了多少次,这就很符合我们前面说的公钥加密的特点:正向简单,逆向困难

ECDHE密钥交换过程

Alice和Bob使用 ECDHE 密钥交换算法的过程:

  1. 双方事先确定好使用哪种椭圆曲线,和曲线上的基点 G,这两个参数都是公开的;
  2. Alice随机生成一个随机数作为私钥a,并与基点G相乘得到公钥A(A = a * G,就是G这个点进行点运算,次数是a,也就是G点G点G点…一共a次),此时Alice的公私钥为 A 和 a,把大A和G发送给Bob
  3. Bob收到后,也生成了一个私钥b,然后生成椭圆曲线上的一个新点,公钥B(B = b * G,就是G点进行小b次运算得到的,也就是G点G点G点…一共b次),此时Bob的公私钥为 B 和 b,把生成的大B发送给Alice,别人知道 B 和 G 也很难得到 b
  4. 最后Alice计算点(x1,y1) = B a,Bob计算点(x2,y2)= A b,由于椭圆曲线上是可以满足乘法交换和结合律,所以 B a = G a b = A b(假设a=3,b=2) ,因此双方的 x 坐标是一样的,所以它是共享密钥,也就是会话密钥。

应用层

请求转发和请求重定向的异同

  1. 跳转机制:
    请求转发(Forward):是服务器内部的行为,当服务器收到客户端的请求后,会将请求转发给目标地址,并将目标地址的响应结果返回给客户端。在整个过程中,客户端对服务器内部的转发过程毫不知情,只感知到一次请求和响应。
    请求重定向(Redirect):是客户端的行为,服务器在接收到客户端的请求后,返回一个临时响应头,其中包含客户端需要再次发送请求的URL地址。客户端在收到这个地址后,会向新的地址发送请求,从而实现页面的跳转。这个过程对客户端来说是两次独立的请求和响应。

  2. 数据共享:
    请求转发:由于是服务器内部操作,整个交互过程中使用的是同一个Request对象和一个Response对象,因此请求和返回的数据是共享的。
    请求重定向:客户端发送两次完全不同的请求,因此两次请求中的数据是不同的。

  3. URL地址的改变:
    请求转发:浏览器上的地址不会改变,因为服务器内部的处理对客户端来说是透明的。
    请求重定向:浏览器上的地址会改变,因为客户端会看到一个不同的URL地址被加载。

  4. 效率:
    请求转发:因为只涉及一次请求和响应,所以效率更高。
    请求重定向:涉及两次请求和响应,所以效率相对较低。

  5. 数据传递:
    请求转发:可以使用Request对象在多个页面间传递参数。
    请求重定向:通常不使用Request对象传递参数,但可以通过URL参数或会话(Session)等方式传递。

  6. 表单重复提交:
    请求转发:可能造成表单的重复提交,因为表单提交后的行为发生在服务器内部,无法直接感知到表单提交的结果。
    请求重定向:不会造成表单的重复提交,因为表单提交后会触发浏览器的跳转,从而避免了重复提交的问题。

  7. 目标服务器:
    请求转发:只能在服务器内部转发,不能跳转到其他服务器。
    请求重定向:可以跳转到其他服务器进行转发。

Cookie、Session、Token

在客户端第⼀次请求后,服务器会下发⼀个装有客户信息的「⼩贴纸」,后续客户端请求服务器的时候,带上「⼩贴纸」,服务器就能认得了,Cookie具有不可跨域名性

Session

  • Session机制是通过检查服务器上的“客户明细表”来确认客户身份。Session相当于程序在服务器上建立的一份客户档案,客户来访的时候只需要查询客户档案表。
  • Session机制决定了当前客户只会获取到自己的Session,而不会获取到别人的Session。各客户的Session也彼此独立,互不可见。
  • Session对象是在客户端第一次请求服务器的时候创建的。Session也是一种key-value的属性对,通过getAttribute(Stringkey)和setAttribute(String key,Objectvalue)方法读写客户状态信息。Servlet里通过request.getSession()方法获取该客户的Session,
  • Session生成后,只要用户继续访问,服务器就会更新Session的最后访问时间,并维护该Session。为防止内存溢出,服务器会把长时间内没有活跃的Session从内存删除。这个时间就是Session的超时时间。如果超过了超时时间没访问过服务器,Session就自动失效了。

    区别

  1. cookie数据存放在客户的浏览器上,session数据放在服务器上。Cookie通过在客户端记录信息确定用户身份,Session通过在服务器端记录信息确定用户身份。
  2. cookie不是很安全,别人可以分析存放在本地的COOKIE并进行COOKIE欺骗,如果主要考虑到安全应当使用session
  3. session会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能,如果主要考虑到减轻服务器性能方面,应当使用COOKIE
  4. 将登陆信息等重要信息存放为SESSION;其他信息如果需要保留,可以放在COOKIE中
  5. 两者最大的区别在于生存周期,一个是IE启动到IE关闭.(浏览器页面一关 ,session就消失了),一个是预先设置的生存周期,或永久的保存于本地的文件。(cookie)

痛点

实际在生产上,为了保障高可用,一般服务器至少需要两台机器,客户端请求后,由负载均衡器(如 Nginx)来决定到底打到哪台机器

假设登录请求打到了 A 机器,A 机器生成了 session 并在 cookie 里添加 sessionId 返回给了浏览器,那么问题来了:下次添加购物车时如果请求打到了 B 或者 C,由于 session 是在 A 机器生成的,此时的 B,C 是找不到 session 的,那么就会发生无法添加购物车的错误,就得重新登录了,此时请问该怎么办。主要有以下三种方式:

  1. session 复制
    A 生成 session 后复制到 B, C,这样每台机器都有一份 session,无论添加购物车的请求打到哪台机器,由于 session 都能找到,故不会有问题。
    缺点:同一样的一份 session 保存了多份,数据冗余;如果节点少还好,但如果节点多的话,可能需要部署成千上万台机器,这样节点增多复制造成的性能消耗也会很大。
  2. session 粘连
    这种方式是让每个客户端请求只打到固定的一台机器上,比如浏览器登录请求打到 A 机器后,后续所有的添加购物车请求也都打到 A 机器上,Nginx 的 sticky 模块可以支持,支持按 ip 或 cookie 粘连等等,如按 ip 粘连方式如下
    1
    2
    3
    4
    5
    upstream tomcats {
      ip_hash;
      server 10.1.1.107:88;
      server 10.1.1.132:80;
    }
    缺点:机器挂了没有其它办法
  3. session 共享
    许多公司普遍采用的方案,将 session 保存在 redis,memcached 等中间件中,请求到来时,各个机器去这些中间件取一下 session 即可。

缺点:就是每个请求都要去 redis 取一下 session,多了一次内部连接,消耗了一点性能,另外为了保证 redis 的高可用,必须做集群。搞个校验机制我还得搭个 redis 集群?Token应运而生

Token令牌

Token是服务端生成的一串字符串,当作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token并将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码

无状态、可扩展:Token是 服务经过计算发给客户端的,服务不保存,每次客户端来请求,经过解密等计算来验证是否是自己下发的

前面说过,Token不需要保存,无论请求哪台机器,根据第一次下发的令牌进行实时的计算,就可以解决session的痛点

------赞助耶耶,加快更新!------

给耶耶买杯咖啡喝