TCP粘包/拆包问题的分析与解决


无论是服务端还是客户端,当我们读取或者发送消息的时候,都需要考虑TCP底层的粘包/拆包机制。

1.TCP粘包/拆包问题

TCP是个“流”协议,所谓流,就是没有界限的一串数据。大家可以想想河里的流水,是连成一片的,其间并没有分界线。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。

1.1 TCP粘包/拆包问题说明

在这里插入图片描述
假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况。

(1)服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
(2)服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包;
(3)服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包;
(4)服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余内容D1_2和D2包的整包。

如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包。

1.2 TCP粘包/拆包发生的原因

问题产生的原因有三个,分别如下。

(1)应用程序write写入的字节大小大于套接口发送缓冲区大小;

(2)进行MSS大小的TCP分段;

(3)以太网帧的payload大于MTU进行IP分片。
在这里插入图片描述
在这里插入图片描述

粘包问题的解决策略
由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。

  • (1)消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格;
  • (2)在包尾增加回车换行符进行分割,例如FTP协议;
  • (3)将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度;
  • (4)更复杂的应用层协议。
    下图说明了接受端接收到数据的各种情况:

当然,接收到第一种情况是最理想的,也不须处理。

2.未考虑TCP粘包导致功能异常案例

在前面的时间服务器例程中,我们多次强调并没有考虑读半包问题,这在功能测试时往往没有问题,但是一旦压力上来,或者发送大报文之后,就会存在粘包/拆包问题。如果代码没有考虑,往往就会出现解码错位或者错误,导致程序不能正常工作。以Netty 入门示例为例。

2.1 示例

SERVER

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;
public class TimeServerHandler extends ChannelHandlerAdapter {
    private int counter;
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        String body = new String(req, "UTF-8").substring(0, req.length - System.getProperty("line.separator").length());
        System.out.println("The time server receive order : " + body + " ; the counter is : " + ++counter);
        String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ?
                new java.util.Date( System.currentTimeMillis()).toString() : "BAD ORDER";
        currentTime = currentTime + System.getProperty("line.separator");
        ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
        ctx.writeAndFlush(resp);
    }
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        ctx.close();
    }
}

每读到一条消息后,就计一次数,然后发送应答消息给客户端。按照设计,服务端接收到的消息总数应该跟客户端发送的消息总数相同,而且请求消息删除回车换行符后应该为"QUERY TIME ORDER"。
Client

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;

public class TimeClientHandler extends ChannelHandlerAdapter {
    private int counter;
    private byte[] req;
    public TimeClientHandler() {
        req = ("QUERY TIME ORDER" + System.getProperty("line.separator")).getBytes();
    }
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        ByteBuf message = null;
        for (int i = 0; i < 100; i++) {
            message = Unpooled.buffer(req.length);
            message.writeBytes(req);
            ctx.writeAndFlush(message);
        }
    }
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        String body = new String(req, "UTF-8");
        System.out.println("Now is : " + body + " ; the counter is : " + ++counter);
    }
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // 释放资源
        ctx.close();
    }
}

客户端跟服务端链路建立成功之后,循环发送100条消息,每发送一条就刷新一次,保证每条消息都会被写入Channel中。按照我们的设计,服务端应该接收到100条查询时间指令的请求消息。客户端每接收到服务端一条应答消息之后,就打印一次计数器。按照设计初衷,客户端应该打印100次服务端的系统时间。

运行结果:

服务端运行结果如下。

The time server receive order : QUERY TIME ORDER

QUERY TIME ORDER

QUERY TIME ORDER ; the counter is : 1

The time server receive order :

QUERY TIME ORDER

QUERY TIME ORDER ; the counter is : 2

服务端运行结果表明它只接收到了两条消息,第一条包含57条“QUERY TIME ORDER”指令,第二条包含了43条“QUERY TIME ORDER”指令,总数正好是100条。我们期待的是收到100条消息,每条包含一条“QUERY TIME ORDER”指令。这说明发生了TCP粘包。

客户端运行结果如下。

Now is : BAD ORDER
BAD ORDER;
the counter is : 1

按照设计初衷,客户端应该收到100条当前系统时间的消息,但实际上只收到了一条。这不难理解,因为服务端只收到了2条请求消息,所以实际服务端只发送了2条应答,由于请求消息不满足查询条件,所以返回了2条“BAD ORDER”应答消息。但是实际上客户端只收到了一条包含2条“BAD ORDER”指令的消息,说明服务端返回的应答消息也发生了粘包。由于上面的例程没有考虑TCP的粘包/拆包,所以当发生TCP粘包时,我们的程序就不能正常工作。

2.2 TCP粘包处理

设置数据包头

// TcpDataSplit.cpp : 定义控制台应用程序的入口点。
//
 
#include "stdafx.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
#define MAX_NETPACK_SIZE	10000
#define MAX_DATA_SIZE			4086
 
/* 数据包头类型 */
struct NetDataHeader_t
{
	int nDataType;											//数据包类型,标识对应的对象类型
	int nDataSize;											//数据包中szData真实数据的长度
};
 
/*  数据包类型 */
struct NetDataBase_t
{
	NetDataHeader_t  dataHeader;			//数据包头
	char	 szData[MAX_DATA_SIZE];				//真实数据
};
 
/**
    其实NetDataBase_t是基础类型,由此我们可以延伸出很多子类型,
	所以我们要清楚,每个类型的长度是不一样的,不都是sizeof(NetDataBase_t),
	就是各个类型对象大小不一样,比如:
	在派生结构体中,NetDataPeople_t和NetDataSchool_t是两个各异的结构体,
	但他们都有相关的Header部分指明结构体类型和长度。
*/
struct NetDataPeople_t
{
	NetDataHeader_t dataHeader;	
	int		nAge;
	char    szName[10];
};
 
struct NetDataSchool_t
{
	NetDataHeader_t dataHeader;	
	char    szShoolName[20];
	char    szShoolAddress[30];
};
 
/**
    处理整理好的对象。
*/
bool HandleNetPack(NetDataHeader_t* pDataHeader);
 
 
bool TcpDataSplit(const char* szRecNetData, int nRecSize)
{
	/**
	    对于szLastSaveData, nRemainSize,为了简单,本例子只
		作为静态变量使用,因此只限于一个socket的数据接收,
		假如要同时处理多个socket数据,请放在对应容器里保存
	*/
	static char szLastSaveData[MAX_NETPACK_SIZE];
	static int nRemainSize = 0;
	static bool bFirst = true;
 
	if (bFirst)
	{
		memset(szLastSaveData, 0, sizeof(szLastSaveData));
		bFirst = false;
	}
 
	/* 本次接收到的数据拼接到上次数据 */
	memcpy( (char*)(szLastSaveData+nRemainSize), szRecNetData, nRecSize );
	nRemainSize = nRecSize + nRemainSize;
 
	/* 强制转换成NetDataPack指针 */
	NetDataHeader_t* pDataHead = (NetDataHeader_t*)szLastSaveData;
 
	/**
	   核心算法 
	*/
	while ( nRemainSize >sizeof(NetDataHeader_t) &&
				nRemainSize >= pDataHead->nDataSize +sizeof(NetDataHeader_t) )
	{
			HandleNetPack(pDataHead);
			int  nRecObjectSize = sizeof(NetDataHeader_t) + pDataHead->nDataSize;		//本次收到对象的大小
			nRemainSize -= nRecObjectSize ;				
			pDataHead = (NetDataHeader_t*)( (char*)pDataHead + nRecObjectSize );		//移动下一个对象头
	}
	
	/* 余下数据未能组成一个对象,先保存起来 */
	if (szLastSaveData != (char*)pDataHead)
	{
		memmove(szLastSaveData, (char*)pDataHead, nRemainSize);
		memset( (char*)( szLastSaveData+nRemainSize), 0, sizeof(szLastSaveData)-nRemainSize );
	}
	
	return true;
}
 
 
/**
    处理整理好的对象。
*/
bool HandleNetPack(NetDataHeader_t* pDataHeader)
{
	//处理数据包
	if  (pDataHeader->nDataType == 1)
	{
		NetDataPeople_t* pPeople = (NetDataPeople_t*)pDataHeader;
		printf("收到People对象,Age:%d, Name:%s\n", pPeople->nAge, pPeople->szName);
	}
	else if (pDataHeader->nDataType == 2)
	{
		NetDataSchool_t* pSchool = (NetDataSchool_t*)pDataHeader;
		printf("收到School对象,SchoolName:%s, SchoolAddress:%s\n", pSchool->szShoolName, pSchool->szShoolAddress);
	}
 
	return true;
}
 
int _tmain(int argc, _TCHAR* argv[])
{
	/* 本例子以两个对象作为接收到的数据 */
	NetDataPeople_t  people;
	people.dataHeader.nDataSize = sizeof(people) - sizeof(NetDataHeader_t);
	people.dataHeader.nDataType = 1;
	people.nAge = 20;
	sprintf(people.szName, "Jim");		//real data
 
	NetDataSchool_t  school;
	school.dataHeader.nDataSize = sizeof(school) - sizeof(NetDataHeader_t);
	school.dataHeader.nDataType = 2;
	sprintf(school.szShoolName, "清华大学");		//real data
	sprintf(school.szShoolAddress, "北京市北京路");		//real data
 
	/* 将两个对象数据合并到一个地址里面以便重现粘包 */
	char szSendData[sizeof(people)+sizeof(school)];
	memcpy(szSendData,  (char*)&people,  sizeof(people));
	memcpy(szSendData+sizeof(people),  (char*)&school,  sizeof(school));
 
	//这里进行收数据操作,这里省略。。。
 
	/**
	    特意设置粘包:
		1.第一次只发送3个字节,还不足以构建包头
		2.第二次发送10个字节,总共13个,但第一个对象大小是8+14=18;因此第一个对象people还没收满
		3.第三次发送剩下的全部,第一个对象剩下的部分与第二个对象粘在一起,验证处理
	*/
	TcpDataSplit((char*)szSendData, 3);
	TcpDataSplit((char*)szSendData+3,  10);
	TcpDataSplit((char*)szSendData+13,  sizeof(szSendData)-13);
 
	getchar();
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值