前言

在日常业务开发中,我们更多使用 HTTP、WebSocket、RPC 或消息队列,很少直接操作 Socket。但这些上层协议和框架最终都要通过操作系统提供的网络能力传输数据,Socket 就是应用程序使用这些能力的重要接口。

直接使用 Socket 时,建立连接只是第一步。真正困难的部分通常在连接之后:如何区分多条消息、如何传输 JSON、如何关联请求和响应、如何检测断线、如何限制恶意数据,以及什么时候应该放弃自定义协议,改用成熟的 HTTP、WebSocket 或 Netty。

本文以 Java TCP Socket 为主线,从基本通信讲到一套可以继续扩展的协议设计。

Socket 是什么

Socket 可以理解为应用程序与网络协议栈之间的通信端点。一条 TCP 连接由以下信息共同标识:

1
客户端 IP + 客户端端口 + 服务端 IP + 服务端端口

Java 中常见的 Socket API 有:

类型 协议 作用
Socket TCP 发起连接,或表示服务端已经接收的一条连接
ServerSocket TCP 绑定并监听端口,通过 accept() 接收连接
DatagramSocket UDP 发送和接收 UDP 数据报
SocketChannel TCP/NIO 支持阻塞或非阻塞的 TCP 通信
ServerSocketChannel TCP/NIO 支持非阻塞监听和接收连接
Selector NIO 用少量线程监听多个 Channel 的就绪事件

需要区分几个容易混淆的概念:

1
2
3
4
5
TCP / UDP:传输层协议
Socket:应用程序使用网络协议栈的编程接口
BIO / NIO:Java 处理 I/O 的编程模型
HTTP / WebSocket:建立在 TCP 等传输能力之上的应用层协议
Netty:封装 NIO、连接管理和编解码能力的网络框架

TCP 和 UDP 怎么选择

TCP 的特点

TCP 在通信前需要建立连接,并提供可靠、有序的字节流传输。它能够处理丢包重传、流量控制和拥塞控制,但不保留应用消息边界。

TCP 适合:

  • 聊天和即时通信长连接。
  • 物联网设备控制和数据上报。
  • 自定义 RPC、数据库协议和消息中间件协议。
  • 文件传输、远程控制和需要完整到达的数据同步。
  • WebSocket、HTTP/1.1 和 HTTP/2 等上层协议的底层传输。

UDP 的特点

UDP 不需要建立连接,以独立数据报为单位发送数据。它保留数据报边界,但不保证送达、顺序和唯一性,业务层需要自行决定是否进行确认、重传和去重。

UDP 适合:

  • 实时音视频和游戏状态同步。
  • DNS 查询。
  • 局域网广播、组播和设备发现。
  • 允许少量丢失的监控指标或遥测数据。
  • 对延迟比完整性更敏感的实时数据。

两者的核心差异如下:

对比项 TCP UDP
是否建立连接
数据形式 连续字节流 独立数据报
消息边界 不保留 保留
可靠性 可靠、有序、自动重传 可能丢失、重复或乱序
额外开销 相对较高 相对较低
常见场景 业务消息、文件、长连接 音视频、游戏、广播、发现

TCP Socket 建立连接后如何通信

TCP 连接建立后,双方都会获得输入流和输出流。TCP 是全双工的,客户端和服务端可以同时发送与接收数据。

flowchart LR
    CO[客户端 OutputStream] --> SI[服务端 InputStream]
    SO[服务端 OutputStream] --> CI[客户端 InputStream]

服务端的基本流程是:

1
2
3
4
5
6
创建 ServerSocket
绑定并监听端口
调用 accept 等待连接
为连接创建输入流和输出流
循环读取、解码、处理和回复消息
连接关闭后释放资源

客户端的基本流程是:

1
2
3
4
5
创建 Socket
连接服务端 IP 和端口
通过输出流发送消息
通过输入流接收响应
完成通信后关闭连接
sequenceDiagram
    participant C as 客户端
    participant S as 服务端

    S->>S: bind + listen
    C->>S: 建立 TCP 连接
    S->>S: accept 返回 Socket
    C->>S: 发送请求帧
    S->>S: 解码并处理请求
    S-->>C: 返回响应帧
    C->>S: 持续发送后续消息或心跳
    C-xS: 任意一方关闭连接

为什么不能直接把一次 read 当作一条消息

TCP 只传输连续字节,不知道 JSON、聊天消息或业务请求的边界。客户端连续执行两次 write()

1
2
write(JSON_A)
write(JSON_B)

服务端可能一次读到 JSON_A + JSON_B,也可能先读到 JSON_A 的一部分,随后才读到剩余内容。这通常被称为粘包和拆包。

flowchart TD
    A[发送端依次写入消息 A 和消息 B] --> B[TCP 连续字节流]
    B --> C1[接收结果:A 与 B 一次读到]
    B --> C2[接收结果:A 被分成多次读取]
    B --> C3[接收结果:读到完整 A 和部分 B]

这不是 TCP 的异常,而是应用层没有定义消息边界。正确做法是设计明确的分帧协议。

常见的消息分帧方案

固定长度

每条消息都占用固定字节数,不足部分填充。

优点是解析简单,适合字段固定的设备协议;缺点是浪费空间,也不适合长度变化较大的 JSON。

分隔符

使用换行符或其他特殊字符表示一条消息结束,例如 NDJSON:

1
2
{"type":"CHAT","content":"你好"}\n
{"type":"PING"}\n

它适合日志、命令行和简单文本协议。如果正文也可能包含分隔符,就必须转义或改用其他方案。

长度字段加消息正文

先发送固定长度的消息头,在消息头中声明正文长度,再发送正文:

1
2
3
+------------------+------------------------+
| 4 字节正文长度 | 指定长度的 JSON 字节 |
+------------------+------------------------+

这种方式支持文本和二进制数据,解析稳定,是自定义 TCP 协议中最常见的方案。

使用成熟协议

HTTP、WebSocket、MQTT、gRPC 等协议已经定义好消息边界、状态语义和生态工具。普通业务接口不需要为了“性能”就从零设计 TCP 协议,只有在设备兼容、特殊长连接、极致报文控制或已有行业协议等场景下,才值得承担自定义协议的长期成本。

使用长度字段传递 JSON

接收端无法提前知道 JSON 有多少字节,因此发送端需要先把 JSON 转为 UTF-8 字节数组,再发送字节长度。

不要使用 json.length() 作为报文长度。它得到的是 Java 字符数量,而协议需要的是编码后的字节数量。中文字符经过 UTF-8 编码后通常占多个字节。

发送一条 JSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final int MAX_FRAME_LENGTH = 1024 * 1024;

static void sendJson(DataOutputStream output, String json)
throws IOException {
byte[] body = json.getBytes(StandardCharsets.UTF_8);

if (body.length == 0 || body.length > MAX_FRAME_LENGTH) {
throw new IOException("非法消息长度:" + body.length);
}

output.writeInt(body.length);
output.write(body);
output.flush();
}

DataOutputStream.writeInt() 会写入 4 字节大端整数。假设 JSON 编码后长度为 48 字节,网络中的消息结构就是:

1
00 00 00 30 + 48 字节 JSON 正文

接收一条 JSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final int MAX_FRAME_LENGTH = 1024 * 1024;

static String receiveJson(DataInputStream input)
throws IOException {
int length = input.readInt();

if (length <= 0 || length > MAX_FRAME_LENGTH) {
throw new IOException("非法消息长度:" + length);
}

byte[] body = new byte[length];
input.readFully(body);

return new String(body, StandardCharsets.UTF_8);
}

这里必须使用 readFully(),不能假设一次 read() 会填满整个数组。readFully() 会持续读取,直到获得指定数量的字节或连接提前断开。

需要特别注意:

  • 发送端和接收端必须使用相同字节序,示例统一为大端。
  • 双方必须使用相同字符编码,示例统一为 UTF-8。
  • 接收端必须先校验长度上限,再分配数组。
  • 同一条连接上的所有消息必须遵循相同的分帧规则。
  • 不建议使用 writeUTF() 设计通用协议,它使用修改版 UTF-8,且长度存在约 64 KB 限制。

一个可运行的 Java Socket 示例

下面使用“4 字节长度 + JSON 正文”的协议,实现一个支持连续请求的简单服务端。

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class JsonSocketServer {

private static final int PORT = 8080;
private static final int MAX_FRAME_LENGTH = 1024 * 1024;

public static void main(String[] args) throws IOException {
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("服务端启动,端口:" + PORT);

while (true) {
Socket socket = serverSocket.accept();
socket.setSoTimeout(30_000);

// Java 21 可以用虚拟线程处理阻塞式 Socket。
Thread.startVirtualThread(() -> handle(socket));
}
}
}

private static void handle(Socket socket) {
String remote = socket.getRemoteSocketAddress().toString();

try (
socket;
DataInputStream input =
new DataInputStream(socket.getInputStream());
DataOutputStream output =
new DataOutputStream(socket.getOutputStream())
) {
while (true) {
String request = receiveJson(input);
System.out.println("收到 " + remote + ":" + request);

String response = """
{"type":"RESPONSE","success":true}
""".trim();

sendJson(output, response);
}
} catch (EOFException e) {
System.out.println("客户端断开:" + remote);
} catch (IOException e) {
System.err.println("连接异常 " + remote + ":" + e.getMessage());
}
}

private static void sendJson(DataOutputStream output, String json)
throws IOException {
byte[] body = json.getBytes(StandardCharsets.UTF_8);
validateLength(body.length);
output.writeInt(body.length);
output.write(body);
output.flush();
}

private static String receiveJson(DataInputStream input)
throws IOException {
int length = input.readInt();
validateLength(length);

byte[] body = new byte[length];
input.readFully(body);
return new String(body, StandardCharsets.UTF_8);
}

private static void validateLength(int length) throws IOException {
if (length <= 0 || length > MAX_FRAME_LENGTH) {
throw new IOException("非法消息长度:" + length);
}
}
}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class JsonSocketClient {

private static final int MAX_FRAME_LENGTH = 1024 * 1024;

public static void main(String[] args) throws IOException {
try (Socket socket = new Socket()) {
socket.connect(
new InetSocketAddress("127.0.0.1", 8080),
5_000
);
socket.setSoTimeout(10_000);

try (
DataInputStream input =
new DataInputStream(socket.getInputStream());
DataOutputStream output =
new DataOutputStream(socket.getOutputStream())
) {
String request = """
{
"requestId": "req-1001",
"type": "CHAT",
"data": {
"content": "你好,服务端"
}
}
""";

sendJson(output, request);
System.out.println("服务端响应:" + receiveJson(input));
}
}
}

private static void sendJson(DataOutputStream output, String json)
throws IOException {
byte[] body = json.getBytes(StandardCharsets.UTF_8);
validateLength(body.length);
output.writeInt(body.length);
output.write(body);
output.flush();
}

private static String receiveJson(DataInputStream input)
throws IOException {
int length = input.readInt();
validateLength(length);

byte[] body = new byte[length];
input.readFully(body);
return new String(body, StandardCharsets.UTF_8);
}

private static void validateLength(int length) throws IOException {
if (length <= 0 || length > MAX_FRAME_LENGTH) {
throw new IOException("非法消息长度:" + length);
}
}
}

示例为了突出通信流程,直接处理 JSON 字符串。真实项目应使用 Jackson、Gson 等成熟库完成对象与 JSON 的序列化,不要使用字符串拼接生成复杂 JSON。

从简单长度帧扩展为业务协议

只有“长度 + JSON”已经能够正确拆分消息,但正式协议通常还需要在解析 JSON 前识别协议版本、消息类型和请求标识。

可以设计一个定长消息头:

1
2
3
4
+--------+---------+-------+------+------------+----------+---------+
| magic | version | flags | type | requestId | length | payload |
| 2 byte | 1 byte | 1 byte|2 byte| 8 byte | 4 byte | N byte |
+--------+---------+-------+------+------------+----------+---------+

各字段的职责如下:

字段 作用
magic 快速判断是否为本协议的数据,例如固定为 0x4A53
version 支持协议演进和兼容性判断
flags 标识压缩、加密、单向消息等可选能力
type 区分登录、业务请求、响应、心跳和错误消息
requestId 关联请求与响应,并支持链路排查和幂等处理
length 表示 payload 的字节长度
payload JSON、Protobuf 或其他业务数据

解码顺序应该是:

flowchart TD
    A[读取固定长度消息头] --> B{magic 是否正确}
    B -- 否 --> X1[关闭连接或进入协议错误处理]
    B -- 是 --> C{version 是否支持}
    C -- 否 --> X2[返回不支持的版本]
    C -- 是 --> D{length 是否在允许范围}
    D -- 否 --> X3[拒绝消息并关闭连接]
    D -- 是 --> E[读取完整 payload]
    E --> F{是否压缩或加密}
    F --> G[解压或解密]
    G --> H[反序列化业务对象]
    H --> I[根据 type 分发处理]

先解析和校验固定头部,再为 payload 分配内存,可以在进入 JSON 解析器之前拒绝错误版本和超大报文。

业务消息应该包含什么

对于请求响应型协议,JSON 正文可以设计为:

1
2
3
4
5
6
7
8
{
"requestId": "req-1001",
"type": "ORDER_QUERY",
"timestamp": 1788192000000,
"data": {
"orderId": "order-2001"
}
}

响应消息可以保持相同的 requestId

1
2
3
4
5
6
7
8
9
10
11
{
"requestId": "req-1001",
"type": "ORDER_QUERY_RESPONSE",
"success": true,
"code": "OK",
"message": "",
"data": {
"orderId": "order-2001",
"status": "PAID"
}
}

常见字段的意义是:

字段 作用
requestId 关联请求和响应,定位超时,也可参与幂等控制
type 决定消息交给哪个业务处理器
timestamp 记录产生时间,可用于超时和重放检查
success 表示业务处理是否成功
code 稳定、可被程序判断的错误码
message 面向调用方的错误说明
data 具体业务数据

协议头和 JSON 中是否都保留 requestIdtype,取决于性能与可读性需求。高性能协议通常在二进制头部保留路由必需字段;简单系统也可以只在 JSON 中保留,避免重复。

请求响应、推送和并发关联

Socket 不会自动把某个响应交给对应请求。允许同一连接并发发送多个请求时,响应顺序甚至可能与请求顺序不同,因此需要使用 requestId 进行关联。

sequenceDiagram
    participant C as 客户端
    participant S as 服务端

    C->>S: requestId=101,查询订单
    C->>S: requestId=102,查询用户
    S-->>C: requestId=102,用户响应
    S-->>C: requestId=101,订单响应

客户端常见做法是维护一个等待响应的映射:

1
requestId -> CompletableFuture<Response>

发送请求时创建 Future 并设置超时;读取线程收到响应后,根据 requestId 找到并完成对应 Future。超时、断线或写入失败时,需要及时移除映射,避免内存泄漏。

对于服务端主动推送,可以使用独立的消息类型,例如 NOTICE_PUSH。推送消息不一定有对应请求,但仍建议携带唯一消息 ID,便于客户端去重和回执。

长连接为什么需要心跳

网络断开不一定会立即触发异常。例如客户端断电、路由设备丢失状态或网络被静默切换时,服务端可能长时间保留一个实际不可用的连接。

应用层可以定义 PINGPONG

sequenceDiagram
    participant C as 客户端
    participant S as 服务端

    C->>S: PING
    S-->>C: PONG
    Note over C,S: 周期性正常通信
    C-xS: 网络中断
    S->>S: 超过读空闲时间
    S->>S: 关闭连接并释放资源
    C->>C: 退避后重新连接

设计心跳时应明确:

  • 哪一方发送心跳,哪一方回复。
  • 心跳周期和连接空闲超时。
  • 连续多少次失败后判定离线。
  • 重连是否采用指数退避和随机抖动。
  • 业务消息能否同时刷新心跳时间。
  • 重连后是否需要重新鉴权和恢复订阅。

socket.setKeepAlive(true) 启用的是操作系统 TCP Keepalive,默认探测周期往往较长。它可以作为补充,但不能代替需要快速发现业务失活的应用层心跳。

并发模型怎么选

阻塞 I/O

传统 ServerSocketSocketaccept()read() 会阻塞线程。它的代码直观,适合连接数有限的内部工具、设备网关原型和教学场景。

Java 21 的虚拟线程让“一连接一虚拟线程”具有更好的可扩展性,但它没有消除消息边界、背压、超时和资源上限等问题。

NIO 和 Selector

大量连接长期空闲时,可以使用 SocketChannelSelector 和非阻塞 I/O,让少量线程处理多个连接的就绪事件。但正确处理半包、写缓冲、状态机和并发边界并不简单。

Netty

需要生产级长连接服务时,通常优先使用 Netty。它提供:

  • EventLoop 和连接生命周期管理。
  • LengthFieldBasedFrameDecoder 等拆包组件。
  • 编码器、解码器和 Pipeline。
  • 空闲检测与心跳支持。
  • 写缓冲水位和背压控制。
  • TLS、WebSocket 等协议支持。

不要在 Netty EventLoop 中执行慢 SQL、同步 HTTP 调用或耗时计算,否则同一 EventLoop 管理的其他连接也会被阻塞。

连接和协议的安全边界

自定义 Socket 服务暴露后,需要把所有网络输入都当作不可信数据处理。

限制报文和资源

至少设置:

  • 连接超时、读取超时和空闲超时。
  • 单条消息最大长度。
  • 单个客户端的连接数和请求速率。
  • 每条连接的待发送队列上限。
  • JSON 嵌套深度、集合大小和字段长度限制。
  • 服务端全局最大连接数和并发任务数。

如果只校验 length > 0,恶意客户端可以声明一个数 GB 的正文长度,诱导服务端分配巨大数组并造成内存耗尽。

身份认证和权限

连接成功不代表身份可信。协议通常需要登录消息或握手阶段,用 Token、证书或签名确认客户端身份。鉴权成功前应只允许少量握手消息,不能直接开放全部业务类型。

加密传输

原始 TCP 默认是明文传输。公网或不可信网络中应使用 TLS,例如 Java 的 SSLSocket,并正确验证服务端证书。设备双向认证场景可以采用 mTLS。

不要自创加密算法,也不要只加密 JSON 的某几个字段就认为整条链路安全。TLS 还能保护协议头、身份凭证和错误响应等元数据不被篡改。

防重放和幂等

TCP 保证有序传输,但不保证业务只执行一次。客户端超时重试、断线重连或网关重发,都可能导致重复请求。

支付、下单和状态变更消息应使用业务幂等键或 requestId,服务端记录处理结果并返回相同响应。高风险协议还可以结合时间戳、随机数和签名限制重放。

常见问题与排查方向

现象 常见原因 处理建议
JSON 偶尔解析失败 把一次 read() 当成一条消息 使用长度字段或分隔符,并维护完整解码状态
中文消息长度不一致 使用 String.length() 计算报文长度 以 UTF-8 字节数组的 length 为准
接收端一直等待 长度写错、未发送完整正文或未 flush() 抓包并核对字节序、长度和实际写入字节数
服务端大量 CLOSE_WAIT 对端已关闭,本端没有释放 Socket 检查正常和异常路径的资源关闭
服务端大量 TIME_WAIT 频繁创建短连接且本端主动关闭 优先使用长连接或连接池,不要先盲调内核参数
内存持续上涨 超大帧、等待响应映射未清理、慢客户端积压 设置消息与队列上限,清理超时请求,实施背压
连接存在但消息发不通 半开连接、网络设备超时或对端卡死 使用应用心跳、读写超时和重连机制
同一连接消息内容交叉 多线程无序写同一个输出流 使用单写线程或发送队列串行编码和写入

什么时候不应该直接使用原始 Socket

技术选型应该先考虑协议生态和维护成本,而不是只比较理论性能。

需求 更合适的选择
普通前后端 CRUD 接口 HTTP/HTTPS + REST
浏览器双向实时通信 WebSocket
内部服务强类型调用 gRPC 或成熟 RPC 框架
异步削峰和可靠消费 Kafka、RabbitMQ、RocketMQ
物联网轻量发布订阅 MQTT
大量自定义 TCP 长连接 Netty
学习网络原理、对接专有设备协议 Java Socket 或 Netty

原生 Java Socket 适合连接量较小、协议简单、依赖受限或需要直接对接既有 TCP 协议的场景。如果连接量大、协议状态复杂、需要 TLS、心跳、重连和背压,使用 Netty 通常更稳妥。

协议设计检查清单

正式实现前,可以用下面的清单检查是否遗漏关键约定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
传输协议:TCP 还是 UDP
字节序:大端还是小端
字符编码:通常使用 UTF-8
消息边界:定长、分隔符还是长度字段
消息上限:头部和正文允许多大
协议版本:如何升级与兼容
消息类型:请求、响应、推送、心跳、错误
请求关联:requestId 如何生成和超时清理
序列化:JSON、Protobuf 或其他格式
身份认证:连接建立后如何确认身份
安全传输:是否使用 TLS 或 mTLS
连接治理:心跳、空闲超时、重连和优雅关闭
流量控制:慢客户端和发送队列如何处理
可靠性:重试、确认、幂等和去重
可观测性:连接数、延迟、错误码和协议版本指标

总结

Java Socket 的核心并不只是 connect()accept()read()write(),而是对字节流和应用层协议边界的正确理解。

可以把完整链路概括为:

flowchart LR
    A[Java 业务对象] --> B[JSON / Protobuf 序列化]
    B --> C[协议头 + payload 编码]
    C --> D[Socket 输出流]
    D --> E[TCP 字节流]
    E --> F[Socket 输入流]
    F --> G[按长度拆分完整消息]
    G --> H[协议校验和反序列化]
    H --> I[业务处理器]

对于 JSON 消息,最实用的起点是“4 字节大端长度 + UTF-8 正文”,接收端先限制长度,再通过 readFully() 读取完整数据。随着业务增长,再逐步增加魔数、版本、消息类型、请求 ID、心跳、鉴权、TLS、幂等和背压。

如果业务本身已经适合 HTTP、WebSocket、MQTT 或 gRPC,就优先使用成熟协议。只有明确需要自定义长连接和报文控制时,才值得自行维护一套 Socket 协议。