一个真实的 HTTP POST 请求长这样
vbnet
POST /api/user/login HTTP/1.1
Host: api.xxx.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Accept: application/json, text/plain, */*
Content-Type: application/json; charset=UTF-8
Content-Length: 56
Connection: keep-alive
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Cookie: SESSIONID=abc123456; userId=1001
Origin: https://www.xxx.com
Referer: https://www.xxx.com/login
{"username":"zhangsan","password":"123456","rememberMe":true}
HTTP 请求固定由 3 部分组成:
1. 请求行(第一行)
sql
GET /user HTTP/1.1
- 方法:GET / POST / PUT / DELETE
- 路径:/user
- 协议版本:HTTP/1.1
2. 请求头(Header)
makefile
Host: api.xxx.com
Content-Type: application/json
Authorization: Bearer xxxxx
键值对格式,告诉服务器:我是谁、我要什么、我发什么格式、长度多少...
3. 空行(必须有!)
(这里空一行)
HTTP 规定:头和体之间必须空一行,服务器读到空行才开始读 body。
4. 请求体(Body)
json
{"id":1,"name":"test"}
GET 一般没有 BodyPOST/PUT 才有
常见问题总结:
1 Accept与Content-Type区别
Accept: application/json, text/plain, */*
-
作用:客户端向服务器声明「我能理解的响应数据格式优先级」
- 优先要
application/json格式的响应 - 其次可以接受
text/plain纯文本 */*兜底:任何格式我都能兼容
- 优先要
-
它只管「响应 」,完全不涉及「请求体」的格式。
Content-Type: application/json; charset=UTF-8
-
作用:客户端向服务器声明「我这次请求的请求体(body)是什么格式」
- 明确告诉服务器:我发的 body 是 JSON 格式,编码是 UTF-8
- 服务器收到后,会用对应的解析器(比如 JSON 解析器)来处理请求体,而不是用表单、XML 等其他解析器
-
它只管「请求体」,和服务器返回什么格式的响应完全无关。
2 http中请求头(Request Headers)组成主要有什么?
Host
makefile
Host: api.xxx.com
- 必须有
- 表示你要访问的服务器域名
- 一台服务器可以有多个网站,靠 Host 区分
User-Agent
css
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...
- 客户端身份标识
- 服务器知道你是:浏览器 / Java / 小程序 / App / 爬虫
Accept
bash
Accept: application/json
- 告诉服务器:我希望你返回 JSON 格式给我
- 不写也可以,但规范要写
Content-Type(最重要之一)
css
Content-Type: application/json; charset=UTF-8
- 表示请求体 body 是什么格式
- POST 必须带,否则后端不知道怎么解析
常见 3 种:
application/json→ JSON 格式(现在最常用)application/x-www-form-urlencoded→ 普通表单multipart/form-data→ 文件上传
Content-Length
css
Content-Length: 56
- 请求体 body 的字节长度
- 服务器读到这么多字节就知道 body 结束了
- 你用 Hutool/OkHttp 会自动计算,不用手写
Connection
makefile
Connection: keep-alive
keep-alive:复用 TCP 连接,不立即断开close:一次请求完就关闭 TCP- HTTP/1.1 默认 keep-alive
Authorization
makefile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
- 身份凭证
- 登录令牌、Token
- 接口鉴权专用
Cookie
ini
Cookie: SESSIONID=abc123; userId=1001
- 浏览器 / 客户端存储的会话信息
- 用来维持登录状态
Origin
arduino
Origin: https://www.xxx.com
- 表示当前请求来自哪个域名
- 跨域 CORS 核心判断依据
Referer
arduino
Referer: https://www.xxx.com/login
- 从哪个页面跳过来的
- 防盗链、日志统计用
3 开发中最常用的 MIME 类型分类详解
文本类(最常用,接口开发核心)
| MIME 类型 | 说明 | 典型场景 |
|---|---|---|
application/json |
JSON 格式数据,前后端接口的「标准格式」 | RESTful 接口的请求 / 响应体、AJAX 数据传输 |
text/plain |
纯文本,无格式 | 简单字符串响应、日志、纯文本文件 |
text/html |
HTML 网页 | 浏览器打开网页、服务端渲染页面返回 |
application/xml / text/xml |
XML 格式数据 | 传统 SOAP 接口、银行 / 金融系统的老接口 |
application/x-www-form-urlencoded |
表单默认编码格式 | 普通 HTML 表单提交(key=value 格式) |
multipart/form-data |
表单二进制编码 | 文件上传、带文件的表单提交 |
text/css |
CSS 样式表 | 网页样式文件 |
application/javascript |
JavaScript 脚本 | 网页 JS 文件 |
二进制 / 文件类(文件传输、下载常用)
| MIME 类型 | 说明 | 典型场景 |
|---|---|---|
application/octet-stream |
通用二进制流 | 任意未知格式的文件下载、强制浏览器下载 |
application/pdf |
PDF 文件 | PDF 预览 / 下载 |
image/jpeg / image/png / image/gif |
图片格式 | 图片展示、上传 |
audio/mpeg / video/mp4 |
音视频格式 | 音视频播放 |
application/zip / application/x-rar-compressed |
压缩包 | 压缩文件下载 |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
Excel (.xlsx) | Excel 文件下载 |
application/msword |
Word (.doc) | Word 文件下载 |
其他高频实用类型
| MIME 类型 | 说明 | 典型场景 |
|---|---|---|
*/* |
通配符,代表「所有格式都接受」 | Accept 头的兜底配置,兼容任意响应 |
application/problem+json |
JSON 格式的错误详情 | REST 接口的标准化错误响应(RFC 7807) |
application/json;charset=UTF-8 |
带编码的 JSON | 明确指定 JSON 的字符编码,避免乱码 |
4 在head与body中间必须要有一个空行
(这里是空行,不能少)
作用:
- 头和体的分隔符
- 服务器读到空行,就知道:接下来全是 body 内容
5 Java 原生 Socket 手动拼接 HTTP POST 报文 + 生产级代码
java
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/**
* 原生 Socket 手动拼接 HTTP POST 请求
* 这就是所有 HTTP 工具的底层原理!
*/
public class ManualHttpPostBySocket {
// 生产环境必须配置的超时
private static final int CONNECT_TIMEOUT = 3000;
private static final int SO_TIMEOUT = 5000;
public static String sendPost(String host, int port, String path, String jsonBody) {
// 1. 拼接完整 HTTP POST 报文(最关键!)
String httpRequest = buildHttpRequest(host, path, jsonBody);
// 2. 通过 Socket(TCP) 发送
try (Socket socket = new Socket()) {
// 连接超时
socket.connect(new java.net.InetSocketAddress(host, port), CONNECT_TIMEOUT);
// 读取超时
socket.setSoTimeout(SO_TIMEOUT);
try (OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) {
// 发送 HTTP 协议报文
out.write(httpRequest.getBytes(StandardCharsets.UTF_8));
out.flush();
// 读取服务器返回的 HTTP 响应
byte[] buffer = new byte[8192];
int len = in.read(buffer);
if (len > 0) {
return new String(buffer, 0, len, StandardCharsets.UTF_8);
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 手动拼接完整 HTTP POST 报文
* 这里就是 HTTP 协议的真面目!
*/
private static String buildHttpRequest(String host, String path, String jsonBody) {
// HTTP 请求规定:头和体之间必须空一行 \r\n\r\n
return "POST " + path + " HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"User-Agent: Java-Manual-Http\r\n" +
"Content-Type: application/json; charset=UTF-8\r\n" +
"Content-Length: " + jsonBody.getBytes(StandardCharsets.UTF_8).length + "\r\n" +
"Connection: close\r\n" +
"\r\n" + // 必须空行!分割 Header 和 Body
jsonBody;
}
public static void main(String[] args) {
// 测试:发送 POST 请求
String host = "jsonplaceholder.typicode.com"; // 免费测试接口
int port = 80;
String path = "/posts";
String json = "{"title":"手动拼HTTP","body":"这是Socket发的","userId":1}";
String response = sendPost(host, port, path, json);
System.out.println("=== 服务器返回的完整 HTTP 响应 ===");
System.out.println(response);
}
}
你代码发送出去的 HTTP 长这样:
css
POST /posts HTTP/1.1
Host: jsonplaceholder.typicode.com
User-Agent: Java-Manual-Http
Content-Type: application/json; charset=UTF-8
Content-Length: 62
Connection: close
{"title":"手动拼HTTP","body":"这是Socket发的","userId":1}
服务器返回的 HTTP 响应长这样:
makefile
HTTP/1.1 201 Created
Date: ...
Content-Type: application/json
Content-Length: ...
{
"id": 101,
"title": "手动拼HTTP"
}
HTTP 就是一段严格格式的文本
你完全手写拼出来,发给服务器。
HTTP 底层就是 TCP (Socket)
没有任何魔法
Socket= TCP 连接- 你写的 HTTP 报文 = 数据
Hutool/HttpClient 只是帮你拼报文而已
它们底层和这段代码一模一样
- 拼接请求头
- 处理 Content-Length
- 处理空行
- 解析响应
6 TCP 和 HTTP 是什么关系
HTTP 是跑在 TCP 上面的 "业务协议",TCP 只是底层传输通道,本身没有任何格式要求。可以只用 TCP 裸奔,也可以在 TCP 上跑 HTTP、MQTT、Redis、MySQL 等各种协议。TCP 本身没有「请求 / 响应」的概念 ,它只是一个可靠的字节流传输管道。
① TCP(底层通道)
- 只管把字节流可靠地从 A 传到 B
- 不关心你传的是文字、图片、JSON 还是乱码
- 没有格式要求:你发
123、abc、hello都行 - 这叫裸 TCP 通信 / 自定义协议
② HTTP(上层应用协议)
-
是大家约定好的一种消息格式
-
必须长这样:
plaintext
makefilePOST /xxx HTTP/1.1 Host: xxx Content-Type: xxx 内容 -
服务器按这个格式解析,才叫 HTTP
-
HTTP 底层一定是 TCP
-
总结就是: HTTP 是「应用层协议」,TCP 是「传输层协议」;HTTP 必须基于 TCP(或 TLS/SSL 封装的 TCP,即 HTTPS)来传输,永远不能脱离 TCP 单独存在。
-
TCP 通讯代码示例(银行 8583 风格)
scss// 银行核心系统 TCP 服务端(简化版) @Slf4j public class TcpServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); log.info("银行TCP服务端启动,端口8080"); while (!Thread.currentThread().isInterrupted()) { Socket socket = serverSocket.accept(); // 长连接,每个连接一个线程处理 new Thread(() -> handleClient(socket)).start(); } } private static void handleClient(Socket socket) { try (InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream()) { // 银行标准:长度域解决粘包,前4字节存报文长度 byte[] lengthBuf = new byte[4]; while (in.read(lengthBuf) == 4) { int length = ByteBuffer.wrap(lengthBuf).getInt(); byte[] dataBuf = new byte[length]; in.readFully(dataBuf); // 读完整报文 // 处理8583报文:解密、验签、业务处理 byte[] response = processIso8583(dataBuf); // 回写响应:先写长度,再写数据 out.write(ByteBuffer.allocate(4).putInt(response.length).array()); out.write(response); out.flush(); } } catch (Exception e) { log.error("TCP连接异常", e); } finally { try { socket.close(); } catch (IOException e) { log.error("关闭TCP连接失败", e); } } } private static byte[] processIso8583(byte[] data) { // 银行8583报文处理逻辑:SM2解密、验签、业务处理 return new byte[0]; } }
案例二 与银行前置机采取NC模式TCP交互代码
生产级 TCP 客户端(银行前置 / 银联前置专用)
java
import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.util.concurrent.locks.ReentrantLock;
/**
* 银行前置 / 银联前置 TCP 客户端(生产级)
* 支持:长连接、自动重连、4字节长度域、线程安全、超时控制
*/
@Slf4j
public class BankFrontTcpClient {
// 基础配置
private final String ip;
private final int port;
private final int connectTimeout = 5000;
private final int readTimeout = 15000;
// TCP 连接
private Socket socket;
private DataOutputStream out;
private DataInputStream in;
// 线程安全(高并发必须加锁)
private final ReentrantLock lock = new ReentrantLock();
// 连接状态
private volatile boolean connected = false;
public BankFrontTcpClient(String ip, int port) {
this.ip = ip;
this.port = port;
}
// -------------------------------------------------------------------------
// 建立连接(长连接)
// -------------------------------------------------------------------------
public void connect() throws Exception {
if (connected && socket != null && socket.isConnected() && !socket.isClosed()) {
return;
}
close();
// 创建 TCP Socket
socket = new Socket();
socket.setTcpNoDelay(true); // 银行前置必须开(低延迟)
socket.setKeepAlive(true); // 长连接保活
socket.setSoTimeout(readTimeout); // 读取超时
// 连接银行前置
socket.connect(new java.net.InetSocketAddress(ip, port), connectTimeout);
// 获取流
out = new DataOutputStream(socket.getOutputStream());
in = new DataInputStream(socket.getInputStream());
connected = true;
log.info("银行前置 TCP 连接成功 → {}:{}", ip, port);
}
// -------------------------------------------------------------------------
// 发送报文 + 接收响应(核心方法)
// 规则:4字节长度头 + 报文
// -------------------------------------------------------------------------
public byte[] sendAndReceive(byte[] requestData) throws Exception {
lock.lock(); // 高并发必须加锁
try {
// 1. 确保连接可用
connect();
// 2. 发送:先发送4字节长度,再发送内容
byte[] lengthBytes = ByteBuffer.allocate(4).putInt(requestData.length).array();
out.write(lengthBytes);
out.write(requestData);
out.flush();
log.info("发送成功,报文长度:{}", requestData.length);
// 3. 接收:先读4字节长度,再读内容
byte[] lenBuf = new byte[4];
int readLen = in.read(lenBuf);
if (readLen != 4) {
throw new IOException("读取长度头失败,连接已断开");
}
// 解析报文总长度
int msgLen = ByteBuffer.wrap(lenBuf).getInt();
if (msgLen <= 0 || msgLen > 1024 * 1024 * 10) { // 10M 限制
throw new IOException("报文长度非法:" + msgLen);
}
// 读取完整报文
byte[] response = new byte[msgLen];
in.readFully(response);
log.info("接收成功,响应长度:{}", msgLen);
return response;
} catch (SocketTimeoutException e) {
log.error("接收银行前置响应超时");
close();
throw e;
} catch (Exception e) {
log.error("通讯异常,准备重连", e);
close();
throw e;
} finally {
lock.unlock();
}
}
// -------------------------------------------------------------------------
// 关闭连接
// -------------------------------------------------------------------------
public void close() {
connected = false;
try {
if (in != null) in.close();
if (out != null) out.close();
if (socket != null) socket.close();
} catch (Exception e) {
log.error("关闭连接异常", e);
}
}
}
三、使用示例(对接银联 / 银行前置)
java
public class TestBankFront {
public static void main(String[] args) throws Exception {
// 1. 创建客户端(银行前置地址)
BankFrontTcpClient client = new BankFrontTcpClient("192.168.1.100", 8888);
try {
// 2. 你的业务报文(8583 / 加密后报文 / 自定义二进制)
byte[] sendData = "你的银行报文或加密串".getBytes("UTF-8");
// 3. 发送并接收
byte[] respData = client.sendAndReceive(sendData);
// 4. 处理响应
String resp = new String(respData, "UTF-8");
System.out.println("银行前置响应:" + resp);
} finally {
client.close();
}
}
}
7 TCP 概述 -- 三次握手 四次挥手
一、TCP 概述:
TCP 是面向连接、可靠、全双工、字节流的传输层协议。核心靠三样保证可靠:
- 序列号 + 确认号
- 超时重传
- 滑动窗口、流量控制、拥塞控制
连接过程分为三部分:
- 三次握手(建立连接)
- 数据传输
- 四次挥手(断开连接)
二、TCP 头部关键字段
- SYN:同步序列号,用于建立连接
- ACK:确认号有效
- FIN:发送方无数据要发,请求关闭
- RST:强制断开连接(异常关闭)
- Seq:本方发送数据的起始字节编号
- Ack:期望收到对方下一个字节的编号 = 对方 Seq + 数据长度
- Window:滑动窗口大小,流量控制
- 源端口 / 目的端口:对应快递的「寄件人电话 / 收件人电话」,用来区分同一个主机上的不同应用(比如浏览器、微信、游戏)。
- 校验和:检查数据在传输中有没有损坏,就像快递的「防伪码」,坏了就丢弃。
- PSH:推送位,告诉对方「立刻把数据交给应用层,别缓存」,比如 Telnet 交互。
三、三次握手(Connection Establish)
- 目的
- 同步双方初始序列号 ISN
- 确认双方发送能力、接收能力都正常
- 防止已失效的连接请求报文再次建立连接,浪费资源
- 详细过程
初始状态:
- 客户端:CLOSED
- 服务端:LISTEN
第一次握手(客户端 → 服务端)
- 客户端发送:SYN=1, Seq=client_isn
- 不携带数据
- 客户端状态:CLOSED → SYN_SENT
第二次握手(服务端 → 客户端)
-
服务端回复:SYN=1, ACK=1, Seq=server_isn, Ack=client_isn+1
-
服务端状态:LISTEN → SYN_RCVD
-
含义:
- ACK:我收到你的 SYN
- SYN:我也同步我的序列号
第三次握手(客户端 → 服务端)
- 客户端发送:ACK=1, Seq=client_isn+1, Ack=server_isn+1
- 客户端状态:SYN_SENT → ESTABLISHED
- 服务端收到后:SYN_RCVD → ESTABLISHED
连接建立完成,双方进入数据传输状态。
- 为什么必须是三次?不能两次?
-
两次只能确认:客户端能发、服务端能收能发
-
但服务端不知道客户端能不能正常接收
-
若只有两次握手,网络延迟导致旧 SYN 到达,服务端直接建立连接,会造成资源浪费
-
如果是「两次握手」:服务器收到 SYN 就直接建连
-
服务器收到这个迟到的
SYN_旧,会直接认为是新的连接请求 ,立刻分配内核资源(socket 缓冲区、进程 / 线程、定时器等),给客户端回一个 SYN+ACK 包,然后就把这个连接设为ESTABLISHED(已建立)状态,等待客户端发数据。 -
但问题来了:
- 客户端 A 早就完成了上一次连接,并且已经关闭了,根本不会再给这个
SYN_旧回 ACK 包。 - 服务器就会一直等,直到超时才会释放这个连接。
- 如果有大量这种延迟的旧 SYN 包(比如网络抖动、恶意攻击),服务器会被无数个「半开连接」占满资源,正常的新连接请求就会被拒绝,这就是资源浪费 ,严重时会导致拒绝服务(DoS) 。
- 客户端 A 早就完成了上一次连接,并且已经关闭了,根本不会再给这个
- 半连接队列 / 全连接队列
- SYN 队列(半连接) :收到 SYN 但未完成三次握手
- Accept 队列(全连接) :已完成三次握手,等待应用 accept
- SYN 攻击:大量发送 SYN 不回 ACK,占满半连接队列,导致正常请求无法入队
四、数据传输过程(核心机制)
连接建立后双方都是 ESTABLISHED。
- 序列号与确认号规则
- 每发送一字节数据,Seq 自动 +1
- Ack = 对方 Seq + 数据长度
- 收到 Ack 表示之前所有数据都已被正确接收
-
可靠传输机制
-
超时重传发送后未收到 ACK,超时则重传
-
快速重传收到 3 个重复 ACK,不等超时立即重传
-
滑动窗口允许连续发送多个包,提高吞吐量
-
流量控制(滑动窗口) 接收方通过 Window 字段告诉发送方 "我还能收多少"
-
拥塞控制慢启动、拥塞避免、快重传、快恢复防止网络拥塞崩溃
-
正常传输流程
发送方发数据 → 接收方回 ACK → 窗口滑动 → 继续发送无丢包则平稳传输;丢包则触发重传。
五、四次挥手(Connection Termination)
- 为什么是四次?
TCP 是全双工的,也就是:
客户端 → 服务器 是一条路
服务器 → 客户端 是另一条路
要断开连接,两条路都要分别关,所以要挥手 4 次。
简单说就是四步:
-
客户端说:我发完了,我要关我的发送通道
-
服务器说:收到,我知道你关了
-
服务器说:我也发完了,我也要关我的发送通道
-
客户端说:收到,我知道你关了
-
详细过程
第一次挥手:客户端 → 服务器(FIN 包)
发送方:客户端
核心动作:发起关闭请求,告诉服务器「我发完了」
| 字段 | 取值 | 含义 |
|---|---|---|
| FIN | 1 | 核心标志位:表示「我(客户端)已经发完所有数据,要关闭我的发送通道,不再给你发数据了」 |
| ACK | 1(强制) | 表示这个包的确认号有效,是对服务器之前发的最后一个数据的确认 |
| SEQ(序号) | X(例:1000) | 客户端当前的发送序号,这个 FIN 包本身占 1 个序号,所以客户端下一个要发的序号是 X+1 |
| ACK_NUM(确认号) | Y(例:5000) | 表示「我已经收到你(服务器)到序号 Y-1 为止的所有数据,下一个期望收到 Y」,是对服务器之前数据的确认 |
状态变化:
- 客户端进入
FIN_WAIT_1状态:等待服务器确认我的 FIN - 服务器收到 FIN 后,进入
CLOSE_WAIT状态:等待自己发完数据,再关闭
第二次挥手:服务器 → 客户端(ACK 包)
发送方:服务器
核心动作:确认收到客户端的 FIN,告诉客户端「我知道你要关了」
| 字段 | 取值 | 含义 |
|---|---|---|
| FIN | 0 | 表示我(服务器)还没发完数据,暂时不关闭我的发送通道 |
| ACK | 1(强制) | 表示确认号有效,专门用来确认客户端的 FIN |
| SEQ(序号) | Y(例:5000) | 服务器当前的发送序号 |
| ACK_NUM(确认号) | X+1(例:1001) | 核心确认:表示「我已经收到你(客户端)的 FIN(序号 X),下一个期望收到 X+1」,正式确认客户端的关闭请求 |
状态变化:
- 服务器进入
CLOSE_WAIT状态:继续给客户端发剩余数据,发完后再发起自己的 FIN - 客户端收到 ACK 后,进入
FIN_WAIT_2状态:等待服务器发完数据,发起 FIN
第三次挥手:服务器 → 客户端(FIN 包)
发送方:服务器
核心动作:服务器发完数据,发起自己的关闭请求,告诉客户端「我也发完了」
| 字段 | 取值 | 含义 |
|---|---|---|
| FIN | 1 | 核心标志位:表示「我(服务器)也发完所有数据,要关闭我的发送通道,不再给你发数据了」 |
| ACK | 1(强制) | 表示确认号有效,是对客户端之前数据的确认 |
| SEQ(序号) | Y(例:5000) | 服务器当前的发送序号,这个 FIN 包本身占 1 个序号,服务器下一个要发的序号是 Y+1 |
| ACK_NUM(确认号) | X+1(例:1001) | 再次确认客户端的 FIN,保证客户端知道服务器收到了关闭请求 |
状态变化:
- 服务器进入
LAST_ACK状态:等待客户端确认我的 FIN - 客户端收到 FIN 后,进入
TIME_WAIT状态:等待 2MSL(最长报文寿命),确保服务器收到 ACK
第四次挥手:客户端 → 服务器(ACK 包)
发送方:客户端
核心动作:确认收到服务器的 FIN,告诉服务器「我知道你关了,连接彻底断开」
| 字段 | 取值 | 含义 |
|---|---|---|
| FIN | 0 | 表示我(客户端)已经关闭发送通道,不再发数据 |
| ACK | 1(强制) | 表示确认号有效,专门用来确认服务器的 FIN |
| SEQ(序号) | X+1(例:1001) | 客户端当前的发送序号(第一次挥手的 FIN 占了 1 个序号,所以 + 1) |
| ACK_NUM(确认号) | Y+1(例:5001) | 核心确认:表示「我已经收到你(服务器)的 FIN(序号 Y),下一个期望收到 Y+1」,正式确认服务器的关闭请求 |
状态变化:
- 客户端收到服务器的 FIN 后,发完这个 ACK,进入
TIME_WAIT状态,等待 2MSL 后彻底关闭 - 服务器收到这个 ACK 后,直接进入
CLOSED状态,彻底断开连接
补充 2 个零基础必懂的关键问题
- 为什么第二次和第三次挥手不能合并?
- 理论上可以合并(服务器发 ACK+FIN 一起发,看起来像 3 次挥手),但逻辑上还是 4 次
- 不能强制合并的原因:服务器收到客户端的 FIN 后,可能还有数据要发给客户端(比如文件还没传完),必须等数据发完,才能发自己的 FIN所以必须先回 ACK 确认,等数据发完,再发 FIN,分成两步
- 为什么客户端要等 2MSL?
-
MSL 是「最长报文寿命」,网络里的包最多存活 MSL 时间
-
等 2MSL 的原因:
- 确保服务器收到最后一个 ACK:如果 ACK 丢了,服务器会重发 FIN,客户端在 2MSL 内会收到,重新发 ACK
- 防止旧连接的包干扰新连接:等 2MSL,旧连接的所有包都消失了,新连接不会收到旧数据
六、完整状态流转
客户端状态
CLOSED → SYN_SENT → ESTABLISHED → FIN_WAIT1 → FIN_WAIT2 → TIME_WAIT → CLOSED
服务端状态
CLOSED → LISTEN → SYN_RCVD → ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED
TCP 连接的核心状态(三次握手涉及的)
| 状态 | 全称 | 核心含义 | 对应阶段 |
|---|---|---|---|
CLOSED |
Closed | 连接完全关闭,无任何连接 | 初始状态 |
LISTEN |
Listen | 服务器监听中,等待客户端连接请求 | 服务器初始状态 |
SYN_SENT |
SYN Sent | 客户端已发送 SYN,等待服务器的 SYN+ACK | 第一次握手后(客户端) |
SYN_RCVD |
SYN Received | 服务器已收到客户端 SYN,发送了 SYN+ACK,等待客户端 ACK | 第二次握手后(服务器) |
ESTABLISHED |
Established | 连接完全建立,可正常收发数据 | 三次握手完成(双方) |
8 什么是粘包、什么是拆包 以及解决方案
一、先一句话总纲
TCP 是流式协议 ,它只保证字节流可靠到达,不帮你划分消息边界。所以:
- 多个小消息挤在一个 TCP 包里 → 粘包
- 一个大消息被拆成多个 TCP 包 → 拆包
这两个问题永远同时存在,不是二选一。
二、什么是粘包(Packet Coalescing)
现象
客户端连续发 3 次小数据:
helloworld!!
服务器一次就收到 :helloworld!!
→ 这就是粘包。
为什么会粘包?
- Nagle 算法TCP 为了减少小包,会攒一攒再发。你发得快、数据又小,它就合并成一个包发。
- 接收缓冲区机制操作系统接收 TCP 数据时,会先放进缓冲区。你读的时候,一次性把缓冲区里所有数据读出来,自然粘在一起。
三、什么是拆包(Packet Fragmentation)
现象
客户端发一个长消息:I am a very long message that cannot be sent in one TCP packet
服务器分两次甚至多次 才收到:第一次:I am a very long第二次: message that cannot be sent
→ 这就是拆包。
为什么会拆包?
- MSS 限制 TCP 每个包不能无限大,一般 MSS 大约 1460 字节。超过就必须拆。(MSS 是TCP 报文段中「数据部分」的最大长度 ,不包含 TCP 首部和 IP 首部 完整的 IP 数据报 = IP 首部(20
60 字节) + TCP 首部(2060 字节) + TCP 数据(MSS 限制的部分) ) - 滑动窗口、网络层分片路由器、带宽、缓冲区满了都会拆。
- 应用层一次读不完缓冲区你只 read (1024),但数据有 2048,就会拆两次读。
四、粘包 + 拆包同时发生(最真实情况)
客户端发:消息 1:abc消息 2:defghijklmnopqrstuvwxyz(很长)
服务器收到:第一次:abcdefghijk第二次:lmnopqrstuvwxyz
→ 前半段粘包 ,后半段拆包。
五、核心本质
TCP 只认字节流,不认消息。 它不知道你的 "一条消息" 从哪开始、到哪结束。它只管:
- 字节不丢
- 字节不乱
- 字节完整
消息边界,必须应用层自己定义
六、怎么解决粘包拆包?(4 种标准方案)
- 固定长度消息(最简单,但不实用)
每条消息固定 100 字节,不够就补空格。缺点:浪费带宽、不灵活。
- 特殊分隔符(如 \n、\r\n)
发消息时末尾加分隔符,接收方读到分隔符就切分。例子:hello\nworld\ntest\n
优点:简单。缺点:如果消息内容里出现分隔符,就乱套,会将一个完整的信息进行拆分导致错乱.
- 长度域 + 消息体(最常用、最标准)
格式:[长度(4字节)][数据体]
流程:
- 先读 4 字节,得到长度 N
- 再精确读 N 字节
- 一条完整消息就出来了
这是 HTTP、Redis、Dubbo、自定义协议 通用方案。
- 应用层协议(HTTP、WebSocket 等)
它们内部已经帮你处理好了粘包拆包,你不用管。
七、用一个最直观的例子彻底懂
你发快递:
- TCP 就是货车
- 字节就是货物
- 你的 "消息" 就是一箱箱水果
货车不管你几箱,它只装满就走。结果:
- 多箱装一车 → 粘包
- 一箱太大分两车 → 拆包
你必须在箱子上贴:这箱重量:5kg收货方才知道怎么拆分。
→ 这就是长度域方案。
八、一句话终极总结
- 粘包:多条消息挤一起
- 拆包:一条消息被切开
- 原因:TCP 是流式协议,无消息边界
- 解决:应用层自己定义边界(长度域最稳)
生产级别解决方案:
【长度域协议】+ Netty 实现这是互联网公司、金融支付、微服务通信的通用方案。
一、协议格式(生产级必用)
css
[ 长度域(4字节) ][ 实际消息内容 ]
- 长度域:int 类型,固定 4 字节,表示后面消息体的长度
- 消息体:任意字节数组(JSON、Protobuf、二进制都行)
优点:
- 无歧义、无冲突、性能高
- 不会因为内容出现特殊符号而解析失败
- 支持超大包、流式解析
二、Netty 服务端代码(生产可用)
- 启动类
java
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.codec.LengthFieldPrepender;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
public class NettyServer {
public static void main(String[] args) {
NioEventLoopGroup boss = new NioEventLoopGroup(1);
NioEventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 100)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// 生产级粘包拆包核心解码器
ch.pipeline().addLast(
new LengthFieldBasedFrameDecoder(
1024 * 1024, // 最大帧长度(防OOM)
0, // 长度域偏移
4, // 长度域占4字节
0, // 长度域调整值
4 // 跳过长度域,只取内容
)
);
// 编码器:自动给消息加长度头
ch.pipeline().addLast(new LengthFieldPrepender(4));
// 业务处理器
ch.pipeline().addLast(new ServerBizHandler());
}
});
ChannelFuture f = b.bind(8888).sync();
f.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
}
- 业务处理器
scala
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import java.nio.charset.StandardCharsets;
public class ServerBizHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
// 这里拿到的已经是**完整一条消息**,无粘包无拆包
String body = msg.toString(StandardCharsets.UTF_8);
System.out.println("收到完整消息:" + body);
// 回写同样自动加长度头
ctx.writeAndFlush(ctx.alloc().buffer().writeBytes(("收到:" + body).getBytes(StandardCharsets.UTF_8)));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
三、Netty 客户端代码(生产可用)
java
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.codec.LengthFieldPrepender;
public class NettyClient {
public static void main(String[] args) {
NioEventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// 同样解决粘包拆包
ch.pipeline().addLast(
new LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4, 0, 4)
);
ch.pipeline().addLast(new LengthFieldPrepender(4));
ch.pipeline().addLast(new ClientBizHandler());
}
});
ChannelFuture f = b.connect("127.0.0.1", 8888).sync();
// 连续发多条,测试粘包
for (int i = 0; i < 10; i++) {
String msg = "hello netty " + i;
f.channel().writeAndFlush(
f.channel().alloc().buffer().writeBytes(msg.getBytes())
);
}
f.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
group.shutdownGracefully();
}
}
}
客户端业务处理器
scala
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import java.nio.charset.StandardCharsets;
public class ClientBizHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
String body = msg.toString(StandardCharsets.UTF_8);
System.out.println("客户端收到:" + body);
}
}
9 全双工是什么意思
两边可以同时说话,互不干扰。
用最通俗的话讲
- 单工:只能单向说话例:广播、电视 → 只能它发,你听。
- 半双工 :同一时间只能一方说话例:对讲机你说完 "收到",松开按键,对方才能说话。不能同时说。
- 全双工 :两边可以同时 收发例:打电话、微信语音通话你说话的同时,也能听到对方说话,双向同时进行。
放到 TCP 里是什么意思?
TCP 是全双工协议:
- 客户端 → 服务器 可以发数据
- 服务器 → 客户端 也可以发数据
- 而且这两个方向完全独立、可以同时进行
这就是为什么:
- 客户端有一套自己的序号(发往服务器)
- 服务器有另一套自己的序号(发往客户端)
- 两套序号互不影响
因为是两条独立的 "单向通道" 绑在一起,变成一条全双工连接。