TCP 协议比较复杂, 接下来分两篇文章浅要介绍 TCP 中的一些要点
本文介绍 TCP 的状态机与重传机制, 下文讲解流量控制与拥塞控制
本文大部分内容基于 TCP 的那些事儿(上) 修改而来, 部分观点与原文不同, 重要地方增加了解释
前置知识
一些网络基础
TCP 在网络 OSI 的七层模型中的第四层 Transport 层, IP 在第三层 Network 层, ARP 在第二层 Data Link 层, 在第二层上的数据, 我们叫 Frame, 在第三层上的数据叫 Packet, 第四层的数据叫 Segment
应用层的数据首先会打到 TCP 的 Segment 中, 然后 TCP 的 Segment 会打到 IP 的 Packet 中, 然后再打到以太网 Ethernet 的 Frame 中, 传到对端后, 各个层解析自己的协议, 然后把数据交给更高层的协议处理
TCP 头格式
在正式讨论之前, 先来看一下 TCP 头的格式:
注意:
TCP 的包是没有 IP 地址的, 那是 IP 层上的事但是有源端口和目标端口
一个 TCP 连接需要四个元组来表示是同一个连接(src_ip, src_port, dst_ip, dst_port)(准确说是五元组, 还有一个是协议, 但因为这里只是说 TCP 协议, 所以, 这里我只说四元组)
注意上图中的四个非常重要的东西:
Sequence Number , 包的序号 Seq, 用于解决网络包乱序 (reordering)
Acknowledgement Number
,Ack 用于确认收到 Seq(Ack = Seq + 1, 表示收到了 Seq 及 Seq 之前的数据包, 期待 Seq + 1), 用于解决丢包
Window , 又叫 Advertised Window , 可以近似理解为 滑动窗口 (Sliding Window)的大小, 用于流控
TCP Flag , 区分包的类型, 如 SYN 包 FIN 包 RST 包等, 主要 用于操控 TCP 状态机
其他字段参考下图:
TCP 的状态机
其实, 网络传输是没有连接的 TCP 所谓的连接, 其实只不过是在通讯的双方维护一个连接状态 , 让它看上去好像有连接一样所以, TCP 的状态转换非常重要
下面是 简化 的 TCP 协议状态机 和 TCP 三次握手建连接 + 传数据 + 四次挥手断连接 的对照图, 两张图本质上都描述了 TCP 协议状态机, 但场景略有不同 这两个图非常重要, 一定要记牢
TCP 协议状态机, 不区分 clientserver:
下图是经典的 TCP 三次握手建连接 + 传数据 + 四次挥手断连接, client 发起握手, 向 server 传输数据(server 不向 client 传), 最后发起挥手:
三次握手与四次挥手
很多人会问, 为什么建连接要三次握手, 断连接需要四次挥手?
三次握手建连接
主要是要 初始化 Sequence Number 的初始值
通信的双方要同步对方 ISN(初始化序列号, Inital Sequence Number)所以叫 SYN(全称 Synchronize Sequence Numbers)也就是上图中的 x 和 y 这个号在以后的数据通信中, 在 client 端按发送顺序递增, 在 server 端按递增顺序重新组织, 以保证应用层接收到的数据不会因为网络问题乱序
四次挥手断连接
其实是 双方各自进行 2 次挥手
因为 TCP 是全双工的, client 与 server 都占用各自的资源发送 segment(同一通道, 同时双向传输 seq 和 ack), 所以, 双方都需要关闭自己的资源 (向对方发送 FIN) 并确认对方资源已关闭(回复对方 Ack) ; 而双方可以同时主动关闭, 也可以由一方主动关闭带动另一方被动关闭只不过, 通常以一方主动另一方被动举例(如图, client 主动 server 被动), 所以看上去是所谓的 4 次挥手
如果两边同时主动断连接, 那么双方都会进入 CLOSING 状态, 然后到达 TIME_WAIT 状态, 最后超时转到 CLOSED 状态下图是双方同时主动断连接的示意图(对应 TCP 状态机中的 Simultaneous Close 分支):
握手过程中的其他问题
建连接时 SYN 超时
server 收到 client 发的 SYN 并回复 Ack(SYN)(此处称为 Ack1)后, 如果 client 掉线了(或网络超时), 那么 server 将无法收到 client 回复的 Ack(Ack(SYN))(此处称为 Ack2), 连接处于一个 中间状态 (非成功非失败)
为了解决中间状态的问题, server 如果在一定时间内没有收到 Ack2, 会重发 Ack1 (不同于数据传输过程中的重传机制)Linux 下, 默认重试 5 次, 加上第一次最多共发送 6 次; 重试间隔从 1s 开始翻倍增长(一种指数回退策略, Exponential Backoff),5 次的重试时间分别为 1s, 2s, 4s, 8s, 16s, 第 5 次发出后还要等待 32s 才能判断第 5 次也超时所以, 至多共发送 6 次, 经过 1s + 2s + 4s+ 8s+ 16s + 32s = 2^6 -1 = 63s,TCP 才会认为 SYN 超时断开这个连接
SYN Flood 攻击
可以利用建连接时的 SYN 超时机制发起 SYN Flood 攻击 给 server 发一个 SYN 就立即下线, 于是服务器默认需要占用资源 63s 才会断开连接发 SYN 的速度是很快的, 这样, 攻击者很容易将 server 的 SYN 队列资源耗尽, 使 server 无法处理正常的新连接
针对该问题, Linux 提供了一个 tcp_syncookies 参数解决这个问题 当 SYN 队列满了后, TCP 会通过源地址端口目标地址端口和时间戳构造一个特别的 Sequence Number 发回去, 称为 SYN Cookie, 如果是攻击者则不会有响应, 如果是正常连接, 则会把这个 SYN Cookie 发回来, 然后 server 端可以通过 SYN Cookie 建连接 (即使你不在 SYN 队列中)至于 SYN 队列中的连接, 则不做处理直至超时关闭请注意, 不要用 tcp_syncookies 参数来处理正常的大负载连接情况 , 因为 SYN Cookie 本质上也破坏了建连接的 SYN 超时机制, 是妥协版的 TCP 协议
对于正常的连接请求, 有另外三个参数可供选择:
tcp_synack_retries 参数设置 SYN 超时重试次数
tcp_max_syn_backlog 参数设置最大 SYN 连接数(SYN 队列容量)
tcp_abort_on_overflow
参数使 SYN 请求处理不过来的时候拒绝连接
ISN 的同步
首先, 不能选择静态的 ISN 例如, 如果连接建好后始终用 1 来做 ISN, 如果 client 发了 30 个 segment(假设一个字节一个 segment)过去, 但是网络断了, 于是 client 重连, 又用了 1 做 ISN, 但是旧连接的那些 segment(称为迷途的重复分组)到了, 由于区分连接的五元组相同(称该新连接为旧连接的化身),server 会把它们当做新连接中的 segment
然后, 从上例还能够得知, 需要使 ISN 随时钟动态增长 , 以保证新连接的 ISN 大于旧连接
最后, 从安全等角度考虑, 也不能使 ISN 的增长呈现规律性 (如简单随时钟正比例增长)这很容易理解, 如果增长规律过于简单, 则很容伪造 ISN 对网络两端发起攻击
最终, 设计了多种 ISN 增长算法, 普遍 使 ISN 随时钟动态增长, 并具有一定的随机性 RFC793 中描述了一种简单的 ISN 增长算法: ISN 会和一个假的时钟绑在一起, 这个时钟会在每 4 微秒对 ISN 做加一操作, 直到超过 2^32, 又从 0 开始这样, 一个 ISN 的周期大约是 4.55( 我算的 4.77??? )个小时定义 segment 在网络上的最大存活时间为 MSL(Maximum Segment Lifetime), 网络中存活时间超过 MSL 的分组将被丢弃因此, 如果使用 RFC793 中的 ISN 增长算法, 则 MSL 的值必须小于 4.55 小时, 以保证不会在相邻的连接中重用 ISN( TIME_WAIT 也有该作用)同时, 这间接限制了网络的大小(当然, 4.55 小时的 MSL 已经能构造非常大的网络了)
MSL 应大于 IP 协议 TTL 换算的时间, RFC793 建议 MSL 设置为 2 分钟, Linux 遵循伯克利习惯设置为 30s
挥手过程中的其他问题
关于 TIME_WAIT
为什么需要 TIME_WAIT
在 TCP 状态机中, 从 TIME_WAIT 状态到 CLOSED 状态, 有一个超时时间 2 MSL 为什么需要 TIME_WAIT 状态, 且超时时间为 2 MSL? 主要有两个原因:
2 * MSL 确保有足够的时间让被动方收到了 ACK 或主动方收到了被动发超时重传的 FIN 即, 如果被动方没有收到 Ack, 就会触发被动方重传 FIN, 发送 Ack + 接收 FIN 正好 2 个 MSL, TIME_WAIT 状态的连接收到重传的 FIN 后, 重传 Ack, 再等待 2 * MSL 时间
确保有足够的时间让
迷途的重复分组
过期丢弃 这只需要 1 * MSL 即可, 超过 MSL 的分组将被丢弃, 否则很容易同新连接的数据混在一起(仅仅依靠 ISN 是不行的)
大规模出现 TIME_WAIT
一个常见问题是大规模出现 TIME_WAIT, 通常是在高并发短连接的场景中, 会消耗很多资源
网上大部分文章都是教你打开两个参数, tcp_tw_reuse 或 tcp_tw_recycle 这两个参数默认都是关闭的, tcp_tw_recycle 比 tcp_tw_reuse 更为激进; 要想使用二者, 还需要打开 tcp_timestamps (默认打开), 否则无效不过, 打开这两个参数可能会让 TCP 连接出现诡异的问题 : 如上所述, 如果不等待超时就重用连接的话, 新旧连接的数据可能会混在一起, 比如新连接握手期间收到了旧连接的 FIN, 则新连接会被重置因此, 使用这两个参数时应格外小心
各参数详细如下:
tcp_tw_reuse : 官方文档上说 tcp_tw_reuse 加上 tcp_timestamps 可以保证 客户端 (仅客户端)在协议角度的安全, 但是需要在两端都打开 tcp_timestamps
tcp_tw_recycle : 如果是 tcp_tw_recycle 被打开了话, 会假设对端开启了 tcp_timestamps , 然后会去比较时间戳, 如果时间戳变大了, 就可以重用连接(NAT 网络有可能建连接失败, 出现 connection time out 的错误)
补充一个参数:
tcp_max_tw_buckets : 控制并发的 TIME_WAIT 的数量 (默认 180000), 如果超限, 系统会把多余的 TIME_WAIT 连接 destory 掉, 然后在日志里打一个警告(如 time wait bucket table overflow) 官网文档说这个参数是用来对抗 DDoS 攻击的, 需要根据实际情况考虑
关于 TIME_WAIT 的建议
总之, TIME_WAIT 出现在主动发起挥手的一方 , 即, 谁发起挥手谁就要牺牲资源维护那些等待从 TIME_WAIT 转换到 CLOSED 状态的连接 TIME_WAIT 的存在是必要的, 因此, 与其通过上述参数破协议来逃避 TIME_WAIT , 不如好好优化业务 (如改用长连接等), 针对不同业务优化 TIME_WAIT 问题
对于 HTTP 服务器, 可以设置 HTTP 的 KeepAlive 参数, 在应用层重用 TCP 连接来处理多个 HTTP 请求 (需要浏览器配合), 让 client 端(即浏览器) 发起挥手, 这样 TIME_WAIT 只会出现在 client 端
示例
下图是我从 Wireshark 中截了个我在访问 coolshell.cn 时的有数据传输的图, 可以参照理解 Seq 与 Ack 是怎么变的(使用 Wireshark 菜单中的 Statistics ->Flow Graph ):
可以看到, Seq 与 Ack 的增加和传输的字节数相关 上图中, 三次握手后, 来了两个 Len:1440 的包, 因此第一个包为 Seq(1), 第二个包为 Seq(1441)然后收到第一个 Ack(1441), 表示 1~1440 的数据已经收到了, 期待 Seq(1441)另外, 可以看到一个包可以同时充当 Ack 与 Seq, 在一次传输中携带数据与响应
如果你用 Wireshark 抓包程序看 3 次握手, 你会发现 ISN 总是为 0 不是这样的, Wireshark 为了显示更友好, 使用了 Relative Seq 相对序号你只要在右键菜单中的 protocol preference 中取消掉就可以看到 Absolute Seq 了
TCP 重传机制
TCP 协议通过重传机制保证所有的 segment 都可以到达对端, 通过滑动窗口允许一定程度的乱序和丢包 (滑动窗口还具有流量控制等作用, 暂不讨论) 注意, 此处重传机制特指数据传输阶段, 握手挥手阶段的传输机制与此不同
TCP 是面向字节流的, Seq 与 Ack 的增长均以字节为单位 在最朴素的实现中, 为了减少网络传输, 接收端只回复最后一个连续包的 Ack , 并相应移动窗口比如, 发送端发送 1,2,3,4,5 一共五份数据(假设一份数据一个字节), 接收端快速收到了 Seq 1, Seq 2, 于是回 Ack 3, 并移动窗口; 然后收到了 Seq 4, 由于在此之前未收到过 Seq 3(乱序), 如果仍在窗口内, 则只填充窗口, 但不发送 Ack 5, 否则丢弃 Seq 3(与丢包的效果相似); 假设在窗口内, 则等以后收到 Seq 3 时, 发现 Seq 4 及以前的数据包都收到了, 则回 Ack 5, 并移动窗口
超时重传机制
当发送方发现等待 Seq 3 的 Ack(即 Ack 4) 超时 后, 会认为 Seq 3 发送失败, 重传 Seq 3 一旦接收方收到 Seq 3, 会立即回 Ack 4
发送方无法区分是 Seq 3 丢包接收方故障还是 Ack 4 丢包, 本文统一表述为 Seq 发送失败
这种方式有些问题: 假设目前已收到了 Seq 4; 由于未收到 Seq 3, 导致发送方重传 Seq 3, 在收到重传的 Seq 3 之前, 包括新收到的 Seq 5 和刚才收到的 Seq 4 都不能回复 Ack, 很容易引发发送方重传 Seq 4Seq5 接收方之前已经将 Seq 4Seq 5 保存到窗口中, 此时重传 Seq 4Seq 5 明显造成浪费
也就是说, 超时重传机制面临 重传一个还是重传所有 的问题, 即:
重传一个: 仅重传 timeout 的包(即 Seq 3), 后续包等超时后再重传节省资源, 但效率略低
重传所有: 每次都重传 timeout 包及之后所有的数据 (即 Seq 345) 效率更高(如果带宽未打满), 但浪费资源
可知, 两种方法都属于 超时重传机制 , 各有利弊, 但二者都需要等待 timeout, 是 基于时间驱动 的, 性能与 timeout 的长度密切相关如果 timeout 很长(普遍情况), 则两种方法的性能都会受到较大影响
快速重传机制
最理想的方案是: 在超时之前, 通过某种机制要求发送方尽快重传 timeout 的包 (即 Seq 3), 如 快速重传机制 (Fast Retransmit) 这种方案浪费资源(浪费多少取决于重传一个还是重传所有, 见下), 但效率非常高(因为不需要等待 timeout 了)
快速重传机制不基于时间驱动, 而 基于数据驱动 : 如果包没有连续到达, 就 Ack 最后那个可能被丢了的包; 如果发送方连续收到 3 次相同的 Ack, 就重传对应的 Seq
比如: 假设发送方仍然发送 1,2,3,4,5 共 5 份数据; 接收方先收到 Seq 1, 回 Ack 2; 然后 Seq 2 因网络原因丢失了, 正常收到 Seq 3, 继续回 Ack 2; 后面 Seq 4 和 Seq 5 都到了, 最后一个可能被丢了的包还是 Seq 2, 继续回 Ack 2; 现在, 发送方已经连续收到 4 次 (大于等于 3 次) 相同的 Ack(即 Ack 2), 知道最大序号的未收到包是 Seq 2, 于是重传 Seq 2, 并清空 Ack 2 的计数器; 最后, 接收方收到了 Seq 2, 查看窗口发现 Seq 345 都收到了, 回 Ack 6 示意图如下:
快速重传解决了 timeout 的问题, 但依然面临重传一个还是重传所有的问题对于上面的示例来说, 是只重传 Seq 2 呢还是重传 Seq 2345 呢?
如果只使用快速重传, 则必须重传所有: 因为发送方并不清楚上述连续的 4 次 Ack 2 是因为哪些 Seq 传回来的假设发送方发出了 Seq 1 到 Seq 20 供 20 份数据, 只有 Seq 161020 到达了接收方, 触发重传 Ack 2; 然后发送方重传 Seq 2, 接收方收到, 回复 Ack 3; 接下来, 发送方与接收方都不会再发送任何数据, 两端陷入等待因此, 发送方只能选择重传所有, 这也是某些 TCP 协议的实际实现, 对于带宽未满时重传效率的提升非常明显
一个更完美的设计是: 将超时重传与快速重传结合起来, 触发快速重传时, 只重传局部的一小段 Seq(局部性原理, 甚至只重传一个 Seq), 其他 Seq 超时后重传
来源: http://www.tuicool.com/articles/7BVJJvM