学完这篇你能拿到三套可直接抄用的 Go 文件处理方案:分块读大文件、多 goroutine 安全写同一个输出、以及用内存映射(mmap)跳过拷贝直接访问文件内容。
第一步:大文件读取,先放弃 ReadAll
os.ReadFile 和 io.ReadAll 会把整个文件塞进内存,1 GB 的日志瞬间吃掉 1 GB 堆,还容易触发 GC 抖动。正确做法是固定缓冲区循环读:
f, err := os.Open("big.log")
if err != nil { return err }
defer f.Close()
buf := make([]byte, 1<<20) // 1 MB
for {
n, err := f.Read(buf)
if n > 0 {
process(buf[:n]) // 注意只处理前 n 字节
}
if err == io.EOF { break }
if err != nil { return err }
}
缓冲区大小 64 KB~1 MB 是甜点区,再大收益很小。如果是"读一个文件写到另一个文件"这种纯搬运,直接用 io.Copy(dst, src),它内部会走 sendfile/splice,比你手写的循环更快。
第二步:按行处理时,别被 Scanner 的 64 KB 卡住
bufio.Scanner 写起来最舒服,但它默认单行上限 64 KB,遇到超长行(比如压缩成一行的 JSON、Base64 数据)会直接返回 bufio.Scanner: token too long:
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 64<<20) // 初始 1 MB,上限 64 MB
for sc.Scan() {
line := sc.Text()
_ = line
}
if err := sc.Err(); err != nil { return err } // 这句千万别省
注意:sc.Scan() 循环结束后一定要检查 sc.Err(),读文件出错时它只是静默返回 false,不查就会把"读到一半失败"当成"读完了"。
如果行长度不可控,改用 bufio.Reader:
r := bufio.NewReaderSize(f, 1<<20)
for {
line, err := r.ReadString('\n')
if len(line) > 0 { process(line) }
if err == io.EOF { break }
if err != nil { return err }
}
第三步:并发写入,先想清楚"写哪里"
多个 goroutine 直接对同一个 *os.File 调 Write 不会崩,但数据会交错——两次 Write 的字节可能穿插在一起。根据场景选方案:
方案 A:单写者 + channel(最常用),把并发收在计算侧,写入永远串行:
ch := make(chan []byte, 1024)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
w := bufio.NewWriterSize(f, 1<<20)
defer w.Flush()
for b := range ch {
if _, err := w.Write(b); err != nil { return }
}
}()
for _, task := range tasks { // worker 并发产出
go func(t Task) { ch <- t.Render() }(task)
}
close(ch)
wg.Wait()
方案 B:WriteAt 按偏移写,适合定长记录(如固定 4 KB 的块)。WriteAt 不共享文件偏移,多个 goroutine 各写各的区间不会互相干扰:
_, err := f.WriteAt(chunk, int64(idx*chunkSize))
方案 C:各写各的分片,最后合并,适合结果顺序无关、想榨满多核的场景。每个 goroutine 用 os.Create(fmt.Sprintf("part-%d.tmp", i)) 写自己的临时文件,wg.Wait() 后用 io.Copy 依次追加到最终文件再 os.Remove 清理。缺点是峰值磁盘占用翻倍。
注意:用了 bufio.Writer 就必须 Flush,而且要放在 defer 里紧跟创建之后——中途 return err 时缓冲区里的最后几 KB 会静默丢失。
第四步:内存映射读超大文件
mmap 把文件页直接映射进进程地址空间,读它就像读一个 []byte,没有 read() 的系统调用和数据拷贝。标准库没有封装,最省事的是 golang.org/x/exp/mmap(跨平台,只读):
r, err := mmap.Open("big.bin")
if err != nil { return err }
defer r.Close()
buf := make([]byte, 4096)
n, _ := r.ReadAt(buf, 1<<30) // 跳到 1 GB 处随机读
需要写入或追求零依赖时用 syscall.Mmap(仅 Unix 系,Windows 不支持):
st, _ := f.Stat()
if st.Size() == 0 { return nil } // 空文件 mmap 会报 EINVAL
data, err := syscall.Mmap(int(f.Fd()), 0, int(st.Size()),
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
if err != nil { return err }
defer syscall.Munmap(data)
data[0] = 'X' // 直接改,MAP_SHARED 会落盘
注意几个真实的坑:
- Munmap 之后那片 data 立刻失效,任何引用(包括子切片)都不能再用,否则是 use-after-free。
- mmap 的内存不计入 Go 堆,GC 完全感知不到,映射几十个 GB 不会触发 GC,只会被操作系统 OOM Killer 干掉。
- 文件被别的进程截断后继续访问越界页,会收到 SIGBUS,整个进程直接挂掉,无法 recover。
- 32 位环境下地址空间只有 3 GB 左右,别映射大文件。
- 顺序扫描整个文件时,mmap 常常不如 f.Read 快(大量缺页中断);它的优势在随机访问和反复读同一片区域。
小结
- 大文件一律分块读,缓冲区 64 KB~1 MB;纯搬运用
io.Copy。
bufio.Scanner 要调 sc.Buffer 抬上限,循环后必须查 sc.Err()。
- 并发写同一文件:交错的根源是共享文件偏移,用单写者 channel、
WriteAt 定长偏移、或分片文件后合并三种方式规避。
- mmap 适合随机读,不适合顺序扫;注意空文件、Munmap 后失效、SIGBUS 和 GC 不感知这四点。
- 所有错误路径都要覆盖,Go 里"文件写完没报错但少了半截"绝大多数是漏了
Flush 或没查 Err()。