35 - Go 文件操作:读写与临时文件

文章目录


35 - Go 文件操作:读写与临时文件

在后端开发里,文件操作几乎无处不在:

  • 日志写入
  • 配置读取
  • 文件上传
  • 数据导出
  • 缓存落盘
  • 临时任务处理中间态

很多人觉得文件 IO 很简单:

go 复制代码
os.ReadFile()
os.WriteFile()

能跑就结束了。

但真正到了线上环境:

  • 文件描述符泄漏
  • 缓冲区没刷盘
  • 并发写文件错乱
  • 临时文件堆积
  • 大文件直接 OOM
  • 权限异常导致服务不可用

这些问题,本质都和 Go 文件系统 API 的设计有关。

这篇文章,我们就系统深入 Go 文件操作的核心机制。


核心概念

Go 文件操作解决什么问题?

本质上:

Go 文件操作是在"用户态程序"和"操作系统文件系统"之间建立桥梁。

你的代码并不直接操作磁盘。

而是:

text 复制代码
Go代码
  ↓
syscall (封装了系统调用)
  ↓
Linux VFS (虚拟文件系统)
  ↓
文件系统(ext4/xfs)
  ↓
磁盘

Go 的 os、io、bufio 等包,本质是:

对系统调用(syscall)的高级封装。


文件本质是什么?

在 Linux 世界:

一切皆文件。

普通文件:

text 复制代码
/data/app.log

其实只是:

text 复制代码
inode + 数据块

而 Go 中的:

go 复制代码
*os.File

本质是:

text 复制代码
文件描述符(fd)

例如:

text 复制代码
fd = 3

操作系统通过 fd 定位具体文件。

所以:

go 复制代码
file.Write()

最终会变成:

text 复制代码
write(fd, data)

Go 为什么把文件设计成 io.Reader / io.Writer?

Go 的设计非常经典:

go 复制代码
type Reader interface { // 读 接口
	Read(p []byte) (n int, err error)
}
type Writer interface { // 写 接口
	Write(p []byte) (n int, err error)
}

文件、网络、内存、压缩流:

都实现了 Reader / Writer。

于是:

text 复制代码
文件 -> 网络
网络 -> 文件
文件 -> gzip
gzip -> 文件

全部可以统一处理。

这就是 Go IO 设计最优雅的地方:

"面向流" 而不是 "面向文件"。


小结

Go 文件操作真正重要的不是 API。

而是:

text 复制代码
统一 IO 抽象 -> 面向流编程

这也是 Go IO 体系极其强大的核心原因。


基础使用示例

读取文件

这是最简单的文件读取方式:

go 复制代码
package main

import (
	"fmt"
	"os"
)

func main() {
	// 读取整个文件内容
	data, err := os.ReadFile("test.txt")
	if err != nil {
		fmt.Println("读取失败:", err)
		return
	}

	fmt.Println(string(data))
}

写入文件

go 复制代码
package main

import (
	"os"
)

func main() {
	content := []byte("hello golang")

	// 如果文件不存在会自动创建
	err := os.WriteFile("output.txt", content, 0644) // 0644 表示文件权限
	if err != nil {
		panic(err)
	}
}

权限 0644 是什么意思?

很多人只会复制:

go 复制代码
0644

但不知道含义。

实际上:

text 复制代码
0    -> 八进制
6    -> owner 权限 (拥有者)
4    -> group 权限 (同组用户)
4    -> other 权限 (其他用户)

对应:

text 复制代码
rw-r--r--

即:

text 复制代码
拥有者:读写
其他人:只读

小结

os.ReadFile 和 os.WriteFile:

适合:

  • 小文件
  • 配置文件
  • 简单脚本

但:

不适合大文件。

因为它会一次性全部加载到内存。


进阶使用示例

大文件流式读取

很多人会这样:

go 复制代码
data, _ := os.ReadFile("10GB.log")

然后:

text 复制代码
OOM

因为:

text 复制代码
整个文件一次性进内存

正确做法:

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	file, err := os.Open("big.log")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	// 创建带缓冲读取器
	reader := bufio.NewScanner(file) // 默认缓冲区大小是4096字节
	// 按行读取文件
	for reader.Scan() { // 按行读取文件内容
		line := reader.Text() // 获取当前行内容
		fmt.Println(line)
	}

	if err := reader.Err(); err != nil {
		panic(err)
	}
}

为什么 bufio 更快?

因为:

text 复制代码
系统调用很贵

如果每读一个字节:

text 复制代码
read syscall

CPU 会频繁从:

text 复制代码
用户态 <-> 内核态

切换。

而 bufio:

text 复制代码
一次读一大块

减少 syscall(系统调用) 次数。

性能提升巨大。


思考点

为什么数据库、Nginx、Kafka 都大量使用缓冲 IO?

本质:

text 复制代码
减少系统调用

追加写日志文件

实际开发最常见:

text 复制代码
日志追加
go 复制代码
package main

import (
	"fmt"
	"os"
)

func main() {
	file, err := os.OpenFile( // 打开文件
		"app.log",                           // 文件名
		os.O_CREATE|os.O_APPEND|os.O_WRONLY, // 打开模式
		0644,                                // 文件权限
	)
	if err != nil {
		panic(err)
	}
	defer file.Close() // 关闭文件

	for i := 0; i < 3; i++ { // 写入日志 3 次
		_, err := file.WriteString(fmt.Sprintf("log line %d\n", i)) // 写入日志
		if err != nil {                                             // 写入失败
			panic(err)
		}
	}
}

OpenFile 参数解析

go 复制代码
os.OpenFile(name, flag, perm)

常见 flag:

flag 含义
os.O_RDONLY 只读
os.O_WRONLY 只写
os.O_RDWR 读写
os.O_CREATE 不存在则创建
os.O_APPEND 追加写
os.O_TRUNC 清空文件

O_APPEND 为什么重要?

如果多个 goroutine 同时写:

没有:

go 复制代码
os.O_APPEND

可能发生:

text 复制代码
写覆盖

因为:

text 复制代码
seek + write = 2 次 syscall

不是原子操作。

而:

text 复制代码
O_APPEND

由内核保证:

text 复制代码
每次写入都追加到文件末尾

临时文件使用

很多场景需要:

text 复制代码
中间态文件

例如:

  • 文件上传
  • 图片处理
  • Excel 导出
  • 压缩解压

Go 推荐:

go 复制代码
os.CreateTemp // 创建临时文件

创建临时文件

go 复制代码
package main

import (
	"fmt"
	"os"
)

func main() {
	file, err := os.CreateTemp("", "demo-*.txt") // 创建临时文件
	if err != nil {
		panic(err)
	}
	defer os.Remove(file.Name()) // 删除临时文件

	fmt.Println("临时文件:", file.Name()) // 打印临时文件路径

	file.WriteString("temporary data") // 写入临时文件
}

输出:

text 复制代码
临时文件: /tmp/demo-4054284754.txt

为什么不要自己拼 tmp 文件名?

很多人会:

go 复制代码
"/tmp/" + time.Now().String()

危险点:

  • 文件名冲突
  • 并发竞争
  • 安全问题
  • 路径注入

而:

go 复制代码
CreateTemp // 创建临时文件

内部会生成随机安全文件名。


小结

临时文件核心不是"方便"。

而是:

text 复制代码
隔离中间态

这在工程里非常重要。


常见错误与坑(重点)

defer Close 放在错误检查前

错误写法:

go 复制代码
file, err := os.Open("test.txt")
defer file.Close()

if err != nil {
	panic(err)
}

为什么会错?

如果打开失败:

go 复制代码
file == nil

最终:

go 复制代码
nil.Close()

直接 panic。


正确写法

go 复制代码
file, err := os.Open("test.txt")
if err != nil {
	panic(err)
}

defer file.Close()

忘记 Flush 导致数据丢失

错误写法:

go 复制代码
writer := bufio.NewWriter(file) // 创建 bufio writer
writer.WriteString("hello")     // 写入数据

程序退出:

text 复制代码
文件为空

为什么?

因为:

text 复制代码
数据还在用户态缓冲区

没有真正写入内核。


正确写法

go 复制代码
writer := bufio.NewWriter(file)
writer.WriteString("hello")

// 刷盘
writer.Flush()

底层原理

bufio:

text 复制代码
先写内存
缓冲满了再 syscall

所以:

text 复制代码
Flush = 真正提交

Scanner 读取超长行失败

错误代码:

go 复制代码
scanner := bufio.NewScanner(file) // 创建 scanner

for scanner.Scan() {
	fmt.Println(scanner.Text()) // 打印行
}

读取大 JSON:

text 复制代码
token too long

为什么?

Scanner 默认:

text 复制代码
64KB token 限制

防止恶意超大行导致内存暴涨。


正确写法

go 复制代码
scanner := bufio.NewScanner(file)

buf := make([]byte, 0, 1024*1024)

scanner.Buffer(buf, 10*1024*1024)

思考点

Go 为什么默认限制 Scanner 大小?

本质:

text 复制代码
安全优先

否则一行 10GB:

程序直接炸。


底层原理解析(核心)

os.File 底层结构

Go 源码中:

go 复制代码
type File struct {
	*file
}

内部核心:

text 复制代码
fd 文件描述符

Linux 中:

text 复制代码
fd -> struct file -> inode  // 文件系统元数据

形成完整映射。


Read 到底发生了什么?

go 复制代码
file.Read(buf)

本质:

text 复制代码
用户态buffer
    ↓
syscall.Read
    ↓
内核页缓存(Page Cache)
    ↓
磁盘

注意:

很多时候:

text 复制代码
根本没读磁盘

而是:

text 复制代码
Page Cache 命中

所以文件 IO 未必慢。


为什么 Page Cache 极其重要?

因为磁盘:

text 复制代码
毫秒级

而内存:

text 复制代码
纳秒级

差距巨大。

操作系统必须缓存。


Write 为什么不一定真正落盘?

很多人以为:

go 复制代码
file.Write()

已经写磁盘。

其实不是。

通常:

text 复制代码
写 Page Cache // 用户态缓冲区 ↓ syscall ↓ 内核缓冲区

真正刷盘:

由内核决定。


如何强制落盘?

go 复制代码
file.Sync() // 强制落盘

例如:

go 复制代码
file.Write(data)
file.Sync()

为什么默认不立即落盘?

因为:

text 复制代码
磁盘 IO 太慢

如果每次都同步:

性能会雪崩。

所以:

text 复制代码
先缓存
批量刷盘

这是现代操作系统的经典优化。


点睛总结

现代 IO 系统:

本质是"用内存换磁盘性能"。


对比与扩展

os.ReadFile vs bufio

对比 os.ReadFile bufio
内存占用 高 低
易用性 简单 略复杂
适合小文件 是 是
适合大文件 否 是
性能控制 差 强

Scanner vs Reader

对比 Scanner Reader
易用性 高 中
性能 一般 更高
超长文本 不友好 友好
适合日志 是 是
适合大文件 一般 更强

临时文件 vs 内存缓存

对比 临时文件 内存
速度 慢 快
容量 大 有限
崩溃恢复 可恢复 不可恢复
适合大数据 是 否

最佳实践

小文件直接 ReadFile

例如:

  • yaml
  • json 配置
  • 小型模板

直接:

go 复制代码
os.ReadFile

最简单。


大文件必须流式处理

永远不要:

go 复制代码
读取整个 20GB 文件

生产环境非常危险。


日志写入必须使用缓冲

go 复制代码
bufio.NewWriter

可以极大降低 syscall。


临时文件一定及时删除

推荐:

go 复制代码
defer os.Remove(file.Name())

否则:

text 复制代码
/tmp 爆满

线上非常常见。


重要数据必须 Sync

例如:

  • WAL
  • 订单
  • 金融数据

否则:

text 复制代码
宕机可能丢数据

小结

文件 IO 真正重要的是:

text 复制代码
性能
一致性
资源管理

而不是:

text 复制代码
API 会不会用

思考与升华

Go IO 为什么这么优雅?

因为它抽象的是:

text 复制代码
数据流

而不是:

text 复制代码
磁盘

所以:

text 复制代码
文件
网络
内存
压缩

都能统一处理。


一个简化版 Reader 实现

go 复制代码
type Reader interface {
	Read(p []byte) (n int, err error)
}

核心思想:

text 复制代码
调用方提供buffer
底层负责填充数据

这样:

  • 避免频繁内存分配
  • 实现零拷贝优化
  • 可复用缓冲区

这是 Go IO 高性能的重要基础。


为什么 Go IO 模型值得学习?

因为它体现了:

text 复制代码
抽象能力

真正优秀的设计:

不是功能堆砌,而是统一模型。

Go 把:

text 复制代码
文件
网络
内存

全部统一成:

text 复制代码
流(stream)

这也是 Go IO 体系最大的设计哲学。


总结

Go 文件操作表面看只是:

go 复制代码
ReadFile
WriteFile

但底层其实涉及:

  • syscall
  • Page Cache
  • 文件描述符
  • 缓冲区
  • 内核态/用户态切换
  • IO 性能优化

真正理解这些后:

你会发现:

文件 IO 从来不是"读写文件",而是"操作系统资源管理"。

相关推荐
徐小黑ACG11 小时前
Golang 基础05 结构体struct
开发语言·算法·golang
北冥you鱼14 小时前
Go 语言空接口(interface{})使用场景与最佳实践
开发语言·windows·golang
徐小黑ACG17 小时前
Golang 基础06 接口interface
开发语言·后端·golang
小小龙学IT17 小时前
Go 语言 cgo 与 C/C++ 互操作深度解析:从 fwlib 直连到工业数采网关实战系列
c语言·c++·golang
牛奔18 小时前
mise 管理多版本 Go 项目
开发语言·前端·chrome·后端·golang
ttwuai20 小时前
Go后台管理系统开源项目有哪些?如何按交付形态筛掉不合适的仓库
开发语言·golang·开源
报错小能手21 小时前
GO面试题 Go基础面试题
golang
皓月盈江1 天前
Go读写TXT文件
开发语言·后端·golang·go·读写txt文件·bufio·go读写txt
JavaPub-rodert1 天前
开源一套 Go + React 后台管理系统,支持 RBAC 权限管理、Docker 一键部署!
react.js·golang·开源
FfHUCisI1 天前
pprof 火焰图(Flame Graph)阅读与热点代码重构实战
架构·golang