从输⼊⼀个 URL 到⻚⾯加载完成的过程:
唯一确定TCP:协议类型 、源ip地址 、 目标ip地址 、源端口 、 目标端口
TCP 为什么是面向连接的?
1. 发送之前需要先建立连接( 三次握手)
2. 使用排序和确认机制( 分组之间并不是独立的;记录了分组之间的状态信息)
3. 具有流量控制与拥塞控制
4. 发送完毕后要释放连接( 四次挥手)
TCP 如何保证传输的可靠性?
1. 三次握手: 确认通信实体存在
2. 序列号:解决乱序问题
3. 确认号 / 超时重传机制:解决丢包问题
4. 数据校验( 校验和) :保证传输数据的正确
有一个 IP 的服务器监听了一个端口 , 它的 TCP 的最大连接数是多少?
服务器通常固定在某个本地端口上监听, 等待客户端的连接请求 。因此, 客户端 IP 和 端口是可变的,
其理论值计算公式如下:
最大TCP连接数=客户端的IP数X客户端的端口数
对 IPv4, 客户端的 IP 数最多为 2^32, 客户端的端口数最多为 2^16, 也就是服务端单机最大 TCP 连
接数, 约为 2^48。
当然, 服务端最大并发 TCP 连接数远不能达到理论上限, 会受以下因素影响:
● 文件描述符限制, 每个 TCP 连接都是一个文件, 如果文件描述符被占满了, 会发生 too many open files 。Linux 对可打开的文件描述符的数量分别作了三个方面的限制:
1. 系统级: 当前系统可打开的最大数量, 通过 cat /proc/sys/fs/file-max 查看;
2. 用户级:指定用户可打开的最大数量, 通过 cat /etc/security/limits.conf 查看; 3. 进程级: 单个进程可打开的最大数量, 通过 cat /proc/sys/fs/nr_open 查看;
● 内存限制, 每个 TCP 连接都要占用 一定内存, 操作系统的内存是有限的, 如果内存资源被占满后, 会发生 OOM。
有 20 字节固定首部:
● 序列号( 32bit) :在建立连接时由计算机生成的随机数作为其初始值, 通过 SYN 包传给接收端主 机, 每发送一次数据, 就「累加」一次该「数据字节数」 的大小, 用于解决乱序问题 。例如序号为 301, 表示第一个字节的编号是301, 如果携带的数据长度为100字节, 那么下一个报文段的序号应 为401
● 确认号( 32bit) :接收方对发送方TCP报文段的响应, 值为序号值加一。
● 首部长(4bit) :标识首部有多大, 最大为15, 即60字节
● 标志位 URG( 6bit) :紧急标志, 用于保证 TCP 连接不被中断, 并且督促中间层设备尽快处理
● 标志位 ACK( 6bit) :标记确认号是否有效( 确认报文段), 用于解决丢包问题
● 标志位 PSH( 6bit) :提示接收端立即从缓冲区读走数据
● 标志位 RST( 6bit) :表示要求对方重新建立连接( 复位报文段)
● 标志位 SYN( 6bit) :发送/同步标志, 用来建立连接, 和 ACK 标志位搭配使用 。A 请求与 B 建 立连接时, SYN=1 ,ACK=0; B 确认与 A 建立连接时, SYN=1 ,ACK=1
● 标志位 FIN( 6bit) :结束标志, 表示关闭一个 TCP 连接
● 窗口 ( 16bit) :接收窗口, 用于告知发送方该端还能接收多少字节数据, 用于解决流控
● 校验和( 16bit) :接收端用CRC检验整个报文段有无损坏
● 源端口 、目标端口
● TCP 、UDP都是传输层协议 。TCP适用于对效率要求相对低, 但是对准确性要求高的场景, 或是要 求有连接的场景 。如文件传输 、发送邮件等 。UDP适用于对效率要求相对高, 对准确性要求相对低 的场景 。如即时通信 、直播等。
● TCP首部20字节; UDP的首部只有8个字节。
● TCP有连接 、可靠的 、面向字节流的; UDP是无连接的 、不可靠的 、面向报文的。
● TCP有拥塞控制和流量控制, 因此传输速度慢; UDP没有较多约束, 传输速度快。
● 每一条TCP连接只能是点到点的; UDP支持一对一, 一对多, 多对一和多对多的交互通信。
● 分片策略不同 。TCP 的数据大小如果大于 MSS( 最大报文长度) , 则会在传输层进行分片, 目标 主机收到后, 也同样在传输层组装 TCP 数据包, 如果中途丢失了一个分片, 只需要传输丢失的这 个分片; UDP 的数据大小如果大于 MTU( 最大传输大小) 大小, 则会在 IP 层进行分片, 目标主 机收到后, 在 IP 层组装完数据, 接着再传给传输层
既然 IP 层会分片 ,为什么 TCP 层还需要 MSS 呢?
● MTU:一个网络包的最大长度, 以太网中一般为 1500 字节;
● MSS: 除去 IP 和 TCP 头部之后, 一个网络包所能容纳的 TCP 数据的最大长度;
如果将 TCP 的整个报文 ( 头部 + 数据) 交给 IP 层进行分片: 当 IP 层有一个超过 MTU 大小的数据
(TCP 头部 + TCP 数据) 要发送, 那么 IP 层就要进行分片, 把数据分片成若干片, 保证每一个分片 都小于 MTU 。把一份 IP 数据报进行分片以后, 由目标主机的 IP 层来进行重新组装后, 再交给上一层 TCP 传输层 。如果一个 IP 分片丢失 ,整个 IP 报文的所有分片都得重传 。因为 IP 层本身没有超时重传 机制, 它由传输层的 TCP 来负责超时和重传 。当接收方发现 TCP 报文 ( 头部 + 数据) 的某一 片丢失 后, 则不会响应 ACK 给对方, 那么发送方的 TCP 在超时后, 就会重发 Γ整个 TCP 报文 ( 头部 + 数 据) 」 。因此, 可以得知由 IP 层进行分片传输, 是非常没有效率的 。经过 TCP 层分片后, 如果一个 TCP 分片丢失后 , 进行重发时也是以 MSS 为单位, 而不用重传所有的分片, 大大提高了重传的效率。
流量控制的目的是控制发送端的发送速度( 同时也受拥塞控制的影响), 使其按照接收端的数据处理速度来发送数据, 避免接收端处理不过来, 产生网络拥塞或丢包。
TCP 实现流量控制的关键是滑动窗口 ( Sliding Window) 。发送端和接收端均有一个滑动窗口, 对应 一个缓冲区, 记录当前发送或接收到的数据 。接收端会在返回的 ACK 报文中包含自己可用于接收数据的缓冲区的大小。
对于发送端来说:
● LastByteAcked 指已发送且收到 ACK 的最后一个位置
● LastByteSent 指向已发送但还未收到 ACK 的最后一个位置
● LastByteWritten 指向上层应用写入但还未发送的最后一个位置
对于接收端来说:
● LastByteRead 指向 TCP 缓冲区中读到的位置
● NextByteExpected 指向收到连续包的最后一个位置
● LastByteRcvd 指向收到的包的最后一个位置
零窗口
如果接收端处理过慢, 那么 window 可能变为 0, 这种情况下发送端就不再发送数据了 。如何在接收端 window 可用的时候通知发送端呢?TCP 使用来 ZWP(Zero Window Probe, 零窗口探针) 技术 。具 体是在发送端引入一个计时器, 每当收到一个零窗口的应答后就启动该计时器 。每间隔一段时间就主动 发送报文, 由接收端来 ACK 窗口大小 。若接收者持续返回零窗口 ( 一般是 3 次), 则有的 TCP 实现
会发送 RST 断开连接。
TCP 拥塞控制
流量控制是接收端控制的, 拥塞控制是发送端控制的 。最终都是控制发送端的发送速率 。发送端维持一 个叫做拥塞窗口 cwnd( congestion window) 的状态变量 。拥塞窗口的大小取决于网络的拥塞程度 , 并且动态地在变化。
为什么要有拥塞控制 ,不是有流量控制了吗? 流量控制是避免 Γ发送方」 的数据填满 Γ接收方」 的缓 存, 但是并不知道网络的通畅情况 。拥塞控制的目的就是避免 Γ发送方」 的数据填满整个网络。
慢启动
连接建立时, 设置 cwnd = 1, 表示可以传一个 MSS 大小的数据 。每经过一个 RTT, cwnd 会翻倍( 指 数增长) 。当 cwnd >= ssthresh (slow start threshold) 时, 进入拥塞避免阶段。
拥塞避免
每经过一个 RTT, cwnd = cwnd + 1( 线性增长)
超时重传
如果发送端超时还未收到 ACK 包, 就可以认为网络出现了拥塞 。这时会把 sshthresh 设为当前拥塞窗 口的一半, 重新开始慢启动过程
快速重传 / 快速恢复
在接收方, 要求每次收到数据都应该对最后一个已收到的有序数据进行确认, 如果发送方收到重复确认, 那么就可以知道下一个报文段丢失, 此时执行快速重传, 立即重传下一个报文段
在这种情况下, 只是丢失个别报文段, 而不是网络拥塞, 因此执行快速恢复, 令阈值为当前拥塞窗口的一半, 拥塞窗口大小等于阈值大小, 此时进入拥塞避免阶段。
可以使用 tcpdump 抓包分析, Linux使用 netstat -napt 查看 TCP 连接状态。
在工作中 tcpdump 只是用来抓取数据包, 然后把抓取的数据包保存成 pcap 后缀的文件, 接着用 Wireshark 工具进行数据包分析 。[1]
第一次握手: 客户端请求建立连接, 向服务端发送一个 同步报文( SYN=1), 同时选择一个随机数seq = x 作为 初始序列号第二次握手: 服务端收到连接请求报文后, 如果同意建立连接, 则向客户端发送 同步确认报文 (SYN=1 ,ACK=1), 确认号 为 ack = x + 1, 同时选择一个随机数 seq = y 作为 初始序列号
第三次握手 ( 可携带数据) :客户端收到服务端的确认后, 向服务端发送一个 确认报文(ACK=1), 确 认号 为 ack = y + 1, 序列号 为 seq = x + 1这时就完成了三次握手, 连接建立成功 。随后, 客户端和服务端的序列号将分别从 x+2 和 y+1 开始进 行传输。为什么需要三次握手 ,而不是两次或四次?
● 最主要原因是防止 Γ历史连接」初始化了连接 。第三次握手可以避免历史连接的影响, 而从及时终 止历史连接 。而两次握手无法解决历史连接问题。
● 避免资源浪费 。如果只有 Γ两次握手」, 当客户端的 SYN 请求连接在网络中阻塞, 客户端没有接 收到 ACK 报文, 就会重新发送 SYN , 由于没有第三次握手, 服务器不清楚客户端是否收到了自己
发送的建立连接的 ACK 确认信号, 所以每收到一个 SYN 就只能先主动建立一个连接, 如果客户端
的 SYN 阻塞了, 重复发送多次 SYN 报文, 那么服务器在收到请求后就会建立多个冗余的无效链
接, 造成不必要的资源浪费。
● 四次握手其实也能够可靠的同步双方的初始化序号, 但可以优化成一步, 所以就成了 Γ三次握手」 。而两次握手只保证了一 方的初始序列号能被对方成功接收, 没办法保证双方的初始序列号都能被确认接收。
为什么每次建立 TCP 连接时 ,初始化的序列号都要求不一样呢?
● 主要为了防止历史报文被下一个相同四元组的连接接收;
● 为了安全性, 防止黑客伪造的相同序列号的 TCP 报文被对方接收;
SYN 报文被丢弃的两种场景:
● 开启 tcp_tw_recycle 参数 ,并且在 NAT 网络环境下 ,造成 SYN 报文被丢弃 。如果开启了
recycle 和 timestamps 选项, 就会开启一种叫 per-host 的 PAWS 机制 。per-host 是对 Γ对端 IP 做 序列号回绕( PAWS) 检查」, 而非对 ΓIP + 端口」 四元组做 PAWS 检查 。但是如果客户端 网络环境是用了 NAT 网关, 那么客户端环境的每一 台机器通过 NAT 网关后, 都会是相同的 IP 地 址, 在服务端看来, 就好像只是在跟一个客户端打交道一样, 无法区分出来 。Per-host PAWS 机 制利用TCP option里的 timestamp 字段的增长来判断干扰数据, 而 timestamp 是根据客户端各自 的 CPU tick 得出的值 。当客户端 A 通过 NAT 网关和服务器建立 TCP 连接, 然后服务器主动关闭 并且快速回收 TIME-WAIT 状态的连接后, 客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,
注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关, 所以是用相同的 IP 地址与服务端建立
TCP 连接, 如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小, 那么由于服务端的 per- host 的 PAWS 机制的作用, 服务端就会丢弃客户端主机 B 发来的 SYN 包。
● TCP 两个队列满了( 半连接队列和全连接队列) ,造成 SYN 报文被丢弃 。若开启
tcp_syncookies 参数则可在半连接队列满的情况下继续建立连接, 前提是全连接队列不满 。而当全 连接队列满了则会丢弃 SYN 报文
在没有开启 TCP keepalive ,且双方一直没有数据交互的情况下 ,如果客户端的 Γ主机崩溃」 了 ,会发 生什么;那 Γ进程崩溃」 的情况呢?
● 客户端主机崩溃了, 服务端是无法感知到的, 在加上服务端没有开启 TCP keepalive, 又没有数据 交互的情况下, 服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态, 直到服务端重启进
程 。所以, 我们可以得知一个点, 在没有使用 TCP 保活机制且双方不传输数据的情况下, 一 方的 TCP 连接处在 ESTABLISHED 状态, 并不代表另一 方的连接一定正常。
● 使用 kill -9 来模拟进程崩溃的情况, 发现在 kill 掉进程后, 服务端会发送 FIN 报文, 与客户端进 行四次挥手 。所以, 即使没有开启 TCP keepalive, 且双方也没有数据交互的情况下, 如果其中一方的进程发生了崩溃, 这个过程操作系统是可以感知的到的, 于是就会发送 FIN 报文给对方, 然后与对方进行 TCP 四次挥手。
SYN 攻击属于 DOS 攻击的一种, 它利用 TCP 协议缺陷, 通过发送大量的半连接请求, 耗费 CPU 和内存资源。
原理:在三次握手过程中, 服务器发送 [SYN/ACK] 包( 第二个包) 之后 、收到客户端的 [ACK] 包( 第 三个包) 之前的 TCP 连接称为半连接( half-open connect), 此时服务器处于 SYN_RECV( 等待客 户端响应) 状态 。如果接收到客户端的 [ACK], 则 TCP 连接成功, 如果未接受到, 则会不断重发请求 直至成功 。SYN 攻击的攻击者在短时间内伪造大量不存在的 IP 地址, 向服务器不断地发送 [SYN] 包 , 服务器回复 [SYN/ACK] 包, 并等待客户的确认 。由于源地址是不存在的, 服务器需要不断的重发直至 超时 。这些伪造的 [SYN] 包将长时间占用未连接队列, 影响了正常的 SYN, 导致目标系统运行缓慢、
网络堵塞甚至系统瘫痪。
检测: 当在服务器上看到大量的半连接状态时, 特别是源 IP 地址是随机的, 基本上可以断定这是一次
SYN 攻击。
防范: 主要有两大类, 一类是通过防火墙 、路由器等过滤网关防护, 另一类是通过加固 TCP/IP 协议栈 防范, 如增加最大半连接队列 、开启tcp_syncookies功能 、减少SYN+ACK重传次数 。但 SYN 攻击不 能完全被阻止, 除非将 TCP 协议重新设计, 否则只能尽可能的减轻 SYN 攻击的危害。
到报文确认号;确认号 = 收到报文序列号 + 1)
第一次挥手:客户端向服务端发送 连接释放报文( FIN=1 ,ACK=1), 主动关闭连接, 同时等待服务端的确认
● 序列号 seq = m, 即客户端上次发送的报文的最后一个字节的序号 + 1
● 确认号 ack = n, 即服务端上次发送的报文的最后一个字节的序号 + 1
第二次挥手: 服务端收到连接释放报文后, 立即发出 确认报文(ACK=1), 序列号 seq = n, 确认号 ack = m + 1 。这时 TCP 连接处于半关闭状态, 这表示客户端已经没有数据发送了, 但是服务端可能还 要给客户端发送数据。第三次挥手: 服务端向客户端发送 连接释放报文( FIN=1 ,ACK=1), 主动关闭连接, 同时等待客户端的确认
● 序列号 seq = p, 即服务端上次发送的报文的最后一个字节的序号 + 1 。此时处于半连接状态, 若 服务端无数据发送, 则 p == n + 1
● 确认号 ack = m + 1, 与第二次挥手相同, 因为这段时间客户端没有发送数据
第四次挥手:客户端收到服务端的连接释放报文后, 立即发出 确认报文(ACK=1), 序列号 seq = m + 1, 确认号为 ack = p + 1 。此时, 客户端就进入了 TIME-WAIT 状态 。注意此时客户端的 TCP 连接还没有释放, 必须经过 2*MSL( 最长报文段寿命) 的时间后, 才进入 CLOSED 状态 。而服务端只要收 到客户端发出的确认, 就立即进入 CLOSED 状态。
● TCP 是全双工的, 一 方关闭连接后, 另一 方还可以继续发送数据 。所以四次挥手, 将断开连接分成 两个独立的过程。
● 第三次挥手前可能还要数据进行向请求关闭方发送。
服务端大量 CLOSE-WAIT 原因
在收到客户端关闭连接以及服务器关闭连接之间, 业务代码处理耗时较多造成的 。可启动异步处理后续
程序避免大量 CLOSE-WAIT 状态。
客户端 TIME_WAIT 过多有什么危害?
● 内存资源占用, 比如文件描述符 、内存资源 、CPU 资源 、线程资源等 。;
● 对端口资源的占用, 一个 TCP 连接至少消耗「发起连接方」 的一个本地端口;
客户端 TIME-WAIT 状态必须等待 2MSL 原因
● 确保 ACK 报文能够到达服务端 ,从而使服务端正常关闭连接 。第四次挥手时, 客户端第四次挥手 的 ACK 报文不一定会到达服务端 。服务端会超时重传 FIN/ACK 报文, 此时如果客户端已经断开 了连接, 那么就无法响应服务端的二次请求, 这样服务端迟迟收不到 FIN/ACK 报文的确认, 就无 法正常断开连接 。MSL 是报文段在网络上存活的最长时间, 客户端等待 2MSL 时间, 即「客户端 ACK 报文 1MSL 超时 + 服务端 FIN 报文 1MSL 传输」, 就能够收到服务端重传的 FIN/ACK 报 文, 然后客户端重传一次 ACK 报文, 并重新启动 2MSL 计时器 。如此保证服务端能够正常关闭。
● 防止已失效的连接请求报文段出现在之后的连接中 。TCP 要求在 2MSL 内不使用相同的序列号 , 客户端在发送完最后一个 ACK 报文段后, 再经过时间 2MSL, 就可以保证本连接持续的时间内产 生的所有报文段都从网络中消失 。这样就可以使下一个连接中不会出现这种旧的连接请求报文段。 或者即使收到这些过时的报文, 也可以不处理它。
SO_REUSEADDR 和 SO_REUSEPORT 问题[1]
1. SO_REUSEADDR
● 解决 server 重启的问题:server 端调用 close, client 还没有调用 close, 则 server 端 socket 处于 FIN_WAIT2 状态, 持续时间60s, 此时 server 重启会失败, 在bind时会报错 Address
already in use 。或者 server 端先调用 close, client 后调用close, 则 server 会处于
TIME_WAIT 状态, 持续时间也是60s, 此时 server 重启会失败, 在bind时也会报错 Address already in use 。解决方法:套接字bind前设置SO_REUSEADDR, 或者 SO_REUSEPORT;
● 解决 ip 为零的通配符问题:例如: socketA 绑定了0.0.0.0:2222, socketB 绑定
10.164.129.22:2222时, 或者相反情况, 都会报错 Address already in use 。因为 0.0.0.0 相当于 通配符, 可以匹配到10.164.129.22, 在没有设置地址复用或者端口复用前就会有此问题 。解决方
法: 方案1:socketA 和 socketB 在 bind 前设置 SO_REUSEADDR, 并且 socketB 必须在
socketA 调用 listen 前调用 bind 。方案2: socketA 和 socketB 在 bind 前均设置了
SO_REUSEPORT 。方案3: socketB 在调用 bind 前设置进行强制bind, 而不用管 socketA 是什么 状态。
2. SO_REUSEPORT:支持多个进程或者线程绑定到同一端口, 提高服务器程序的性能 。可以在调用 bind 绑定端口号之前进行设置
● 允许多个套接字 bind()/listen() 同一个TCP/UDP 端口: 每一个线程拥有自己的服务器套接字, 在 服务器套接字上没有了锁的竞争;
● 内核层面实现负载均衡(“惊群”的解决方法之一 );
● 安全层面 ,监听同一个端口的套接字只能位于同一个用户下面。
TCP 的数据块是没有边界 、没有结构的字节流, 因此可能产生粘包:发送方为了将多个发往接收端的包 更有效的发到对方, 使用了优化方法( Nagle算法), 将多次间隔较小 、数据量小的数据包, 合并成一 个大的数据包一次性发送 。接收方不能及时读取数据, 导致缓冲区中的多个包粘连。
解决方法:
1. 发送方关闭 Nagle 算法( 使用 TCP_NODELAY 可以禁用Nagle算法)
2. 应用层定义消息边界, 最常见的两种解决方案就是基于长度或者基于终结符
● 基于长度的实现有两种方式, 一种是使用固定长度; 另一种方式是使用不固定长度, 但是需要在应 用层协议的协议头中增加表示负载长度的字段, HTTP 协议的消息边界就是基于长度实现的;
● HTTP 协议除了使用基于长度的方式实现边界, 也会使用基于终结符的策略, 当 HTTP 使用块传输 机制时, HTTP 头中就不再包含 Content-Length 了, 它会使用负载大小为 0 的 HTTP 消息作为 终结符表示消息的边界。
3. 基于特定的规则实现消息的边界, 例如:使用 TCP 协议发送 JSON 数据, 接收方可以根据接收到
的数据是否能够被解析成合法的 JSON 判断消息是否终结。
UDP 是面向报文的, 应用层交给 UDP 多长的报文, UDP 就照样发送, 既不合并, 也不拆分, 而是保留这些报文的边界。
服务器:
1. 创建socket int socket(int domain, int type, int protocol);
domain :协议域, 决定了socket的地址类型, IPv4为AF_INET
type:指定socket类型, SOCK_STREAM为TCP连接
protocol:指定协议 。IPPROTO_TCP表示TCP协议, 为0表示默认协议
2. 绑定socket和端口号 int bind(int sockfd, const struct sockaddr* addr, socklen_t addr len); sockfd:socket返回的套接字描述符, 类似于文件描述符
addr :有个sockaddr类型数据的指针, 指向的是被绑定结构变量
addr len :地址长度。
1 // IPv4的sockaddr地址结构
2 struct sockaddr_in {
3 sa_family_t sin_family; // 协议类型,AF_INET
4 in_port_t sin_port; // 端口号
5 struct in_addr sin_addr; // IP地址
6 };
7 struct in_addr {
8 uint32_t s_addr;
9 }
3. 监听端口号 int listen(int sockfd, int backlog);
sockfd:要监听的sock描述字
backlog:accept队列( 将要建立连接的队列) 长度=min(somaxconn, backlog), 其中 somaxconn是内核参数
4. 接收用户请求 int accept(int sockfd, struct sockaddr *addr, socklen_t *addr len); ( 三次握
手完成后)
sockfd: 服务器socket描述字
addr :指向地址结构指针
addr len :协议地址长度
5. 从socket中读取字符 size_t read(int fd, void *buf, size_t count);
fd: 连接描述字
buf:缓冲区
count:缓冲区长度
6. 关闭socket int close(int fd);
fd:accept返回的连接描述字, 每个连接有一个 。而sockfd是监听描述字, 一个服务器只有一个, 用于监听是否有连接
客户机:
1. 创建socket
2. 连接指定计算机
握手完成后)
sockfd:客户端的sock描述字
addr :服务器地址
addr len :socket地址长度
3. 向socket写入信息 size_t write(int fd, const void *bud, size_t count);
4. 关闭socket int close(int fd)
close 关闭本进程的 socket id , 但链接还是开着的( 根据计数值决定是否关闭), 用这个 socket id 的其他进程还能用这个链接, 能读/写这个 socket id;shutdown 则破坏了 socket 链接, 读的时候可能检测到EOF结束符, 写的时候可能会收到一个信号,这个信号可能直到 socket 缓冲区被填充了才收到, 其次还有关闭链接类型的参数, 0 不能再读, 1 不能 再写, 2 读写都不能。
首部字段只有 8 个字节, 包括源端口 、 目的端口 、长度 、校验和。
HTTP 是超文本传输协议, 是一个在计算机世界里专门在 「两点」 之间「传输」文字 、图片 、音频 、视 频等「超文本」数据的「约定和规范」 。
HTTP 请求方法
● GET:获取服务器的指定资源
● HEAD:与 GET 方法一样, 都是发出一个获取服务器指定资源的请求, 但服务器只会返回 Header 而不会返回 Body 。用于确认 URI 的有效性及资源更新的日期时间等 。一个典型应用是下载文件
时, 先通过 HEAD 方法获取 Header, 从中读取文件大小 Content-Length;然后再配合 Range 字段, 分片下载服务器资源
● POST:提交资源到服务器 / 在服务器新建资源
● PUT:替换整个目标资源
● PATCH:替换目标资源的部分内容
● DELETE:删除指定的资源
● OPTIONS: 可以检测服务器支持哪些 HTTP 方法
● TRACE:执行一个消息环回测试, 返回到服务端的路径 。客户端请求连接到目标服务器时可能会通 过代理中转, 通过 TRACE 方法可以查询发送出去的请求的一系列操作幂等的
int socket(int domain, int type, int protocol);
int connect(int sockfd, struct sockaddr *addr, socklen_t addr len);( 第二次
一个 HTTP 方法是幂等的, 指的是同样的请求执行一次与执行多次的效果是一样的 。换句话说就是, 幂
等方法不应该具有副作用。
● 常见的幂等方法:GET, HEAD, PUT, DELETE, OPTIONS
● 常见的非幂等方法: POST
安全的
一个 HTTP 方法是安全的, 指这是一个对服务器只读操作的方法, 不会修改服务器数据。
● 常见的安全方法:GET, HEAD, OPTIONS
● 常见的不安全方法: PUT, DELETE, POST
所有安全的方法都是幂等的;有些不安全的方法如 DELETE 是幂等的, 有些不安全的方法如 PUT 和
DELETE 则不是。
可缓存的:GET 、HEAD。
GET 和 POST 区别
GET |
POST |
|
作用 |
获取服务器资源, 可被缓存 |
添加 / 修改服务器资源, 不能被缓存 |
幂等 / 安全性 |
幂等, 安全( 不会改变服务器上的资 源) |
非幂等, 不安全( 会对服务器资源进行改 变) |
参数位置 |
明文暴露在URL链接中 |
消息体中 |
参数长度 |
2KB( HTTP本身无限制, 浏览器限 制) |
无限制 |
信息响应( 100– 199)
● 100 Continue :表明到目前为止都很正常, 客户端可以继续发送请求或者忽略这个响应 成功响应( 200–299)
● 200 OK
● 204 No Content:该请求已成功处理, 但响应头没有 body 数据 。通常用于只需要从客户端往服务 器发送信息, 而不需要返回数据时
● 206 Partial Content: 是应用于 HTTP 分块下载或断点续传, 表示响应返回的 body 数据并不是资 源的全部, 而是其中的一部分, 也是服务器处理成功的状态。
重定向( 300–399)
● 301 Moved Permanently:永久性重定向 。说明请求的资源已经不存在了, 需改用新的 URL 再次访问。
● 302 Found: 临时性重定向, 说明请求的资源还在, 但暂时需要用另一个 URL 来访问 。常见应用 场景是通过 302 跳转将所有的 HTTP 流量重定向到 HTTPS
● 304 Not Modified: 响应不包含消息体 。不具有跳转的含义, 表示资源未修改, 重定向已存在的缓 冲文件, 也称缓存重定向, 也就是告诉客户端可以继续使用缓存资源, 用于缓存控制。
客户端错误(400–499)
● 400 Bad Request:请求报文中存在语法错误, 或者参数有误
● 403 Forbidden :表示服务器禁止访问资源, 并不是客户端的请求出错。
● 404 Not Found:表示请求的资源在服务器上不存在或未找到, 所以无法提供给客户端。
服务器错误 ( 500–599)
● 500 Internal Server Error:发生不可预知的错误。
● 501 Not Implemented:表示客户端请求的功能还不支持, 类似“即将开业, 敬请期待”的意思。
● 502 Bad Gateway: 通常是服务器作为网关或代理时返回的错误码, 表示服务器自身工作正常, 访 问后端服务器发生了错误。
● 503 Service Unavailable :表示服务器当前很忙, 暂时无法响应客户端, 类似“网络服务正忙, 请 稍后重试”的意思。
301 、302重定向的原理: 返回的 Header 中有一个 Location 字段指向目标 URL, 浏览器会重定向到 这个 URL。
浏览器可以将已经请求过的资源( 如图片 、JS 文件) 缓存下来, 下次再次请求相同的资源时, 直接从缓
存读取 。浏览器采用的缓存策略有两种: 强制缓存 、协商缓存 。浏览器根据第一次请求资源时返回的
HTTP 响应头来选择缓存策略 。强制缓存优先级大于协商缓存。
强制缓存
强缓存是利用 HTTP 响应头部字段实现的, 它们都用来表示资源在客户端缓存的有效期:
● Cache-Control, 是一个相对时间;Cache-Control的优先级高于 Expires 。
● Expires, 是一个绝对时间;
具体的实现流程如下:
1. 当浏览器第一次请求访问服务器资源时, 服务器会在返回这个资源的同时, 在响应头部加上 Cache-Control 字段, 其中设置了过期时间;
2. 浏览器再次请求访问服务器中的该资源时, 会先通过请求资源的时间与 Cache-Control 中设置的 过期时间来计算出该资源是否过期, 如果没有, 则使用该缓存, 否则重新请求服务器;
3. 服务器再次收到请求后, 会更新响应头部的 Cache-Control。
协商缓存
协商缓存可以基于两种头部来实现。
● 第一种:请求头部中的 If-Modified-Since 字段与响应头部中的 Last-Modified 字段实现 。响应 头部中的 Last-Modified 标示这个响应资源的最后修改时间;请求头部中的 If-Modified-Since 表示当资源过期时, 发现响应头中具有 Last-Modified 声明, 则再次发起请求的时候带上 Last- Modified 的时间, 服务器收到请求后发现有 If-Modified-Since 则与被请求资源的最后修改时间 进行对比, 如果最后修改时间较新( 大), 说明资源又被改过, 则返回最新资源, HTTP 200
OK; 如果最后修改时间较旧( 小), 说明资源无修改, 响应 HTTP 304 走缓存。
● 第二种:请求头部中的 If-None-Match 字段与响应头部中的 ETag 字段( 唯一标识响应资源) 。 其中请求头部中的 If-None-Match 表示当资源过期时, 浏览器发现响应头里有 Etag, 则再次向服 务器发起请求时, 会将请求头If-None-Match 值设置为 Etag 的值 。服务器收到请求后进行比对 , 如果资源没有变化返回 304, 如果资源变化了返回 200。
第一种实现方式是基于时间实现的, 第二种实现方式是基于一个唯一标识实现的, 相对来说后者可以更加准确地判断文件内容是否被修改, 避免由于时间篡改导致的不可靠问题。
注意, 只有在未能命中强制缓存的时候, 才能发起带有协商缓存字段的请求。
请求报文
HTTP 协议以 ASCII 码传输, 请求报文由请求行 、请求头 、( 空行以及) 消息体组成 。一个消息主体一 定包含一个实体主体, 通常情况下消息主体等于实体主体, 实体主体也可能经过传输编码机制处理, 在通信时按某种编码方式传输。
响应报文
HTTP 响应报文也由三部分组成:状态行 、响应头 、( 空行以及) 消息体。
HTTP 分块传输编码
在 HTTP 通信过程中, 请求的编码实体资源尚未全部传输完成之前, 浏览器无法显示请求页面 。在传输 大容量数据时, 通过把数据分割成多块, 能够让浏览器逐步显示页面 。这种把实体主体分块的功能称为 分块传输编码( Chunked Transfer Coding) 。它通过在 Header 里两个参数实现的, 客户端发请求时 对应的是 Range , 服务器端响应时对应的是 Content-Range 。Range 用于请求头中, 指定第一个字 节的位置和最后一个字节的位置 。Content-Range 用于响应头中, 在发出带 Range 的请求后, 服务器 会在 Content-Range 头部返回当前接受的范围和文件总大小 。[1]
HTTP 常见字段:
● Host 字段:客户端指定服务器的域名, 可以将请求发往 Γ同一 台」 服务器上的不同网站。
● Content-Length 字段: 服务器返回数据的长度。
● Content-Type 字段: 服务器回应客户端的数据格式 。客户端请求的时候, 可以使用 Accept 字段 声明自己可以接受哪些数据格式。
● Content-Encoding 字段: 服务器返回数据使用的压缩格式 。客户端在请求时, 用 Accept- Encoding 字段说明自己可以接受哪些压缩方法。
● Connection 字段: 最常用于客户端要求服务器使用 TCP 持久连接, 以便其他请求复用。
HTTP/1.1 版本的默认连接都是持久连接, 但为了兼容老版本的 HTTP, 需要指定 Connection 首部 字段的值为 Keep-Alive。
二者都是用来跟踪浏览器用户身份的会话方式。
Cookie:存在浏览器里, 可以设置过期时间 。每次访问服务器时, 浏览器会自动在 header 中携带 cookie 。如果浏览器禁用了 cookie, 可以使用 URL 地址重写机制, 将信息保存在 URL 里
Session:存在服务端, 由服务器维护, 服务端设置过期时间 。如果用户长时间不和服务器交互( 比如 30 分钟), 那么 session 就会被销毁, 交互则会刷新 session 。浏览器的 cookie 中只保存一个sessionId, 所有其他信息均保存在服务端, 由 sessionId 标识。
Manager 充当一个 session 管理器的角色, 主要存储一些配置信息, 比如 session 的存活时间,
cookie 的名字等等 。而所有的 session 存在 Manager 内部的一个 Provider 中 。所以 Manager 会把 sid( sessionID) 传递给 Provider, 让它去找这个 ID 对应的具体是哪个 session。
Provider 就是一个容器, 最常见的应该就是一个散列表, 将每个 sid 和对应的 session 一一 映射起来 。收到 Manager 传递的 sid 之后, 它就找到 sid 对应的 session 结构, 也就是 Session 结构, 然后 返回它。
Session 中存储着用户的具体信息, 由 Handler 函数中的逻辑拿出这些信息, 生成该用户的 HTML 网 页, 返回给客户端。
既然 session 就是键值对 ,为啥不直接用哈希表?
1. 可以存储一些辅助数据, 比如 sid, 访问次数, 过期时间或者最后一次的访问时间, 这样便于实现 像 LRU 、LFU 这样的算法。
2. 可以有不同的存储方式, 比如存入缓存数据库 Redis, 或者存入 MySQL 等等 。如果用编程语言内 置的哈希表, 那么 session 数据就是存储在内存中, 如果数据量大, 很容易造成程序崩溃, 而且一 旦程序结束, 所有 session 数据都会丢失。
Provider 为啥要抽象出来?
上图的 Provider 就是一个散列表, 保存 sid 到 Session 的映射, 但是实际中肯定会更加复杂 。我们不 是要时不时删除一些 session 吗, 除了设置存活时间之外, 还可以采用 一些其他策略, 比如 LRU 缓存 淘汰算法, 这样就需要 Provider 内部使用哈希链表这种数据结构来存储 session。
Manager 为啥要抽象出来?
大部分具体工作都委托给 Session 和 Provider 承担了, Manager 主要就是一个参数集合, 比如
session 的存活时间, 清理过期 session 的策略, 以及 session 的可用存储方式 。Manager 屏蔽了操 作的具体细节, 我们可以通过 Manager 灵活地配置 session 机制。
HTTP/1.1
优点: 简单 、灵活和易于扩展 、应用广泛和跨平台
缺点: 无状态 、明文传输 、不安全
HTTP/1.1 相比 HTTP/1.0 性能上的改进:
● 使用长连接的方式改善了 HTTP/1.0 短连接造成的性能开销, 不过长连接会占用服务器的资源。
● 支持管道( pipeline) 网络传输, 只要第一个请求发出去了, 不必等其回来, 就可以发第二个请求 出去, 可以减少整体的响应时间。
性能瓶颈:
● 请求 / 响应头部 未经压缩就发送, 首部信息越多延迟越大, 且每次互相发送相同的首部造成的浪费 较多;
● 服务器是按请求的顺序响应的, 如果服务器响应慢, 会导致客户端一直请求不到数据, 也就是队头 阻塞;
● 没有请求优先级控制;
● 请求只能从客户端开始, 服务器只能被动响应。
如何优化?
● 尽量避免发送 HTTP 请求:尽量使用缓存处理。
● 尽量减少请求次数:利用代理服务器等减少重定向请求次数;合并请求资源;延迟发送请求。
● 减少响应的数据大小: 通过有损或无损压缩对响应的资源进行压缩。
HTTP/2.0 改进点 [基于 HTTPS ]
1. Header 压缩: 头部的编码通过「静态表( 保存常用字段编码并固定在协议中) 、动态表( 动态添 加静态表没有的字段编码) 、Huffman 编码」 共同完成的。
2. 二进制分帧: HTTP/1.x 采用文本格式传输数据 。HTTP/2.0 将所有传输信息分割为若干个帧, 采 用二进制格式进行编码 。具体实现上, 是在应用层( HTTP) 和传输层(TCP) 之间增加一个二进 制分帧层 。每个请求对应一个流, 有个唯一 的标识符 。请求报文会被拆分为一个或多个帧( 帧头 +消息负载), 每个帧有序列号, 以及自己所属流的标识符, 接收端自行合并 。同时, 二进制分帧
采用的流传输也为多路复用提供了基础。
3. 多路复用:每个请求或响应的数据包称为一个数据流, 每个数据流都有唯一 的编号, 因此不同流的 帧是可以乱序发送的( 即可以并发不同的流) 。因为每个帧的头部会携带流编号信息, 所以接收端 可以通过流编号有序拼接 HTTP 消息 。客户端和服务器双方都可以建立流, 其中客户端建立的流
编号必须是奇数号, 而服务器建立的流编号必须是偶数号 。客户端还可以指定数据流的优先级。
4. 服务端推送: 服务端根据客户端的请求, 提前推送额外的资源给客户端, 可以减轻数据传输的冗余 步骤, 同时加快页面响应速度, 提升用户体验 。比如在发送页面 HTML 时主动推送其它 CSS/JS 资源, 而不用等到浏览器解析到相应位置, 发起请求再响应。
HTTP/2.0 缺陷
● HTTP 队头阻塞 。HTTP/2 是基于 TCP 协议来传输数据的 ,TCP 是字节流协议 ,TCP 层必须保证 收到的字节数据是完整且连续的, 这样内核才会将缓冲区里的数据返回给 HTTP 应用, 那么当
「前 1 个字节数据」没有到达时, 后收到的字节数据只能存放在内核缓冲区里, 只有等到这 1 个字 节数据到达时, HTTP/2 应用层才能从内核中拿到数据, 这就是 HTTP/2 队头阻塞问题 。所以, 一 旦发生了丢包现象, 就会触发 TCP 的重传机制, 这样在一个 TCP 连接中的所有的 HTTP 请求都
必须等待这个丢了的包被重传回来。
● 慢启动降低效率 。TCP 由于具有「拥塞控制」 的特性, 所以刚建立连接的 TCP 会有个「慢启动」 的过程, 它会对 TCP 连接产生 "减速"效果。
● 网络切换重连 。一个 TCP 连接是由四元组( 源 IP 地址, 源端口, 目标 IP 地址, 目标端口) 确定 的, 这意味着如果 IP 地址或者端口变动了, 就会导致需要 TCP 与 TLS 重新握手, 这不利于移动 设备切换网络的场景, 比如 4G 网络环境切换成 WIFI。
HTTP/3 优化?
● 改进头部压缩算法 。HTTP/3 中的 QPACK 也采用了静态表 、动态表及 Huffman 编码 。HTTP/2 和 HTTP/3 的动态表编解码方式不同, QUIC 会有两个特殊的单向流, 这两个特殊的单向流是用来 同步双方的动态表, 编码方收到解码方更新确认的通知后, 才使用动态表编码 HTTP 头部。
● 更换传输协议 。HTTP/2 虽然通过多个请求复用 一个 TCP 连接解决了 HTTP 的队头阻塞 , 但是一 旦发生丢包, 就会阻塞住所有的 HTTP 请求, 这属于 TCP 层队头阻塞 。所以 HTTP/3 把 HTTP
下层的 TCP 协议改成了 UDP, 基于 UDP 的 QUIC 协议 可以实现类似 TCP 的可靠传输。
QUIC 实现了两种级别的流量控制 ,分别为 Stream 和 Connection 两种级别:
● Stream 流量控制:每个 HTTP 请求对应一个流, 每个 Stream 都有独立的滑动窗口, 所以每个 Stream 都可以做流量控制, 防止单个 Stream 占用连接的全部接收缓冲。
● Connection 流量控制: 限制连接中所有 Stream 加起来的总字节数, 防止发送方超过连接的缓冲 容量。
QUIC 有以下 3 个特点
● 无队头阻塞: 当某个流发生丢包时, 只会阻塞这个流, 其他流不会受到影响, 因此不存在队头阻塞 问题 。而 HTTP/2 只要某个流中的数据包丢失了, 其他流也会受影响。
● 更快的建立连接:对于 HTTPS 和 HTTP/2 协议 ,TCP 和 TLS 是分层的, 需要分批次来握手, 先 TCP 握手, 再 TLS 握手 。HTTP/3 在传输数据前虽然需要 QUIC 协议握手, 这个握手过程只需要 1 RTT, 握手的目的是为确认双方的「连接 ID」, 连接迁移就是基于连接 ID 实现的 。不过 QUIC 协议并不是与 TLS 分层, 其内部包含了 TLS, 再加上 QUIC 使用的是 TLS/1.3, 因此仅需 1 个
RTT 就可以「同时」完成建立连接与密钥协商。
● 连接迁移: 基于 TCP 传输协议的 HTTP 协议, 由于是通过四元组( 源 IP 、源端口 、 的 IP 、 目的
端口) 确定一条 TCP 连接, 那么当移动设备的网络从 4G 切换到 WIFI 时, 意味着 IP 地址变化
了, 那么就必须要断开连接, 然后重新建立连接 。而建立连接的过程包含 TCP 三次握手和 TLS 四 次握手的时延, 以及 TCP 慢启动的减速过程, 给用户的感觉就是网络突然卡顿了一下, 因此连接 的迁移成本是很高的 。而 QUIC 协议没有用四元组的方式来“绑定”连接, 而是通过连接 ID来标记通 信的两个端点, 客户端和服务器可以各自选择一组 ID 来标记自己, 因此即使移动设备的网络 IP 地址变化了, 只要仍保有上下文信息( 比如连接 ID 、TLS 密钥等), 就可以“无缝”地复用原连接,消除重连的成本, 没有卡顿感, 达到了连接迁移的功能。