先搞清楚:这层缓冲到底解决什么问题
没有缓冲时,每次 Read 或 Write 都直接触达底层 io.Reader 或 io.Writer。对文件来说你可能感觉不到,但如果底层是 socket,一次交互就涉及内核态切换、协议处理、网络往返。频繁的小数据读写,大部分时间都耗在系统调用上,业务代码本身反而很快。bufio 的思路,就是在这个接口中间插入一块用户态内存,把多次小读写聚合成一次大的系统调用。

省了系统调用,代价是数据不再即时到达。读侧的数据先落在缓冲区,意味着你拿到的是缓存;写侧的数据先待在缓冲区,意味着你不调 Flush,它可能永远不会落盘。后面所有复杂性,都来自这两个即时性损失。
bufio.Reader:读缓冲的边界和生命周期
Reader 的内部结构不算复杂:一块缓冲区,加两个下标,已读位置和已写位置。每次调用 Read,它会先看当前缓冲区还有没有可读数据,有就直接从内存中取;没有才从底层把一批数据填进来。默认缓冲区大小是 4096 字节。
这种设计的收益很清晰。假设你循环调用 ReadByte 读一个 1KB 的文件,底层 Read 可能只被触发一次或几次,剩下的操作全是内存索引。相比直接循环 Read 到小切片,省掉的是几十上百次系统调用。
但缓冲也让 Reader 的一部分 API 变得危险。比如 Peek(n) 看起来很方便,返回一个长度至少为 n 的切片,让你能提前看一眼后续数据。问题在于这个切片直接指向 Reader 内部缓冲区,不是拷贝。下一次任何 Read 操作都可能覆盖这块内存,如果你把 Peek 返回的切片存起来,后面再用,读到的就是脏数据。
使用 Peek 的安全做法,是立刻把它复制到自己管理的切片里。
r := bufio.NewReader(conn)
header, err := r.Peek(4)
if err != nil {
// 处理错误
}
tmp := make([]byte, 4)
copy(tmp, header) // 必须自己拷贝
ReadSlice 也类似,它返回的字节是对内部缓冲区的引用,只有在没碰到缓冲边界时才有效。碰到边界时返回 ErrBufferFull,你还需要重新处理。相比之下 ReadBytes 和 ReadString 会申请新内存拼接完整结果,语义上更安全,代价是多一次分配。
所以在需要精确控制读取格式的场景里,你不光要选对方法,还要能接受它带来的内存和生命周期约束。
bufio.Writer:写入缓冲的本质是延迟落盘
Writer 的原理正好和 Reader 反着来。你写入的数据先攒在内存里,等缓冲区满了,或者你主动调 Flush,才真正交给底层 io.Writer。默认也是 4096 字节。对高并发日志、网络响应这类场景,这个机制能极大减少系统调用,避免大量小包造成的网络效率问题。
但很多人忽略了一点:如果写入的数据量很大,Writer 并不会死板地把所有数据都压进缓冲。它的 Write 方法会先尝试把数据填充到缓冲区,如果填充完还有剩余,且剩余长度已经超过缓冲区剩余空间,Writer 会先刷新缓冲,再直接调用底层的 Write 把剩余大块数据写出去。这样能避免一次大量数据还要在内存里多拷贝一次。也就是说,缓冲只对小写入有意义,大写入会被短路。
使用 Writer 时的核心纪律是:记住 Flush。这个动作不能靠 defer 里随便写一句就完事,因为 defer 会丢掉错误返回值。更稳妥的方式是这样:
w := bufio.NewWriter(conn)
_, err := w.Write(payload)
if err == nil {
err = w.Flush()
}
if err != nil {
// 处理失败,比如关闭连接
}
不要以为调用 Write 返回 nil 数据就到了对端。Write 成功只代表数据进入了缓冲区,Flush 成功才代表底层接受了数据。
还有一个很多人没注意到的细节:bufio.Writer 实现了 io.ReaderFrom,所以在 io.Copy 场景下,如果底层 writer 也实现了 ReaderFrom,它会让底层直接处理拷贝,避免在 bufio 的缓冲里倒一次手。这个优化在写大文件、大网络流时收益很可观。
bufio.Scanner:友好背后的边界限制
Scanner 是在 Reader 之上封装的高层扫描工具,它把按行、按空格、按分隔符这类逻辑抽象成一个 SplitFunc 函数。默认情况下它从一个初始 4096 字节的缓冲开始,超过后自动扩容,但单个 token 的上限是 64KB。这里的 token 可以理解成一次 SplitFunc 返回的完整语义单元,比如一行文本。一旦这一行超过 64KB,Scan 就会返回 false,而 Err 则是 ErrTooLong。
这种默默中断的行为在生产环境里特别危险。想象一个日志采集程序,平时日志都很短,突然有一条错误堆栈或者对象序列化结果特别长,Scanner 停下来,而你的循环只判断了 Scan 的返回值,没查 Err(),整个日志解析就会在那一行断了,后面全丢。日志系统看起来还在运行,实际已经罢工。
要处理超长行,可以调 Buffer 扩大上限:
scanner := bufio.NewScanner(src)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 4*1024*1024)
for scanner.Scan() {
process(scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
这里把最大 token 放宽到 4MB。注意,max 不是越大越好:内存受限的服务端如果允许无限长的 token,攻击者可以轻易制造一个超大 token 让你的 scanner 不断分配内存。合理的做法依然是评估业务边界,设置一个明确的、比最长数据略大的上限。
另外 Scanner 对底层数据是流式读取,不会像 ReadAt 那样随机访问,所以适合顺序扫描大文件。
三种缓冲机制怎么选
实际项目里,Reader 更接近底层控制,Writer 解决写吞吐,Scanner 则是面向高层语义的扫描器。它们不是三个互斥的选项,反而经常配合使用。Scanner 可以认为是复用 Reader 的缓冲思想,在同样的缓冲区模型上加了 token 切分逻辑。
| 类型 | 核心作用 | 默认缓冲 | 主要风险 | 推荐场景 |
|---|---|---|---|---|
| bufio.Reader | 从底层分批读取到内存 | 4096 字节 | Peek/ReadSlice 返回内部引用,易失效 | 需要 peek、按块处理、自定义解析 |
| bufio.Writer | 聚合写入,延迟落盘 | 4096 字节 | 忘记 Flush,数据丢失 | 批量写文件、网络响应、日志输出 |
| bufio.Scanner | 按 token 扫描数据 | 4096 字节,token 上限 64KB | 超长 token 导致 ErrTooLong | 按行或分隔符提取文本、流式解析 |
这个表格不只是在帮你记忆三个类型,更重要的是提示你:它们各自的风险点都在于缓冲带来的状态。Reader 的风险是状态暴露给上层,Writer 的风险是状态藏在内部,Scanner 的风险则是状态可能被超限数据破坏。
真实项目里最常见的三个坑
- 把 Scanner 当作万能的按行读取工具,却不检查 Err。很多线上系统都是 Scan 循环正常返回,某天突然因 ErrTooLong 退出,代码看起来没有 panic,但处理已经停了。
- 错误地持久化 Peek 返回的切片。这种情况在写协议解析时特别典型。你 Peek 了几个字节判断消息类型,然后放到一个结构体里,等后续协程去用,结果底层 Reader 又被读了几次,之前的内容全被覆盖。
- 只信任 Write 返回的 nil,不主动 Flush。在 Web 服务中对 ResponseWriter 包一层 bufio.Writer 时尤其容易出问题。请求处理完,框架可能直接关闭连接,如果你的缓冲没刷新,客户端会一直等到超时。
这三个坑对应的是缓冲策略的语义边界。读缓冲把数据所有权变得模糊,写缓冲把写成功分成了两步,扫描缓冲则把数据形状内在化了。理解了边界,使用上就不容易踩空。
落地时的几个判断标准
- 按行处理普通文本,优先选择 Scanner。但一开始就评估行长分布,用 Buffer 设置一个明确的上限,并始终在循环后检查 Err。
- 需要拆包、解析协议帧、或者预读数据时,用 Reader。锻炼一个习惯:从 bufio 返回的切片如果需要复用,立刻 copy 出来。
- 写网络数据或者批量日志时,用 Writer。把 Flush 当作一个独立步骤,不要塞进 defer 里。如果数据真的需要低延迟,就不要套缓冲,或者写完立即 Flush。
- 关注交互场景。在 RPC 框架里,如果每个请求都要 Flush,缓冲带来的收益会变小;反而是攒够一批再发的场景更适合。不要因为大家都说 bufio 有性能收益就无脑套。
收个尾
回到 bufio 包的三个组件,你会发现它们解决的问题高度一致:用可控的内存,避免无意义的系统调用。但缓冲也引入了新的不确定性,需要开发者显式处理 Flush、处理超长 token、处理数据生命周期。真正决定代码质量的,往往不是那些常规路径,而是这些边界情况。
所以与其记背 API,不如记住一条线索:Reader 缓冲的是读多少,Writer 缓冲的是何时写,Scanner 缓冲的是怎么切。在不同的项目阶段,你可能会在它们之间切换,但底层逻辑始终是同一门关于权衡的功课。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/792/