libhv TCP 粘包与拆包处理说明
本文说明 libhv 如何处理 TCP 粘包、拆包问题,并结合当前项目中的 HTcpServer 和 RpcMessage 协议格式分析实际处理流程。
1. TCP 粘包与拆包背景
TCP 是面向字节流的协议,本身不保留应用层消息边界。因此应用层发送的多条消息,在接收端可能出现以下情况:
- 粘包:一次读取到多个完整应用层包。
- 拆包/半包:一次只读取到一个应用层包的一部分。
- 混合情况:一次读取到前一个包的尾部、若干完整包、下一个包的头部。
因此,业务层不能直接把一次 socket read 当作一条完整消息处理,必须根据应用层协议重新切分字节流。
2. libhv 的处理方式
libhv 通过 setUnpack,也就是 C 接口中的 hio_set_unpack,为连接设置拆包规则。设置后,libhv 会在底层读事件中先完成应用层包切分,只有当得到完整包时,才触发业务层 onMessage 回调。
libhv 支持三种常见拆包方式:
UNPACK_BY_FIXED_LENGTH:按固定包长拆包。UNPACK_BY_DELIMITER:按分隔符拆包,适合文本协议。UNPACK_BY_LENGTH_FIELD:按头部长度字段拆包,适合二进制协议。
当前项目使用的是 UNPACK_BY_LENGTH_FIELD。
3. 当前项目中的拆包配置
配置位于 server/tcp_server.cpp 的 HTcpServer 构造函数中:
cpp
HTcpServer::HTcpServer(int port, int thread_num) {
memset(&protorpc_unpack_setting_, 0, sizeof(unpack_setting_t));
protorpc_unpack_setting_.mode = UNPACK_BY_LENGTH_FIELD;
protorpc_unpack_setting_.package_max_length = DEFAULT_PACKAGE_MAX_LENGTH;
protorpc_unpack_setting_.body_offset = RUNTIME_MSG_HEADER_SIZE;
protorpc_unpack_setting_.length_field_offset =
PROTORPC_HEAD_LENGTH_FIELD_OFFSET;
protorpc_unpack_setting_.length_field_bytes =
PROTORPC_HEAD_LENGTH_FIELD_BYTES;
protorpc_unpack_setting_.length_field_coding = ENCODE_BY_BIG_ENDIAN;
setUnpack(&protorpc_unpack_setting_);
port_ = port;
setThreadNum(thread_num);
onMessage = std::bind(&HTcpServer::HandleMessage, this, std::placeholders::_1,
std::placeholders::_2);
}
相关宏定义位于 src/rpc_message.h:
cpp
#define RUNTIME_MSG_HEADER_SIZE (16)
#define JSON_STRING_MINIMUM_SIZE (2)
#define RUNTIME_MSG_HEADER_PRIFIX (0xABCDDCBA)
#define PROTORPC_HEAD_LENGTH_FIELD_OFFSET (12)
#define PROTORPC_HEAD_LENGTH_FIELD_BYTES (4)
也就是说,当前协议使用 16 字节固定头,长度字段从偏移 12 开始,占 4 字节,并按大端解析。
4. 当前协议包格式
RpcMessage 的二进制协议头结构如下:
text
0 4 8 10 12 16
+-------+-------+------+------+--------+
| magic | reqid | cmd | type | length |
+-------+-------+------+------+--------+
4 bytes, big endian
+----------------------------------------------------------+
| payload, length bytes |
+----------------------------------------------------------+
字段含义:
magic:4 字节协议标识,当前为0xABCDDCBA。reqid:4 字节请求 ID。cmd:2 字节命令。type:2 字节消息类型。length:4 字节 payload 长度,大端编码。payload:长度为length的消息体。
因此,一个完整包长度为:
text
package_len = RUNTIME_MSG_HEADER_SIZE + length
package_len = 16 + length
5. libhv 长度字段拆包算法
libhv 的 UNPACK_BY_LENGTH_FIELD 核心逻辑可以概括为:
text
head_len = body_offset
body_len = read(length_field_offset, length_field_bytes, length_field_coding)
package_len = head_len + body_len + length_adjustment
映射到当前项目:
text
head_len = 16
body_len = 从 offset 12 读取 4 字节大端整数
length_adjustment = 0
package_len = 16 + body_len
libhv 每次收到数据后,会把新数据追加到连接自己的读缓存中,然后循环尝试切包:
text
while remain >= body_offset:
读取 length 字段
计算 package_len = 16 + length
if remain >= package_len:
回调业务层 onMessage,一个完整包处理完成
移动指针,继续判断剩余数据中是否还有完整包
else:
数据不足一个完整包,停止处理,缓存剩余数据等待下一次读取
6. 粘包处理过程
如果一次 TCP read 读到了多个完整包,例如:
text
[packet1][packet2][packet3]
libhv 会在内部循环中依次计算每个包的长度,只要缓存中的剩余数据满足一个完整包长度,就调用一次业务回调。
因此业务层会看到 3 次 onMessage 回调:
text
onMessage(packet1)
onMessage(packet2)
onMessage(packet3)
业务代码不需要自己再从一个大 buffer 中拆出多个包。
7. 拆包/半包处理过程
如果一次 TCP read 只读到了半个包,例如:
text
第一次读取: [header + half payload]
第二次读取: [remaining payload]
libhv 在第一次读取后会发现:
text
remain < package_len
此时它不会触发业务回调,而是把未处理的数据保留在连接读缓存中。第二次读到剩余 payload 后,libhv 会继续用缓存中的旧数据加新数据计算完整包长度。只有当数据满足完整包长度后,才触发 onMessage。
因此业务层不会收到不完整包。
8. 业务层收到的数据
当前项目的业务回调位于 src/tcp_server.cpp:
cpp
void HTcpServer::HandleMessage(const TSocketChannelPtr& socketChannel,
Buffer* buff) {
RpcMessage rpc_msg;
memset(&rpc_msg, 0, sizeof(rpc_msg));
int packlen = rpc_msg.protorpc_unpack(buff->data(), buff->size());
if (packlen < 0) {
LOG(ERROR) << "protorpc_unpack failed!";
return;
}
if (message_callback_ != nullptr) {
message_callback_(socketChannel, rpc_msg);
}
}
...
}
这里的 buff 理论上已经是 libhv 按 UNPACK_BY_LENGTH_FIELD 切好的一个完整包。RpcMessage::protorpc_unpack 的职责是解析业务协议字段,而不是处理 TCP 层的粘包或半包。
RpcMessage::protorpc_unpack 会再次读取协议头中的 length_,并计算业务包长度:
cpp
int RpcMessage::protorpc_package_length(const RpcMessage* msg) {
return RUNTIME_MSG_HEADER_SIZE + msg->length_;
}
如果 len < packlen,会返回错误。但正常情况下,这种半包已经被 libhv 缓存层挡住,不应进入业务回调。
9. 包长保护
当前配置中:
cpp
protorpc_unpack_setting_.package_max_length = DEFAULT_PACKAGE_MAX_LENGTH;
libhv 默认最大包长是 2MB:
cpp
#define DEFAULT_PACKAGE_MAX_LENGTH (1 << 21)
如果根据长度字段计算出来的 package_len 超过 package_max_length,libhv 会认为包长度异常,设置错误并关闭连接。这可以避免异常长度字段导致读缓存无限扩容。
10. 注意事项
unpack_setting_t的生命周期必须长于连接使用周期。当前项目把protorpc_unpack_setting_作为HTcpServer成员变量保存,这是合适的,不能把它定义成构造函数里的局部变量后传给setUnpack。length_field_offset + length_field_bytes必须小于或等于body_offset。当前项目为12 + 4 == 16,正好等于协议头长度。length_field_coding必须和打包函数保持一致。当前RpcMessage::protorpc_pack使用大端方式写入length_,拆包配置也使用ENCODE_BY_BIG_ENDIAN,两者一致。length字段表示 payload 长度,不包含 16 字节协议头,因此length_adjustment保持默认值 0 即可。
11. 总结
libhv 不是依赖 TCP read 次数来判断消息边界,而是根据 setUnpack 配置维护连接级读缓存并执行应用层协议切分。
在当前项目中,完整包长度由 16 字节协议头中的 4 字节 length 字段决定:
text
完整包长度 = 16 字节协议头 + length 字节 payload
当数据不足一个完整包时,libhv 会缓存等待;当数据包含多个完整包时,libhv 会循环拆出多个包并多次回调业务层。因此,JTcpServer::HandleMessage 中收到的 Buffer* buff 应当已经是一条完整的 RpcMessage。