# 【NO.69】TCP收发数据“丢失”问题的排查与解决

我们协议栈的某条业务线数据的收发基于TCP连接的，在测试过程中发现，TCP数据的接收与发送各有一次数据“丢失”的问题。我们知道TCP数据的收发是可靠的，不会发生数据丢失的情况，本文将讲的数据“丢失”是出问题时给人的一种假象。下面本文就来详细地讲述一下收发数据失败问题的排查过程。

![img](https://pic4.zhimg.com/80/v2-290f50dc6aa2952aef3292e1901b46e7_720w.webp)

## 1.TCP发送数据的“丢失”问题

### 1.1 问题描述

平台的同事向我们反馈，换了新版本的协议栈后，发送大的消息（发送的是1669个字节的数据）会有概率失败的情况。失败不是说，发送端调用我们协议栈的接口发送数据时接口就返回失败，而是返回了成功，但接收端却并没有收到此消息。

### 1.2 问题分析

老版本的协议栈和新版本的协议栈差异化还是很大的，所以不可能直接去对比所有协议栈的内容。所以只能一步步从这个问题的源头查起。

首先分析是发送端的问题还是接收端的问题，经过在给接口处加打印、数据收发处加打印以及抓包分析后发现，接口处传入的数据都是正确的，但是从包里看却发现是发送端出了数据“丢失”、“乱序”的问题。

因为数据流太大，协议栈会进行分包发送，每包最大512bytes。那么1699bytes的数据就应该分为4包发送，且按顺组成的内容应该跟原始数据一致。如图所示：

![img](https://pic1.zhimg.com/80/v2-0fb79e4477d39189a9f5c4a79d2190d8_720w.webp)

数据中包括一个TPKT头（03 00 06 a3），其中06 a3指示数据大小为1699bytes，4包数据为一个完整数据。

上图是正确情况下的内容，出问题时就会出现如下图所示的“乱序”问题：

![img](https://pic2.zhimg.com/80/v2-895efeccae78c324577e786a2e3dc2b5_720w.webp)

从上图中我们可以看到第四包数据的内容也出现了一个TPKT头（03 00 06 a3），实际上数据中有和头部相同的数据是没什么奇怪的，但是分析发现第四包数据内容和第一包数据一模一样。

对比打印中正确的数据，第四包的数据就是错的，而且打印中发现，业务是发送了两次非标数据，那么第四包的数据应该就是第二次发送的数据头，这样的话给我们的感觉就是TCP竟然出现了“乱序”！检查了包里的内容发现，后边缺失的数据也没看到有发送，那么分析下来就是出现“丢失”数据了，而不是“乱序”！

分析代码时经过高人指点，发现协议栈发送数据时是切分数据后直接循环调用send发送，会不会是发送太快又频繁而导致TCP的发送缓冲区不够用了呢。那么在每次发送后都sleep上几毫秒后测试，每次发送都是正确的了。

### 1.3 问题解决

我们知道，调用套接字的send函数后，返回>0的值并不代表tcp已经把这么多数据发送给对方了，而是拷贝给了发送缓冲区多少个字节（非阻塞模式）。Tcp从缓冲区中发送数据也是需要消耗时间的，那么使用sleep就可以多给tcp点时间去把缓冲区的数据发送出去从而移除掉。

sleep虽然可以解决这个问题，但是sleep多长时间合适呢，而老版本协议栈为什么没有问题呢？此时再去查看老版本协议栈的代码，发现它并没有用sleep，那么只有放大缓冲区大小可以解决这个问题了。经查看，4.0协议栈确实在创建socket的时候，都会把收发缓冲区的大小设置下。

新版本的协议栈也使用此方法后问题解决。设置方法就是使用setsockopt函数，套接字选项为SO_SNDBUF、SO_RCVBUF。

肯定有人会问，当发送缓冲区满时，send会返回-1的啊，或者要发送的数据大于缓冲区中剩余的数据也回返回实际放入的数据值啊。好吧，确实是我们使用的失误，对返回值的操作没有处理好，而是自以为的都发送成功了，从而导致了数据“丢失”。所以当时看协议栈打印并没有报错的地方，从而把人引入更晕乎的状态。赶紧修改之。



## 2.TCP接收数据的“丢失”问题

### 2.1 问题描述

测试人员反馈，在他的一台win7电脑上测试软件的某一项功能时会大概率性失败，而其他电脑则没有这个问题。

### 2.2 问题分析

首先分析失败原因，从打印中看，是因为平台侧认为超时没收到此win7电脑的MSD（主从决定）消息，从而导致MSDACK无法正确完成，从而导致问题。

但抓win7电脑和平台侧的包后发现，win7侧信令有发送出去，平台侧的抓包看是有收到此包数据的。但是看平台侧打印就是没有收到此信令的打印，以至于解析win7电脑发来的MSD消息的TPKT头的打印都没有。查看解析数据的代码，并没有看到有什么特殊处理的地方，为什么只针对此win7电脑有问题呢，而且还不是必现？！

在分析打印的时候，看到平台侧收到win7电脑致邻发送的TCS（能力集）数据流，数据的末尾和某类型的TPKT头格式相同，都是06 00 xx xx的格式。而其他电脑致邻的TCS数据却不是这样，会不会是因为数据和TPKT头相似而导致的解析错误呢？

协议栈的数据处理过程：首先它会读取数据的TPKT头部分，根据头部指示数据的大小再读入相应大小的数据。那么即使数据部分和头部相同应该也不会有把数据部分当做头部处理的情况。

在正确的情况下：

![img](https://pic1.zhimg.com/80/v2-9ffcd5a77ed24fcafb5fb2a043d0712c_720w.webp)

接收MSD消息打印分析收到win7发送的MSD消息过程：首先读取数据的前4个字节（TPKT头大小）的数据，TPKT头前两字节固定是03 00（某类型的TPKT头是06 00，会转化成03 00），后两个字节是数据大小，那么数据大小是00 0B=11bytes,减去TPKT头大小4bytes，剩下的就是7bytes，那么再读取7bytes的数据从而组成一个完整的MSD消息。

有问题时的抓包如下：

![img](https://pic4.zhimg.com/80/v2-754f751e0b198a8b6e42dc0fc01999cf_720w.webp)

对比上面两个图，01 00 32 80就是MSD的数据部分，后续再读取的3个字节总共7个字节就是MSD消息（传入的len=512，是因为发现之前读的数据不符合预期中的TPKT头，那么就一次读入512数据放到临时缓冲中，在从缓冲中逐个字节去找TPKT头，防止因为tcp数据流的传输方式导致一次的错误而把后续的有效消息也丢失的问题），但是为什么没有MSD消息的TPKT头部分呢？难道tcp数据出现了“丢失”？！但从wireshark抓包来看，其收到的数据是完整的！

在某特殊类型的产品中，把原来标准的TPKT头03 00 xx xx变为06 00 xx xx，并且数据部分插入4个bytes的0。这个处理被放到了调用send时转换为该特殊类型的产品数据，在recv收到数据后再恢复到标准数据。所以根据协议处理数据原理，先读TPKT头，然后根据头部读数据。在图4中的体现是：TPKT头“丢”了，而直接读取的是插入的4个0数据，发现不符合TPKT头格式再继续以4个字节大小尝试读取头部数据。

检查了tcp的socket接收缓冲区大小，已经被设置为挺大的值了，那么看来tcp数据的“丢失”不是对socket的设置导致，那就只能是我们代码哪里处理有问题了。因为win7电脑的TCS数据的“特殊化”，又继续分析了其TCS数据。对比成功和失败时候的打印，虽然测试人员说什么都没有改动，但是从打印中能看出来，成功和失败时候win7电脑致邻发送能力的大小是不一样的！

成功时接收TCS消息打印如下：

![img](https://pic2.zhimg.com/80/v2-9c83a7444a9edcb8d082f5baf7a8f00d_720w.webp)

发送失败时接收TCS消息打印如下：

![img](https://pic3.zhimg.com/80/v2-51a0ab96bfc473023749830f7aed55d6_720w.webp)

然后就注意到了，上面接收失败时读取数据时候出现了一个错误。虽然返回值是没有错误，而且打印也没有报错，但是从打印中看到我们要读取len=4字节长度的时候，实际received却是8！对于recv函数来说，除非接收buffer中数据长度小于你要读取的数据长度时，会返回和你要读取的数据不一样的值，否则你要读取多少字节就应该返回多少字节的数据。所以这个返回值肯定就是出现问题的元凶！

那么成功和失败时候的能力区别在哪里呢，对比包发现，呼叫时候的码率不同会导致能力字节大小不一样，所以在呼叫码率是8M的时候是必现，其他小点的码率就不出现。

### 2.3 问题解决

协议解析数据会先读取TPKT头数据的大小，为了解析某特殊类型产品的数据，在recv到数据后判断如果是该特殊类型产品的TPKT头，那么要做的就是改变头内容（把06变为03，指示数据长度的地方减去插入的4个bytes）、去掉后续是4个0的数据。

在错误情况下解析TCS数据的时，它刚好暴露了我们代码中的一个错误点。如图6所示，因为它的能力字节大小是520byets，去掉头部4bytes后还有516个。协议栈读数据时如果其大小超过512块大小就分块读取。所以先读512bytes，再读剩下的4bytes。而每次读取4bytes时，我们都会判断是否是某特殊类型产品的TPKT头，根据2.2.2中怀疑点，TCS的最后四个字节刚好和该特殊类型产品的TPKT头格式相同。而刚好因为其字节数的大小，与块大小的原因我们单独读取了这四个字节！

在判断是该特殊类型产品的TPKT头后，我们做了头部操作，然后再读取4个字节，判断是不是全0，如果是就去掉，否则就返回8（返回8是因为已经读了8个字节）。所以出现了接收失败时我们要读取4bytes但是返回8的现象。

所以最简单的解决方案就是实现“预读”。recv函数原型：

```text
int recv( _In_ SOCKET s, _Out_ char *buf, _In_ int len, _In_ int flags);
```

参数flags值：

MSG_DONTROUTE 绕过路由表查找。

MSG_DONTWAIT 仅本操作非阻塞。

MSG_OOB 发送或接收带外数据。

MSG_PEEK 窥看外来消息。

MSG_WAITALL 等待所有数据。

一般情况下，我们recv的第四个值都会写为0，那么我们调用了recv后，已经读过的数据就会从socket的缓冲区中移除。而MSG_PEEK值却可以为我们实现“预读”功能，即我们预读的数据就不会从socket接收缓冲区移除，在预读发现如果不是4个0字节，那么就直接返回已读的TPKT头大小数据，否则就使用从socket接收缓冲区中移除的方式读取完成删除插入的4个0字节的功能。

## 3.最后

在对分析问题的过程中，并没有像上述分析过程那样顺利，整个过程时艰难曲折的，中途查阅了不少资料。经历了这两个问题详细排查过程，使得我们对TCP的读写数据的原理及细节有了更为深刻的认识。

原文地址：https://zhuanlan.zhihu.com/p/580513576

作者：linux