https://zhuanlan.zhihu.com/p/77275039
簡介
拆包和粘包是在socket編程中經常出現的情況,在socket通訊過程中,如果通訊的一端一次性連續發送多條數據包,tcp協議會將多個數據包打包成一個tcp報文發送出去,這就是所謂的粘包。而如果通訊的一端發送的數據包超過一次tcp報文所能傳輸的最大值時,就會將一個數據包拆成多個最大tcp長度的tcp報文分開傳輸,這就叫做拆包。
一些基本概念
MTU
泛指通訊協議中的最大傳輸單元。一般用來說明TCP/IP四層協議中數據鏈路層的最大傳輸單元,不同類型的網絡MTU也會不同,我們普遍使用的以太網的MTU是1500,即最大只能傳輸1500字節的數據幀。可以通過ifconfig命令查看電腦各個網卡的MTU。
MSS
指TCP建立連接后雙方約定的可傳輸的最大TCP報文長度,是TCP用來限制應用層可發送的最大字節數。如果底層的MTU是1500byte,則 MSS = 1500- 20(IP Header) -20 (TCP Header) = 1460 byte。
示意圖
如圖所示,客戶端和服務端之間的通道代表TCP的傳輸通道,兩個箭頭之間的方塊代表一個TCP數據包,正常情況下一個TCP包傳輸一個應用數據。粘包時,兩個或多個應用數據包被粘合在一起通過一個TCP傳輸。而拆包情況下,會一個應用數據包會被拆成兩段分開傳輸,其他的一段可能會和其他應用數據包粘合。
場景實例
下面通過簡單實現兩個socket端通訊,演示粘包和拆包的流程。客戶端和服務端都在本機進行通訊,服務端使用127.0.0.1監聽客戶端,客戶端也在127.0.0.1發起連接。
1. 粘包
a. 實現服務端代碼,服務監聽55533端口,沒有指定IP地址默認就是localhost,即本機IP環回地址 127.0.0.1,接着就等待客戶端連接,代碼如下:
public class SocketServer { public static void main(String[] args) throws Exception { // 監聽指定的端口 int port = 55533; ServerSocket server = new ServerSocket(port); // server將一直等待連接的到來 System.out.println("server將一直等待連接的到來"); Socket socket = server.accept(); // 建立好連接后,從socket中獲取輸入流,並建立緩沖區進行讀取 InputStream inputStream = socket.getInputStream(); byte[] bytes = new byte[1024 * 1024]; int len; while ((len = inputStream.read(bytes)) != -1) { //注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8 String content = new String(bytes, 0, len,"UTF-8"); System.out.println("len = " + len + ", content: " + content); } inputStream.close(); socket.close(); server.close(); } }
b. 實現客戶端代碼,連接服務端,兩端連接建立后,客戶端就連續發送100個同樣的字符串;
public class SocketClient { public static void main(String[] args) throws Exception { // 要連接的服務端IP地址和端口 String host = "127.0.0.1"; int port = 55533; // 與服務端建立連接 Socket socket = new Socket(host, port); // 建立連接后獲得輸出流 OutputStream outputStream = socket.getOutputStream(); String message = "這是一個整包!!!"; for (int i = 0; i < 1; i++) { //Thread.sleep(1); outputStream.write(message.getBytes("UTF-8")); } Thread.sleep(20000); outputStream.close(); socket.close(); } }
c. 先運行服務端代碼,運行到server.accept()時阻塞,打印“server將一直等待連接的到來”來等待客戶端的連接,接着再運行客戶端代碼;
d. 客戶端代碼運行后,就能看到服務端的控制台打印結果如下:
server將一直等待連接的到來
len = 21, content: 這是一個整包!!!
len = 168, content: 這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!
len = 105, content: 這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!
len = 42, content: 這是一個整包!!!這是一個整包!!!
len = 42, content: 這是一個整包!!!這是一個整包!!!
len = 63, content: 這是一個整包!!!這是一個整包!!!這是一個整包!!!
len = 42, content: 這是一個整包!!!這是一個整包!!!
len = 21, content: 這是一個整包!!!
len = 42, content: 這是一個整包!!!這是一個整包!!!
len = 21, content: 這是一個整包!!!
len = 147, content: 這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!
len = 63, content: 這是一個整包!!!這是一個整包!!!這是一個整包!!!
len = 21, content: 這是一個整包!!!
len = 252, content: 這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!這是一個整包!!!
按照原來的理解,在客戶端每次發送一段字符串“這是一個整包!!!”, 分別發送了50次。服務端應該也會是分50次接收,會打印50行同樣的字符串。但結果卻是這樣不尋常的結果,這就是由於粘包導致的結果。
總結出現粘包的原因:
- 要發送的數據小於TCP發送緩沖區的大小,TCP將多次寫入緩沖區的數據一次發送出去;
- 接收數據端的應用層沒有及時讀取接收緩沖區中的數據;
- 數據發送過快,數據包堆積導致緩沖區積壓多個數據后才一次性發送出去(如果客戶端每發送一條數據就睡眠一段時間就不會發生粘包);
- 出現粘包的原因其實很復雜,涉及到TCP的多個特性,關於TCP更深入的解釋可以參考我的另一篇文章:
2. 拆包
如果數據包太大,超過MSS的大小,就會被拆包成多個TCP報文分開傳輸。所以要演示拆包的情況,就需要發送一個超過MSS大小的數據,而MSS的大小是多少呢,就要看數據所經過網絡的MTU大小。由於上面socket中的客戶端和服務端IP都是127.0.0.1, 數據只在回環網卡間進行傳輸,所以客戶端和服務端的MSS都為回環網卡的 MTU - 20(IP Header) -20 (TCP Header),沿用粘包的例子,下面是拆包的處理步驟。
a. mac電腦可以通過ifconfig查看本地的各個網卡的MTU,以下我的電腦運行ifconfig后輸出的一部分,其中lo0就是回環網卡,可看出mtu是16384:
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
options=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>
inet 127.0.0.1 netmask 0xff000000
inet6 ::1 prefixlen 128
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
nd6 options=201<PERFORMNUD,DAD>
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether 88:e9:fe:76:dc:57
inet6 fe80::18d4:84fb:fa10:7f8%en0 prefixlen 64 secured scopeid 0x6
inet 192.168.1.8 netmask 0xffffff00 broadcast 192.168.1.255
inet6 240e:d2:495f:9700:182a:c53f:c720:5f63 prefixlen 64 autoconf secured
inet6 240e:d2:495f:9700:d96:48f2:8108:2b33 prefixlen 64 autoconf temporary
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active
en1: flags=8963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1500
options=60<TSO4,TSO6>
ether 7a:00:5c:40:cf:01
media: autoselect <full-duplex>
status: inactive
en2: flags=8963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1500
options=60<TSO4,TSO6>
ether 7a:00:5c:40:cf:00
media: autoselect <full-duplex>
status: inactive
......
b. 服務端代碼和粘包時一樣,將客戶端代碼改為發送一個超過16384字節的字符串,假設使用UTF-8編碼的中文字符一個文字3個字節,那么就需要發送一個大約5461字的字符串,TCP才會拆包,為了篇幅不會太長,發送的字符串我只用一小段文字代替。客戶端代碼如下:
public class SocketClient { private final static String CONTENT = "這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很.....長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串";//測試時大於5461文字,由於篇幅所限,只用這一段作為代表 public static void main(String[] args) throws Exception { // 要連接的服務端IP地址和端口 String host = "127.0.0.1"; int port = 55533; // 與服務端建立連接 Socket socket = new Socket(host, port); // 建立連接后獲得輸出流 OutputStream outputStream = socket.getOutputStream(); outputStream.write(CONTENT.getBytes("UTF-8")); Thread.sleep(20000); outputStream.close(); socket.close(); } }
c. 和粘包的代碼示例一樣,先運行原來的的服務端代碼,接着運行客戶端代碼,看服務端的打印輸出。
server將一直等待連接的到來
len = 22328, content: 這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很.....長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串這是一個很長很長的字符串...(有22328字節數組的文字)
通過輸出的log,可發現客戶端發送的字符串並沒有在服務端被拆開,而是一次讀取了客戶端發送的完整字符串。是不是就沒有被拆包呢,其實不是的,這是因為字符串被分拆成兩個TCP報文,發送到了服務端的緩沖數據流中,服務端一次性讀取了流中的數據,顯示的結果就是兩個tcp數據報串接在一起了。我們可以通過tcpdump抓包查看數據的傳送細節:
在控制台輸入sudo tcpdump -i lo0 'port 55533',作用是監聽回環網卡lo0上在55533端口傳輸的數據包,有從這個端口出入的數據包都會被抓獲並打印出來,這個命令需要管理員權限,輸入用戶密碼后,開始監聽數據。這時我們按照剛才的測試步驟重新運行一遍,抓包的結果如下:
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes
23:15:44.641208 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [S], seq 2331897419, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 261991443 ecr 0,sackOK,eol], length 0
23:15:44.641261 IP 192.168.1.8.55533 > 192.168.1.8.58748: Flags [S.], seq 3403812509, ack 2331897420, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 261991443 ecr 261991443,sackOK,eol], length 0
23:15:44.641270 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [.], ack 1, win 6379, options [nop,nop,TS val 261991443 ecr 261991443], length 0
23:15:44.641279 IP 192.168.1.8.55533 > 192.168.1.8.58748: Flags [.], ack 1, win 6379, options [nop,nop,TS val 261991443 ecr 261991443], length 0
23:15:44.644808 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [.], seq 1:16333, ack 1, win 6379, options [nop,nop,TS val 261991446 ecr 261991443], length 16332
23:15:44.644812 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [P.], seq 16333:22329, ack 1, win 6379, options [nop,nop,TS val 261991446 ecr 261991443], length 5996
23:15:44.644835 IP 192.168.1.8.55533 > 192.168.1.8.58748: Flags [.], ack 22329, win 6030, options [nop,nop,TS val 261991446 ecr 261991446], length 0
- 第三行中,客戶端發起連接請求,options參數中有一個mss 16344的參數,就表示連接建立后,客戶端能接收的最大TCP報文大小,超過后就會被拆包分開傳送;
- 前四行都是兩端的連接過程;
- 第五行客戶端口58748向服務端口55533傳輸了16332字節大小的數據包;
- 第六行客戶端口58748向服務端口55533傳輸了5996字節大小的數據包;
從抓包過程就能看出,客戶端發送一個字符串,被拆成了兩個TCP數據報進行傳輸。
解決方案
對於粘包的情況,要對粘在一起的包進行拆包。對於拆包的情況,要對被拆開的包進行粘包,即將一個被拆開的完整應用包再組合成一個完整包。比較通用的做法就是每次發送一個應用數據包前在前面加上四個字節的包長度值,指明這個應用包的真實長度。如下圖就是應用數據包格式。
下面我修改前文的代碼示例,來實現解決拆包粘包問題,有兩種實現方式: 1. 一種方式是引入netty庫,netty封裝了多種拆包粘包的方式,只需要對接口熟悉並調用即可,減少自己處理數據協議的繁瑣流程; 2. 自己寫協議封裝和解析流程,相當於實現了netty庫拆粘包的簡易版本,本篇文章是為了學習需要,就通過這個方式實現:
a. 客戶端。每次發送一個字符串前,都將字符串轉為字節數組,在原數據字節數組前再加上一個四個字節的代表該數據的長度,然后將組合的字節數組發送出去;
public class SocketClient { public static void main(String[] args) throws Exception { // 要連接的服務端IP地址和端口 String host = "127.0.0.1"; int port = 55533; // 與服務端建立連接 Socket socket = new Socket(host, port); // 建立連接后獲得輸出流 OutputStream outputStream = socket.getOutputStream(); String message = "這是一個整包!!!"; byte[] contentBytes = message.getBytes("UTF-8"); System.out.println("contentBytes.length = " + contentBytes.length); int length = contentBytes.length; byte[] lengthBytes = Utils.int2Bytes(length); byte[] resultBytes = new byte[4 + length]; System.arraycopy(lengthBytes, 0, resultBytes, 0, lengthBytes.length); System.arraycopy(contentBytes, 0, resultBytes, 4, contentBytes.length); for (int i = 0; i < 10; i++) { outputStream.write(resultBytes); } Thread.sleep(20000); outputStream.close(); socket.close(); } } public final class Utils { //int數值轉為字節數組 public static byte[] int2Bytes(int i) { byte[] result = new byte[4]; result[0] = (byte) (i >> 24 & 0xFF); result[1] = (byte) (i >> 16 & 0xFF); result[2] = (byte) (i >> 8 & 0xFF
