前言
什麼是粘包拆包?
拆包和粘包是在socket程式設計中經常出現的情況,在socket通訊過程中,如果通訊的一端一次性連續發送多條資料包,tcp協定會将多個資料包打包成一個tcp封包發送出去,這就是所謂的粘包。而如果通訊的一端發送的資料包超過一次tcp封包所能傳輸的最大值時,就會将一個資料包拆成多個最大tcp長度的tcp封包分開傳輸,這就叫做拆包。
我們看一下下面這張圖就知道了:

粘包拆包産生的原因
資料流在TCP協定下傳播,因為協定本身對于流有一些規則的限制,這些規則會導緻目前對端接收到的資料包不完整,歸結原因有下面三種情況:
- Socket 緩沖區與滑動視窗
- MSS/MTU限制
- Nagle算法
1. Socket緩沖區與滑動視窗
對于 TCP 協定而言,它傳輸資料是基于位元組流傳輸的。應用層在傳輸資料時,實際上會先将資料寫入到 TCP 套接字的緩沖區,當緩沖區被寫滿後,資料才會被寫出去。每個TCP Socket 在核心中都有一個發送緩沖區(SO_SNDBUF )和一個接收緩沖區(SO_RCVBUF),TCP 的全雙工的工作模式以及 TCP 的滑動視窗便是依賴于這兩個獨立的 buffer 以及此 buffer 的填充狀态。
SO_SNDBUF:
程序發送的資料的時候假設調用了一個 send 方法,将資料拷貝進入 Socket 的核心發送緩沖區之中,然後 send 便會在上層傳回。換句話說,send 傳回之時,資料不一定會發送到對端去(和write寫檔案有點類似),send 僅僅是把應用層 buffer 的資料拷貝進 Socket 的核心發送 buffer 中。
SO_RCVBUF:
把接收到的資料緩存入核心,應用程序一直沒有調用 read 進行讀取的話,此資料會一直緩存在相應 Socket 的接收緩沖區内。不管程序是否讀取 Socket,對端發來的資料都會經由核心接收并且緩存到 Socket 的核心接收緩沖區之中。read 所做的工作,就是把核心緩沖區中的資料拷貝到應用層使用者的 buffer 裡面,僅此而已。
接收緩沖區儲存收到的資料一直到應用程序讀走為止。對于 TCP,如果應用程序一直沒有讀取,buffer 滿了之後發生的動作是:通知對端 TCP 協定中的視窗關閉。這個便是滑動視窗的實作。保證 TCP 套接口接收緩沖區不會溢出,進而保證了 TCP 是可靠傳輸。因為對方不允許發出超過所通告視窗大小的資料。 這就是 TCP 的流量控制,如果對方無視視窗大小而發出了超過視窗大小的資料,則接收方 TCP 将丢棄它。
滑動視窗:
TCP連接配接在三次握手的時候,會将自己的視窗大小(window size)發送給對方,其實就是 SO_RCVBUF 指定的值。之後在發送資料的時,發送方必須要先确認接收方的視窗沒有被填充滿,如果沒有填滿,則可以發送。
每次發送資料後,發送方将自己維護的對方的 window size 減小,表示對方的 SO_RCVBUF 可用空間變小。
當接收方處理開始處理 SO_RCVBUF 中的資料時,會将資料從 Socket 在核心中的接受緩沖區讀出,此時接收方的 SO_RCVBUF 可用空間變大,即 window size 變大,接受方會以 ack 消息的方式将自己最新的 window size 傳回給發送方,此時發送方将自己的維護的接受的方的 window size 設定為ack消息傳回的 window size。
此外,發送方可以連續的給接受方發送消息,隻要保證對方的 SO_RCVBUF 空間可以緩存資料即可,即 window size>0。當接收方的 SO_RCVBUF 被填充滿時,此時 window size=0,發送方不能再繼續發送資料,要等待接收方 ack 消息,以獲得最新可用的 window size。
由于發送端或者接收端的Socket大小動态變化,是以就有滑動視窗這個概念,進而封包并不會按照原先的分包來發送
2. MSS/MTU分片
MTU (Maxitum Transmission Unit,最大傳輸單元)是鍊路層對一次可以發送的最大資料的限制。
MSS(Maxitum Segment Size,最大分段大小)是 TCP 封包中 data 部分的最大長度,是傳輸層對一次可以發送的最大資料的限制。
資料在傳輸過程中,每經過一層,都會加上一些額外的資訊:
- 應用層:隻關心發送的資料 data,将資料寫入 Socket 在核心中的緩沖區 SO_SNDBUF 即傳回,作業系統會将 SO_SNDBUF 中的資料取出來進行發送;
- 傳輸層:會在 data 前面加上 TCP Header(20位元組);
- 網絡層:會在 TCP 封包的基礎上再添加一個 IP Header,也就是将自己的網絡位址加入到封包中。IPv4 中 IP Header 長度是 20 位元組,IPV6 中 IP Header 長度是 40 位元組;
- 鍊路層:加上 Datalink Header 和 CRC。會将 SMAC(Source Machine,資料發送方的MAC位址),DMAC(Destination Machine,資料接受方的MAC位址 )和 Type 域加入。SMAC+DMAC+Type+CRC 總長度為 18 位元組;
- 實體層:進行傳輸。
在回顧這個基本内容之後,再來看 MTU 和 MSS。MTU 是以太網傳輸資料方面的限制,每個以太網幀最大不能超過 1518bytes。刨去以太網幀的幀頭(DMAC+SMAC+Type域) 14Bytes 和幀尾 (CRC校驗 ) 4 Bytes,那麼剩下承載上層協定的地方也就是 data 域最大就隻能有 1500 Bytes 這個值 我們就把它稱之為 MTU。
MSS 是在 MTU 的基礎上減去網絡層的 IP Header 和傳輸層的 TCP Header 的部分,這就是 TCP 協定一次可以發送的實際應用資料的最大大小。
CopyMSS = MTU(1500) -IP Header(20 or 40)-TCP Header(20)
由于 IPV4 和 IPV6 的長度不同,在 IPV4 中,以太網 MSS 可以達到 1460byte。在 IPV6 中,以太網 MSS 可以達到 1440byte。
也就是說在發送TCP請求時,在每一層都會封裝一些首部和尾部字段,如果按照一個請求隻封裝在一個包的話,那麼就算一個位元組的傳輸到最後也會變成 41 或者 61 個位元組,浪費了網絡帶寬。是以一般會多個一起發。
3.Nagle 算法
由于底層的TCP無法了解上層的業務資料,是以在底層是無法保證資料包不被拆分和重組的,這個問題隻能通過上層的應用協定棧設計來解決,根據業界的主流協定的解決方案,可以歸納如下。
Nagle 算法的規則:
- 如果 SO_SNDBUF 中的資料長度達到 MSS,則允許發送;
- 如果該 SO_SNDBUF 中含有 FIN,表示請求關閉連接配接,則先将 SO_SNDBUF 中的剩餘資料發送,再關閉;
- 設定了
選項,則允許發送。TCP_NODELAY 是取消 TCP 的确認延遲機制,相當于禁用了 Negale 算法。正常情況下,當 Server 端收到資料之後,它并不會馬上向 client 端發送 ACK,而是會将 ACK 的發送延遲一段時間(一般是 40ms),它希望在 t 時間内 server 端會向 client 端發送應答資料,這樣 ACK 就能夠和應答資料一起發送,就像是應答資料捎帶着 ACK 過去。當然,TCP 确認延遲 40ms 并不是一直不變的, TCP 連接配接的延遲确認時間一般初始化為最小值 40ms,随後根據連接配接的重傳逾時時間(RTO)、上次收到資料包與本次接收資料包的時間間隔等參數進行不斷調整。另外可以通過設定 TCP_QUICKACK 選項來取消确認延遲。TCP_NODELAY=true
- 未設定 TCP_CORK 選項時,若所有發出去的小資料包(包長度小于MSS)均被确認,則允許發送。(總結就是是否要等更多資料再一起發)
- 上述條件都未滿足,但發生了逾時(一般為200ms),則立即發送。
Netty 對于粘包拆包的解決方案
首先我們先來了解一下TCP請求中是怎麼向上傳遞的
首先我們用戶端會先将要發送的資料進行編碼,把要發送的資料變成網絡中能傳數的格式,然後傳到伺服器中,由伺服器進行解碼,變成我們能看得懂的資料,然後再由業務邏輯來處理。
舉個例子:
比如我們使用者 1 發送一個你好消息給 2 :
{
from:1,
to:2,
msg:你好
}
經過編碼會變成一堆二進制的01,然後發送給服務端,那麼問題來了,服務端怎麼知道要怎麼将資料進行區分呢?
答案:解碼
Netty 的解決方案
為了解決TCP粘包/拆包導緻的半包讀寫問題,Netty預設提供了多種編解碼器用于處理半包,使其解決TCP粘包問題變得非常容易,主要有:
- FixedLengthFrameDecoder(使用定長的封包來分包)
- LineBasedFrameDecoder
- DelimiterBasedFrameDecoder(添加特殊分隔符封包來分包)
- LengthFieldBasedFrameDecoder
FixedLengthFrameDecoder
定長來區分,它能夠按照指定的長度對消息進行自動解碼,開發者不需要考慮TCP的粘包/拆包等問題,非常實用。
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 100)
.handler(new LoggingHandler(LogLevel.INFO))//配置日志輸出
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch)
throws Exception {
ch.pipeline().addLast(new FixedLengthFrameDecoder(1<<5));
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());
ch.pipeline().addLast(new ServerHandler());
}
});
利用 FixedLengthFrameDecoder 解碼器,無論一次接收到多少資料報,它都會按照構造函數中設定的固定長度進行解碼,如果是半包消息,FixedLengthFrameDecoder 會緩存半包消息并等待下個包到達後進行拼包,直到讀取到一個完整的包。但是大部分資料并不是定長的。
LineBasedFrameDecoder
LineBasedFrameDecoder的工作原理是它依次周遊ByteBuf中的可讀位元組,判斷是否有
“\n”或“\r\n”
,如果有,就以此位置為結束位置,從可讀索引到結束位置區間的位元組就組成了一行。它是以換行符為結束标志的解碼器,支援攜帶結束符或不攜帶結束符兩種解碼方式,同時支援配置單行的最大長度。如果連接配接讀取到最大長度後仍然沒有發現換行符,就會抛出異常,同時忽略掉之前讀到的異常碼流。防止由于資料報沒有攜帶換行符導緻接收到 ByteBuf 無限制積壓,引起系統記憶體溢出。
通常LineBasedFrameDecoder會和StringDecoder搭配使用。StringDecoder的功能非常簡單,就是将接收到的對象轉換成字元串,然後繼續調用後面的Handler。LineBasedFrameDecoder+StringDecoder組合就是按行切換的文本解碼器,它本設計用來支援TCP的粘包和拆包。對于文本類協定的解析,文本換行解碼器非常實用,例如對 HTTP 消息頭的解析、FTP 協定消息的解析等。
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new LineBasedFrameDecoder(1024));
p.addLast(new StringDecoder());
p.addLast(new StringEncoder());
p.addLast(new LineServerHandler());
}
});
DelimiterBasedFrameDecoder
DelimiterBasedFrameDecoder是分隔符解碼器,使用者可以指定消息結束的分隔符,它可以自動完成以分隔符作為碼流結束辨別的消息的解碼。回車換行解碼器實際上是一種特殊的DelimiterBasedFrameDecoder解碼器。和LineBasedFrameDecoder差不多
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new DelimiterBasedFrameDecoder(1024, Unpooled.copiedBuffer(Constants.DELIMITER.getBytes())));
p.addLast(new StringDecoder());
p.addLast(new StringEncoder());
p.addLast(new DelimiterServerHandler());
}
});
DelimiterBasedFrameDecoder 原理分析:解碼時,判斷目前已經讀取的 ByteBuf 中是否包含分隔符 ByteBuf,如果包含,則截取對應的 ByteBuf 傳回,源碼如下:
LengthFieldBasedFrameDecoder
LengthFieldBasedFrameDecoder
相對就高端一點。前面我們使用到的拆包都是基于一些約定來做的,比如固定長度,特殊分隔符,這些方案總是有一定的弊端。最好的方案就是:發送方告訴我目前消息總長度,接收方如果沒有收到該長度大小的資料就認為是沒有收完繼續等待。
裡面有四個參數:
比如:
使用以下參數組合進行解碼:
- lengthFieldOffset = 0;從第幾開始算起
- lengthFieldLength = 2;幾個位元組代表長度
-
lengthAdjustment = 0;多長是要拒絕的
總長度**,接收方如果沒有收到該長度大小的資料就認為是沒有收完繼續等待。