Go运行时不调度文件I/O,需手动优化缓冲、并发与读写顺序:用64KB bufio.NewReaderSize适配SSD/网络,避免多Reader竞争偏移,定长记录直调f.Read,io.CopyBuffer+sync.Pool降GC,分段Seek处理大文件,mmap慎用而ReadAt更可控。

别指望 Go 运行时自动调度文件 I/O —— 它根本不管,全靠你手动组织读写顺序、并发粒度和缓冲生命周期。
bufio.NewReaderSize 为什么比默认 Reader 更关键
默认 bufio.NewReader 用 4KB 缓冲,在 NVMe 或高吞吐日志场景下,仍会频繁触发 read 系统调用,尤其当单次读取量远小于缓冲区(如逐字节解析)时,实际吞吐卡在 syscall 切换上。
- 顺序读大文本(CSV/JSON/日志流):显式用
bufio.NewReaderSize(f, 64*1024),64KB 对多数 SSD 和千兆网络更匹配;太小(1MB)易引发 CPU cache miss 和 goroutine 阻塞 - 绝对不要对同一个
*os.File套多个bufio.Reader:底层文件偏移由每个 Reader 自己维护,必然跳字节或重复读 - 定长二进制记录(如协议头、索引项):跳过
bufio,直接复用预分配的[]byte调f.Read(buf),避免ReadString或ReadLine的长度不确定性
io.CopyBuffer 替代 io.Copy 控制内存与 GC
io.Copy 内部硬编码 32KB 临时切片,每次调用都 make([]byte, 32*1024),高频小文件拷贝(如微服务间中转)会显著抬高 GC 压力。
- 用
sync.Pool预分配并复用缓冲切片:var bufPool = sync.Pool{New: func() interface{} { return make([]byte, 0, 32*1024) }} - 调用
io.CopyBuffer(dst, src, bufPool.Get().([]byte)),拷贝完立刻bufPool.Put(buf) - 注意:第三个参数必须是切片(
[]byte),传[32*1024]byte{}会导致每次调用都 new 数组,反而更差 - 若
dst是 TLS 连接等不支持部分写的net.Conn,大缓冲可能触发多次Write,需实测验证
并发读写同一文件时的调度陷阱
盲目开大量 goroutine 并发读写单个文件,不是加速而是制造磁盘争用——尤其在机械盘或高负载服务器上,I/O 吞吐常断崖下跌。
立即学习“go语言免费学习笔记(深入)”;
- 分段处理可分割大文件(如日志按时间戳切片):用
f.Seek(offset, 0)定位,每个 worker 处理固定字节范围,配合sync.WaitGroup协调 - 同一文件并发写入必须序列化:要么加
sync.Mutex,要么用 channel 把写请求排队,否则数据错乱 - worker 数量别拍脑袋:SSD 上通常 8–16 个足够,HDD 上 4–8 更稳;超了反而因调度和寻道开销拖慢整体
- 别在循环里反复
defer f.Close():大量小文件场景下,close系统调用本身就有延迟累积
mmap 不是银弹,ReadAt + 缓冲池往往更可控
mmap 适合 GB 级只读随机访问(如索引查找),但它把页管理交给了虚拟内存子系统,一旦文件被 truncate 或 Unmap,映射地址立即失效;且写入需手动 Msync,出错易丢数据。
- 更通用的替代方案:用
f.ReadAt(buf, offset)+sync.Pool管理固定大小缓冲(如 128KB),完全绕过bufio偏移同步问题,也避免 mmap 的生命周期风险 - 打开文件时慎用
O_DIRECT:要求 buffer 地址和长度均对齐 512 字节,否则返回syscall.EINVAL;且绕过页缓存后,你得自己实现预读逻辑 - 追加写日志优先用
os.O_APPEND标志:内核保证原子性,比手动Seek(0, io.SeekEnd)更快更稳
真正卡性能的从来不是某一行代码,而是缓冲区大小是否匹配硬件块、并发数是否贴合磁盘寻道能力、以及 mmap 地址是否在文件 truncate 后还被当成有效指针用 —— 这些细节不实测就容易翻车。


















