Go 语言文件处理:大文件读取 / 并发写入 / 内存映射

shandian
shandian 见习用户见习用户
发布于 2026-10-17 12:22 ·9 浏览 ·3 回复
内容摘要

针对Go处理大文件时一次性读取会占满内存并引发GC抖动的问题,给出分块读大文件、多goroutine安全写同一输出及用mmap直接访问文件内容三套方案,并主张以固定缓冲区循环读取替代os.ReadFile和io.ReadAll。

学完这篇你能拿到三套可直接抄用的 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()。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-866.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

aixiu
aixiu 正式会员正式会员认证极客认证极客 1楼 2026-10-17 12:32

这套写法基本可以直接抄,我补几个实战里真会踩的点,尤其是第三步后面被截断的那部分。

先说缓冲复用的坑:process(buf[:n]) 里如果只做同步处理没问题,但一旦把这段切片丢进 channel 或 append 到某个结构体里,下一轮 Read 就会把它覆盖掉,数据静默错乱。要异步就 append([]byte(nil), buf[:n]...) 拷一份,别省这个分配。

io.Copy 走 sendfile 是有前提的:dst 和 src 必须都是 *os.File(或实现了 ReaderFrom/WriterTo);中间只要包一层 bufio.Writer 或自己写循环,内核零拷贝就失效了,退化成普通 read/write。纯搬运就用裸的 io.Copy(dst, src),别包装。

并发写除了单写者,还有两条路:一是分片临时文件——每个 goroutine 写自己的 xxx.part,结束时按序 io.Copy 合并,简单、无锁、崩溃还能续;二是WriteAt 定偏移写,适合输出位置可预先算出的场景(比如按索引分块输出),前提是文件先 Truncate 到最终大小,否则并发写尾部会互相覆盖。sync.Mutex 包一层当然也行,但那样并发就只是把计算并行了,写入依旧串行,跟 channel 方案本质一样,选哪个看你要不要背压。

mmap 那段(后面被截了)提醒两句:Go 里用 syscall.Mmap 拿到的 []byte GC 是感知不到的,必须自己 Munmap,否则句柄泄漏;映射期间文件被其他进程截短,访问越界会直接 SIGBUS 崩进程,不是返回 error。所以 mmap 适合"只读、大小固定、随机访问"的场景,日志这种持续增长的文件别用。

另外 f.Read 完全可能返回 n > 0 同时 err == io.EOF,你贴的处理顺序是对的——先消数据再看 err,很多人反过来写就丢最后一块。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #872 2楼 2026-10-17 12:38
aixiu:这套写法基本可以直接抄,我补几个实战里真会踩的点,尤其是第三步后面被截断的那部分。 **先说缓冲复用的坑**:`process(buf[:n])` 里如果只做…

这几条都踩在点上,尤其 SIGBUS 那条——那不是 error 是信号,recover 都拦不住,只能靠约束使用场景来避。我补几个他还没提到的边角。

缓冲复用如果想省分配而不是拷:sync.Pool 能管住分配,但归还时机很坑——得确保没人还引用那块内存才能 Pool.Put,异步场景很容易被"刚归还就被下一轮 Write 覆盖"。更干净的路子是按块并行读:用 io.SectionReader 把文件切段,每个 goroutine 拿自己的 buffer 读自己的段,既不用拷也没有共享,本质是把"并发写"变成"并发读+串行拼装"。

io.Copy 的零拷贝不止 sendfile:Go 1.15 起,两个 *os.File 之间会优先走 copy_file_range,同盘大文件拷贝比 sendfile 还快(不用经过用户态也不用 socket)。另外 io.TeeReader、限速 reader 这类包装同样会打断零拷贝,要进度条就只能放弃内核路径,二选一。

WriteAt 补两点:os.File.WriteAt 走的是 pwrite,不改文件偏移,本身并发安全,这点可以放心;但 Truncate 出来的是稀疏文件,尾部块落盘可能触发额外分配,想稳就用 fallocate 预分配(Linux)。分片临时文件合并时记得 f.Sync() 再 os.Rename 原子替换,否则断电容易半截。

mmap 跨平台要注意:syscall.Mmap 是类 Unix 专有,Windows 上没有,要跨平台得走 golang.org/x/sys 或第三方库;只读场景其实 golang.org/x/exp/mmap 就够,省得自己管 Munmap。另外写映射改完要 msync,不然进程正常退出也不保证落盘。

常见坑:n > 0 && err == io.EOF 这个顺序对了,但别忘了 Close() 的错误在多写场景下也可能带出延迟写失败——写文件那条路,defer f.Close() 之后最好再显式查一次。

1717左
1717左 见习用户见习用户 #874 3楼 2026-10-17 12:46
laoxie:这几条都踩在点上,尤其 SIGBUS 那条——那不是 error 是信号,recover 都拦不住,只能靠约束使用场景来避。我补几个他还没提到的边角。 **缓…

这几条补得比我原帖还细,尤其 copy_file_range 和目录 fsync 这两点,我原帖完全没提。

SectionReader 分段并行读这个思路我很赞成,但有个必须提醒的点:按字节切段会切断行/记录边界。按行处理时每段得先扫到第一个换行才开始,末尾的残行交给下一段或者单独收尾缓冲,否则会丢行、串行。所以它更适合定长记录或自己能解析边界的格式(比如按 offset 索引的块),纯文本日志还得做残行拼接。另外物理层面:多段并行读在 NVMe 上是真加速,机械盘上反而可能因为寻道退化更慢,并发度别照抄 GOMAXPROCS,先量一遍再定。

copy_file_range 补一句边界:它只在同一文件系统内有效,跨设备返回 EXDEV 让 Go 回退普通拷贝;早期内核跨挂载点还有过数据不一致的 bug,Go 侧对 ENOSYS/EINVAL 都做了回退,所以业务上一般不用管,但要知道"零拷贝"不是必然发生的,别把性能预期写死在设计里。

fallocate 那条同意,标准库确实没有,得 golang.org/x/sys/unix.Fallocate;注意容器 overlayfs、部分老文件系统会返回 EOPNOTSUPP,要有回退到 Truncate 的分支,不然上线就 panic。

Close 检查建议直接写成具名返回:

func writeTo(name string) (err error) {
	f, err := os.Create(name)
	if err != nil { return err }
	defer func() {
		if cerr := f.Close(); err == nil { err = cerr }
	}()
	// ...
}

包了 bufio.Writer 的话顺序是 Flush → 查 Flush 错误 → Close,Flush 那步丢错的概率比 Close 高得多。