TCP/IP 是互联网相关的各类协议族的总称,其中包括 TCP,UDP,IP,FTP,HTTP,ICMP,SMTP 等。TCP/IP 中两个具有代表性的传输层协议是 TCP 和 UDP:
传输层:
- 作用:为上面的应用层提供通信服务,提供应用进程间的逻辑通信
- 在 OSI 七层参考模型中,传输层是 面向通信部分的最高层,用户功能中的最底层
- 两大重要的功能:1)复用,2)分用。复用是指,在发送端,多个应用进程共用一个传输层。分用是指,在接收端,传输层会根据端口号将数据分派给不同的应用进程
- 传输层和网络层的区别:1)网络层为不同主机提供通信服务,而传输层为不同主机的不同应用提供通信服务;2)网络层只对报文头部进行差错检测,而传输层对整个报文进行差错检测
传输层的两个主要协议:
- 传输控制协议 TCP(Transmission Control Protocol)
- 用户数据报协议 UDP(User Datagram Protocol)
1. 用户数据报协议 UDP
概念: UDP(User Datagram Protocol)在 OSI 模型中处于传输层,属于无连接协议。UDP 尽最大可能交付, 面向报文, 没有拥塞控制, 支持单播、多播、广播的交互通信。UDP 既可以使用 IPv4 也可以使用 IPv6。
【用户数据报协议 UDP 的特点】
- 面向无连接(目的是减少开销和发送时延):通信前不需要建立连接,通信结束也无需释放连接,即 UDP 客户与服务器不必存在长期的关系
- 尽最大努力交付:它是尽力而为交付,不能确保每一个数据报都送达
- 不可靠性:由于 “尽最大努力交付” 的,所以不保证每个数据报都到达目的地,也不保证各个数据报的先后顺序跨网络保持不变,也不保证每个数据报只到达一次
- 面向报文:对于应用程序传下来的报文不合并也不拆分,只是添加 UDP 首部
- 没有拥塞控制:当网络出现堵塞不会使源主机的发送速率降低,这对实时视频会议比较重要,即允许在网络发生堵塞时丢失一些数据,但不允许数据有太大时延
- 支持一对一,一对多,多对一,多对多的交互通信(单播、多播、广播),而TCP只支持一对一通信
- 首部开销小(只有 8 个字节),而TCP头部至少20字节
相关解释:
(1)面向无连接:不需要像 TCP 一样在发送数据前进行三次握手,想发就直接发了,它只是数据的搬运工,不会对数据进行任何拆分和拼接操作。具体来说,就是在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增加一个 UDP 头,标识一下是 UDP 协议,然后就传递给网络层了;在接收端,网络层将数据传递给传输层,UDP 只去除 IP 报文头就传递给应用层,不会任何拼接操作。
(2)有单播、多播、广播功能:支持一对一、一对多、多对多、多对一的方式传输方式。
(3)面向报文:所谓面向报文就是指UDP数据传输的单位是报文,且不会对数据作任何拆分和拼接操作。在发送端,应用程序给传输层的UDP什么样的数据,UDP不会对数据进行切分,只增加一个UDP头并交给网络层(IP层)。在接收端,UDP收到网络层的数据报后,去除IP数据报头部后遍交给应用层,不会作任何拼接操作。
(4)不可靠性:首先不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠。并且收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了。再者网络环境时好时坏,但是 UDP 因为没有拥塞控制,一直会以恒定的速度发送数据。即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用 UDP 而不是 TCP。UDP只会把想发的数据报文一股脑的丢给对方,并不在意数据有无安全完整到达。
(5)头部开销小:只有 8 字节,相比 TCP 的至少 20 个字节要少得多,在传输数据报文时是很高效的。UDP 头部包括:端口号(源+目的) + 报文长度 + 检验和。
(6)没有拥塞控制:UDP始终以恒定的速率发送数据,并不会根据网络拥塞情况对发送速率作调整。这种方式有利有弊。弊端:网络拥塞时有些报文可能会丢失,因此UDP不可靠。优点:有些使用场景允许报文丢失,如:直播、语音通话,但对实时性要求很高,此时UDP还是很有用武之地的。
【UDP 报文头(共8个字节)】
- 源端口(2个字节)
- 目的端口(2个字节)
- 整个数据报的长度(2个字节)
- 数据报的检验和(2个字节)
- 源端口(可选):接收端可以利用这个字段的值作为发送响应的目的地址,但是发送端不一定必须把自己的端口号写入,如果不写入则把这个字段设置为 0,这样接收端就不用发送响应。
- 目的端口:必须指定,UDP 做广播时也必须指定目的端口号。
- UDP 长度:表示 UDP 报文头 + UDP 数据的总长度。
- UDP 检验和:用于检验数据在传输过程中是否被损坏,损坏则直接丢弃。
关于如何校验数据是否被损坏:发送方会计算报文段中所有 16 bit 字的和,并计算该和的反码,这个反码作为 UDP 报文段中的检验和字段。接收方收到报文后也计算所有 16 bit 字的和,同时加上检验和,如果没有差错,得到的结果是全 1 的,如果出现一个 0,说明数据有差错。
参考:UDP报文结构及检验和
2. 传输控制协议 TCP
概念: TCP(Transmission Control Protocol)是 面向连接 的,提供可靠交付,有 流量控制,拥塞控制,提供 全双工通信,面向 字节流,每一条 TCP 连接只能是 点对点 的(一对一)的传输层通信协议。TCP将用户数据打包成报文段,它发送后会启动一个定时器。TCP 既可以使用 IPv4 也可以使用 IPv6。
【传输控制协议 TCP 的特点】
- 面向连接的:通信前需要建立连接(三次握手),通信结束也需要释放连接(四次挥手)
- 提供可靠交付:保证数据无重复、无丢失、无错误、与发送端顺序一致。当使用 TCP 向另一端发送数据时,它要求对端返回一个确认,如果没有收到确认,TCP 会自动重传数据并等待更长时间,数次重传失败后,TCP 才放弃
- 面向字节流:把应用层传下来的报文看成字节流,把字节流组织成大小不等的数据块
- 提供流量控制: TCP 会告诉对端它能接收多少字节的数据,称作“通知窗口”,该窗口任何时刻都指出接收缓冲区中的可用空间,从而确保发送端发送的数据不会溢出接收缓冲区
- 提供拥塞控制
- 点对点通信(单播):TCP 只能提供点到点的通信,而 UDP 可以任意方式的通信
- 提供全双工通信:全双工通信指的是 TCP 的两端既可以作为发送端,也可以作为接收端,且两个方向可以同时进行发送和接收,就好比双行道(UDP 其实也可以是全双工的)
相关解释:
(1)面向连接:面向连接,是指发送数据之前必须在两端建立连接。建立连接的方法是“三次握手”,建立连接是为数据的可靠传输打下基础。
(2)仅支持单播:每条 TCP 传输连接只能有两个端点,只能进行点对点的数据传输,不支持多播和广播传输方式。
(3)面向字节流:TCP 不像 UDP 一样那样一个个报文独立地传输,而是在不保留报文边界的情况下以字节流方式进行传输。所谓面向字节流指的是 TCP 以字节为单位。虽然传输的过程中数据被划分成一个个数据报,但这只是为了方便传输,接收端最终接受到的数据将与发送端的数据一模一样。
(4)可靠传输:对于可靠传输,判断丢包,误码靠的是TCP的段编号以及确认号。TCP为了保证报文传输的可靠,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的字节发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据(假设丢失了)将会被重传。
(5)提供拥塞控制:当网络出现拥塞的时候,TCP能够减小向网络注入数据的速率和数量,缓解拥塞。
(6)提供全双工通信:TCP 允许通信双方的应用程序在任何时候都能发送数据,因为 TCP 连接的两端都设有缓存,用来临时存放双向通信的数据。当然,TCP可以立即发送一个数据段,也可以缓存一段时间以便一次发送更多的数据段(最大的数据段大小取决于MSS)。
【其他补充】
(1)关于 TCP 面向字节流和 UDP 面向报文区别的解释
TCP 的传输方式好比向水桶加水和从水桶接水,往水桶倒几次水和接几次水是没有必然联系的。比如说,现在有 5L 水,你可以一次性都倒进桶里,然后分 5 次接完;也可以分 7 次倒进去,一次性接完或者分几次接完,都随便你。但要注意,倒水的时候不能超过目前水桶剩余的总容量,否则水就会溢出。
每个 TCP Socket 连接在当前主机上有都有一个发送缓冲区和一个接收缓冲区(因为是全双工的),缓冲区好比水桶,往发送缓冲区加数据,从接收缓冲区读数据。假如调用一次 write 发送了 100 个字节(实际是放在了发送缓冲区里),对端可以分 10 次接收,每次 10 个字节;也可以调用 10 次 write 发送数据,每次 10 个字节,对方一次接收。但是发送的数据量不能大于对端的接收缓存容量(流量控制),否则缓存满了就会丢弃数据。
UDP 的传输方式好比读信(报文),信要一封一封的读,也就是发送端调用了几次 write,接收端必须以相同次数的 read 读取。接收端在接收时,每次最多只能读取一个报文,报文和报文之间不会合并。
TCP 面向字节和 UDP 面向报文是由两者传输方式和特性决定的,TCP 仅支持一对一的连接,也就是发送端是确定的,只要保证数据有序到达就能恢复出原始信息。而 UDP 是无连接的协议,只要知道接收端的 IP 和端口,且网络可达,任何主机都可以向接收端发送数据,此时不管是按字节接收还是一次读取多个报文,都会导致数据混乱,这样的数据是没有意义的。
(2)TCP 全双工和流量控制的实现
每个 TCP Socket 连接创建后,都会自动分配两个缓冲区:输入缓冲区和输出缓冲区(buffer),关闭 Socket 将丢失输入缓冲区的数据,但会继续传送输出缓冲区中遗留的数据。TCP 全双工通信模式和流量控制都是依赖这两个缓冲区实现的。
主机 A 调用 write() / send() 向对端发送数据,其实并不是立即向网络中传输数据,而是先将数据写入输出缓冲区中,再由 TCP 协议将数据从输出缓冲区取出发送到目标机器。一旦将数据写入到缓冲区,write() / send() 函数就可以成功返回,不管数据到底有没有到达目标机器,也不管它们何时被发送到网络,这些都是 TCP 协议负责的事情。TCP协议独立于 write() / send() 函数,数据有可能刚被写入输出缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,比如 nagle 算法,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。read() / recv() 函数同样,也是从输入缓冲区中读取数据,而不是直接从网络中读取。
对于TCP,如果收端一直没有读取数据,其接收缓冲区满了之后,发生的动作是:收端通知发端,接收窗口关闭(win=0),即滑动窗口的实现。保证 TCP 套接口接收缓冲区不会溢出,从而保证了 TCP 是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。 这就是 TCP 的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方 TCP 将丢弃它。
输入输出缓冲区的默认大小一般都是 8K,可以通过 getsockopt() 函数获取。
【TCP头部】
TCP 头部长度有 20 字节的固定部分,选项部分长度不定,但最多 40 字节,因此 TCP 头部在 20-60 字节之间。
报头字段含义解释:
- 源端口(Source Port):数据发起者的端口号
- 目的端口(Destination Port):数据接收者的端口号(传输层和网络层一大重要区别就是传输层指定了数据报发往的应用进程,因此需要端口号标识)
- 序列号(Sequence Number,seq):用于在数据通信中解决网络包乱序(reordering)问题,以保证应用层接收到的数据不会因为网络上的传输问题而乱序
- 确认号(Acknowledgment Number,ack):表示当前主机作为接收端时,期望接收的下一个字节的编号是多少,因此确认号是当前主机已经正确接收的最后一个字节序号(seq)+ 1
- 报头长度(Offset):数据包头部的长度,存储报文头中有多少个32bit,存储长度为 4bit,最大可表示(2 ^ 3+2 ^ 2+2 ^ 1 + 1)x 32bit = 60bytes 的报文头
- 保留字段(Reserved):均为 0
- 标识符(TCP Flags):TCP有 7 种标识符,用于表示 TCP 报文的性质。它们只能为 0 或 1
- 窗口大小(Window):用于实现 TCP 的流量控制
- 检验和(Checksum):该字段检验的范围包括头部和数据这两部分。由发端计算和存储,并由收端进行验证
- 紧急指针(Urgent Pointer):紧急指针在 URG=1 时才有效,它指出本报文段中的紧急数据的字节数
- 选项字段(TCP Options):可选的,且长度可变,最长40字节,最常用的选项字段为 MMS:最大报文长度
TCP 标识符(TCP Flags): TCP有 7 种标识符,用于表示 TCP 报文的性质。它们只能为 0 或 1
- URG = 1:当 URG 字段被置1,表示本数据报的数据部分包含紧急信息,此时紧急指针有效。
- ACK = 1:ACK 被置 1 后确认号字段才有效,TCP 规定,在连接建立后传送的所有报文段都必须把 ACK 置1。
- PSH = 1:当接收方收到 PSH=1 的报文后,会立即将数据交付给应用程序,而不会等到缓冲区满后再提交。一些交互式应用需要这样的功能,降低命令的响应时间。
- RST = 1:当该值为 1 时,表示当前 TCP 连接出现严重问题,必须要释放重连。
- SYN = 1:SYN 在建立连接时使用。当 SYN=1,ACK=0时,表示当前报文段是一个连接请求报文。当 SYN=1,ACK=1 时,表示当前报文段是一个同意建立连接的应答报文。
- FIN = 1:FIN=1 表示此报文段是一个释放连接的请求报文。
【TCP 的连接和终止】
服务端先调用 socket 函数(返回一个套接字),然后套接字与地址、端口绑定(bind),对端口进行监听(listen),调用 accept 阻塞,等待客户端连接。这时如果有个客户端调用 socket 函数(返回一个套接字),然后连接服务器(connect),如果连接成功,客户端和服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理,然后将响应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。
- TCP 连接是一种抽象的概念,表示一条可以通信的链路。每条 TCP 连接有且仅有两个端点,表示通信的双方。且双发在任意时刻都可以作为发送者和接收者。
- 一条 TCP 连接的两端就是两个套接字,套接字 = IP地址:端口号,因此,TCP连接 =(套接字1,套接字2)=(IP1:端口号1,IP2:端口号2)
2.1 TCP 三次握手
基本思想:让我知道你已经知道了
TCP 协议中,主动发起请求的一端称为客户端,被动连接的一端称为服务端。不管是客户端还是服务端,TCP 连接建立完后都能发送和接收数据。
最初服务器和客户端都为 CLOSED 状态。在通信开始前,双方创建各自的传输控制块(TCB)。
服务器创建完 TCB 后便进入 LISTEN 状态,此时准备接收客户端发来的连接请求。
- 第一次握手:服务器 B 处于 监听(LISTEN) 状态,此时客户端 A 向 B 发送请求连接报文段。该报文段中 SYN = 1,ACK = 0,seq = x,A 进入 同步已发送(SYN-SENT) 状态,等待服务器确认;(A 问 B 你可以吗?)【ps:SYN = 1,ACK = 0 表示该报文段为请求连接报文;seq = x,x 为本次 TCP 通信的字节流的初始序号,TCP 规定 SYN=1 的报文段不能有数据部分,但要消耗掉一个序号】
- 第二次握手:服务器 B 收到 A 的连接请求报文段,如果同意建立连接,则向 A 发送连接确认:SYN = 1,ACK = 1,seq = y,ack = x + 1,进入 同步已收到(SYN-RCVD) 状态;(B 告诉 A 我可以)【ps:SYN = 1,ACK = 1 表示该报文段为连接同意的应答报文;seq = y 表示服务端作为发送者时,发送字节流的初始序号;ack = x+1 表示服务端希望下一个数据报发送序号从 x+1 开始的字节】
- 第三次握手:客户端 A 收到 B 同意连接的应答后,再向 B 发送一次确认报文段,该报文段头部为:ACK = 1,seq = x+1,ack = y+1,客户端发送完这个确认报文后就进入 已建立连接(ESTABLISHED) 状态(A 再告诉 B 我也可以)
- B 收到 A 的确认后,B 也进入 已建立连接(ESTABLISHED) 状态,完成三次握手
字段含义:
- 序列号 seq:占 4 个字节,用来标记数据段的顺序,TCP 把连接中发送的所有数据字节都编上一个序号,第一个字节的编号由本地随机产生;给字节编上序号后,就给每一个报文段指派一个序号;序列号 seq 就是这个报文段中的第一个字节的数据编号。
- 确认号 ack:占 4 个字节,期待收到对方下一个报文段的第一个数据字节的序号;序列号表示报文段携带数据的第一个字节的编号;而确认号指的是期望接收到下一个字节的编号;因此当前报文段最后一个字节的编号+1即为确认号。
- 确认号 ACK(标志位):占 1 位,当 ACK=1 时,表示确认号有效,反之无效。
- 同步号 SYN(标志位):连接建立时用于同步序号。当 SYN=1,ACK=0 时表示这是一个连接请求报文段。若同意连接,则在响应报文段中使得 SYN=1,ACK=1。因此,SYN=1 表示这是一个连接请求,或连接接受报文。SYN 这个标志位只有在 TCP 建立连接时才会被置 1,握手完成后 SYN 标志位被置 0。
- 终止号 FIN(标志位):用来释放一个连接。FIN=1 表示此报文段的发送方的数据已经发送完毕,并要求释放运输连接。
- URG:紧急指针是否有效。为1,表示某一位需要被优先处理。
- PSH:提示接收端应用程序立即从TCP缓冲区把数据读走。
- RST:对方要求重新建立连接,复位。
ACK、SYN 和 FIN 这些大写的单词表示标志位,其值要么是 1,要么是 0;ack、seq小写的单词表示序号。
【简化版】
TCP 三次握手经历了:1)客户端向服务器发送一个 SYN J
,服务器向客户端响应一个 SYN K
,并对 SYN J
进行确认 ACK J+1
,最后客户端再向服务器发一个确认 ACK K+1
。
- 客户端调用 connect 主动打开,客户端向服务器发送 SYN J(告诉服务器客户端将要发送的数据的初始序列号),此时 connect 进入阻塞状态。
- 服务器监听到连接请求(SYN J),调用 accept 接受请求,向客户端发送 SYN K(告诉客户端服务器将要发送的数据的初始序列号)、ACK J+1,此时 accept 进入阻塞状态。
- 客户端收到 SYN K、ACK J+1 后,connect 返回,并对 SYN K 进行确认;服务器收到 ACK K+1 后,accept 返回。此时三次握手完毕,连接建立。
其他解释:
TCP 的三次握手还是挺有意思的,基本思想就是 “让我知道你已经知道” 了。 服务器监听请求,客户端发起连接请求(第一次连接),请求在路上可能存在丢失的风险, 所以当请求到了服务器后如果服务器同意建立连接会给客户端一个回信(第二次连接),告诉它:我已经收到请求,可以连接。 但是回信也存在一个问题,那就是回信能不能到客户端?它需要客户端给他一个回信说我已经收到批准通知了, 如果客户端一直不回复的话意味着客户端没有收到批准通知。因此客户端一收到批准通知就立马回复(第三次连接):OK 老铁我收到你的批准通知了。至此,三次握手结束。
这种“让我知道你已经知道了”的想法是一种约定俗成的 可靠信息交互的基本方式, 基于此想法构建的信息交互框架叫做协议。
【相关面试题】
1)为什么用三次握手?
- (主要原因)通过三次握手避免打开历史连接请求。如果客户端发送的连接请求在网络中滞留太久,客户端等待一个超时重传时间后,就会重新请求连接。有三次握手,服务器虽然收到了两个连接请求,并回发了两个请求确认,但客户端不会回应前一个请求确认,就不会打开这个失效的链接请求【具体就是通过服务端返回的 ack 来验证的,如果客户端发现不是自己期望的 ack 号,就会发起 RST 报文中止这个失效链接】。
- 通过三次握手同步双方的初始序列号。
【图解:避免打开历史连接请求】
【图解:同步双方的初始序列号】
图片来源:为什么需要三次握手
相关解释:
- 失效的连接请求:若客户端向服务端发送的连接请求丢失,客户端等待应答超时后就会再次发送连接请求,此时,上一个连接请求就是失效的。
- 历史的连接请求:若客户端向服务器发送的连接请求因为某种原因滞留,客户端等待应答超时后再次发送一个新的连接请求,后来发送的请求先到达了服务端,并成功建立了连接;前一个请求后到达服务端,此时该连接请求就是历史的重复请求。
2)为什么不能用两次握手进行连接?
答: 三次握手完成两个重要的功能,既:1)双方都知道彼此已准备好,2)允许双方就初始序列号进行协商,这个序列号在握手过程中进行发送和确认。
如果把三次握手改成两次握手,可能发生死锁。比如:客户端发出去的一个连接请求由于某些原因在网络节点中滞留导致延迟,客户端等待一个超时时间后重新请求,那么前一个请求就是失效的,但是却又到达了服务端,此时服务端仍然认为这是客户端的建立连接请求第一次握手,于是服务端回应了客户端,这是第二次握手,但是客户端并不会发来新的数据,服务端就这么傻等着,形成了死锁。
3)如果已经建立了连接,但是客户端突然出现故障了怎么办?
答: TCP 设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为 2 小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔 75 秒钟发送一次。若一连发送 10 个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。
2.2 TCP 四次挥手
TCP 连接是全双工的,因此在四次挥手中,每个方向都必须单独地进行关闭。前两次挥手用于断开一个方向的连接,后两次挥手用于断开另一方向的连接。
- 第一次挥手:客户端 A 发送连接释放请求,并停止发送数据,该请求只有报文头,主要参数:FIN = 1,seq = u。此时客户端进入 终止等待1(FIN-WAIT-1) 状态;【FIN=1 表示该报文段是一个连接释放请求,seq = u,u-1 是 A 向 B 发送的最后一个字节的序号】
- 第二次挥手:服务器 B 收到 A 的释放请求后发出确认,确认报文包括:ACK = 1,seq = v,ack = u + 1。此时 TCP 处于半关闭状态,服务器进入 关闭等待(CLOSE-WAIT) 状态,B 能向 A 发数据但 A 不会向 B 发数据。此时 A 进入 终止等待2(FIN-WAIT-2) 状态,等待 B 传输最后的数据并发送连接释放请求。第二次挥手完成后,A 到 B 方向的连接已经释放,B 不会再接收数据,A 也不会再发送数据。但 B 到 A 方向的连接仍然存在,B 可以继续向 A 发送数据。【ACK=1:除TCP连接请求报文段以外,TCP通信过程中所有数据报的ACK都为1,表示应答;seq=v,v-1是B向A发送的最后一个字节的序号;ack = u+1 表示希望收到从第 u+1 个字节开始的报文段,并且已经成功接收了前 u 个字节】
- 第三次挥手:当 B 不再需要连接时,向 A 发送连接释放请求,请求头:FIN = 1,ACK = 1,seq = w,ack = u + 1,此时 B 进入 最后确认(LAST-ACK) 状态,等待 A 的确认
- 第四次挥手:A 收到 B 的请求释放后进行确认,ACK = 1,ack = w + 1,B 进入 CLOSED 状态,A 进入 TIME-WAIT 状态,等待 2 MSL(Maximum Segment Lifetime,最大报文存活时间)若没有收到 B 的重发请求的话,就释放连接并进入 CLOSED 状态。
补充:
- 服务器结束 TCP 连接的时间要比客户端早一些;
- TCP 关闭时,每一端都要发送一个 FIN;
- 进程中止时所有打开的 TCP 连接上都会发出一个 FIN;
- 客户端和服务器端都可以执行主动关闭,通常情况是客户端主动,HTTP 则是服务器主动。
- 2 MSL 是两倍的 MSL(Maximum Segment Lifetime)。MSL 指一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间。如果直到 2MSL,Client 都没有再次收到 FIN,那么 Client 推断 ACK 已经被成功接收,可以结束 TCP 连接。
【简化版】
- 应用进程调用 close,执行主动关闭,该端 TCP 发送 FIN M,表示数据发送完毕。
- 另一端收到 FIN M 后,执行被动关闭,对发来的 FIN 进行确认 ACK M+1,意味着不会再收到来自主动关闭端的信息。
- 一段时间后,被动关闭端也调用 close 关闭它的套接口,并发送一个 FIN N 给主动关闭端。
- 主动关闭端收到 FIN N 后对其进行确认 ACK N+1。
【相关面试题】
1)为什么用四次挥手?
答: 主要原因在于 TCP 连接是全双工的,每个方向都必须单独地进行关闭,前两次挥手用于断开一个方向的连接,后两次挥手用于断开另一方向的连接。关闭连接时,当 B 端收到 A 端的 FIN 报文通知时,仅仅表示 A 端没有数据发给 B 了,但未必 B 的数据也已经都发给 A 了,所以 B 不会立马关闭 Socket,当 B 发送完自己想发的全部数据后,再发送 FIN 给 A 表示 B 也同样没有数据给对方了。针对每一个 FIN 报文,都需要一个 ACK,所以需要四次挥手。
2)为什么要有 TIME-WAIT 状态?
答: (1)为了保证 TCP 全双工连接的可靠性,用来重发可能丢失的 ACK 报文。 若 A 发完确认应答 ACK 后直接进入 CLOSED 状态,那么如果该 ACK 丢失,B 等待超时后就会重新发送连接释放请求,但此时 A 已经关闭了,不会作出响应,因此 B 永远无法正常关闭。因此 A 在 TIME-WAIT 状态等待 2 MSL 时间,如果在该时间内再次收到 FIN,那么 A 会重发 ACK 并再次等待 2MSL。(2)为了允许老的重复分节在网络中消失。若关闭某个 TCP 连接后,又建立了一个新的 TCP 连接且其 IP 地址和端口和之前 TCP 连接相同,此时就会被误解为同一个 TCP。为了保证不出现这种情况,TCP 要求不能给处于 TIME-WAIT 状态的连接启动新的化身,这就保证了当成功建立一个 TCP 连接时,来自该连接先前化身的老重复分组已经消失。
3)为什么连接的时候是三次握手,关闭的时候却是四次握手?
答: 主要在于 同步 和 应答 是否能同时发送,其实握手也是四次,只不过由于同步和应答合成了一次握手,所以才是三次握手。建立连接时,当服务端收到客户端的 SYN 连接请求报文后,可以直接发送 SYN+ACK 报文。其中 ACK 报文是用来应答的,SYN 报文是用来同步的。但关闭连接时,当服务端收到 FIN 报文时,很可能并不会立即关闭 Socket,所以只能先回复一个 ACK 报文,告诉 Client 端," 你发的 FIN 报文我收到了,但只有我 Server 端所有的报文都发送完了,我才能发送 FIN 报文 "。因此不能一起发送。故需要四步握手。
4)四次挥手一定需要 4 个报文段吗?
答:不一定,如果收到对方的 FIN 报文时,自己的全部数据也都已经发给对方了,就可以将 ACK 与 FIN 合并为一个报文段,此时仅需 3 个报文。
5)UDP 和 TCP 有什么区别?
- TCP 向上层提供 面向连接的、可靠的服务 ,UDP 向上层提供 无连接的、不可靠服务,TCP 保证数据顺序,UDP 不保证
- TCP 传输单位为 TCP 报文段,UDP 传输单位为用户数据报
- 虽然 UDP 并没有 TCP 传输来的准确,但有实时性要求高、对系统资源要求少、网络开销小等优点,当某个程序的目标是尽快地传输尽可能多的信息时,可以使用 UDP。对数据准确性要求高,速度可以相对较慢的,可以选用 TCP
- TCP 对应的协议包括:FTP(文件传输协议)、SMTP(邮件传送协议)、HTTP(从Web服务器传输超文本到本地浏览器的传送协议)等。UDP对应的协议包括:DNS(域名解析服务)、SNMP(简单网络管理协议)、TFTP(简单文件传输协议)等。
6)如何用 UDP 实现可靠传输?
答: 可以参考 TCP,引入序列号保证数据顺序,引入确认应答保证对端收到数据,引入超时重传确保所有数据都能到达。
2.3 Socket 基本操作
套接字(Socket)是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。
常用套接字接口函数:
1)socket() 函数:int socket(int domain, int type, int protocol);
,domain 为协议域,type 为套接口类型,protocol 为协议。
- 协议域决定了套接口的地址类型,可选参数:AF_INET、AF_INET6、AF_LOCAL。比如 AF_INET 决定了要用 IPv4 地址 + 端口号组合。
- 套接口类型可选参数:SOCK_STREAM(流套接字)、SOCK_DGRAM(数据报套接字)、SOCK_RAW(原始套接字)、SOCK_SEQPACKET 等。
- 协议可选参数:IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP 等,分别对应 TCP 协议、UDP 协议、STCP 协议。
注意 type 和 protocol 不可以随意组合,比如 SOCK_STREAM 用于提供面向连接、可靠的数据传输服务,所以不可以和 IPPROTO_UDP 组合使用。
2)bind() 函数:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
,sockfd 为套接字,通过 socket() 函数创建;addr 为指向协议地址的指针;addrlen 指出 addr 中结构体的长度。
服务器在启动时会绑定一个地址(IP + 端口号)用于提供服务,客户端可以通过该地址来连接服务器。所以服务端会在 listen 前调用 bind,而客户端就不需要。
3)listen() 函数:int listen(int sockfd, int backlog);
listen 仅由服务器调用。socket 创建一个套接口时,默认是一个主动套接口,也就是将会调用 connect 发起连接。listen 将未连接的套接口转换成一个被动套接口,指示内核应接受指向该套接口的连接请求。backlog 规定了内核应该为相应套接口排队的最大连接个数。
4)connect() 函数:TCP 客户端用 connect 来建立与服务器的连接。
5)accept() 函数:由 TCP 服务器调用。
6)close() 函数:用于关闭套接口并终止 TCP 连接。close 操作只是使相应套接字的引用数减 1,只有当引用计数为 0 时,才会触发 TCP 客户端向服务器发送终止连接请求。
7)shutdown() 函数:int shutdown(int sockfd, int howto)
,sockfd 是需要关闭的套接口描述字,howto 指定了 shutdown 方式:
- SHUT_RD:关闭连接的读端,即套接字不再接收数据,但仍然可写。
- SHUT_WR:关闭连接的写端,进程不能对此套接字发出写操作,但仍然可读,此时称半关闭
- SHUT_RDWR:相当于调用 shutdown 两次,第一次指定为 SHUT_RD,第二次指定为 SHUT_WR
TCP 的半关闭:TCP 客户端在结束它的发送后,仍希望能收到来自服务器的数据,此时的状态称作 “半关闭”。半关闭可以通过调用 shutdown 函数代替 close 函数实现。
2.4 TCP 可靠传输的实现
TCP的可靠性表现在:它向应用层提供的数据是无差错的、有序的、无丢失的,简单的说就是:TCP最终递交给应用层的数据和发送者发送的数据是一模一样的。TCP 可靠性的实现机制:面向连接、确认应答机制、超时重传、流量控制、拥塞控制。
- 确认应答:TCP 对数据进行编号(seq),通过确认号(ack)告诉发送者已经收到的数据,且我希望下一个数据从哪里开始发送。
- 超时重传:如果发端超过一定时间未收到接收端的应答,可以认为是数据包丢失或应答丢失,此时发端会重发数据,这个时间一般是 2RTT+偏差值(RRT,报文段往返时间)。TCP 采用了自适应算法来确认超时重传时间(记录一个报文发出时间以及收到确认的时间,两者之差即为报文往返时间 RRT)。
- 流量控制:流量控制就是让发送方别发太快,让接收端来得及接收。主机间传数据实际是放在缓冲区的,接收端会把自己当前可用缓冲区大小放在 TCP 报文头中的 “窗口大小字段”,在应答(ACK)时通知发送端当前可接收大小。如果接收缓存区满了,就会将窗口设置为 0,这时发送方不再发送数据,但需要定期发送一个窗口探测数据段,让接收端把当前窗口大小告诉发送端。
- 拥塞控制:慢启动 + 拥塞避免,快重传、快恢复。
3. 自动重传请求(ARQ协议)
ARQ(Automatic Repeat Request)自动重传请求,是 OSI 七层模型中数据链路层的错误纠正协议之一。传统 ARQ 分为:1)停等式 ARQ(stop-and-wait),2)回退 n 帧 ARQ(go-back-n),3)选择性重传 ARQ(selective repeat)
自动重传请求保证:当请求失败时会自动重传,直到请求被正确接收为止。这种机制保证了每个分组都能被正确接收。
3.1 停止等待 ARQ 协议
停止等待 ARQ 是以停止等待协议(stop-and-wait)为思想的,是最简单但也是最基础的数据链路层协议。它规定一次只发送一个分组的消息,等收到对方确认之后再继续发后面的,所以这种传输方式的缺点是发送方每次只能发送一个分组,在应答到来前必须等待,所以通信信道利用率低,传输时延大。
停止等待协议的原理:
- 无差错的情况:A 向 B 每发送一个分组,都要停止发送,等待 B 的确认应答;A 只有收到了 B 的确认应答后才能发送下一个分组
- 分组丢失和出现差错的情况:发送者拥有超时计时器。每发送一个分组便会启动超时计时器,等待 B 的应答。若超时仍未收到应答,则 A 会重发刚才的分组。分组出现差错:若 B 收到分组,但通过检验和字段发现分组在运输途中出现差错,B 会直接丢弃该分组,并且不会有任何其他动作。A 超时后便会重新发送该分组,直到 B 正确接收为止。分组丢失:若分组在途中丢失,B 并没有收到分组,因此也不会有任何响应。当 A 超时后也会重传分组,直到正确接收到该分组的应答为止。综上所述:当分组丢失或出现差错的情况下,A 都会超时重传分组。
- 应答丢失和应答迟到的情况:TCP 会给每个字节都打上序号(seq),用于判断该分组是否已经接收。应答丢失:若 B 正确收到分组,并已经返回应答,但应答在返回途中丢失了。此时 A 未收到应答,从而超时重传。紧接着 B 又收到了该分组,B 根据序号判断当前收到的分组是否已经接收,若已接收则直接丢弃,并补上一个确认应答(ack = seq + 1)。应答迟到:若由于网络拥塞,A 迟迟收不到 B 发送的应答,因此会超时重传。B 收到该分组后,发现已经接收,便丢弃该分组,并向 A 补上确认应答。A 收到应答后便继续发送下一个分组。但经过了很长时间后,那个失效的应答最终抵达了 A,此时 A 可根据序号判断该分组已经接收,此时只需简单丢弃即可。
停止等待 ARQ 协议的注意点:
- 每发送完一个分组,该分组必须被保留,直到收到确认应答为止
- 必须给每个分组进行编号。以便按序接收,并判断该分组是否已被接收
- 必须设置超时计时器。每发送一个分组就要启动计时器,超时就要重发分组
- 计时器的超时时间要大于应答的平均返回时间,否则会出现很多不必要的重传,降低传输效率。但超时时间也不能太长
3.2 连续 ARQ 协议
为了克服停止等待 ARQ 的缺点,提出了连续 ARQ 协议,而且 TCP 实际使用的也是连续 ARQ 协议。
连续 ARQ 协议基于滑动窗口,在发送方维持着一个一定大小的发送窗口,位于发送窗口内的所有分组都可连续发送,而不需要等待接收端的确认,发送方每收到一个确认就可以把窗口向前滑动一个分组的位置。连续 ARQ 降低了等待时间,提高了传输效率。
连续 ARQ 协议有几个关键技术点:发送窗口、接收窗口和累计确认。
- 发送窗口:发送窗口的大小由接收窗口的剩余大小决定。接收方会把当前接收窗口的剩余大小写入应答 TCP 报文段的头部,发送者收到应答后根据该值和当前网络拥塞情况设置发送窗口的大小,发送窗口的大小是不断变化的(前面讲过的流量控制)。
- 接收窗口:接收者收到的字节会存入接收窗口,接收者会对已经正确接收的有序字节进行累计确认,发送完确认应答后,接收窗口就可以向前移动指定字节。如果某些字节并未按序收到,接收者只会确认最后一个有序的字节,从而乱序的字节就会被重新发送。
- 累计确认:接收方有接收窗口,所以并不需要每收到一个分组就返回一个应答,可以连续收到几个分组之后统一返回一个应答,这样可以节省流量。TCP 头部的 ack 字段就是用来累计确认,它表示已经确认的字节序号+1,也表示期望发送者发送的下一个分组的起始字节号。
【例子】
发送窗口由三个指针构成:p1,p2,p3
- p1 指向发送窗口(黑色部分)的后沿(左侧),它左边的字节表示已经发送且已收到应答
- p2 指向允许发送但尚未发送的第一个字节
- p3 指向发送窗口的前沿(右侧),它右边的字节尚未发送,且不允许发送
p1 之前的字节表示已经发送且已收到应答;p1-p2 间的字节表示已经发送,但还没收到确认应答,这部分的字节仍需保留,因为可能还要超时重发。p2-p3 间的字节表示可以发送,但还没有发送的字节。发送者每收到一个应答,窗口就可以向前移动指定的字节。
连续 ARQ 协议的注意点:
- 同一时刻发送窗口的大小并不一定和接收窗口一样大。虽然发送窗口的大小是根据接收窗口的大小来设定的,但应答在网络中传输是有时间的,有可能 t1 时间接收窗口大小为 m,但当确认应答抵达发送者时,接收窗口的大小已经发生了变化。此外发送窗口的大小还随网络拥塞情况影响。当网络出现拥塞时,发送窗口将被调小。
- TCP 标准并未规定未按序到达的字节的处理方式,但 TCP 一般都会缓存这些字节,等缺少的字节到达后再交给应用层处理。这比直接丢弃乱序的字节要节约带宽。
- TCP 标准规定接收方必须要有累计确认功能。接收方可以对多个 TCP 报文段同时确认,但不能拖太长时间,一般是 0.5s 以内。此外,TCP 允许接收者在有数据要发送的时候捎带上确认应答。但这种情况一般较少,因为一般很少有两个方向都要发送数据的情况。
- 其实当连续 ARQ 协议的发送和接收窗口都等于 1 时,就是停止等待 ARQ 协议。
4. 流量控制
1. 什么是流量控制?
如果发送者发送过快,接收者来不及接收,那么就会有分组丢失。为了避免分组丢失,接收者主动控制发送者的发送速度,使得接收者来得及接收,这就是流量控制。
2. 流量控制的目的?
流量控制根本目的是防止分组丢失,它是构成 TCP 可靠性的一方面。
3. 如何实现流量控制?
由滑动窗口协议(连续ARQ协议)实现。滑动窗口协议既保证了分组无差错、有序接收,也实现了流量控制。主要的方式就是接收方返回的 ACK 中会包含自己的接收窗口的大小,并且利用大小来控制发送方的数据发送。
4. 流量控制引发的死锁及解决方案
当发送者收到了一个窗口为 0 的应答,便停止发送,等待接收者的下一个应答。但如果这个接收者返回的窗口不为 0 的应答在传输过程丢失,发送者一直等待下去,而接收者以为发送者已经收到该应答,等待接收新数据,这样双方就相互等待,从而产生死锁。
为了避免流量控制引发的死锁,TCP 使用了持续计时器。每当发送者收到一个 0 窗口的应答后就启动该计时器。时间一到便主动发送报文询问接收者的窗口大小。若接收者仍然返回 0 窗口,则重置该计时器继续等待;若窗口不为 0,则表示应答报文丢失了,此时重置发送窗口后开始发送,这样就避免了死锁的产生。
5. 拥塞控制
拥塞避免并非能够完全避免拥塞,而是说在拥塞避免阶段将拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。
1. 拥塞控制和流量控制 的区别?
- 拥塞控制:拥塞控制是作用于网络的,它是防止过多的数据注入到网络中,避免出现网络负载过大的情况;
- 流量控制:流量控制是作用于接收者的,它是控制发送者的发送速度从而使接收者来得及接收。
2. 拥塞控制的目的?
- 缓解网络压力
- 保证分组按时到达
3. 拥塞控制方法:慢开始(slow-start)、拥塞避免(congestion avoidance)、快重传(fast retransmit)和快恢复(fast recovery)。
发送方维持一个拥塞窗口 cwnd ( congestion window )的状态变量。拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化。发送方让自己的发送窗口等于拥塞。
发送方控制拥塞窗口的原则是:只要网络没有出现拥塞,拥塞窗口就再增大一些,以便把更多的分组发送出去。但只要网络出现拥塞,拥塞窗口就减小一些,以减少注入到网络中的分组数。
5.1 慢开始
慢开始:当主机开始发送数据时,如果立即将大量数据字节注入到网络,那么就有可能引起网络拥塞,因为现在并不清楚网络的负荷情况。因此,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,这就是慢开始的思路。
发送方维护一个拥塞窗口 cwnd(congestion window),拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化。发送方让自己的发送窗口等于拥塞窗口,另外考虑到接受方的接收能力,发送窗口可能小于拥塞窗口。
通常在刚刚开始发送报文段时,先把拥塞窗口 cwnd(congestion window)设置为一个最大报文段 MSS(Maximum Segment Size)的数值。在每收到一个确认后,把就拥塞窗口数值翻倍。用这样的方法逐步增大发送方的拥塞窗口(cwnd),可以使分组注入到网络的速率更加合理。
每经过一个传输轮次,拥塞窗口的大小就翻倍。一个传输轮次是说把拥塞窗口所允许发送的报文段都连续发送出去,并收到了对已发送的最后一个字节的确认。
注意慢开始的 “慢” 并不是指拥塞窗口的增长速率慢,而是指在 TCP 开始发送报文段时先设置拥塞窗口大小等于 1,使得发送方在开始时只发送一个报文段(目的是试探一下网络的拥塞情况),然后再逐渐增大拥塞窗口大小。
为了防止拥塞窗口 cwnd 增长过大引起网络拥塞,还需要设置一个慢开始门限 ssthresh,慢开始门限的用法如下:
- 当 cwnd < ssthresh 时,使用慢开始算法
- 当 cwnd > ssthresh 时,停止使用慢开始算法而改用拥塞避免算法
- 当 cwnd = ssthresh 时,既可使用慢开始算法,也可使用拥塞控制避免算法
若发送发没有收到确认,则表明网络出现拥塞。此时:1)慢开始门限设为当前发送窗口的一半;2)拥塞窗口设为 1;3)重新执行慢开始算法。
慢开始算法的作用:慢开始算法将发送窗口从小扩大,而且按指数级扩大,从而避免一开始就往网络中注入过多的分组从而导致拥塞;它将窗口慢慢扩大的过程其实也在探测网络拥塞情况的过程,当发现出现拥塞时,及时降低发送速度,从而减缓网络拥塞。
5.2 拥塞避免
拥塞避免算法 让拥塞窗口缓慢地增大,即每经过一个往返时间 RTT 就把发送方的拥塞窗口加 1,而不是加倍。这样拥塞窗口(cwnd)按线性规律缓慢增长,比慢开始算法的拥塞窗口增长速率缓慢得多。
无论在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其依据就是没有收到确认),就要把慢开始门限设置为出现拥塞时的发送方窗口值的一半(但不能小于2)。然后把拥塞窗口重新设置为1,执行慢开始算法。这样做的目的就是要迅速减少主机发送到网络中的分组数,使得发生拥塞的路由器有足够时间把队列中积压的分组处理完毕。
慢开始和拥塞避免示意图:
- TCP连接初始化,cwnd = 1,ssthresh = 16
- 执行慢开始算法,cwnd 经过 4 轮的指数级增长,cwnd = 16 = ssthresh,改执行拥塞避免算法,cwnd 进行线性增长
- 假定 cwnd = 24 时网络出现超时,则 ssthresh = 12(减半),cwnd = 1(重设),开始执行慢开始算法,当 cwnd = 12 = ssthresh 时,改执行拥塞避免算法
AIMD 算法(加法增大乘法减小算法,Additive Increase & Multiplicative Decrease):慢开始算法和拥塞避免算法合称加法增大乘法减小算法。
- 乘法减小:指的是不管当前正使用慢开始算法还是拥塞避免算法,只要发生拥塞时,慢开始门限将会变成当前窗口的一半,并执行慢开始算法,所以当网络频繁出现拥塞时,ssthresh下降的很快,以大大减少注入到网络中的分组数。
- 加法增大:指的是拥塞避免算法,使得发送窗口以线性的方式增长,以防止过早出现拥塞。
拥塞避免算法的作用:拥塞避免算法使发送窗口以线性方式增长,而非指数级增长,从而使网络更加不容易发生拥塞。
5.3 快重传
TCP 具有累计确认的能力,因此接收者收到一个分组的时候不会立即发出应答,可能需要等待收到多个分组之后再同一发出累计确认。但是,快重传要求接收方在收到一个失序的报文段后就应立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方,可提高网络吞吐量约20%)而不要等到自己发送数据时捎带确认。快重传算法规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。
当发送端的 2001-3000 报文丢失后,发送端会一直收到接收端 1001 这样的 ACK,如果发送端主机连续三次收到同样的 1001 应答,就会将对应的数据 1001-2000 重新发送,当成功接收到 1001 之后,再次返回的 ACK 就是 6001 了,接收端在之前就已经收到(2001-7000)了,被放到了接收端操作系统内核的接收缓冲区中。
如果发送方设置的超时计时器时限已到但还没有收到确认,那么很可能是网络出现了拥塞,致使报文段在网络中的某处被丢弃。这时,TCP马上把拥塞窗口 cwnd 减小到1,并执行慢开始算法,同时把慢开始门限值减半。这是不使用快重传的情况。
5.4 快恢复
快恢复原理:当发送者收到同一个分组的三个确认应答后,就基本可以判断这个分组已经丢失了,这时候无需等待超时,直接执行:1)将慢开始门限减半,2)将拥塞窗口设置为减半后的慢开始门限(不是设为1),3)执行拥塞避免算法。
解释:
- 将慢开始门限减半:当发送方连续收到三个重复确认 ACK 时,就执行“乘法减小”算法,把慢开始门限减半,这是为了预防网络发生拥塞;
- 将拥塞窗口设置为减半后的慢开始门限:如果网络阻塞,发送方就不会收到三个重复的确认,所以发送方会认为此时网络并没有拥塞,因此与慢开始不同之处是现在不执行慢开始算法(即拥塞窗口 cwnd 不设置为1),而是把 cwnd 值设置为慢开始门限 ssthresh 减半后的数值,然后开始执行拥塞避免算法(“加法增大”),使拥塞窗口缓慢地线性增大。
快重传和快恢复的示意图:
在采用快恢复算法时,慢开始算法只是在 TCP 连接建立时和网络出现超时时才使用,使用快恢复拥塞控制方法使得 TCP 的性能有明显的改进。
慢开始算法和拥塞避免算法能保证网络出现拥塞时进行相应的处理,而快重传和快恢复是一种拥塞预防的方式,此时网络可能尚未出现拥塞,但已经有拥塞的征兆,因此得作出一些预防措施。
参考资料:
[1] 计算机网络传输层知识点全覆盖
[2] TCP/IP详解–拥塞控制 & 慢启动 快恢复 拥塞避免
[3] TCP的三次握手与四次挥手理解及面试题
[4] TCP 和 UDP 的区别
[5] 动窗口 停止等待 退后N帧 选择重传
转载:https://blog.csdn.net/qq_31347869/article/details/106555229