网络层基础:TCP与UDP的选择、自定义二进制协议设计
各位同学,今天我们来聊聊网络层。这是整个存储中间件的「血管」——数据怎么从客户端流到服务端,又怎么回来。我做了这么多年存储系统,可以负责任地说:网络层设计的好坏,直接决定了你的中间件能扛多大并发、能跑多快。
说白了,网络层就干三件事:选协议、定格式、防粘包。咱们一个一个来。
TCP vs UDP:怎么选?
先问个问题:Tick级数据存储,对延迟的要求有多高?微秒级。对可靠性的要求呢?一条都不能丢。
好,那答案就很明显了——必须用TCP。为什么?
- 可靠性:UDP丢包了不会自动重传,你得自己在应用层实现。我见过有人用UDP做金融行情系统,结果丢包率在千分之一时,数据对不上了,排查了三天……嗯,后来还是换成了TCP。
- 流式传输:TCP是字节流,天然适合我们这种连续写入的场景。UDP是数据报,有长度限制(通常64KB),超过就得自己分片。
- 拥塞控制:TCP会自适应网络状况,不会把网络打爆。UDP?你发多快它就扔多快。
核心结论:Tick级存储中间件,网络层必须用TCP。UDP只适合那些可以容忍丢包、对延迟极度敏感的场景(比如视频通话、游戏帧同步)。
不过话说回来,TCP也不是没有代价。它的三次握手、慢启动、拥塞控制都会增加延迟。那怎么办?我个人的做法是:连接复用。客户端和服务端之间建立长连接,避免频繁握手。另外,可以调整TCP参数,比如禁用Nagle算法(TCP_NODELAY),让数据包不等待直接发送。
自定义二进制协议:Header + Payload
协议定了,接下来就是数据格式。HTTP?JSON?别闹了。Tick级系统,每微秒都很宝贵,你搞个HTTP头几百字节,解析还要正则匹配,这延迟谁受得了?
所以,必须用自定义二进制协议。结构很简单:
+----------------+------------------+
| Header (固定长度) | Payload (变长) |
+----------------+------------------+
Header里放什么?我一般放这四个字段:
| 字段 | 长度 | 说明 |
|---|---|---|
| Magic Number | 4字节 | 固定值,比如0xDEADBEEF,用于快速校验 |
| Payload Length | 4字节 | Payload的字节数,解决粘包的关键 |
| Message Type | 2字节 | 比如0x01表示写入请求,0x02表示查询请求 |
| Checksum | 4字节 | 对Header+Payload做CRC32校验 |
总共14字节的Header。你想想看,HTTP一个请求头动不动几百字节,我们14字节搞定,这差距就是数量级的。
小技巧:Magic Number选个有意义的。我习惯用0xDEADBEEF,好记。有些团队用0xCAFEBABE(Java的class文件魔数),也挺有意思。
Payload就是业务数据了。比如写入一条Tick数据,Payload里放时间戳、价格、成交量等字段。格式也是二进制的,不要用字符串。
粘包与半包处理
好,协议设计好了,数据开始发了。然后你发现——服务端收到的数据怎么跟发的不一样?有时候多条粘在一起,有时候一条被拆成两半。
这就是TCP的「粘包」和「半包」问题。为什么会这样?因为TCP是流式协议,它不关心你应用层的消息边界。你发两次write,它可能合并成一次发出去(粘包);你发一次write,它可能拆成两次发(半包)。
怎么解决?我常用的方法就两种:
- 固定长度法:每个消息固定长度,比如1024字节。不够的补零。简单粗暴,但浪费带宽。
- 长度前缀法:就是上面说的Header里放Payload Length。服务端先读固定长度的Header,解析出Payload Length,再读对应长度的Payload。
我个人强烈推荐第二种。它灵活、高效,而且实现起来也不复杂。伪代码大概这样:
// 服务端接收循环
while (true) {
// 1. 先读Header(14字节)
byte[] header = readExactly(14);
if (header == null) break; // 连接关闭
// 2. 解析Header
int magic = readInt(header, 0);
int length = readInt(header, 4);
short type = readShort(header, 8);
int checksum = readInt(header, 10);
// 3. 校验Magic Number
if (magic != 0xDEADBEEF) {
// 数据异常,可能需要重新同步
continue;
}
// 4. 读Payload
byte[] payload = readExactly(length);
// 5. 校验Checksum
if (crc32(header, payload) != checksum) {
// 数据损坏,丢弃
continue;
}
// 6. 处理消息
processMessage(type, payload);
}
注意:readExactly这个函数很关键。它必须保证读到指定长度的字节,否则就阻塞。不能用普通的read,因为read可能只返回部分数据(半包)。
我曾经在一个项目中,因为偷懒用了read而不是readExactly,结果线上频繁出现数据解析错误。排查了整整两天,最后发现是半包问题。嗯,从那以后我再也不敢在这上面省事了。
Netty vs Tokio:框架选型
框架选型,说白了就是选Java还是选Rust。我两个都用过,说说我的感受。
| 维度 | Netty (Java) | Tokio (Rust) |
|---|---|---|
| 生态成熟度 | 极高,文档丰富,踩坑经验多 | 中等,但增长很快 |
| 性能 | 优秀,零拷贝、epoll等都用上了 | 极优,无GC、零成本抽象 |
| 开发效率 | 高,Java生态完善 | 中等,Rust学习曲线陡 |
| 内存安全 | 依赖JVM,有GC停顿 | 编译期保证,无GC |
我的建议是:
- 如果团队Java经验丰富,选Netty。它内置了粘包半包处理器(比如LengthFieldBasedFrameDecoder),开箱即用。
- 如果追求极致性能,或者想用Rust,选Tokio。它的异步模型非常优雅,而且没有GC停顿。
我个人习惯用Netty。不是因为Rust不好,而是Java的生态太强了。你要集成个监控、做个管理界面,Java随手就能搞定。Rust的话,很多库还得自己造轮子。
选型总结:Netty适合快速落地、团队技术栈统一;Tokio适合性能敏感、愿意投入学习成本的项目。没有绝对的好坏,只有合不合适。
本章小结
好了,这一章的内容就这些。我们讲了:
- 为什么选TCP而不是UDP
- 怎么设计一个14字节的二进制协议头
- 粘包半包怎么处理(长度前缀法)
- Netty和Tokio怎么选
这些都是网络层的基础。下一章,我们会把这些知识落地,真正开始写代码。到时候,我会带着大家用Netty实现一个完整的网络层,包括连接管理、心跳检测、断线重连这些实战内容。
嗯,今天就到这里。有问题随时问我。