为什么粘包是个问题
TCP 是面向流的协议,只有字节流概念,没有"消息"边界。Producer 连续发三个请求,接收方一次 read 可能只读到第一个的一半(半包),也可能把三个全读进来(粘包),甚至是前两个完整加第三个的一半。
如果应用层不做边界处理,收到的数据根本没法解析。Kafka 作为一个二进制协议,消息体里可能包含任意字节(包括换行符),不能用分隔符;消息大小从几十字节到几 MB 不等,不能用固定长度。Kafka 选择了工业级二进制协议的标准方案:长度前缀帧格式。
01 长度前缀方案:4 字节界定消息边界
Kafka 每条消息前面放 4 字节 int32(大端),表示后续消息体的字节数。读取时先读 4 字节得到长度 N,再读满 N 字节就是一条完整消息。

为什么不用其他方案:
- 固定分隔符(\n):消息体不能含分隔符需转义,二进制数据不适用,逐字节扫描性能差
- 固定长度:消息大小差异大时浪费空间,无法支持变长消息,扩展困难
- 长度前缀:支持任意长度二进制数据,读取效率高(先读长度再批量读),消息损坏只影响当前条不扩散
这套方案不是 Kafka 独创------Thrift、gRPC、Protobuf 帧都用长度前缀。Kafka 的选择是经过验证的最优解。
02 NetworkReceive:累积不完整数据
KafkaChannel 每个连接维护一个 NetworkReceive 对象,负责处理读取过程中的半包和粘包。

NetworkReceive 内部结构:
- size(ByteBuffer,4字节):存储消息长度,size.remaining()==0 表示长度已读全
- buffer(ByteBuffer):消息体,按 size 分配,buffer.remaining()==0 表示消息已读全
- source(String):来源节点 ID,用于匹配 InFlight 请求
- done():size 和 buffer 都读全才返回 true
读取流程: Selector.poll() 中 OP_READ 就绪时调用 channel.read(),数据进入 NetworkReceive.readFrom()。先读 size,size 未满则累积等下一轮;size 读全后按长度分配 buffer,再读 body,body 未满也累积。done()=true 时消息完整,加入 completedReceives 交给上层解析,同时创建新的 NetworkReceive 等下一条。
粘包天然支持 :一条解析完,剩余字节自动作为下一条的 size 继续读,无需特殊处理。半包自动累积:size 或 body 未读全时保留在 NetworkReceive,不丢数据,下一轮 poll 继续读。
03 Kafka 协议帧格式与请求响应匹配
请求帧和响应帧结构略有不同,但都用 4 字节长度前缀。

请求帧结构: size(4B) + api_key(2B) + api_version(2B) + correlation_id(4B) + client_id(可变) + request_body(可变)。api_key 标识请求类型(Produce=0、Fetch=1、Metadata=3),api_version 支持协议演进,correlation_id 是请求唯一标识。
响应帧结构: size(4B) + correlation_id(4B) + response_body(可变)。响应没有 api_key/api_version/client_id,靠 correlation_id 匹配对应的请求。
匹配流程: 发送请求时分配 correlationId 并加入 InFlightRequests;收到响应后取出 correlationId,从对应节点的 InFlight 队首取出请求,触发 callback;成功则 batch.done() 完成 Future,失败则可重试的放回队首、不可重试的抛异常;InFlight 计数减 1,可以发新请求。
关键设计细节
- 大端字节序:size 用 int32 大端(网络字节序),跨平台兼容
- 最大消息限制:size 超过 max.request.size(默认1MB)直接拒绝,防止 OOM
- 内存池化:body ByteBuffer 从 ByteBufferPool 分配,解析完归还复用
- 每连接独立 receive:每个 KafkaChannel 有自己的 NetworkReceive,互不干扰
- 错误隔离:一条消息长度异常只影响当前连接,不会污染其他连接
- 零拷贝友好:body 可直接用 FileChannel.transferTo 写入磁盘(Broker 端)
写在最后
Kafka 处理粘包的方案看似简单------4 字节长度前缀,但背后是一整套完整的设计:NetworkReceive 的半包累积、粘包自动续读、correlationId 的请求响应匹配、ByteBufferPool 的内存池化。简单的接口背后是严谨的工程实现。
这也是读源码的价值:不是看用了什么复杂算法,而是看怎么用简单的机制把边界情况处理干净。下一篇看响应处理的完整流程------ProduceResponse 怎么解析、怎么触发 callback、失败怎么重试。