diff --git a/notes/pics/tcp-sticky.png b/notes/pics/tcp-sticky.png new file mode 100644 index 000000000..1584d5b12 Binary files /dev/null and b/notes/pics/tcp-sticky.png differ diff --git "a/notes/\350\256\241\347\256\227\346\234\272\347\275\221\347\273\234 - \344\274\240\350\276\223\345\261\202.md" "b/notes/\350\256\241\347\256\227\346\234\272\347\275\221\347\273\234 - \344\274\240\350\276\223\345\261\202.md" index 21305f181..ad3441878 100644 --- "a/notes/\350\256\241\347\256\227\346\234\272\347\275\221\347\273\234 - \344\274\240\350\276\223\345\261\202.md" +++ "b/notes/\350\256\241\347\256\227\346\234\272\347\275\221\347\273\234 - \344\274\240\350\276\223\345\261\202.md" @@ -12,6 +12,7 @@ * [TCP 拥塞控制](#tcp-拥塞控制) * [1. 慢开始与拥塞避免](#1-慢开始与拥塞避免) * [2. 快重传与快恢复](#2-快重传与快恢复) + * [TCP 粘包与拆包](#tcp-粘包与拆包) @@ -67,7 +68,7 @@ 第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。 -客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。如果有第三次握手,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,不进行第三次握手,因此就不会再次打开连接。 +客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。有了第三次握手,客户端收到服务器对滞留请求的确认时,发现确认号不是自己期望的,就回复 RST,服务器收到后放弃这个连接,不会多打开一个连接。 ## TCP 的四次挥手 @@ -97,6 +98,22 @@ - 等待一段时间是为了让本连接持续时间内所产生的所有报文都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文。 +2MSL 正好覆盖一个来回:A 的确认最多在网络中存活 1 MSL,B 超时重传的 FIN 最多再存活 1 MSL。Linux 中 TIME_WAIT 固定为 60 秒(TCP_TIMEWAIT_LEN)。 + +**大量 TIME_WAIT 与 CLOSE_WAIT** + +TIME_WAIT 出现在主动关闭的一方,使用短连接的机器会积累大量 TIME_WAIT,例如每次请求都新建连接去访问下游服务。每个 TIME_WAIT 连接占用一个本地端口 60 秒,Linux 默认的本地端口范围是 32768~60999,共 28232 个,连接同一个目标地址和端口时,每秒新建超过约 470 个连接就会耗尽端口。 + +解决方案: + +- 使用长连接或连接池,例如 HTTP 的 keep-alive、数据库连接池; +- 在发起连接的一方开启 net.ipv4.tcp_tw_reuse,允许新连接复用处于 TIME_WAIT 的端口,它依靠 TCP 时间戳选项识别旧连接的报文; +- 扩大本地端口范围 net.ipv4.ip_local_port_range。 + +另一个参数 tcp_tw_recycle 在 NAT 环境下会丢弃正常的连接,Linux 4.12 已将其移除。 + +CLOSE_WAIT 出现在被动关闭的一方,表示对方已经关闭,本方的应用还没有调用 close()。连接大量停在 CLOSE_WAIT 通常是程序的问题,例如异常分支没有关闭连接、从连接池借出的连接没有归还,需要排查代码,保证每个连接最终都会被关闭。 + ## TCP 可靠传输 TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。 @@ -115,7 +132,7 @@ RTTs = (1 - a) × RTTs + a × RTT RTO = RTTs + 4 × RTTd ``` -其中 RTTd 为偏差的加权平均值。 +其中 RTTd 为偏差的加权平均值。RFC 6298 建议 a 取 1/8,计算 RTTd 时的权重取 1/4,并要求 RTO 不小于 1 秒;Linux 的下限为 200 ms。 ## TCP 滑动窗口 @@ -171,3 +188,52 @@ TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、 慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。

+ +## TCP 粘包与拆包 + +TCP 面向字节流,只保证字节按顺序到达,不保留应用每次写入的边界。发送方写入两次,接收方的一次读取可能读到两条消息拼在一起,称为粘包;也可能只读到一条消息的一部分,称为拆包(也叫半包)。 + +例如客户端依次写入 hello、world 和一条 200000 字节的消息,服务器等待 200 ms 后,用 1024 字节的缓冲区反复读取: + +

+ +产生的原因主要有以下几种: + +- 数据先进入接收缓冲区,应用读取时缓冲区里有多少就读出多少,最多读满自己的缓冲区; +- 发送方的 Nagle 算法会把多次写入的小数据合并成一个报文段发送; +- 超过最大报文段长度(Maximum Segment Size, MSS)的消息会被分成多个报文段,可能分多次读到。 + +UDP 面向报文,一次读取得到的就是对方一次发送的完整数据报,不存在粘包。 + +解决方法是在应用层约定消息的边界,常用以下三种: + +- 固定长度:每条消息长度相同,不足的部分补齐,浪费空间; +- 分隔符:用特殊字符标记消息结束,例如 Redis 协议的每一行以 `\r\n` 结束,消息中出现分隔符时需要转义; +- 长度字段:在消息头部写明消息体的长度,接收方先读长度,再读够这么多字节。 + +HTTP/1.1 同时用了后两种:首部以一个空行结束,消息体的长度由 Content-Length 给出,或者用分块传输编码在每一块前写明长度。Netty 提供了对应的解码器 FixedLengthFrameDecoder、DelimiterBasedFrameDecoder 和 LengthFieldBasedFrameDecoder。 + +用长度字段收发消息的最小实现如下,按这种格式重复上面的例子,接收方依次得到 hello、world 和 200000 字节的消息: + +```java +static final int MAX = 1 << 20; + +static void send(DataOutputStream out, String msg) throws IOException { + byte[] body = msg.getBytes(StandardCharsets.UTF_8); + out.writeInt(body.length); // 4 字节的长度 + out.write(body); + out.flush(); +} + +static String receive(DataInputStream in) throws IOException { + int len = in.readInt(); // 读满 4 字节 + if (len < 0 || len > MAX) { + throw new IOException("bad length " + len); + } + byte[] body = new byte[len]; + in.readFully(body); // 读满 len 字节,不够就继续等待 + return new String(body, StandardCharsets.UTF_8); +} +``` + +需要注意的是,长度字段来自网络,必须限制上限,否则对方发来一个 2 GB 的长度,服务器就会为它分配 2 GB 内存。 diff --git a/tools/diagram/src/tcp-sticky.svg b/tools/diagram/src/tcp-sticky.svg new file mode 100644 index 000000000..7e90c7b72 --- /dev/null +++ b/tools/diagram/src/tcp-sticky.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + 粘包与拆包:写入 3 次,读取 3 次,边界对不上 + ① 发送方写入 + write 3 次 + + hello + + world + + 第 3 条消息 200000 字节 + ② 字节流 + 没有消息边界 + + hello + + world + + + 共 200010 字节 + + + ③ 接收方读取 + read 3 次 + + 第 1 次 1024 字节 + hello + world + 1014 + + 第 2 次 131072 字节 + + 第 3 次 67914 字节 + 第 1 次读到两条完整消息和第 3 条的开头(粘包),第 3 条消息分 3 次才读完(拆包)。 + ④ 加上长度字段 + 先读 4 字节长度 + + 5 + + hello + + 5 + + world + + 200000 + + 消息体 200000 字节 + 灰色是 4 字节的长度:接收方先读满 4 字节得到长度 n,再读满 n 字节,就是一条完整的消息。 + macOS 本机实测:服务器等待 200 ms 后用 1024 字节的缓冲区读取,之后的读取大小随系统和缓冲区变化。 +