一、协议日志该记什么
协议日志的目标不是“把所有字节保存下来”,而是让另一个人在不连接现场的情况下回答三个问题:这条消息属于哪个连接、对应哪次请求、结果如何。完整 body 既解决不了这三个问题,又会带来隐私和体积风险。
推荐的最小字段集:
| 字段 | 示例 | 用途 |
|---|---|---|
| 时间 | 09:36:40.123 | 与客户端日志、抓包时间对齐 |
| 方向 | C>S / S>C | 区分请求与响应,排查单向丢失 |
| 连接 | 10.0.0.8:51234 | 多连接并发时定位到具体会话 |
| cmd | 0x03e9 | 知道发生了什么业务 |
| seq | 42 | 把响应配回请求 |
| 长度 | len=10 | 判断半包、粘包、异常大包 |
| 耗时 | cost=3ms | 区分网络慢和逻辑慢 |
| 结果码 | code=0 | 失败时无需再查 body |
这八个字段足以覆盖大多数排障路径。body 只在明确需要时按采样打开,并且要经过脱敏。
为什么不要打印完整 body:
- 隐私:聊天内容、账号 token、设备标识、支付信息都可能出现在 body 里。
- 体积与噪声:全量日志在万人同屏时每秒能产生数百 MB,真正有用的“哪个 cmd、多长、多久、成功没有”反而被淹没。
如果确实需要内容,记录 body 的哈希、前若干字节的长度,或者按命令号做结构化摘要,而不是原样倾倒。
二、十六进制 dump 的读法
一段标准的 16 字节一行 dump 如下。这是本实验 Dump 的输出格式:
00000000 47 53 01 01 03 e9 00 00 00 07 00 00 00 0a c0 44 |GS.............D|
00000010 41 8a 00 00 4d 4f 56 45 20 31 30 20 32 30 |A...MOVE 10 20|
读法从左到右:
- 偏移列
00000000:该行第一个字节在缓冲区中的位置,十六进制。跨行引用时直接说“偏移 0x10”,比“第二行第五个字节”可靠。 - 十六进制区:每行 16 字节,中间多一个空格把 8 字节分成一组,方便肉眼数位。
- ASCII 区:可打印字符原样显示,其余显示为点。它只用于快速认出
MOVE这样的字符串,不能作为解析依据。
用包络结构对照这段字节:
| 偏移 | 字节 | 含义 |
|---|---|---|
| 0x00 | 47 53 | magic,ASCII 为 GS |
| 0x02 | 01 | 版本 1 |
| 0x03 | 01 | flags,请求 |
| 0x04 | 03 e9 | cmd = 1001 |
| 0x06 | 00 00 00 07 | seq = 7 |
| 0x0a | 00 00 00 0a | body 长度 10 |
| 0x0e | c0 44 41 8a | CRC32 |
| 0x12 | 00 00 | reserved |
| 0x14 | 4d 4f 56 45 20 31 30 20 32 30 | body,ASCII 为 MOVE 10 20 |
几个肉眼可查的规律:
- 长度字段在大端下通常呈现为“前面很多 0,最后一个非零字节”,例如
00 00 00 0a。 - 如果长度字段看起来像
0a 00 00 00,要么协议是小端,要么这段已经错位。 - magic 出现在非预期偏移,说明前面有垃圾字节或上一帧长度算错。
- ASCII 区出现连续可读文本但偏移对不上,通常是 body 被当成了包头。
三、从抓包定位半包、粘包与错位
3.1 半包
现象:日志里出现 len=10,但缓冲区只有 8 个 body 字节;下一轮解析从 body 中间开始,报 bad magic。
判断方法:看“声明的整包长度”和“本次实际收到的字节数”。半包不是协议错误,而是读取循环没有保留剩余缓冲。修复点在读取层,不在解析层。
3.2 粘包
现象:一次读取拿到两帧,第二帧的 magic 紧跟在第一帧 body 之后。若代码只解析第一帧就丢弃整个缓冲区,第二帧会永久丢失;若把整个缓冲区当一帧,长度字段会离谱。
判断方法:解析完第一帧后,检查 consumed 是否小于缓冲区长度。小于就继续解析,直到不足一个包头。
3.3 错位
现象:后续所有帧的 magic 都对不上,或者 cmd、长度出现明显不合理的值。
常见原因有三个:
- 半包时消费了字节;
- 长度字段的口径不一致(一方算整包,一方算 body);
- 压缩或加密层与包络层的顺序搞反,解析器看到了密文。
定位时先用 Resync 找下一个可能的 magic,再从那里尝试解析。如果能连续解析出几帧合理数据,说明错位点就在上一个 magic 之后;如果只能解析出一帧又坏,可能是长度口径问题,而不是简单错位。
3.4 一张症状对照表
| 症状 | 优先怀疑 | 下一步 |
|---|---|---|
| 持续 bad magic | 半包消费、长度口径不一致 | 打印 consumed、声明长度、缓冲区长度 |
| 偶发 checksum 失败 | 抓包不完整、中间设备改写、压缩层顺序 | 对比两端原始字节与校验范围 |
| 帧能解析但 cmd 乱码 | 字节序或字段偏移错误 | 用固定值构造最小帧逐字段核对 |
| 响应总是超时 | 方向字段缺失,响应被当推送丢弃 | 按 seq 查发送与接收两侧日志 |
四、可复现:原始字节和解析结果一起落盘
只记录“解析成功/失败”不够。复现问题时,需要同时保存:
- 原始字节(或其哈希 + 采样片段);
- 解析出的方向、cmd、seq、长度、耗时、结果码;
- 解析器版本与协议版本;
- 如果是失败样本,记录失败时的偏移和已消费字节数。
这样即使现场已经恢复,也能在本地用同一段字节重放。建议一个排障包包含 raw.bin 和 frames.jsonl:前者是原始流,后者是逐帧解析结果,二者用偏移关联。
采样策略要按场景区分:
| 场景 | 建议 |
|---|---|
| 正常生产 | 只记结构化元数据,body 不落盘 |
| 错误比例上升 | 对失败连接开启短时全量,自动过期 |
| 玩家举报 | 按连接或玩家 ID 定向抓取,设置时长上限 |
脱敏要在写入前完成,而不是事后清洗。token、cookie、聊天文本、手机号等字段应在结构化日志层被替换为固定占位符;原始字节如果必须保留,应加密存储并限制访问。
五、实现:可读的 dump 与可恢复的解析
下面的代码提供三样东西:带偏移和 ASCII 的 Dump、能从任意偏移解析单帧的 ParseFrame、以及用于错位后寻找候选起点的 Resync。Record 故意没有 body 字段,从类型上杜绝“顺手把负载打进日志”。
// file: ch03/debug/dump.go
// tier: L1
// verify: go test ./ch03/debug/
// Package debug turns raw protocol bytes into evidence: a bounded hex dump,
// a frame parser that can resynchronise after garbage, and log records that
// deliberately carry no payload.
package debug
import (
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"time"
)
const (
HeaderSize = 20
Magic = 0x4753 // "GS"
MaxBody = 1 << 20
)
var (
ErrTruncated = errors.New("debug: truncated frame")
ErrBadMagic = errors.New("debug: bad magic")
ErrTooLarge = errors.New("debug: declared body too large")
)
// Dump writes a classic 16-byte-per-row hex dump. Keeping the offset column
// makes a pasted log usable: a colleague can point at "00000023" instead of
// counting bytes.
func Dump(w io.Writer, data []byte) error {
for off := 0; off < len(data); off += 16 {
end := off + 16
if end > len(data) {
end = len(data)
}
line := data[off:end]
var b strings.Builder
fmt.Fprintf(&b, "%08x ", off)
for i := 0; i < 16; i++ {
if i < len(line) {
fmt.Fprintf(&b, "%02x ", line[i])
} else {
b.WriteString(" ")
}
if i == 7 {
b.WriteByte(' ')
}
}
b.WriteString(" |")
for _, c := range line {
if c >= 0x20 && c < 0x7f {
b.WriteByte(c)
} else {
b.WriteByte('.')
}
}
b.WriteString("|\n")
if _, err := io.WriteString(w, b.String()); err != nil {
return err
}
}
return nil
}
// FrameInfo is the envelope as seen by a log reader. Version is reported but
// not rejected: an unknown version is exactly what you want to see in a
// capture taken from an older or newer client.
type FrameInfo struct {
Offset int
Version uint8
Flags uint8
Cmd uint16
Seq uint32
BodyLen int
Total int
Checksum uint32
}
// ParseFrame parses the frame starting at off and returns the offset of the
// next frame. It never skips bytes on its own; when the magic is wrong the
// caller decides whether to resynchronise or to declare corruption.
func ParseFrame(buf []byte, off int) (FrameInfo, int, error) {
if off < 0 || off > len(buf) {
return FrameInfo{}, off, fmt.Errorf("debug: offset %d out of range", off)
}
if off == len(buf) {
return FrameInfo{}, off, io.EOF
}
rest := buf[off:]
if len(rest) < HeaderSize {
return FrameInfo{}, off, fmt.Errorf("%w: header %d/%d at %d", ErrTruncated, len(rest), HeaderSize, off)
}
if got := binary.BigEndian.Uint16(rest[0:2]); got != Magic {
return FrameInfo{}, off, fmt.Errorf("%w: 0x%04x at %d", ErrBadMagic, got, off)
}
bodyLen := binary.BigEndian.Uint32(rest[10:14])
if bodyLen > MaxBody {
return FrameInfo{}, off, fmt.Errorf("%w: %d at %d", ErrTooLarge, bodyLen, off)
}
total := HeaderSize + int(bodyLen)
if len(rest) < total {
return FrameInfo{}, off, fmt.Errorf("%w: have %d need %d at %d", ErrTruncated, len(rest), total, off)
}
return FrameInfo{
Offset: off,
Version: rest[2],
Flags: rest[3],
Cmd: binary.BigEndian.Uint16(rest[4:6]),
Seq: binary.BigEndian.Uint32(rest[6:10]),
BodyLen: int(bodyLen),
Total: total,
Checksum: binary.BigEndian.Uint32(rest[14:18]),
}, off + total, nil
}
// Resync returns the first offset at or after from that could start a frame.
// It is intentionally a cheap byte scan; the caller must still parse and
// verify the candidate before trusting it.
func Resync(buf []byte, from int) int {
if from < 0 {
from = 0
}
for i := from; i+2 <= len(buf); i++ {
if buf[i] == byte(Magic>>8) && buf[i+1] == byte(Magic&0xff) {
return i
}
}
return -1
}
// Record is one application-level log line. There is no Body field on
// purpose: command, sequence and length are enough to correlate a request
// with its response, while payloads may contain tokens or chat text.
type Record struct {
Time time.Time
Dir string // "C>S" or "S>C"
Conn string
Cmd uint16
Seq uint32
BodyLen int
Elapsed time.Duration
Code int32
}
func (r Record) String() string {
return fmt.Sprintf("%s %s conn=%s cmd=0x%04x seq=%d len=%d cost=%s code=%d",
r.Time.Format("15:04:05.000"), r.Dir, r.Conn, r.Cmd, r.Seq,
r.BodyLen, r.Elapsed, r.Code)
}
// Sampler keeps full dumps bounded. Every Nth frame is allowed through, so
// the cost stays flat even when the connection is saturated.
type Sampler struct {
every uint64
n atomic.Uint64
}
func NewSampler(every uint64) *Sampler {
if every == 0 {
every = 1
}
return &Sampler{every: every}
}
func (s *Sampler) Allow() bool {
return s.n.Add(1)%s.every == 0
}
ParseFrame 不校验版本和 checksum,这是刻意的:排障时你需要看到“版本 9”或“校验值是多少”,而不是被解析器提前拒绝。真正的业务接收路径仍应使用 wire 包的严格解码。
六、工具对照
| 工具 | 能回答的问题 | 回答不了的问题 |
|---|---|---|
| tcpdump | 连接是否建立、包何时到达、是否有重传、字节数 | 业务命令含义、请求与响应的业务关联 |
| Wireshark | 逐包时间线、TCP 流重组、自定义协议解析 | 大规模聚合、生产环境长期留存 |
| 应用层结构化日志 | 哪个 cmd、seq、耗时、结果码、哪个玩家 | 数据包是否在网卡前丢失 |
| 指标(Metrics) | 延迟分位、错误率、超时率、队列深度 | 单个连接的具体字节内容 |
排障顺序通常是:指标发现异常 → 应用日志定位到连接与命令 → tcpdump/Wireshark 确认网络层是否丢包或重传 → 回到应用日志核对解析结果。三者不是替代关系,缺一层就会在错误的地方浪费时间。
七、验证
ch03/debug 的测试覆盖了 dump 的偏移、ASCII 可打印字符替换、带垃圾前缀的错位重同步、截断帧、超大声明长度、日志记录不含负载,以及 1/N 采样。
go test -race ./ch03/debug/
三个实验包共 24 个测试,本机执行全部通过。错位测试特意在有效帧前插入 3 个垃圾字节,先断言直接解析失败,再用 Resync 找到偏移 3 并成功解析,确保“重同步”不是纸上谈兵。
常见错误与边界
- 日志里打印 token 或完整聊天内容:一旦日志被采集、转发或导出,泄露面会迅速扩大。
- 只记“收到消息”不记 cmd 和长度:出问题时无法判断是哪个命令、是否半包。
- 生产环境全量 dump:磁盘和 IO 会被日志吃掉,真正的事故现场反而被轮转掉。
- 脱敏放在事后:原始日志已经落盘并被采集,事后清洗无法收回。
- 把 ASCII 区当解析结果:点号可能代表任意不可打印字节,长度和命令号必须以十六进制为准。
- 重同步后直接信任:magic 只是候选,仍需校验版本、长度上限和 checksum。
- 只依赖抓包不看应用日志:能看到字节,却不知道服务端把它解析成了什么。
小结
协议调试的核心是“用最少的字段回答最多的问题”。方向、连接、cmd、seq、长度、耗时、结果码这七个维度,配合偏移明确的十六进制 dump,足以定位大多数半包、粘包和错位问题。原始字节与解析结果一起保存,才能让一次故障可复现;采样和写入前脱敏,才能让日志长期可用。tcpdump、Wireshark、应用日志和指标各管一段,按“指标 → 日志 → 抓包 → 回日志”的顺序使用,比任何单一工具都更接近真相。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。