不是所有文件都应该一次读进内存
入门阶段我们常用 os.ReadFile 一次性读取文件内容,这种方式对配置文件和小数据文件非常方便。但当文件增长到几百 MB 甚至几个 GB 时,一次读进内存就不再合适。日志分析、CSV 处理、文本转换、命令行过滤工具,都更适合边读边处理的方式。
Go 标准库的 bufio 包就是为这类场景设计的。它基于 io.Reader 和 io.Writer 接口提供了缓冲层,大幅减少系统调用次数,提升 IO 效率。bufio.Scanner 适合逐行扫描,代码简洁直观;bufio.Reader 提供更精细的读取控制;bufio.Writer 实现缓冲写入,减少写入时的系统调用开销。掌握这些工具后,你就能写出在处理大规模数据时依然稳定高效的程序。
本文从 Scanner 逐行读取开始,深入 Reader 和 Writer 的缓冲原理,展示大文件处理、HTTP 流读取、CSV 和 JSON 流式解析中的实战应用,帮助你建立完整的 bufio 知识体系。
Scanner 逐行读取机制
基本逐行扫描
假设日志文件每行代表一个请求记录:
package main
import (
"bufio"
"fmt"
"os"
)
func countLines(path string) (int, error) {
file, err := os.Open(path)
if err != nil {
return 0, fmt.Errorf("open file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
count++
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("scan file: %w", err)
}
return count, nil
}
scanner.Scan() 每次读取一个 token,默认以换行符分割。scanner.Text() 返回当前 token 的字符串形式,scanner.Bytes() 返回字节切片形式。循环结束后必须检查 scanner.Err(),否则读取中途出错会被静默忽略。
统计状态码实战
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func countStatusCodes(path string) (map[string]int, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
counts := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
status := fields[2]
counts[status]++
}
if err := scanner.Err(); err != nil {
return nil, err
}
return counts, nil
}
这段代码不会把整个日志读进内存,只保存状态码计数。对于 GB 级别的日志文件,内存占用始终保持在 O(1) 级别。
Scanner 的缓冲限制与调整
Scanner 默认的最大 token 长度是 64KB,处理特别长的一行时会报错:bufio.Scanner: token too long。这在处理 JSON 日志行或压缩数据时很常见。
调大缓冲区
scanner := bufio.NewScanner(file)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024) // 最大 1MB
这表示初始缓冲区 64KB,最大可增长到 1MB。如果每行可能非常大,考虑使用 bufio.Reader 来代替 Scanner。
自定义 SplitFunc
Scanner 的强大之处在于可以通过自定义 SplitFunc 实现任意分词逻辑:
// 按段落分割(空行分隔)
func ScanParagraphs(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
// 查找两个连续换行
for i := 0; i < len(data)-1; i++ {
if data[i] == '\n' && data[i+1] == '\n' {
return i + 2, data[:i+1], nil
}
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil // 请求更多数据
}
func main() {
scanner := bufio.NewScanner(strings.NewReader(text))
scanner.Split(ScanParagraphs)
for scanner.Scan() {
fmt.Println("=== Paragraph ===")
fmt.Println(scanner.Text())
}
}
Go 标准库内置了多种 SplitFunc:
| 函数 | 分割规则 |
|---|---|
ScanBytes | 逐字节 |
ScanRunes | 逐 Unicode 码点 |
ScanLines | 逐行(默认) |
ScanWords | 逐词 |
bufio.Reader:精细读取控制
当 Scanner 的默认行为不满足需求时,bufio.Reader 提供更灵活的读取能力。
ReadString 逐行读取
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
if len(line) > 0 {
processLine(line)
}
break
}
return err
}
processLine(line)
}
ReadString 读到分隔符为止。如果最后一行没有换行符,遇到 io.EOF 时仍可能返回有效数据,必须处理 line != "" 的情况。
ReadLine:更底层的行读取
reader := bufio.NewReader(file)
for {
line, isPrefix, err := reader.ReadLine()
if err != nil {
if err == io.EOF {
break
}
return err
}
if isPrefix {
// 行太长,需要拼接
var fullLine []byte
fullLine = append(fullLine, line...)
for isPrefix {
line, isPrefix, err = reader.ReadLine()
if err != nil {
return err
}
fullLine = append(fullLine, line...)
}
processLine(string(fullLine))
} else {
processLine(string(line))
}
}
ReadLine 不返回换行符,且当行超过缓冲区时返回 isPrefix = true。它的开销比 ReadString 稍低,但使用更繁琐。
Peek:预读不消费
reader := bufio.NewReader(file)
peek, err := reader.Peek(10)
if err != nil {
return err
}
if bytes.HasPrefix(peek, []byte("HEADER")) {
// 处理头格式
}
Peek(n) 返回接下来的 n 个字节但不移动读取位置。这在协议识别和格式探测中很有用。
ReadBytes 与 ReadSlice
// ReadBytes 读到分隔符,返回切片的副本
line, err := reader.ReadBytes('\n')
// ReadSlice 读到分隔符,返回底层缓冲区的引用(更快但不安全持久化)
line, err := reader.ReadSlice('\n')
ReadSlice 返回的数据可能被后续读取覆盖,如果要长期保存,需要使用 ReadBytes 或自行拷贝。
bufio.Writer:缓冲写入
大量写入时,bufio.Writer 通过批量刷新来减少系统调用:
基本用法
func writeReport(path string, counts map[string]int) error {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("create report: %w", err)
}
defer file.Close()
writer := bufio.NewWriter(file)
defer writer.Flush()
for status, count := range counts {
if _, err := fmt.Fprintf(writer, "%s,%d\n", status, count); err != nil {
return fmt.Errorf("write report: %w", err)
}
}
return writer.Flush()
}
Flush 至关重要。缓冲写入的数据不一定立刻落到文件,忘记 Flush 可能导致内容不完整。为了处理 Flush 错误,关键数据写入应显式检查。
批量写入的 Flush 策略
func processLargeOutput(input io.Reader, output io.Writer) error {
reader := bufio.NewReader(input)
writer := bufio.NewWriter(output)
defer writer.Flush()
linesProcessed := 0
for {
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return err
}
processed := transformLine(line)
if _, err := writer.WriteString(processed); err != nil {
return err
}
linesProcessed++
// 每 1000 行主动 Flush,避免数据丢失风险
if linesProcessed%1000 == 0 {
if err := writer.Flush(); err != nil {
return fmt.Errorf("flush failed: %w", err)
}
}
if err == io.EOF {
break
}
}
return writer.Flush()
}
io.Copy 与 bufio 的协作
func copyFileWithBuffer(src, dst string) (int64, error) {
in, err := os.Open(src)
if err != nil {
return 0, err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return 0, err
}
defer out.Close()
// io.Copy 内部会根据源和目标的类型选择最优策略
return io.Copy(out, in)
}
io.Copy 在内部会使用 bufio 的缓冲策略(如果源和目标没有实现 io.WriterTo/io.ReaderFrom)。但你也可以显式包装:
reader := bufio.NewReader(in)
writer := bufio.NewWriter(out)
written, err := io.Copy(writer, reader)
if err != nil {
return 0, err
}
if err := writer.Flush(); err != nil {
return 0, err
}
HTTP 响应体读取优化
处理 HTTP 响应时,bufio 可以帮助优化读取性能:
func fetchAndProcess(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// resp.Body 已经是 bufio.Reader 包装过的
// 对于大多数场景直接使用即可
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
processLine(line)
}
return scanner.Err()
}
对于大文件下载,流式处理避免内存堆积:
func downloadToFile(url, path string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
// io.Copy 会自动处理缓冲
_, err = io.Copy(out, resp.Body)
return err
}
CSV 流式解析中的 bufio 应用
对于超大 CSV 文件,流式解析是必须的:
func processLargeCSV(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
reader := csv.NewReader(bufio.NewReader(file))
reader.ReuseRecord = true // 重用切片减少分配
// 跳过表头
if _, err := reader.Read(); err != nil {
return err
}
recordsProcessed := 0
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("row %d: %w", recordsProcessed, err)
}
if err := processRecord(record); err != nil {
return fmt.Errorf("process row %d: %w", recordsProcessed, err)
}
recordsProcessed++
// 批量提交或大文件时的进度报告
if recordsProcessed%10000 == 0 {
fmt.Printf("Processed %d records\n", recordsProcessed)
}
}
return nil
}
csv.Reader 内置了 bufio.Reader 的缓冲机制,配合 ReuseRecord = true 可以大幅减少内存分配。
JSON 流式解析中的 bufio 应用
对于 JSON Lines 格式(每行一个 JSON 对象),使用 json.Decoder 配合 bufio.Scanner:
func processJSONLines(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNum := 0
for scanner.Scan() {
lineNum++
var event Event
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
return fmt.Errorf("line %d: %w", lineNum, err)
}
if err := handleEvent(event); err != nil {
return fmt.Errorf("line %d: %w", lineNum, err)
}
}
return scanner.Err()
}
对于 JSON 数组的流式解析,使用 json.Decoder:
func processJSONArray(r io.Reader) error {
decoder := json.NewDecoder(bufio.NewReader(r))
// 读取开始数组标记
token, err := decoder.Token()
if err != nil {
return err
}
if token != json.Delim('[') {
return fmt.Errorf("expected array start")
}
count := 0
for decoder.More() {
var item Item
if err := decoder.Decode(&item); err != nil {
return err
}
if err := processItem(item); err != nil {
return err
}
count++
}
// 读取结束数组标记
token, err = decoder.Token()
if err != nil {
return err
}
if token != json.Delim(']') {
return fmt.Errorf("expected array end")
}
fmt.Printf("Processed %d items\n", count)
return nil
}
大文件逐行读取的性能优化
处理 GB 级别文件时,以下优化策略非常关键:
1. 合理设置缓冲区大小
// 64KB 缓冲区适合大多数场景
reader := bufio.NewReaderSize(file, 64*1024)
// 对于非常规则的小行(如固定长度记录),可以更大
reader := bufio.NewReaderSize(file, 256*1024)
2. 避免字符串转换(如果可能)
// 如果后续处理只需要字节,避免 string 转换
for scanner.Scan() {
line := scanner.Bytes() // 字节切片,不重新分配
processBytes(line)
}
3. 减少内存分配
type LineProcessor struct {
buffer []byte // 复用缓冲区
}
func (p *LineProcessor) Process(r io.Reader) error {
reader := bufio.NewReader(r)
for {
p.buffer = p.buffer[:0] // 重置但不释放
line, err := reader.ReadSlice('\n')
p.buffer = append(p.buffer, line...)
if err != nil && err != bufio.ErrBufferFull {
if err == io.EOF {
if len(p.buffer) > 0 {
processLine(p.buffer)
}
break
}
return err
}
processLine(p.buffer)
}
return nil
}
常见问题与最佳实践
问题 1:忘记检查 scanner.Err()
// 错误:扫描错误被忽略
for scanner.Scan() {
process(scanner.Text())
}
// scanner.Err() 未被检查!
// 正确
for scanner.Scan() {
process(scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan failed: %w", err)
}
问题 2:Flush 处理不当
// 危险:defer 中的 Flush 错误被忽略
writer := bufio.NewWriter(file)
defer writer.Flush() // Flush 的错误被丢弃
// 更好:关键数据显式 Flush
if err := writer.Flush(); err != nil {
return fmt.Errorf("flush failed: %w", err)
}
问题 3:Reader 和 Scanner 混用
// 错误:同一个底层 reader 被两种包装器读取
reader := bufio.NewReader(file)
scanner := bufio.NewScanner(file)
// 正确:基于同一个底层 reader,或者只用其中一种
scanner := bufio.NewScanner(file)
// 或
reader := bufio.NewReader(file)
最佳实践总结
- 小文件用
os.ReadFile,大文件用bufio.Scanner - Scanner 默认 64KB token 上限,处理长行时调大
- Reader 比 Scanner 更灵活,但代码更复杂
- Writer 一定要记得 Flush,关键数据显式检查错误
- 批量处理时定期 Flush 可减少数据丢失风险
- 使用
ReuseRecord = true优化 csv.Reader 的内存使用 - JSON 流式解析用
json.Decoder而非一次性json.Unmarshal
小结
bufio 是 Go 文本处理的基石包。Scanner 适合逐行读取,代码简洁;Reader 适合更灵活的读取控制;Writer 适合缓冲输出,减少系统调用。处理大文件时,流式处理比一次读入内存更可靠。
在 HTTP 响应读取、CSV 解析、JSON 流式处理等场景中,合理配置 bufio 的缓冲策略,可以显著提升程序性能。入门阶段可以记住一个判断原则:配置文件和小数据用 os.ReadFile 很方便;日志、导出文件、长文本和流式输入,用 bufio.Scanner 或 Reader 更稳。工具选对了,程序在数据变大时才不会突然撑爆内存。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。