朕喜技术(ZhenxiJS)
2026.09.08
1 分钟阅读

Go TCP 粘包/半包问题详解:长度前缀分帧(length-prefixed framing)的正确实现

关键词: TCP 粘包, 半包, length-prefix framing, Go io.ReadFull, encoding/binary, 长度前缀分帧, TCP 字节流, 消息边界

问题背景

开发一个基于 TCP 的 Go 自定义协议服务,协议规定每条消息 = [4字节大端长度][payload]。 服务端上线后收到的消息大量错乱:粘包(多条独立消息被合并成一条处理)、半包(一条完整消息被拆成多段分别处理)。 日志中长度前缀字节(\x00\x00\x00\x05)混进了"消息正文",完全无法还原。

复现步骤

cd scenario/2026-09-08-0000
docker compose up -d --build
docker compose exec app bash
bash reproduce.sh

复现输出示例:

"\x00\x00\x00\x05hello\x00\x00\x00Bthi"    ← 粘包+半包
"s is a longer me"                          ← 半包(16字节读缓冲切碎)

根本原因

TCP 是面向字节流的协议,它本身不提供"消息边界"。具体:

  1. 粘包:发送端多次 Write 的消息可能被 Nagle 算法 / 内核缓冲 / 网络合并,一次性到达接收端;
  2. 半包:接收端一次 conn.Read 可能只读到一条消息的一部分(读缓冲大小、网络分片)。

服务端错误代码的核心:

n, err := conn.Read(buf)
fmt.Fprintf(logw, "%q\n", string(buf[:n]))  // 错误假设:一次 Read = 一条消息

解决办法

核心思路:长度前缀分帧(length-prefixed framing)——先精确读 4 字节得到 payload 长度 L,再精确读 L 字节作为完整消息。

正确实现(Go)

import (
    "encoding/binary"
    "io"
    "net"
)

func handle(conn net.Conn, logw *os.File) {
    defer conn.Close()
    lenBuf := make([]byte, 4)

    for {
        // 1. 读 4 字节长度前缀(io.ReadFull 保证读够)
        if _, err := io.ReadFull(conn, lenBuf); err != nil {
            if err != io.EOF && err != io.ErrUnexpectedEOF {
                fmt.Fprintf(os.Stderr, "read length err: %v\n", err)
            }
            return
        }
        payloadLen := binary.BigEndian.Uint32(lenBuf)

        // 2. 防御:过滤异常长度
        if payloadLen == 0 { continue }
        if payloadLen > 64*1024*1024 {
            fmt.Fprintf(os.Stderr, "payload too large: %d, closing\n", payloadLen)
            return
        }

        // 3. 精确读够 payloadLen 字节
        payload := make([]byte, payloadLen)
        if _, err := io.ReadFull(conn, payload); err != nil {
            fmt.Fprintf(os.Stderr, "read payload err: %v\n", err)
            return
        }

        // 4. 整条消息已完整还原
        fmt.Fprintf(logw, "%q\n", string(payload))
    }
}

关键 API

API作用
io.ReadFull(conn, buf)精确读够 len(buf) 字节,一次不够就持续读;读到 EOF 返回 io.ErrUnexpectedEOF
encoding/binary.BigEndian.Uint32()把 4 字节大端字节序解析为 uint32(协议约定)
conn.CloseWrite()(客户端)只关闭写方向,通知服务端没有更多数据

验证

docker compose exec app bash
cd /workspace/scenario
bash check.sh

输出:

"hello"
"this is a longer message that will be split by a small read buffer"
"abc"
"another moderately long payload over here"
"hi"
"the quick brown fox jumps over the lazy dog 0123456789"

PASS: 6 条消息全部被正确还原,粘包/半包已解决。

经验与避坑

  1. 永远不要假设「一次 Read = 一条消息」,TCP 不保证消息边界,必须自己实现分帧。
  2. 用 io.ReadFull 而非循环内手动累加:更简洁可靠,遇 EOF 自动返回 io.ErrUnexpectedEOF。
  3. 先读长度头再读 payload:这是最常见的自定协议做法,简单高效。
  4. 小读缓冲会加剧半包:调试阶段加大缓冲或正确用 ReadFull 即可区分"没读够"和"连接关闭"。
  5. 防御性校验:对 payload 长度设置合理上限(如 64MB),避免恶意/异常包导致内存耗尽。
  6. 区分 EOF 与 UnexpectedEOF:io.ReadFull 在读到部分数据后连接关闭会返回 io.ErrUnexpectedEOF,需妥善处理而非当普通错误。
  7. Docker 注意:修改宿主源码后需 docker compose up -d --build 重建镜像,单纯 docker compose exec 内不会同步卷外代码。

相关文件

  • 修复代码:scenario/2026-09-08-0000/app/server.go
  • 完整操作记录:scenario/2026-09-08-0000/SOLUTION.md
  • 复现脚本:reproduce.sh / check.sh

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-08-0008-tcp-sticky-packet-length-prefix-framing

分享这篇文章