概述TCP/IP网络模型?
TCP/IP网络模型自顶向下一共分为4层。
- 应用层
直接为用户提供服务,该层常见的协议比如HTTP协议、DNS协议、FTP协议。
- 我们手机或电脑的应用软件就都是在应用层实现的,当两个不同的应用需要通信时,应用就把应用数据传给下一层,也就是传输层。应用层专注为用户提供应用功能,而不关心数据如何传输,比如支持Web应用的HTTP协议、DNS协议、支持文件传输的FTP协议等。
OSI七层网络体系中,将应用层继续划分
- 应用层:提供系统与用户的接口。比如文件传输、访问Web应用。涉及到FTP协议,HTTP协议。
- 表示层:将数据从主机持有的格式转换为网络持有的标准传输格式。
- 会话层:管理主机间会话进程。包括建立、管理、终止进程间的会话。
- 传输层
传输层为应用层提供网络支持,主要使用TCP和UDP两个协议。比如TCP协议可以实现可靠传输,进行流量控制、超时重传、拥塞控制等。
- TCP 的全称叫传输控制协议(Transmission Control Protocol),大部分应用使用的正是 TCP 传输层协议,比如 HTTP 应用层协议。TCP 相比 UDP 多了很多特性,比如流量控制、超时重传、拥塞控制等,这些都是为了保证数据包能可靠地传输给对方。
UDP 相对来说就很简单,简单到只负责发送数据包,不保证数据包是否能抵达对方,但它实时性相对更好,传输效率也高。
- 当设备作为接收方时,传输层则要负责把数据包传给应用,但是一台设备上可能会有很多应用在接收或者传输数据,因此需要用一个编号将应用区分开来,这个编号就是端口。
- 网络层
负责将应用数据从一个设备传输到另一个设备,使用IP协议,让数据包通过路由器找到目的主机。IP地址分为网络号和主机号。IP可以通过寻址,告诉我们下一个方向应该怎么走,路由可以根据下一个目的地选择路径。
- 我们不希望传输层协议处理太多的事情,只需要服务好应用即可,让其作为应用间数据传输的媒介,帮助实现应用到应用的通信,而实际的传输功能就交给下一层,也就是网络层(Internet Layer)。
- 网络接口层
网络接口层为网络层提供链路级别的传输服务,负责在以太网、WIFI这样的底层网络上传输数据。比如会使用MAC地址来标识网络上的设备。
- 以太网就是一种在「局域网」内,把附近的设备连接起来,使它们之间可以进行通讯的技术。
OSI七层网络体系中,将网络接口层划分为物理层和数据链路层
- 物理层:传输比特流,为设备提供传输的数据通路。
- 数据链路层:将IP数据报封装成帧。(帧中添加MAC地址) 在相邻节点的链路中传输数据。
网络接口层的传输单位是帧(frame),IP 层的传输单位是包(packet),TCP 层的传输单位是段(segment),HTTP 的传输单位则是消息或报文(message)。但这些名词并没有什么本质的区分,可以统称为数据包。
键入网址到网页显示,期间发生了什么?
首先,浏览器通过解析URL生成HTTP请求,确定Web服务器和应用名。
之后使用DNS查询服务器的域名,获取到IP地址。
- 如果浏览器本地有缓存,就直接返回,或者配置的hosts文件,如果都没有,就去访问本地DNS服务器。
随后就将传输工作交给协议栈,应用程序可以通过调用Socket库,委托给操作系统中的协议栈工作。
协议栈的上半部分有两块,分别是负责收发数据的 TCP 和 UDP 协议,这两个传输协议会接受应用层的委托,执行收发数据的操作。
- 如果使用的是TCP协议,TCP会通过三次握手建立连接,确保双方都有发送和接收的能力。(TCP头部中包含源端口号和目标端口号,指明发送给哪个应用,还有窗口大小,目的是进行流量控制。)HTTP报文+TCP头部组成TCP报文。
协议栈的下面一半是用 IP 协议控制网络包收发操作,在互联网上传数据时,数据会被切分成一块块的网络包,而将网络包发送给对方的操作就是由 IP 负责的。
- IP协议:将报文封装成IP数据包。(IP头部中有源IP地址和目的IP地址,这样就可以确定路由)HTTP报文+TCP头部+IP头部组成数据包。
- IP协议中包括ICMP协议和ARP协议
ICMP
用于告知网络包传送过程中产生的错误以及各种控制信息。
ARP
用于根据 IP 地址查询相应的以太网 MAC 地址。
生成IP头部之后,还要加上MAC头部,MAC头部是以太网使用的头部,包含了接收方和发送方的MAC地址等信息。
- MAC包头包含接收方MAC地址、发送方MAC地址、协议类型。
为了获得发送方MAC地址,需要ARP协议,ARP协议会在以太网中以广播的形式,对所有的设备发送请求,请求对应IP地址的MAC地址。随后将本次查询到的结果放到ARP缓存的内存空间。
在后续操作中,会先查询ARP缓存,如果已经保存了对方的MAC地址,就不需要发送ARP查询,直接使用ARP缓存中的地址。
网卡:网卡可以将包转为电信号,通过网线发送出去。
- 网卡驱动程序获取网络包之后,会复制到网卡内的缓冲区,在开头加上报头和起始帧分界符,在末尾加上FCS(检测错误的帧校验序列)。
交换机:交换机工作在MAC层,也称为二层网络设备。电信号到达网线接口,将电信号转为数字信号,通过FCS校验错误。如果没问题,就放到缓冲区中。随后根据交换机的MAC地址表查找MAC地址对应的端口,然后将信号发送到对应的端口。
路由器:路由器和交换机不同,路由器是基于IP设计的,俗称三层网络设备,路由器的各个端口都有MAC地址。当转发包时,会检查接收方MAC地址,检查是不是发给自己的包,之后根据IP头部中的内容,查询路由表来判断转发的目标。
- 完成包接收操作之后,路由器就会去掉包开头的MAC头部,因为MAC头部的作用就是将包送达路由器。
服务器与客户端:通过一系列取消头部的操作,确认该数据包就是发送给自己的,之后向客户端发送响应报文。最后,客户端发起TCP四次挥手,双方断开连接。
HTTP常见面经
HTTP基本概念
HTTP是什么?
HTTP是超文本传输协议,也就是HyperText Transfer Protocol。
具体来说,HTTP协议是在计算机网络中的两点之间传输超文本的一种约定和规范。
HTTP常见字段?
- 通用首部字段
- Connection字段:最常用于客户端要求服务器使用 TCP 持久连接,以便其他请求复用。HTTP/1.1 版本的默认连接都是持久连接,但为了兼容老版本的 HTTP,需要指定
Connection
首部字段的值为 Keep-Alive
。HTTP长连接。
- Cache-control字段:该字段可以控制缓存的行为。
- 请求首部字段
- Host字段:发送请求时,指定服务器的域名。Host 首部字段在 HTTP/1.1 规范内是唯一一个必须被包含在请求内的首部字段。
- Accept字段:Accept 首部字段可通知服务器,客户端能够处理的媒体类型及媒体类型的相对优先级。
- 响应首部字段
- ETag字段:首部字段 ETag 能告知客户端、资源的标识。它是一种可将资源以字符串形式做唯一性标识的方式。服务器会为每份资源分配对应的 ETag值。
- Location字段:该字段会配合 3xx 的响应码,提供客户端重定向的URL。
- 实体首部字段
- Allow字段:用于通知客户端能够支持的所有 HTTP 方法。
- Content—Length字段:表明本次回应的数据长度。
- Content-Type 字段:用于服务器回应时,告诉客户端,本次数据是什么格式。
- Content-Encoding 字段:说明数据的压缩方法。表示服务器返回的数据使用了什么压缩格式。
HTTP常见状态码?
2xx
类状态码表示服务器成功处理了客户端的请求,也是我们最愿意看到的状态。
- 「 200 OK」是最常见的成功状态码,表示一切正常。
- 「204 No Content」也是常见的成功状态码,与 200 OK 基本相同,但没有返回实体内容。
- 「206 Partial Content」该状态码表示客户端进行了范围请求,而服务器成功执行了部分的GET 请求。响应报文中包含由 Content-Range 指定范围的实体内容。
3xx
类状态码表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向。
- 「301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。
- 「302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。
301 和 302 都会在响应头里使用字段 Location
,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。
- 「304 Not Modified」该状态码表示资源未被修改,客户端可以继续使用缓存资源,用于缓存控制。
4xx
类状态码表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。
- 「400 Bad Request」表示客户端请求报文中有语法错误。
- 「401 ** Unauthorized」该状态码表示客户端发送的请求需要有通过 HTTP 认证的认证信息**(可能服务器会发送一个基于表单的认证信息,来确认用户身份)。另外若之前已进行过 1 次请求,则表示用户认证失败。
- 「403 Forbidden」表示服务器禁止访问资源,比如未获得文件系统的访问授权,访问权限出了问题。
- 「404 Not Found」表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端。
5xx
类状态码表示服务器处理时内部发生了错误,属于服务器端的错误码。
- 「500 Internal Server Error」该状态码表明服务器端在执行请求时发生了错误。也有可能是 Web应用存在的 bug 或某些临时的故障。
- 「501 Not Implemented」表示客户端请求的功能还不支持,类似“即将开业,敬请期待”的意思。
- 「502 Bad Gateway」通常是服务器作为网关或代理时返回的错误码,表示服务器自身工作正常,访问后端服务器发生了错误。
- 「503 Service Unavailable」表示服务器当前很忙,暂时无法响应客户端,类似“网络服务正忙,请稍后重试”的意思。
GET与POST
GET与POST有什么区别?
GET 的语义是从服务器请求获取指定的资源。GET 请求的参数位置写在 URL 中,url会有长度限制。
POST 的语义是根据报文主体对指定的资源做出具体的处理,比如「新增或提交数据」的操作。具体的处理方式视资源类型而不同。而Post请求所携带的数据一般在body中,没有大小限制。
GET与POST都是安全或幂等的吗?
- GET 方法就是安全且幂等的,因为它是「只读」操作,无论操作多少次,服务器上的数据都是安全的,且每次的结果都是相同的。所以,可以对 GET 请求的数据做缓存,这个缓存可以做到浏览器本身上(彻底避免浏览器发请求),也可以做到代理上(如nginx),而且在浏览器中 GET 请求可以保存为书签。
- get请求也可以带body,但是RFC的规范是GET请求是获取资源,所以在这个语义下,不需要带body。另外,URL中查询参数也不是POST独有的。
- POST 因为是「新增或提交数据」的操作,会修改服务器上的资源,所以是不安全的,且多次提交数据就会创建多个资源,所以不是幂等的。所以,浏览器一般不会缓存 POST 请求,也不能把 POST 请求保存为书签。
HTTP缓存技术
HTTP缓存有哪些实现方式?
使用缓存,可以避免重复发送HTTP请求。主要有两种实现方式,分别是强制缓存和协商缓存。
- 强制缓存
强制缓存指的是,只要浏览器的缓存资源没有过期,就直接使用浏览器的本地缓存。不会访问服务器。是否使用缓存由浏览器这边控制。
要实现强制缓存的话,是利用两个字段实现的:Cache-control
、Expires
。
浏览器第一次请求服务器资源时,服务器会在返回资源的同时,在响应头加上Cache-control设置过期时间大小。浏览器再次请求服务器中的该资源时,会先比较请求资源的时间和Cache-Control 中的过期时间的大小,计算是否过期,如果没有,则使用该缓存,否则进行协商缓存。服务器收到请求后,会再次更新响应头的Cache-Control。
如果 HTTP 响应头部同时有 Cache-Control 和 Expires 字段的话,Cache-Control的优先级高于 Expires 。Cache-control 选项更多一些,设置更加精细,建议使用Cache-Control 来实现强缓存。
200( from disk cache)
:就代表使用了强制缓存。 不访问服务器,已经在之前的某个时间加载过该资源,直接从硬盘中读取缓存,关闭浏览器后,数据依然存在,此资源不会随着该页面的关闭而释放掉下次打开仍然会是from disk cache。
200 (form memory cache)
: 不访问服务器,一般已经加载过该资源且缓存在了内存当中,直接从内存中读取缓存。浏览器关闭后,数据将不存在(资源被释放掉了),再次打开相同的页面时,不会出现from memory cache。
- 协商缓存
当本地的缓存过期,浏览器就会向服务器发送请求,之后由服务器告知客户端是否可以使用缓存,这种方式就是协商缓存。通过协商结果来判断是否使用本地缓存。
- 协商缓存的两种头部实现方式
- 请求头部的
If-Modified-Since
和响应头部的Last-Modified
- 请求头部的
If-None-Match
和响应头部中的Etag
字段。
- 第一种头部实现的具体方式
当本地资源过期了,发现响应中出现Last-Modified
,那么第二次请求就会带上这个时间,服务器把last-Modified
和If-Modified-Since
两个时间进行对比,如果最后修改的时间(last-Modified
)比较新,就返回最新的资源。否则就返回状态码304,继续使用本地缓存。
- 第二种头部实现的具体方式
当本地资源过期时,发现响应中出现Etag
,那么第二次向服务器发送请求时,会将请求头If-None-Match
设置为Etag
的值。服务器收到请求后进行对比,如果资源没有变化则返回304,如果变化,则返回新的资源和状态码200。
- 同时有
Etag
和Last-Modified
?
Etag的优先级更高,因为Etag能解决Last-Modified难以解决的问题。
- 文件没变,但是有可能最后修改时间改变了·。
If-Modified-Since
检查的粒度是秒级的,如果文件在1秒以内进行修改,无法使用。
- 服务器可能并不能准确获取文件的最后修改时间。
最后注意:协商缓存都必须配合强制缓存中的Cache-control来使用,只有不能使用强制缓存时,才可以用协商缓存。
HTTP特性
HTTP(1.1)的优点有哪些?
HTTP最突出的优点是:简单、灵活和易于拓展、应用广泛和跨平台。
- 简单
报文格式就是请求行、请求头、请求体。头部信息也是key-value
的文本形式,易于理解。
- 灵活和易于扩展
灵活:各种请求方法、URL、头部字段都没有固定,开发人员可以自定义请求方法。
易于扩展:HTTP工作在应用层,它的下层可以随意变化,比如HTTPS就是在HTTP与TCP层之间增加SSL/TLS安全传输层。(注:TLS(传输层安全)是更为安全的升级版 SSL。由于 SSL 这一术语更为常用,因此我们仍然将我们的安全证书称作 SSL。)
- 应用广泛
应用领域:从简单的Web网页,各种浏览器或者APP等,处处都在用HTTP。
开发领域:不限定编程语言或者操作系统,具有跨语言,跨平台的优越性。
HTTP(1.1)的缺点有哪些?
- 缺点/优点:无状态
无状态的坏处:完成有关联性的操作时会很麻烦,比如用户从登录到下单支付,都需要验证用户身份。如果无状态,用户的每一次操作都需要验证身份。
但是现在可以使用Cookie技术来解决。Cookie通过在请求和响应报文中写入Cookie信息来控制客户端的状态。
无状态的好处:不需要额外的资源记录状态信息,减轻服务器端压力。
- 缺点:不安全(使用HTTPS解决)
- 通信使用明文,虽然方便阅读,但是内容可能被窃听。
- 不验证通信方的身份,有可能遭遇伪装。
- 无法验证报文完整性。
- Cookie技术:
第一次客户端发送没有Cookie的信息,服务器就生产一个Cookie,在响应中返回。
第二次客户端发送的请求就会带上Cookie。服务器进行检查Cookie,确认身份,之后进行响应。
HTTP(1.1)的性能如何?
- HTTP/1.1实现了长链接(KeepAlive),不需要每次请求都重新建立和断开TCP连接。减轻了服务器端的压力。性能得到一个提升。
- 实现了管线化传输,一个TCP连接中,可以发起多个HTTP请求。但是服务器必须按照接收请求的顺序发送对这些管道化请求的响应。这样就有可能发生队头阻塞,前面的一个连接丢失,后面的所有请求都不能进行响应。HTTP/1.1只解决了请求的队头阻塞,没有解决响应的队头阻塞。
HTTP/1.1的性能一般,HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。
HTTP与HTTPS
HTTP与HTTPS有哪些区别?
- 建立连接方面:HTTP建立连接比较简单,只需要TCP三次握手后便可以进行进行HTTP报文传输;但是HTTPS在经过TCP三次握手后,还需要进行SSL握手过程,才可以进行报文加密传输。
- 传输数据方面:HTTP的传输是明文传输,存在安全问题。而HTTPS在TCP层和HTTP层之间加入了SSL安全协议,可以使报文加密传输。
- HTTPS协议需要申请CA证书,确保服务器的身份。
- 两者端口号也不同,前者是80,后者是443。
HTTPS是如何建立连接的?其间交互了什么?
SSL/TLS协议的基本流程:
- 客户端向服务器发起加密通信请求,
ClientHello
请求。
主要发送
- SSL/TLS协议版本信息
- 客户端产生的随机数(
Client Random
),作为会话密钥的条件之一
- 客户端支持的密码套件列表(加密算法)
- 服务器收到请求后,向客户端发出响应,
SeverHello
请求。
主要发送
- 确认SSL/TLS协议版本
- 服务器生产的随机数(
Server Random
),作为会话密钥的条件之一
- 确认的密码套件列表
- 服务器的数字证书(带有公钥)
- 客户端进行回应,首先验证服务器的数字证书的真实性。取出数字证书中服务器的公钥,然后使用它加密报文发送信息。
- 一个随机数(
pre-master key
)。该随机数被服务器公钥加密。
- 加密通信算法改变通知,表示随后会使用会话密钥进行加密通信。
- 客户端握手结束通知,同时把之前的内容做个摘要,用来供服务器校验。
- 服务器端收到第三个随机数后,使用私钥对随机数(
pre-master key
)进行解密。通过协商的加密算法,计算通信的会话密钥,发送最后的信息。
- 加密通信算法改变通知,表示知道随后的信息使用会话密钥进行加密通信。
- 服务器握手结束通知,同时把之前的内容做个摘要,用来供客户端校验。
HTTPS解决了HTTP的哪些问题?
- 保证了信息的机密性。HTTPS采用混合加密的方式实现信息的机密性。
使用公开密钥加密(非对称加密)的方式来交换会话密钥。
在通信过程中使用对称加密的形式来加密明文数据。
- 对称加密共同使用一个密钥。运算速度快。但是必须确保密钥的保密性,所以先使用非对称加密对密钥进行加密。
- 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发,私钥保密,解决了密钥交换问题。但是速度比较慢。
- 保证传输内容不被修改。使用摘要算法计算出内容的哈希值,再使用数字签名算法(非对称加密),对内容的哈希值进行加密。
- 身份验证。首先服务器把自己的公钥登录到数字证书认证机构,由数字证书认证机构用自己的私钥,去签发公钥证书(CA证书)来确保公开密钥的真实性。客户端收到数字证书 + 服务器公钥后,用CA的公钥来确认证书的真实性。最后客户端使用服务器的公钥进行进一步会话。
HTTPS的应用数据是如何保证完整性的?
TLS协议在实现上分为握手协议和记录协议两层:
握手协议即之前的四次握手过程,负责协商加密算法,生产会话密钥,后序使用该密钥来保护应用程序数据。
记录协议即负责保护应用数据的完整性和来源。
具体过程如下:
- 分割、压缩消息片段:消息被分割成多个较短的片段,然后分别对每个片段进行压缩。
- 添加消息认证码:片段会被加上消息认证码(MAC值,通过哈希算法生成),通过MAC码值,可以识别出篡改的消息,从而保证数据的完整性。
- 数据再进行加密,之后加上数据类型、版本号、压缩后的长度等组成的头部就是最终的报文数据。
新的方案选择先加密再 MAC,这种替代方案中,首先对明文进行填充和加密,再将结果交给 MAC 算法。这可以保证主动网络攻击者不能操纵任何加密数据。
HTTPS一定安全可靠吗?
这个问题的场景如下:客户端通过浏览器向服务器段发起HTTPS请求时,被假基站转发到了一个中间人服务器,于是客户端是和中间人服务器完成了TLS握手,然后这个中间人服务器再与真正的服务器完成TLS握手。
具体过程如下:
- 客户端向服务端发起 HTTPS 建立连接请求时,然后被「假基站」转发到了一个「中间人服务器」,接着中间人向服务端发起 HTTPS 建立连接请求,此时客户端与中间人进行 TLS 握手,中间人与服务端进行 TLS 握手;
- 在客户端与中间人进行 TLS 握手过程中,中间人会发送自己的公钥证书给客户端,客户端验证证书的真伪,然后从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给中间人,中间人使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(A),后续客户端与中间人通信就用这个对称加密密钥来加密数据了。
- 在中间人与服务端进行 TLS 握手过程中,服务端会发送从 CA 机构签发的公钥证书给中间人,从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给服务端,服务端使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(B),后续中间人与服务端通信就用这个对称加密密钥来加密数据了。
- 后续的通信过程中,中间人用对称加密密钥(A)解密客户端的 HTTPS 请求的数据,然后用对称加密密钥(B)加密 HTTPS 请求后,转发给服务端,接着服务端发送 HTTPS 响应数据给中间人,中间人用对称加密密钥(B)解密 HTTPS 响应数据,然后再用对称加密密钥(A)加密后,转发给客户端。
要发生这种场景的关键前提是用户点击接受了中间人服务器的证书。这个证书能够被浏览器识别出是非法的,于是就会提醒用户该证书存在问题。如果用户执意继续浏览网站,相当于接受了中间人伪造的证书,那么后续的HTTPS通信都能够被中间人监听了。
所以,HTTTPS协议本身到目前为止还是没有任何漏洞的,即使成功进行中间人攻击,本质上是利用了客户端的漏洞(用户执意继续访问),并不是HTTPS不够安全。
HTTP的更新迭代
HTTP/1.1做了什么优化?
- 使用长连接的方式改善了HTTP/1.0短连接造成的性能开销。
- 支持管线化网络传输,第一个请求发送,不必等待响应,就可以发第二个请求。
HTTP/1.1的性能瓶颈:
- 请求/响应头部未经压缩就发送,首部信息越多延迟就越大,只能压缩主体部分。【HTTP/2解决】
- 请求只能从客户端开始,服务器端接收。【HTTP/2解决】
- 会造成队头阻塞【HTTP/3彻底解决了队头阻塞问题】
HTTP/2做了什么优化?
- 头部压缩
HTTP/2会压缩请求头,如果多个请求头是一样的或者相似的,会消除重复的部分。
使用了HPACK算法,在客户端和服务器同时维护头信息表,将字段存入信息表中,生成索引号,以后只发送索引号即可。
- 二进制格式
HTTP/2全面采用二进制格式,头部信息和数据体都是二进制,并且统称为帧:头部帧和数据帧。
收到报文后,无需将明文转为二进制,而是直接解析二进制报文即可。增加了数据传输的效率。
- 并发传输
HTTP/2引入了Stream的概念,多个Stream复用在同一条TCP的连接上。
不同的HTTP请求,都有独一无二的StreamID,接收端可以通过StreamID来组装HTTP消息。
不同的Stream的帧可以乱序发送,所以HTTP/2可以并行交错的发送请求和响应。
一个TCP连接中包含多个Stream,Stream里可以包含多个Message,Message就对应HTTP/1中的请求和响应。Message里可以包含一条或多个Frame(帧)。
- 服务器推送
服务器不再被动的响应,而是可以主动地向客户端发送消息。
主要是因为客户端和服务端都可以建立Stream,StreamID也做了区分,客户端的StreamID必须是奇数,而服务器建立的StreamID必须是偶数号。
HTTP/3做了什么优化?
HTTP/3将TCP协议改为了UDP协议。UDP协议不管顺序,也不管丢包。
- 无队头阻塞
基于UDP的QUIC协议实现了类似TCP的可靠传输。当某个流发生丢包时,只会阻塞这个流,其他流不会收到影响,因此不存在队头阻塞的问题。
- 更快建立连接
HTTP/1和HTTP/2协议,TCP和TLS是分层的,难以合并在一起,需要分批次来握手。
但是HTTP/3的QUIC协议内部包含了TLS。会在发送帧的同时携带TLS的记录,再加上QUIC使用的是TLS/1.3,仅需一个RTT就可以同时完成建立连接与密钥协商。
- 连接迁移
QUIC 协议没有用TCP四元组的方式来“绑定”连接,而是通过连接 ID来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
TCP三次握手与四次挥手面试题
TCP基本认识
什么是TCP
- TCP头格式有哪些
- 序列号:每个TCP都有一个标识,通过SYN包传给接收端,每发送一次数据,就累加数值,目的是防止TCP在网络中乱序。
- 确认应答号:确认应答号表示下一个分配的序列号,接收端主机收到确认应答号,表示收到该应答号之前的网络包。目的是解决TCP包在网络中丢失的问题。
- 控制位
ACK:ACK为1,表示收到之前的消息,确认应答位有效。
RST:RST为1,表示发生异常,连接强制中断。
SYN:SYN为1,表示建立连接,序列化进行初始化。
FIN:FIN为1,表示希望关闭连接,之后不会有数据发送。
- 为什么要用TCP
TCP工作在传输层,主要解决包在网络中可靠传输的问题(无损、有序)。
- 什么是TCP/TCP连接呢?
TCP有三个关键词:面向连接、可靠传输、字符流。
- 面向连接是指TCP必须在两端之间建立一对一连接才可以通信。
- 可靠传输是指TCP在网络中不会丢失,一定会到达另一方。
- 字符流是指TCP会把报文拆分,通过消息的边界来判断有效的用户消息。
TCP连接是指客户端和服务端达成三个信息的共识,就叫建立了连接。
- Socket:有IP地址和端口号组成
- 序列号:解决乱序问题。
- 窗口大小:流量控制。
- 确认一个唯一的TCP连接
TCP四元组可以确认一个唯一的TCP连接【目的端口、目的地址;源地址、源端口】
其中源地址、目的地址在IP头部,用来确认报文发送给网络中的哪个主机。
而源端口、目的端口在TCP头部,用来确认报文发送给主机中的哪个进程。
最大TCP连接数 = IP数 x 端口数,当然,这只是理论值,实际还受两个方面的影响:
- 文件描述符:TCP连接本质是一个文件,受文件描述符上限的控制
- 内存限制:每个TCP连接都要占用一定内存。
TCP/UDP
- UDP和TCP有什么区别?应用场景?
UDP是不可靠传输,并且是无连接的,也就是不依靠连接来传输数据,不止可以进行一对一,还可以一对多发送数据。
UDP的头部没有首部字段,因为UDP的首部大小是固定的,而TCP因为还有选项字段,首部大小不固定。
分片不同
- TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片。
- UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,接着再传给传输层。
应用场景:UDP:DNS、视频等媒体;TCP:HTTP/HTTPS、文件传输。
- TCP和UDP的端口可以使用同一个吗?
可以使用一个,端口只不过是用来表示同一个主机中的不同进程的。传输层的端口号,用来表示主机上的不同程序的数据包。
当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。
TCP连接建立
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt
命令查看。
三次握手
连接建立
- 初始化序列号为什么要随机
总结:防止历史报文被下一个相同的TCP四元组接收。如果初始化序列号都是一样的,在网络中延迟发送的数据包就有可能落在下一个滑动窗口内,之后被滑动窗口接收。但是此时的报文已经不是我们想要的了。
为了解决这个问题,使用了ISN算法,来随机序列号,这个随机的序列号与时钟计数器有关,是递增的。
但是并不是无限递增,也会发生回绕为初始值的情况,所以TCP头部有一个时间戳选项,是默认开启的。引入了时间戳,就可以防止序列号发生回绕(PAWS,Protection Against Wrapped Sequence number)。他会要求连接双方维护最近一次收到的数据包的时间戳,在下一次收到数据包时,会将两个时间戳进行对比,如果后来的数据包的时间戳比上一次的数据包时间戳小,说明数据包是过期的,就会丢弃这个数据。
而时间戳也不是无限递增的,与对端主机时钟频率有关。如果时钟频率很快,那么时间戳也有可能产生回绕,就可能会有包越过时间戳检查(PAWS)。
想要避免时间戳回绕,可以考虑两个方案解决:增加时间戳大小或者将一个与时钟频率无关的值作为时间戳。
- 具体分析
如果每次建立连接,客户端和服务器的初始化序列号都一样,很容易出现历史报文被下一个TCP连接接收的问题。
主要原因:防止历史报文被下一个相同四元组的连接接收。
如果能够正常四次挥手,TIME_WAIT状态会持续2MSL,历史报文会在下一个连接之前就会自然消失。
但是并不能保证每次连接都能够通过四次挥手正常关闭。
即使初始化序列号不一样,也有可能发生这样的事情。
因为历史报文能否被对方接收,还要看该历史报文的序列号是否正好在对方接收窗口内,如果不在就会丢弃,如果在才会接收。
如果每次建立连接客户端和服务器的初始化序列号都不一样,就有大概率因为历史报文的序列号不在对方接收窗口,很大程度上避免历史报文。
相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到历史报文的序列号刚好在对方的接收窗口内,从而导致历史报文被新连接成功接收。
所以,每次初始化序列号不一样能够很大程度上避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了。
- 序列号是随机的,那也有可能随机成一样的啊
RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M (计时器)+ F(localhost, localport, remotehost, remoteport)。
随机数是会基于时钟计数器递增的,基本不可能会随机成一样的初始化序列号。
- 序列号(SEQ)与初始序列号(ISN)可能产生回绕,引入时间戳!
- 序列号,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。
- 初始序列号,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。
可见序列号与初始化序列号并不是无限递增的,会发生回绕为初始值的情况,所以无法根据序列号的大小来判断新老数据。在一个速度足够快的网络中传输大量数据时,序列号的回绕时间就会变短。我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。
为了解决这个问题,就需要有 TCP 时间戳。tcp_timestamps
参数是默认开启的,开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)。
防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。
- 如果时间戳也发生回绕怎么办?
时间戳回绕速度只与主机的时钟频率有关。
时间戳的大小是32bit,所以理论上也有可能发生回绕,解决时间说回绕,可以有以下解决方案:
- 增加时间戳的大小,由32bit扩展到64bit
这样虽然可以在能够预见的未来解决时间戳回绕的问题,但会导致新旧协议兼容性问题,像现在的IPv4与IPv6一样
- 将一个与始终频率无关的值作为时间戳
随着时钟频率的提高,TCP在相同时间内能够收发的包也会越来越多。如果时间戳的增速不变,则会有越来越多的报文使用相同的时间戳。这种趋势到达一定程度则时间戳就会失去意义,除非在可预见的未来这种情况不会发生。
- 怎么随机序列号
RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。
- M是一个计时器,这个计时器每隔 4 微秒加1。
- F 是一个 Hash 算法,根据源IP、目的IP、源端口、目的端口生成一个随机数值,要保证 hash 算法不能被外部轻易推算得出。
- IP会分片,为什么需要MSS?
MTU:网络包的最大长度,一般为1500字节。
MSS:除去IP和TCP头部,一个网络包能容纳的最大TCP数据长度。
如果IP层有超过MTU大小的数据,IP层就会进行分片,来保证每一片的长度小于MTU。
如果把TCP的分片交给IP,如果一个IP分片丢失,整个IP报文的所有分片都要重传。所以为了达到最佳的传输效率,TCP协议在建立连接时,通常要协商双方的MSS值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。
握手丢失
- 第一次握手(SYN)丢失
客户端迟迟收不到ACK,触发超时重传机制,而且重传的SYN报文的序列号是一样的。
在 Linux 里,客户端的 SYN 报文最大重传次数由 tcp_syn_retries
内核参数控制,这个参数是可以自定义的,默认值一般是 5。
一般超时重传的等待时间为上一次超时时间的二倍。
- 第二次握手(SYN+ ACK)丢失
可能会让客户端以为自己的SYN丢失,并且服务端认为自己的SYN + ACK丢失,服务端和客户端都会重传
- 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由
tcp_syn_retries
内核参数决定;
- 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由
tcp_synack_retries
内核参数决定。
- 第三次握手(ACK)丢失
ACK报文不会重传,ACK报文丢失,服务端重传对应的SYN报文。
SYN攻击
- 什么是SYN攻击?如何避免?
- SYN攻击原理:
攻击者伪造不同IP地址的SYN报文请求连接,服务端收到连接请求后分配资源,回复
ACK+SYN包,但是由于IP地址是伪造的,无法收到回应,久而久之造成服务端半连接队列被占满,无法正常工作。
- 预防SYN攻击的方法:
-
增大半连接队列
要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列
tcp_max_syn_backlog
(最大TCPSYN队列积压):处于SYN_RECV的TCP最大连接数。
-
开启tcp_syncookies功能
开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接。
当半连接队列满了,开启这个功能可以让后续连接不进入半连接队列,而是计算一个cookie,作为请求报文序列号发给客户端,如果服务器端收到客户端确认报文,会检查ack包合法性,如果合法,直接加入到accept队列。
-
减少SYN + ACK 重传次数
当服务端受到 SYN 攻击时,就会有大量处于 SYN_RECV 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,我们可以减少 SYN+ACK 的重传次数,以加快处于 SYN_RECV 状态的 TCP 连接断开。
SYN-ACK 报文的最大重传次数由 tcp_synack_retries
内核参数决定(默认值是 5 次),比如将 tcp_synack_retries 减少到 2 次
TCP连接断开
四次挥手
- 四次挥手过程
客户端发送FIN报文,客户端进入FIN_WAIT_1
状态。
- 服务端接收报文,发送ACK报文,服务端进入
CLOSE_WAIT
状态。
- 客户端收到ACK报文,进入
FIN_WAIT_2
状态。
- 服务端处理完数据后,也发送FIN报文,服务端进入
LAST_ACK
状态。
- 客户端接收报文,发送ACK报文,进入
TIME_WAIT
状态。
- 服务器收到ACK报文,进入
CLOSE
状态。至此,服务器关闭连接。
- 客户端在经过2MSL时间后,自动进入
CLOSE
状态。至此,客户端关闭连接。
- 为什么是四次挥手?不是三次?
在关闭连接时,客户端向服务器发送FIN,仅仅表示客户端不再发送数据,但是可以接收数据。
服务器收到FIN报文时,先回复ACK应答报文,而服务器可能还有数据要处理和发送,等服务器端不再发送数据时,才发送FIN报文给客户端表示同意关闭连接。
所以,因为服务端要等待完成数据的发送和处理,所以服务端的ACK 和FIN是分开发送的,所以需要四次挥手。
- 四次挥手可以变为三次吗?
在一些情况下,四次挥手可以变为三次。当被动关闭方在TCP挥手过程中,没有数据要发送并且开启了TCP延迟确认机制,那么第二次和第三次挥手就会合并传输。
- 什么是TCP延迟确认机制?
为了解决ACK报文传输效率低的问题,衍生出了TCP延迟确认机制。
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方,回到四次挥手,就是一起发送FIN与ACK。
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
- 挥手丢失会发送什么?
- 第一次挥手丢失,客户端收不到ACK,会触发超时重传机制,重传FIN报文。
- 重发次数由
tcp_orphan_retries
参数控制。当客户端重传 FIN 报文的次数超过 tcp_orphan_retries
后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么直接进入到 close
状态。
- 第二次挥手丢失,客户端收不到ACK,会触发超时重传机制,重传FIN报文。(ACK报文不会重传)
- 如果关闭方调用close()函数关闭连接,FIN_WAIT2状态不可以持续太久,而
tcp_fin_timeout
控制了这个状态下连接的持续时长,默认值是 60 秒。
- 如果关闭方调用shutdown()函数关闭连接,指定了之关闭发送方向,而接收方向没有关闭,那么意味着主动关闭方还是可以接收数据的。此时,如果主动关闭方一直没收到第三次挥手,那么主动关闭方的连接将会一直处于
FIN_WAIT2
状态
- 第三次挥手丢失,服务端收不到ACK,会触发超时重传机制,服务端重传FIN报文。
- 当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于
CLOSE_WAIT
状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。
此时,内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。
服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。
- 第四次挥手丢失,服务端收不到ACK,会触发超时重传机制,服务端重传FIN报文。
- 当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入
TIME_WAIT
状态。在 Linux 系统,TIME_WAIT 状态会持续 2MSL 后才会进入关闭状态。
TIME_WAIT
- 什么是MSL?
MSL
是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
- IP 头中有一个
TTL
字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
- 为什么TIME_WAIT等待的时间是2MSL?
TIME_WAIT的时间是2倍的MSL,如果被动关闭方没有收到第四次握手的ACK报文,就会触发超时重复FIN,另一方接收到FIN后,会重发ACK给被动关闭方,一俩一回正好2个MSL。
- 假如ACK在一个MSL内丢失,被动方重发的FIN会在第二个MSL内到达,连接到达后TIME_WAIT状态的连接可以应对。
总的来说,就是2MSL的时长,可以至少允许报文丢失一次。
2MSL的时间是客户端收到FIN后发送ACK开始计时的。如果在TIME_WAIT时间内,客户端的ACK没有传输到ACK,客户端再次接收到了服务器端重发的FIN报文,那么2MSL将重新计时。
Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
- 为什么需要TIME_WAIT状态?
- 防止历史连接中的数据,被后面的相同四元组错误接收。
- 由于序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。2MSL这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
- 等待足够的时间,确保最后的ACK能让被动关闭连接的一方接收,从而被正确的关闭。
- 2MSL的时长相当于至少允许报文丢失一次,比如ACK在一个MSL内丢失,这样被动方重发的FIN报文就会在第二个MSL内到达。
- TIME_WAIT过多的危害
- 服务端(主动发起关闭连接方)的TIME_WAIT状态过多,占用系统资源,比如文件描述符、CPU、内存资源、线程资源。
- 客户端(主动发起关闭连接方)的TIME_WAIT状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。
- 如何优化TIME_WAIT?
Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用。
net.ipv4.tcp_tw_reuse = 1
有一点需要注意的是,tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。
- 服务器出现大量TIME_WAIT状态的原因?
如果服务器出现大量TIME_WAIT状态,说明服务器主动断开了很多TCP连接。
什么场景下,服务端会主动断开连接?
- HTTP没有使用长连接
- HTTP长连接超时
- HTTP长连接的请求数量达到上限
Socket编程
针对TCP如何Socket编程
int main()
{
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(listen_fd, xxx);
listen(listen_fd, 128);
cfd = accept(listen_fd, xxx);
n = read(cfd, buf, sizeof(buf));
}
- 服务端和客户端初始化
socket
,得到文件描述符;
- socket就是ip + port的封装,会提供具体的方法供实例调用。
- Linux 中一切都可以看作文件,包括普通文件、链接文件、Socket 以及设备驱动等,对其进行相关操作时,都可能会创建对应的文件描述符。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符。
- 服务端调用
bind
,将 socket 绑定在指定的 IP 地址和端口;
- 服务端调用
listen
,进行监听;
- 服务端调用
accept
,等待客户端连接;
- 客户端调用
connect
,向服务端的地址和端口发起连接请求;
- 服务端
accept
返回用于传输的 socket
的文件描述符(fd);
- 此时的Socket是已完成连接的Socket,用来传输数据。
- 客户端调用
write
写入数据;服务端调用 read
读取数据;
- 客户端断开连接时,会调用
close
,那么服务端 read
读取数据的时候,就会读取到了 EOF
,待处理完数据后,服务端调用 close
,表示连接关闭。
三次握手的细节分析
服务端对socket执行bind方法之后可以绑定监听端口,执行listen方法后,会进入listen状态,此时会为每一个处于Listen状态的socket分配两个队列,分别叫半连接队列和全连接队列。
accept发生在三次握手的哪一步?
connect返回成功(单向连接建立成功)是在第二次握手之后,服务端accept成功返回是在三次握手成功之后。
上图的具体步骤
- 客户端的协议栈向服务端发送了 SYN 包,并告诉服务端当前发送序列号 client_isn,客户端进入 SYN_SENT 状态;
- 服务端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包 client_isn 的确认,同时服务端也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务端进入 SYN_RCVD 状态;
- 客户端协议栈收到 ACK 之后,使得应用程序从
connect
调用返回,表示客户端到服务端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务端的 SYN 包进行应答,应答数据为 server_isn+1;
- ACK 应答包到达服务端后,服务端的 TCP 连接进入 ESTABLISHED 状态,同时服务端协议栈使得
accept
阻塞调用返回,这个时候服务端到客户端的单向连接也建立成功。至此,客户端与服务端两个方向的连接都建立成功。
没有accept,可以建立TCP连接吗?
答:可以建立连接,因为accept不参与三次握手。
accept 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket,就可以对该 socket 进行读写操作了。所以就算不执行accept()方法,三次握手照常进行,并且会顺利建立连接。
没有listen,可以建立TCP连接吗?
可以建立连接,因为客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen,就能 TCP 建立连接。
- 既然TCP自连接了,客户端会有半连接队列吗?
没有,客户端没有执行listen
,因为半连接队列和全连接队列都是在执行listen
方法时,内核自动创建的。
但是连接信息一定要有一个地方存放,方便握手的时候能根据 IP + 端口等信息找到对应的 socket。
我们在内核还有个全局hash表,可以用于存放socket
连接的信息。
在TCP自连接的情况中,客户端在connect
方法时,最后会将自己的连接信息放入到这个全局hash表中,然后将信息发出,消息在经过回环地址重新回到TCP传输层的时候,就会根据IP端口信息,再一次从这个全局hash中取出信息。于是握手包一来一回,最后成功建立连接。
服务端没有listen,客户端发起连接建立,会发生什么?
如果服务端只bind了IP地址和端口,而没有调用listen,客户端对服务端发起连接建立,服务端会返回RST报文。
listen时候参数backlog的意义?
int listen (int socketfd, int backlog)
在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。
但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
客户端调用close,连接断开的流程是什么?
调用close(),说明没有数据要传输了,服务端收到FIN后调用会read,返回EOF,当服务器端发送FIN,服务端会调用close(),客户端收到后进入2MSL的计时,经过2MSL后,进入CLOSE状态。
TCP重传、滑动窗口、流量控制、拥塞控制
重传机制
TCP实现可靠传输的方式之一,是通过序列号与确认应答。但如果数据包在传输过程中丢失,就会用重传机制解决。
超时重传
超时重传就是设定一个定时器,当超过指定的时间后,没有收到对方的ACK确认应答报文,就会重发该数据。
主要在以下两种情况时发生超时重传:
- 数据包丢失。
- 确认应答丢失。
一般超时重传的时间都会设定为略大于一个RTT(往返时延)。如果超时重发的数据再次超时,TCP的策略是超时间隔加倍。每遇到一次超时重传,就会将下一次的时间间隔设置为当前的2倍。
超时触发重传的问题是,超时周期可能相对比较长,所以可以使用快速重传机制来解决超时重发的时间等待。
快速重传
快速重传不以时间为驱动,而是以数据为驱动。
当收到三个相同的ACK 报文时,会在定时器过期之前,重传丢失的报文段。
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传一个,还是重传所有的问题。
- 举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的, 那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、 Seq4、Seq5、 Seq6) 呢?
- 如果只选择重传 Seq2 一个报文,那么重传的效率很低。因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。
- 如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。
可以看到,不管是重传一个报文,还是重传已发送的报文,都存在问题。
为了解决不知道该重传哪些 TCP 报文,于是就有 SACK
方法。
SACK方法
SACK(Selective Acknowledgment):选择性确认,即在TCP头部字段中加一个SACK,可以将已收到的数据的信息发送给发送方,这样发送方就知道哪些数据收到了,哪些没有收到,这样发送方就可以只重传丢失的数据。
Duplicate SACK
Duplicate SACK 又称 D-SACK。主要使用SACK来告诉发送方有哪些数据被重复接收了。
D-SACK
有这么几个好处:
-
可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
-
可以知道是不是「发送方」的数据包被网络延迟了;
滑动窗口
为了解决每发送一次数据,都要进行一次应答这种低效率模式,TCP使用了滑动窗口。
窗口大小指无需等待确认应答,而可以继续发送数据的最大值。
- 窗口的实现实际是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。
而应答方式使用累计确认的方式,即使中间报文丢失,可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。
窗口大小哪一方决定?
TCP头部中字段Window,也就是窗口大小。
使用这个字段可以告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。
所以,通常窗口的大小是由接收方的窗口大小来决定的。
发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。
接收窗口和发送窗口的大小相等吗?
并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。
因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
流量控制
发送方在发送数据时,要考虑接收方的处理能力。
如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。
为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。
- 操作系统缓冲区与滑动窗口的关系?
前面的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整。
当应用进程没办法及时读取缓冲区的内容时,也会对我们的缓冲区造成影响。
如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。
为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。
- 窗口关闭
在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。
如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。
接收方向发送方通告窗口大小时,是通过 ACK
报文来通告的。
那么,当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了。
- TCP如何解决窗口关闭时,潜在的死锁现象?
为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。
如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
- 糊涂窗口综合征
如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。
到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
要解决这个问题,就要同时解决两个问题:
- 让接收方不通告小窗口给发送方。
当「窗口大小」小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0
,也就阻止了发送方再发数据过来。
等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。
- 让发送方避免发送小数据。
使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才可以发送数据:
- 条件一:要等到窗口大小 >= MSS
并且 数据大小 >= MSS
;
- 条件二:收到之前发送数据的 ack
回包;
只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件。
所以,接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症。
拥塞控制
流量控制是避免发送方的数据填满接收方的缓存,但是并不知道网络中发生了什么。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大…
于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。
- 拥塞窗口和发送窗口的关系?
拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。
我们在前面提到过发送窗口 swnd
和接收窗口 rwnd
是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd
变化的规则:
- 只要网络中没有出现拥塞,
cwnd
就会增大;
- 但网络中出现了拥塞,
cwnd
就减少;
其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
- 拥塞控制的算法:慢启动、拥塞避免、拥塞发生、快速恢复。
- 慢启动
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗?
慢启动的算法记住一个规则就行:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。
有一个叫慢启动门限 ssthresh
(slow start threshold)状态变量。
- 当
cwnd
< ssthresh
时,使用慢启动算法。
- 当
cwnd
>= ssthresh
时,就会使用「拥塞避免算法」。
- 拥塞避免算法
进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
当触发了重传机制,也就进入了「拥塞发生算法」。
- 拥塞发生
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
- 超时重传
当发生了「超时重传」,则就会使用拥塞发生算法。
这个时候,ssthresh 和 cwnd 的值会发生变化:
ssthresh
设为 cwnd/2
,
cwnd
重置为 1
(是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)
- 快速重传
还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh
和 cwnd
变化如下:
cwnd = cwnd/2
,也就是设置为原来的一半;
ssthresh = cwnd
;
- 进入快速恢复算法
- 快速恢复
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO
超时那么强烈。
正如前面所说,进入快速恢复之前,cwnd
和 ssthresh
已被更新了:
cwnd = cwnd/2
,也就是设置为原来的一半;
ssthresh = cwnd
;
然后,进入快速恢复算法如下:
- 拥塞窗口
cwnd = ssthresh + 3
( 3 的意思是确认有 3 个数据包被收到了);
- 重传丢失的数据包;
- 如果再收到重复的 ACK,那么 cwnd 增加 1;
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
TCP半连接队列和全连接队列
如何建立的队列?
服务端收到客户端发起的 SYN (Synchronous,同步)请求后,此时的TCP是半连接状态,会将其加入到半连接队列(SYN queue)中,并向客户端响应 SYN+ACK,接着客户端会返回 ACK(ACKnowledge Character,确认字符),服务端收到第三次握手的 ACK 后,TCP连接已建立(established,已确立的),从半连接队列中移出不需要的连接,并添加全连接到 accept 队列,等待进程调用 accept 函数时把连接取出来。
SYN_RECV(SYN_RECEIVED,已收到SYN):在TCP三次握手的第二个阶段,客户端发送一个SYN包给服务器端,服务器端接收到这个SYN包后,就会发送一个SYN+ACK包给客户端。当客户端收到服务器端发送的SYN+ACK包后,会再次发送一个ACK包给服务器端,完成TCP三次握手,建立连接。在这个过程中,服务器端在等待客户端发送ACK包的阶段就处于SYN_RECEIVED状态。
两个队列的数据结构?
虽然都叫队列,但其实全连接队列(icsk_accept_queue)是个链表,而半连接队列(syn_table)是个哈希表。
先对比下全连接里队列,他本质是个链表,因为也是线性结构,说它是个队列也没毛病。它里面放的都是已经建立完成的连接,这些连接正等待被取走。而服务端取走连接的过程中,并不关心具体是哪个连接,只要是个连接就行,所以直接从队列头取就行了。这个过程算法复杂度为O(1)
。
而半连接队列却不太一样,因为队列里的都是不完整的连接,等待着第三次握手的到来。那么现在有一个第三次握手来了,则需要从队列里把相应IP端口的连接取出,如果半连接队列还是个链表,那我们就需要依次遍历,才能拿到我们想要的那个连接,算法复杂度就是O(n)。
而如果将半连接队列设计成哈希表,那么查找半连接的算法复杂度就回到O(1)
了。
因此出于效率考虑,全连接队列被设计成链表,而半连接队列被设计为哈希表。
TCP全连接队列溢出
当TCP的全连接超过了TCP最大全连接队列,服务端则会丢掉后续进来的TCP连接。
当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。发生 TCP 全连接队溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象。
然而,丢掉TCP连接是默认行为,我们可以选择向客户端发送RST复位报文(Reset Segment),告诉客户端连接已经建立失败。
- 增大TCP全连接队列
使用ss
命令查看TCP全连接队列
其中Send-Q
是指全连接队列的最大值;Recv-Q
是指当前的全连接队列的使用值,用了0
个,也就是全连接队列里为空,连接都被取出来了。
想要增大TCP全连接队列,调大somaxconn和backlog参数。TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。
TCP半连接队列溢出
一般是丢弃,但这个行为可以通过 tcp_syncookies
参数去控制。但是更重要的是要了解半连接队列为什么会被打满?
如何优化TCP?
TCP三次挥手的性能提升
TCP 是面向连接的、可靠的、双向传输的传输层通信协议,所以在传输数据之前需要经过三次握手才能建立连接。
我们可以通过调整三次握手中的参数,来提高TCP三次握手的性能。
上图可见,客户端和服务端都可以针对三次握手优化性能,但是两者的优化方式是不同的。
三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠传输,TCP的许多特性都依赖于序列号实现,比如流量控制、丢包重传。SYN的全称就叫做Synchronized Sequence Numbers(同步序列号)
- 客户端优化
- 优化SYN_SENT状态
SYN_SENT状态是客户端发送SYN包之后的状态,客户端在等待ACK报文,正常情况下,服务器会在几毫秒内返回SYN + ACK,如果长时间没有收到SYN + ACK,客户端会重发SYN包,重发次数由tcp_syn_retries
(tcp同步复审)参数决定。
一般来说,每次超时重传时间是上一次的2倍。
- 服务端优化
- 调整SYN半连接队列大小
当服务端SYN半连接队列溢出,会导致后续连接被丢弃,可以通过nestat -s 观察半连接队列溢出的情况,如果SYN半连接队列溢出情况比较严重,可以通过tcp_max_syn_backlog、somaxconn、backlog参数来调整SYN半连接队列大小。
- 不使用SYN半连接队列
开启syncookies功能,可以在不使用SYN半连接队列的情况下成功建立连接。
- SYN_RECV状态优化
当客户端收到SYN + ACK报文后,就会回复ACK给服务器,客户端的连接状态从SYN_SENT转变为ESTABLISHED,表示建立连接成功。
服务端收到ACK后,服务端的连接才转变为ESTABLISHED,如果没有收到ACK,就会重发SYN +ACK,我们可以通过tcp_synack_retries
参数调整重发次数。
- 调整accept队列策略
我们可以通过tcp_abort_on_overflow
(在溢出时中止)参数,来调整当accept队列满的策略。
- 0:如果accept队列满了,那么server扔掉client发过来的ack;
- 1:如果accept队列满了,那么server发送一个RST(Reset the connection)给client,表示废掉握手过程和连接。
通常情况下,如果服务器上的进程只是因为短暂的繁忙造成accept队列满,当accept队列为空时,再次接收到ACK报文,仍然会重新成功建立连接。
所以,tcp_abort_on_overflow
设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
- 调整accept队列的长度
accept 队列的长度取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog),其中:
- somaxconn 是 Linux 内核的参数,默认值是 128,可以通过
net.core.somaxconn
来设置其值;
- backlog 是
listen(int sockfd, int backlog)
函数中的 backlog 大小;
- 绕过三次握手
TCP四次挥手的性能提升
在TCP四次挥手中,客户端和服务端双方都可以主动断开连接,通常先关闭连接的一方称为主动方,后关闭连接的一方为被动方。
四次挥手只涉及了两种报文,分别是FIN 和ACK。
- FIN就是结束连接,谁发出FIN,就表示它不会再发送任何数据,关闭这一方的传输通道。
- ACK 就是确认,用来通知对方,你方的发送通道已经关闭。
四次挥手的过程:
- 当主动方关闭连接时,会发送 FIN 报文,此时发送方的 TCP 连接将从 ESTABLISHED 变成 FIN_WAIT1。
- 当被动方收到 FIN 报文后,内核会自动回复 ACK 报文,连接状态将从 ESTABLISHED 变成 CLOSE_WAIT,表示被动方在等待进程调用 close 函数关闭连接。
- 当主动方收到这个 ACK 后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,也就是表示主动方的发送通道就关闭了。
- 当被动方进入 CLOSE_WAIT 时,被动方还会继续处理数据,等到进程的 read 函数返回 0 后,应用程序就会调用 close 函数,进而触发内核发送 FIN 报文,此时被动方的连接状态变为 LAST_ACK。
- 当主动方收到这个 FIN 报文后,内核会回复 ACK 报文给被动方,同时主动方的连接状态由 FIN_WAIT2 变为 TIME_WAIT,在 Linux 系统下大约等待 1 分钟后,TIME_WAIT 状态的连接才会彻底关闭。
- 当被动方收到最后的 ACK 报文后,被动方的连接就会关闭。
你可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
- 主动方的优化
- 调用close()函数和shutdown()函数有什么区别?
close()函数意味完全断开连接,无法发送/传输数据。调用close()一方的连接叫做孤儿连接。
而使用shutdown(),可以控制只关闭一个方向的连接。其中的参数可以决定连接断开的方式。
- SHUT_RD(0):关闭连接的「读」这个方向,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
- SHUT_WR(1):关闭连接的「写」这个方向,这就是常被称为「半关闭」的连接。如果发送缓冲区还有未发送的数据,将被立即发送出去,并发送一个 FIN 报文给对端。
- SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。
- FIN_WAIT1状态的优化
主动方发送FIN后,就到了FIN_WAIT1的状态,如果迟迟收不到ACK,就会重发FIN报文。重发次数可以由tcp_orphan_retries (orphan:孤儿)参数控制。
如果 FIN_WAIT1 状态连接很多,我们就需要考虑降低 tcp_orphan_retries 的值,当重传次数超过 tcp_orphan_retries 时,连接就会直接关闭掉。
但是如果遭到恶意攻击,FIN报文无法发送出去,主要由两个特性决定:
- TCP报文是有序的,当缓冲区还有数据,FIN报文不能提前发送。
- 其次,当TCP有流量控制的功能时,接收方接收窗口为0,发送方就不再发送数据,攻击者可以将接收窗口设置为0,就会使得FIN报文无法发送,连接会一直处于FIN_WAIT1的状态。
可以通过调整tcp_max_orphans参数,来调整孤儿连接的最大数量。
当进程调用了 close
函数关闭连接,此时连接就会是「孤儿连接」,因为它无法再发送和接收数据。Linux 系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 tcp_max_orphans
参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。
- FIN_WAIT2状态的优化
如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长
- TIME_WAIT状态的优化
TIME_WAIT 状态的连接,在主动方看来确实快已经关闭了。然后,被动方没有收到 ACK 报文前,还是处于 LAST_ACK 状态。如果这个 ACK 报文没有到达被动方,被动方就会重发 FIN 报文。重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。
TIME-WAIT 的状态尤其重要,主要是两个原因:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 保证「被动关闭连接」的一方,能被正确的关闭;
- 优化方式一:
Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭:
当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 tcp_max_tw_buckets
参数,减少不同连接间数据错乱的概率。tcp_max_tw_buckets 也不是越大越好,毕竟系统资源是有限的。
- 优化方式二:
有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态的连接,那就是打开 tcp_tw_reuse 参数。但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。
- 优化方式三:
我们可以在程序中设置 socket 选项,来设置调用 close 关闭连接行为。如果 l_onoff
为非 0, 且 l_linger
值为 0,那么调用 close 后,会立该发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。
这种方式只推荐在客户端使用,服务端千万不要使用。因为服务端一调用 close,就发送 RST 报文的话,客户端就总是看到 TCP 连接错误 “connnection reset by peer”。(对等方重置连接)
- 被动方的优化
- 被动关闭的连接方应对非常简单,它在回复 ACK 后就进入了 CLOSE_WAIT 状态,等待进程调用 close 函数关闭连接。因此,出现大量 CLOSE_WAIT 状态的连接时,应当从应用程序中找问题。
当被动方发送 FIN 报文后,连接就进入 LAST_ACK 状态,在未等到 ACK 时,会在 tcp_orphan_retries
参数的控制下重发 FIN 报文。
TCP传输数据的性能提升
之前说的是在三次握手和四次挥手的优化策略,接下来主要介绍的是 TCP 传输数据时的优化策略。
- 滑动窗口如何影响传输速度?
由于TCP报文发出,必须接收到对方返回的确认报文ACK,如果未收到,就会超时重发该报文,直到收到ACK为止。所以,TCP报文发出后,并不会立刻在内存中删除。
如果TCP每次发一条数据,接收方每次发送一条确认应答,这样效率很低,所以我们并行批量发送报文,再批量确认报文。
这时就要考虑一个问题,就是接收方的处理能力,所以,TCP提供了一种机制可以让发送方根据接收方的实际接收能力,来控制发送的数据量,这就是滑动窗口。
接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用 read 函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。
因此,接收窗口并不是恒定不变的,接收方会把当前可接收的大小放在 TCP 报文头部中的窗口字段,这样就可以起到窗口大小通知的作用。
窗口字段只有两个字节,因此最多能表达65535字节大小的窗口,也就是64KB大小。后续又提出了扩充窗口的想法:
在 TCP 选项字段定义了窗口扩大因子,用于扩大 TCP 通告窗口,其值大小是 2^14,这样就使 TCP 的窗口大小从 16 位扩大为 30 位(2^16 * 2^ 14 = 2^30),所以此时窗口的最大值可以达到 1GB。
要使用窗口扩大选项,通讯双方必须在各自的 SYN 报文中发送这个选项:
- 主动建立连接的一方在 SYN 报文中发送这个选项;
- 而被动建立连接的一方只有在收到带窗口扩大选项的 SYN 报文之后才能发送这个选项。
但是,网络传输的能力也是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。
- 如何确定最大传输速度?
窗口大小由内核缓冲区大小决定。如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最大化。
网络是有「带宽」限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同:
- 带宽是单位时间内的流量,表达是「速度」,比如常见的带宽 100 MB/s;
- 缓冲区单位是字节,当网络速度乘以时间才能得到字节数;
如果最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着客户端到服务端的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。
这个 1MB 是带宽和时延的乘积,所以它就叫「带宽时延积」(缩写为 BDP,Bandwidth Delay Product)。同时,这 1MB 也表示「飞行中」的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1 MB,就会导致网络过载,容易丢包。
**由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。**发送缓冲区与带宽时延积的关系:
- 如果发送缓冲区「超过」带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包;
- 如果发送缓冲区「小于」带宽时延积,就不能很好的发挥出网络的传输效率。
所以,发送缓冲区的大小最好是往带宽时延积靠近。
- 怎样调整缓冲区大小?
发送缓冲区是自行调节的,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。可以通过tcp_wmem 参数配置。
**接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:**需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能:
- 如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而提升发送方发送的传输数据数量;
- 反之,如果系统的内存很紧张,就会减少缓冲区,这虽然会降低传输效率,可以保证更多的并发连接正常工作;
- 怎样知道当前内存是否紧张或充分呢?
可以通过 tcp_mem 配置完成。表示页面大小,1页表示4KB,如果计算的值大于tep_mem的最大值,系统将无法为新的TCP连接分配内存,即TCP连接被拒绝。
- 实际场景的调节策略?
在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。
同时,如果这是网络 IO 型服务器,那么,调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能。
如何理解TCP面向字节流协议?
TCP是面向字节流的协议,UDP是面向报文的协议,主要是因为操作系统对两个协议的发送机制不同。
- 为什么UDP是面向报文的协议?
从操作系统发送出去的UDP报文就是完整的用户信息,每个UDP报文就是一个用户消息的边界。
当收到UDP报文后,会将其插入队列,队列中一个元素就是一个UDP报文,这样当用户调用 recvfrom()(receive from,接收数据的函数) 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。
- 为什么TCP是面向字节流的协议?
用户消息通过TCP协议传输时,一个完整的用户信息,会被拆分为多个TCP报文进行传输。
这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。
当两个消息的某个部分被分到同一个TCP报文时,就会发生我们常说的TCP粘包问题。要解决这个问题,就要交给应用程序。
- 如何解决粘包?
知道一个用户的消息边界在哪,就可以解决粘包的问题。
一般有三种分包方式:
- 固定长度的消息
即每个消息都是固定长度,但是这种方式不灵活。
- 特殊字符作为边界
即在两个用户消息之间插入一个特殊的字符串,如果接收方读到了特殊字符串,就认为读完了一个完整的消息。
- 自定义消息结构
我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。
当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。
SYN报文什么情况下会被丢弃?
- 开启tcp_tw_recycle参数 + tcp_timestamps参数,并且在NAT的环境下,可能会造成SYN报文被丢弃。
- tcp_tw_recycle:就意味着time_wait状态下的TCP连接可以被快速回收。
- tcp_timestamps (时间戳,开启tcp_tw_recycle也要开启该参数):意为着开启了防止序列号回绕(PAWS)。
但是对于服务器来说,如果同时开启了tcp_tw_recycle和tcp_timestamps ,就会开启一种称之为per-host 的 PAWS 机制。per-host 是对「对端 IP 做 PAWS 检查」,而非对「IP + 端口」四元组做 PAWS 检查。
如果客户端的网络环境使用了NAT网关,客户端A通过NAT网关与服务器建立TCP链接,并且A开启了tcp_tw_recycle,此时服务器就开启了per-host的PAWS机制,也即是只对IP做PAWS检查。此时客户端B再经过NAT网关与服务器建立TCP连接,会使用与A相同的IP地址进行连接。如果此时B的时间戳比A的时间戳要小,那么服务器就会丢弃该SYN报文。
所以在 Linux 4.12 版本后,直接取消了tcp_tw_recycle这一参数。
NAT网关(Network Address Translation,网络地址转换):简单的说,NAT就是在局域网内部网络中使用内部地址,而当内部节点要与外部网络进行通讯时,就在网关(可以理解为出口,打个比方就像院子的门一样)处,将内部地址替换成公用地址,从而在外部公网(internet)上正常使用,NAT可以使多台计算机共享Internet连接,这一功能很好地解决了公共IP地址紧缺的问题。通过这种方法,您可以只申请一个合法IP地址,就把整个局域网中的计算机接入Internet中。这时,NAT屏蔽了内部网络,所有内部网计算机对于公共网络来说是不可见的,而内部网计算机用户通常不会意识到NAT的存在。
- 半连接/全连接队列满了
当服务器收到syn攻击,半连接队列就有可能会满,这时后面来的syn报文就会被丢弃。
在服务端并发处理大量请求时,如果 TCP accpet 队列过小,或者应用程序调用 accept() 不及时,就会造成 accpet 队列满了 ,这时后续的连接就会被丢弃,这样就会出现服务端请求数量上不去的现象。解决这个问题,就可以通过调大somaxconn和backlog参数,来调整accept队列的大小。
一个已经建立的 TCP 连接,客户端中途宕机了,而服务端此时也没有数据要发送,一直处于 Established 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理?
然后这个场景中,客户端的 IP、服务端 IP、目的端口并没有变化,所以这个问题关键要看客户端发送的 SYN 报文中的源端口是否和上一次连接的源端口相同。
已建立连接的TCP,收到SYN会发生什么?
- 如果SYN报文中的源端口与上一次连接相同。处于Established状态的服务器,收到客户端的SYN报文,会回复一个携带了正确序列号和确认号的ACK报文,这个ACK 称为Challenge ACK。
客户端收到Challenge ACK后,发现确认号不是自己期望收到的,就会回复RST报文(Reset Segment,表明TCP连接建立失败),服务端收到后就释放连接。
- 如果SYN报文中的源端口与上一次连接不同,服务端会以为是一个新的连接要建立,会通过三次握手来建立新的连接。
旧连接中处于Established状态的服务端如果没有发送数据包给客户端,在超过一段时间后,TCP保活机制会启动,检测客户端是否存活,接着服务器就会释放该连接。
如果旧连接中的服务端发送了数据包,客户端的内核就会回复RST报文,服务器收到后就会释放连接。
如何关闭一个TCP连接?
- 杀掉进程
- 在客户端杀掉进程的话,就会发送 FIN 报文,来断开这个客户端进程与服务端建立的所有 TCP 连接,这种方式影响范围只有这个客户端进程所建立的连接,而其他客户端或进程不会受影响。
- 而在服务端杀掉进程影响就大了,此时所有的 TCP 连接都会被关闭,服务端无法继续提供访问服务。
- 伪造一个四元组相同的SYN报文,拿到合法序列号,将该序列号作为RST报文的序列号,发送给服务端,此时服务端会认为这个 RST 报文里的序列号是合法的,于是就会释放连接!
- 如果处于 Established 状态的服务端,收到四元组相同的 SYN 报文后,会回复一个 Challenge ACK,这个 ACK 报文里的「确认号」,正好是服务端下一次想要接收的序列号,说白了,就是可以通过这一步拿到服务端下一次预期接收的序列号。
- 在 Linux 上有个叫 killcx 的工具,就是基于上面这样的方式实现的,它会主动发送 SYN 包获取 SEQ/ACK 号,然后利用 SEQ/ACK 号伪造两个 RST 报文分别发给客户端和服务端,这样双方的 TCP 连接都会被释放,这种方式活跃和非活跃的 TCP 连接都可以杀掉。
- tcpkill 工具是在双方进行 TCP 通信时,拿到对方下一次期望收到的序列号,然后将序列号填充到伪造的 RST 报文,并将其发送给对方,达到关闭 TCP 连接的效果。
tcpkill 工具属于被动获取,就是在双方进行 TCP 通信的时候,才能获取到正确的序列号,很显然这种方式无法关闭非活跃的 TCP 连接,只能用于关闭活跃的 TCP 连接。因为如果这条 TCP 连接一直没有任何数据传输,则就永远获取不到正确的序列号。
killcx 工具则是属于主动获取,它是主动发送一个 SYN 报文,通过对方回复的 Challenge ACK 来获取正确的序列号,所以这种方式无论 TCP 连接是否活跃,都可以关闭。
四次挥手中收到乱序的FIN包会如何处理?
如果 FIN 报文比数据包先抵达客户端,此时 FIN 报文其实是一个乱序的报文,此时客户端的 TCP 连接并不会从 FIN_WAIT_2 状态转换到 TIME_WAIT 状态。在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就被会加入到「乱序队列」,并不会进入到 TIME_WAIT 状态。
等再次收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这时才会进入 TIME_WAIT 状态。
在TIME_WAIT状态的TCP连接,收到SYN后会发生什么?
收到SYN的一方会先判断SYN是否合法;合法:确认序列号和时间戳都比上一次要大。不合法:其中一者不符合要求。
- 如果SYN合法:收到SYN后,判断合法,于是会重用处于TIME_WAIT状态的连接。发送SYN + ACK。
- 如果SYN不合法:收到SYN后,判断不合法,于是会发送RST报文,表示断开当前TCP连接。
- TIME_WAIT状态的连接收到RST是否会断开连接与
net.ipv4.tcp_rfc1337
参数有关
- 如果
net.ipv4.tcp_rfc1337
参数为 0,则提前结束 TIME_WAIT 状态,释放连接。
- 如果
net.ipv4.tcp_rfc1337
参数为 1,则会丢掉该 RST 报文。
总结:发送SYN,说明想要重新三次握手建立连接,那么如果你的SYN符合要求,那么就可以建立连接。即使我处于TIME_WAIT状态,我也会重新使用这段TCP连接。但是如果SYN不符合要求,那么当然就不会使用,我要发送一个RST告诉对方,我要关闭连接了,不要再发送类似的报文了。
TCP连接,一端断电和进程崩溃有什么区别?
- 一端断电(主机崩溃)
客户端的主机崩溃,服务端是无法感知的。这时就需要TCP keep-alive。
- TCP keepalive会定义一个时间段,在这个时间段内,如果没有任何报文传输,TCP保活机制就会开启。保活机制会每隔一段时间发送一段探测报文,如果连续的几个探测报文都没有得到回复,就认为当前的TCP连接已经死亡,系统内核将错误信息上传给上层应用。
tcp_keepalive_time:设置开始保活机制的时间。
tcp_keepalive_intvl:每次探测间隔。
tcp_keepalive_probes:保活探测次数。
如果没有开启keepalive,那么服务器会一直处于Established状态,直到服务端重启。
- 此时如果服务端向客户端发送报文,不会得到任何的相应,在一定时长后,服务端就会触发超时重传机制,重传未得到响应的报文。
- 如果此时客户端重启了,客户端的内核收到了重传的报文,就会传递给对应的进程。
- 如果客户端上的目标端口号没有对应的进程了。那么客户端内核就会回复 RST 报文,重置该 TCP 连接;即使有对应的进程,但是因为客户端重启了,已经失去了TCP连接的数据结构,客户端内核里协议栈会发现找不到该 TCP 连接的 socket 结构体,于是就会回复 RST 报文,重置该 TCP 连接。
- 如果客户端一直都没有重启,这种情况,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。
- 进程崩溃
TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP四次挥手的过程。
拔掉网线后,原本的TCP连接还存在吗?
拔掉网线并不会影响TCP连接的状态,但是要分情况讨论。
- 拔掉网线后,有数据传输
服务端发送数据不会得到任何相应,在等待一定时长后,服务端就会触发超时重传机制,重传未得到响应的数据报文。
如果在服务端重传报文的过程中,客户端刚好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。就好像什么事情都没有发生。
但是,如果如果在服务端重传报文的过程中,客户端一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。
而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元祖的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。此时,客户端和服务端的 TCP 连接都已经断开了。
- 拔掉网线后,没有数据传输
针对拔掉网线后,没有数据传输的场景,还得看是否开启了 TCP keepalive 机制 (TCP 保活机制)。
如果没有开启 TCP keepalive 机制,在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。
而如果开启了 TCP keepalive 机制,在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文:
- 如果对端是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
- 如果对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。
tcp_tw_reuse为什么默认关闭?
设计 TIME_WAIT 状态,主要有两个原因:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 保证「被动关闭连接」的一方,能被正确的关闭;
tcp_tw_reuse
:如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。所以该选项只适用于连接发起方。
tcp_tw_recycle
:如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收,该参数在 NAT 的网络下是不安全的!
- 为什么tcp_tw_reuse默认关闭?
tcp_tw_reuse 的作用是让客户端快速复用处于 TIME_WAIT 状态的端口,相当于跳过了 TIME_WAIT 状态,这可能会出现这样的两个问题:
- 历史 RST 报文可能会终止后面相同四元组的连接,因为 PAWS 检查到即使 RST 是过期的,也不会丢弃。
- 如果第四次挥手的 ACK 报文丢失了,有可能被动关闭连接的一方不能被正常的关闭;
虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。
HTTPS中TLS和TCP能同时握手吗?
触发这个场景,需要两个前提条件:
- 客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;
- 客户端和服务端已经完成过一次通信。
TCP Fast open
如果客户端和服务端都开启了TCP Fast Open。第一次客户端和服务器进行通信的时候,还是需要正常的三次握手。但是客户端会多保存一个Fast Open Cookie。这个东西可以用来向服务器证明之前与客户端的握手已经完成。
对于客户端和服务器的后续通信,客户端可以在第一次握手的时候就携带应用数据,从而达到绕过三次握手发送数据的效果。
具体流程如下:
- 客户端发送 SYN 报文,该报文可以携带「应用数据」以及此前记录的 Cookie;
- 支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和「数据」进行确认,服务器随后将「应用数据」递送给对应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的「应用数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号,也就是回归正常的三次握手。
- 由于服务器接收到SYN后的应用数据,服务器就可以在握手完成之前发送响应数据,这样就减少了1个RTT的时间消耗。
- 客户端将发送 ACK,来确认服务器发回的 SYN 以及「应用数据」,但如果客户端在初始的 SYN 报文中发送的「应用数据」没有被确认,则客户端将重新发送「应用数据」;
- 此后的 TCP 连接的数据传输过程和非 TCP Fast Open 的正常情况一致。
TLS v1.3
TLS1.3不再使用静态的RSA密钥交换,相比于TLS1.2,只需要一次往返(1个RTT)即可完成握手。
客户端和服务端同时支持 TCP Fast Open 功能的情况下,在第二次以后到通信过程中,客户端可以绕过三次握手直接发送数据,而且服务端也不需要等收到第三次握手后才发送数据。
如果 HTTPS 的 TLS 版本是 1.3,那么 TLS 过程只需要 1-RTT。
因此如果「TCP Fast Open + TLSv1.3」情况下,在第二次以后的通信过程中,TLS 和 TCP 的握手过程是可以同时进行的。
也就是说,TCP Fast Open绕过了三次握手,
如果基于 TCP Fast Open 场景下的 TLSv1.3 0-RTT 会话恢复过程,不仅 TLS 和 TCP 的握手过程是可以同时进行的,而且 HTTP 请求也可以在这期间内一同完成。
TCP Keepalive 和HTTP Keep-Alive是一个东西吗?
对于这个问题,我们要先知道这两个KeepAlive分别代表什么?
- TCP的Keepalive是由TCP层(内核层)实现的,称为TCP保活机制;如果两端TCP连接一直没有数据交互,就会触发该机制,内核里的TCP协议栈会发送探测报文。
- 如果对端程序正常工作,TCP保活时间会被重置。
- 如果对端主机宕机,TCP会报告TCP连接已经死亡。
- HTTP的Keep-Alive是由应用层(用户层)实现的,称为HTTP长连接;
先来了解什么是短链接
- HTTP采用的是请求—应答模式,并且是基于TCP传输协议实现的。客户端与服务端进行HTTP通信前,要建立HTTP连接,然后客户端发送HTTP请求,服务端收到后就返回响应,至此,请求—响应的模式就完成了,然后就会释放TCP连接。这个过程就是HTTP短连接。
使用HTTP的Keep-Alive就可以实现在完成HTTP请求后不断开TCP连接,让后序的HTTP请求继续使用该TCP连接。这就避免了连接的重复建立和释放的开销。
如果要开启Keep-Alive,就要在请求头中添加:Connection: Keep-Alive
但服务器收到请求后,它也在响应头中添加:Connection:Keep-Alive
从HTTP1.1开始,就默认开启了Keep-alive,如果要关闭Keep-Alive,需要在HTTP请求包头中添加:Conntection:close
HTTP长连接为HTTP流水线提供了可能,所谓的 HTTP 流水线,是客户端可以先一次性发送多个请求,而在发送过程中不需先等待服务器的回应,可以减少整体的响应时间。但服务器是按照顺序响应的。
一般的,防止长连接占用资源,webweb 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。比如设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。
TCP协议有什么缺陷
主要有以下几个方面,UDP协议的可靠传输使用的QUIC协议就解决了TCP的痛点,并且现在已经应用到HTTP/3了。
-
升级 TCP 的工作很困难;
TCP 协议是在内核中实现的,应用程序只能使用不能修改,如果要想升级 TCP 协议,那么只能升级内核。但是升级内核很麻烦。
-
TCP 建立连接的延迟;
基于TCP实现的应用协议,都要首先建立三次握手才能进行数据传输。比如HTTPS协议,在经过TCP三次握手之后,还需要经过TLS四次握手,才能进行HTTP数据的传输,这就增加了数据传输的延迟。
TCP Fast Open 减少了第二次TCP建立连接时的时延。但是提出的较晚,很难被普及。
-
TCP还可能不安全;
因为TLS是在应用层实现的握手,TCP是在内核实现的握手,这两个握手过程无法结合在一起,总是得先完成TCP握手,才能进行TLS握手,所以TLS无法对TCP头部进行加密,这意味着TCP的序列号都是明文传输的,存在安全问题
一个典型的例子就是攻击者伪造一个的 RST 报文强制关闭一条 TCP 连接,而攻击成功的关键则是 TCP 字段里的序列号位于接收方的滑动窗口内,该报文就是合法的。
为此 TCP 也不得不进行三次握手来同步各自的序列号,而且初始化序列号时是采用随机的方式(不完全随机,而是随着时间流逝而线性增长,到了 2^32 尽头再回滚)来提升攻击者猜测序列号的难度,以增加安全性。
但是这种方式只能避免攻击者预测出合法的 RST 报文,而无法避免攻击者截获客户端的报文,然后中途伪造出合法 RST 报文的攻击的方式。
-
TCP 存在队头阻塞问题;
因为TCP是字节流的协议,TCP层必须要保证收到的字节数据是完整并且有序的,当序列号较低的TCP段在网络中丢失,即使序列号较高的TCP段已经被接受了,但是应用层也无法从内核读取这段数据。(接收窗口不会向前滑动)这就是TCP队头阻塞的问题。
HTTP/2通过抽象出Stream概念,实现了HTTP并发传输,一个Stream就代表HTTP/1.1中的请求和响应。但是多个Stream请求是在一个TCP连接中进行传输的,当TCP连接丢包,整个TCP就要等待重传,那么就会阻塞该TCP连接中的所有请求,所以HTTP/2队头阻塞问题就是因为TCP协议导致的。
-
网络迁移需要重新建立 TCP 连接;
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。
那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接。
而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
如何基于UDP协议实现可靠传输?
市面上已经存在UDP协议实现的可靠传输了,就是QUIC( Quick UDP Internet Connection)协议。
QUIC要实现可靠传输,就要在应用层下功夫,比如HTTP/3,在UDP报文头部和HTTP消息之间存在三层头部
- Packet Header
Packet Header 细分这两种:
- Long Packet Header 用于首次建立连接。
- Short Packet Header 用于日常传输数据。
- QUIC Frame Header
一个 Packet 报文中可以存放多个 QUIC Frame。
QUIC 如何解决TCP队头阻塞问题?
QUIC 也借鉴 HTTP/2 里的 Stream 的概念,在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (Stream)。
但是 QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接上的多个 Stream 之间没有依赖关系,都是相互独立的,各自控制的滑动窗口。
假如 Stream2 丢了一个 UDP 包,也只会影响 Stream2 的处理,不会影响其他 Stream,与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
QUIC 如何做流量控制?
QUIC 实现流量控制的方式:
- 通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
- 通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。
QUIC 实现了两种级别的流量控制,分别为 Stream 和 Connection 两种级别:
- Stream 级别的流量控制:Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。
- Connection 流量控制:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。
QUIC对拥塞控制改进?
QUIC 协议当前默认使用了 TCP 的 Cubic 拥塞控制算法(我们熟知的慢开始、拥塞避免、快重传、快恢复策略),同时也支持 CubicBytes、Reno、RenoBytes、BBR、PCC 等拥塞控制算法,相当于将 TCP 的拥塞控制算法照搬过来了。
但是QUIC处于应用层,应用程序的层面可以实现不同的拥塞控制算法。所以可以针对不同的应用设置不同的拥塞控制算法。并且QUIC可以随浏览器更新,QUIC的拥塞控制算法就可以有较快的迭代速度。
QUIC更快的连接建立
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
QUIC如何迁移连接?
QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
TCP和UDP可以同时绑定相同的端口吗?
TCP 和 UDP 服务端网络相似的一个地方,就是会调用 bind 绑定端口。UDP 网络编程如下,服务端是没有监听这个动作的,只有执行 bind() 系统调用来绑定端口的动作。
在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。
所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。
传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。
当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。
- 多个TCP服务进程可以绑定同一个端口吗?
如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。
- 重启TCP服务进程,为什么会报错“Address already in use”?
当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。
当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。
而等 TIME_WAIT 状态的连接结束后,重启 TCP 服务进程就能成功。
- 重启 TCP 服务进程时,如何避免“Address in use”的报错信息?
我们可以在调用 bind 前,对 socket 设置 SO_REUSEADDR 属性,可以解决这个问题。
因为 SO_REUSEADDR 作用是:如果当前启动进程绑定的 IP+PORT 与处于TIME_WAIT 状态的连接占用的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功。
客户端的端口可以复用吗?
所以,客户端的端口选择的发生在 connect 函数,内核在选择端口的时候,会从 net.ipv4.ip_local_port_range
这个内核参数指定的范围来选取一个端口作为客户端端口。
TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。
- 多个客户端可以bind同一个端口吗?
如果我们想自己指定连接的端口,就可以用 bind 函数来实现:客户端先通过 bind 函数绑定一个端口,然后调用 connect 函数就会跳过端口选择的过程了,转而使用 bind 时确定的端口。
如果多个客户端同时绑定的 IP 地址和端口都是相同的,那么执行 bind() 时候就会出错,错误是“Address already in use”。
一般而言,客户端不建议使用 bind 函数,应该交由 connect 函数来选择端口会比较好,因为客户端的端口通常都没什么意义。
- 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗?
针对这个问题要看,客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。
如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。
但是,只要客户端连接的服务器不同,端口资源可以重复使用的。
- 如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题?
开启tcp_tw_reuse参数。
- 在建立连接时,如果没有开启 net.ipv4.tcp_tw_reuse 内核参数,那么内核就会选择下一个端口,然后继续判断,直到找到一个没有被相同四元组的连接使用的端口, 如果端口资源耗尽还是没找到,那么 connect 函数就会返回错误。
- 如果开启了 net.ipv4.tcp_tw_reuse 内核参数,就会判断该四元组的连接状态是否处于 TIME_WAIT 状态,如果连接处于 TIME_WAIT 状态并且该状态持续的时间超过了 1 秒,那么就会重用该连接,于是就可以使用 2222 端口了,这时 connect 就会返回成功。
用了TCP协议,数据一定不会丢吗?
TCP协议只保证传输层的数据不丢失(实现传输层的可靠传输),如果想要实现应用层的数据不丢失,还需要自己实现应用层的具体逻辑。
- 建立连接时的丢包
在进行TCP三次握手的过程中,需要维护半连接队列和全连接队列。队列就有对应的长度,所以就有可能队列满,导致丢包。
- 流量控制时的丢包
数据包在进入网卡之前,会进入qdisc(Queueing Disciplines,排队规则),这就是流量控制机制。
而只要是队列,都会有一个长度,当发送数据过快,流控队列长度txqueuelen
又不够大时,就容易出现丢包现象。
- 网卡丢包
- RingBuffer丢包
在接收数据时,会将数据暂存到RingBuffer
接收缓冲区中,然后等着内核触发软中断慢慢收走。
如果这个缓冲区过小,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生丢包。
- 网卡性能不足
网卡作为硬件,传输速度是有上限的。当网络传输速度过大,达到网卡上限时,就会发生丢包。这种情况一般常见于压测场景。
- 接收缓冲区丢包
当接受缓冲区满了,它的TCP接收窗口会变为0,也就是所谓的零窗口,并且会通过数据包里的win=0
,告诉发送端停止发送。一般这种情况下,发送端就该停止发消息了,但如果这时候确实还有数据发来,就会发生丢包。
- 两端之间的网络丢包
可以使用ping命令和mtr命令。
ping:知道你的机器和目的机器之间有没有丢包
mtr:可以查看到你的机器和目的机器之间的每个节点的丢包情况。
TCP序列号和确认号是如何变化的?
- 序列号:是建立连接时由内核生成的随机数,通过 SYN 报文传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
- 确认号:指下一次「期望」收到的数据的序列号,发送端收到接收方发来的 ACK 确认报文以后,就可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
- **控制位:**用来标识 TCP 报文是什么类型的报文,比如是 SYN 报文、数据报文、ACK 报文,FIN 报文等。
三次握手阶段的序列号变化
在TCP三次握手阶段,服务端收到客户端的SYN报文后,会发送SYN-ACK报文,此时的序列号和确认号会设置为:
- 序列号设置为服务端随机初始化的序列号 server_isn。
- 确认号设置为 client_isn + 1,服务端上一次收到的报文是客户端发来的 SYN 报文,该报文的 seq = client_isn,那么根据公式 2(确认号 = 上一次收到的报文中的序列号 + len。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前确认号 = client_isn + 1。
客户端收到SYN-ACK报文后,会将ACK报文中的序列号和确认号设置为:
- 序列号设置为 client_isn + 1。客户端上一次发送报文是 SYN 报文,SYN 的序列号为 client_isn,根据公式 1(序列号 = 上一次发送的序列号 + len。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 + 1),所以当前的序列号为 client_isn + 1。
- 确认号设置为 server_isn + 1,客户端上一次收到的报文是服务端发来的 SYN-ACK 报文,该报文的 seq = server_isn,那么根据公式 2(确认号 = 收到的报文中的序列号 + len。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前确认号 = server_isn + 1。
可见,第二、三次握手的确认号都是收到的对方的序列号 + 1,这样主要有两个目的:
- 告诉对方,我方已经收到 SYN 报文。
- 告诉对方,我方下一次「期望」收到的报文的序列号为此确认号,比如客户端与服务端完成三次握手之后,服务端接下来期望收到的是序列号为 client_isn + 1 的 TCP 数据报文。
数据传输阶段的序列号变化
客户端发送 10 字节的数据,通常 TCP 数据报文的控制位是 [PSH, ACK],此时该 TCP 数据报文的序列号和确认号分别设置为:
- 序列号设置为 client_isn + 1。客户端上一次发送报文是 ACK 报文(第三次握手),该报文的 seq = client_isn + 1,由于是一个单纯的 ACK 报文,没有携带用户数据,所以 len = 0。根据公式 1(序列号 = 上一次发送的序列号 + len),可以得出当前的序列号为 client_isn + 1 + 0,即 client_isn + 1。
- 确认号设置为 server_isn + 1。没错,还是和第三次握手的 ACK 报文的确认号一样,这是因为客户端三次握手之后,发送 TCP 数据报文 之前,如果没有收到服务端的 TCP 数据报文,确认号还是延用上一次的。
- 客户端与服务端完成 TCP 三次握手后,发送的第一个 「TCP 数据报文的序列号和确认号」都是和「第三次握手的 ACK 报文中序列号和确认号」一样的。
接着,当服务端收到客户端 10 字节的 TCP 数据报文后,就需要回复一个 ACK 报文,此时该报文的序列号和确认号分别设置为:
- 序列号设置为 server_isn + 1。服务端上一次发送报文是 SYN-ACK 报文,序列号为 server_isn,根据公式 1(序列号 = 上一次发送的序列号 + len。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 + 1),所以当前的序列号为 server_isn + 1。
- 确认号设置为 client_isn + 11 。服务端上一次收到的报文是客户端发来的 10 字节 TCP 数据报文,该报文的 seq = client_isn + 1,len = 10。根据公式 2(确认号 = 上一次收到的报文中的序列号 + len),也就是将「收到的 TCP 数据报文中的序列号 client_isn + 1,再加上 10(len = 10) 」的值作为了确认号,表示自己收到了该 10 字节的数据报文。
四次挥手阶段的序列号变化
客户端发送的第一次挥手的序列号和确认号分别设置为:
- 序列号设置为 client_isn + 11。客户端上一次发送的报文是 [PSH, ACK] ,该报文的 seq = client_isn + 1, len = 10,根据公式 1(序列号 = 上一次发送的序列号 + len),可以得出当前的序列号为 client_isn + 11。
- 确认号设置为 server_isn + 1。客户端上一次收到的报文是服务端发来的 ACK 报文,该报文的 seq = server_isn + 1,是单纯的 ACK 报文,不携带用户数据,所以 len 为 0。那么根据公式 2(确认号 = 上一次收到的序列号 + len),可以得出当前的确认号为 server_isn + 1 + 0 (len = 0),也就是 server_isn + 1。
服务端发送的第二次挥手的序列号和确认号分别设置为:
- 序列号设置为 server_isn + 1。服务端上一次发送的报文是 ACK 报文,该报文的 seq = server_isn + 1,而该报文是单纯的 ACK 报文,不携带用户数据,所以 len 为 0,根据公式 1(序列号 = 上一次发送的序列号 + len),可以得出当前的序列号为 server_isn + 1 + 0 (len = 0),也就是 server_isn + 1。
- 确认号设置为 client_isn + 12。服务端上一次收到的报文是客户端发来的 FIN 报文,该报文的 seq = client_isn + 11,根据公式 2(_确认号= 上一次_收到的序列号 + len,特殊情况,如果收到报文是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前的确认号为 client_isn + 11 + 1,也就是 client_isn + 12。
服务端发送的第三次挥手的序列号和确认号还是和第二次挥手中的序列号和确认号一样。
- 序列号设置为 server_isn + 1。
- 确认号设置为 client_isn + 12。
客户端发送的四次挥手的序列号和确认号分别设置为:
- 序列号设置为 client_isn + 12。客户端上一次发送的报文是 FIN 报文,该报文的 seq = client_isn + 11,根据公式 1(序列号 = 上一次发送的序列号 + len。特殊情况,如果收到报文是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前的序列号为 client_isn + 11 + 1,也就是 client_isn + 12。
- 确认号设置为 server_isn + 2。客户端上一次收到的报文是服务端发来的 FIN 报文,该报文的 seq = server_isn + 1,根据公式 2(确认号 = 上一次_收到的序列号 + len,特殊情况,如果收到报文是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前的确认号为 server_isn + 1 + 1,也就是 server_isn + 2。
总之,发送的 TCP 报文:
- 公式一:序列号 = 上一次发送的序列号 + len(数据长度)。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 上一次发送的序列号 + 1。
- 公式二:确认号 = 上一次收到的报文中的序列号 + len(数据长度)。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1。
完。