文章目录
- [35 - Go 文件操作:读写与临时文件](#35 - Go 文件操作:读写与临时文件)
- 核心概念
- 基础使用示例
- 进阶使用示例
- 临时文件使用
- 常见错误与坑(重点)
- 底层原理解析(核心)
-
- [os.File 底层结构](#os.File 底层结构)
- [Read 到底发生了什么?](#Read 到底发生了什么?)
- [为什么 Page Cache 极其重要?](#为什么 Page Cache 极其重要?)
- [Write 为什么不一定真正落盘?](#Write 为什么不一定真正落盘?)
- 对比与扩展
-
- [os.ReadFile vs bufio](#os.ReadFile vs bufio)
- [Scanner vs Reader](#Scanner vs Reader)
- [临时文件 vs 内存缓存](#临时文件 vs 内存缓存)
- 最佳实践
-
- [小文件直接 ReadFile](#小文件直接 ReadFile)
- 大文件必须流式处理
- 日志写入必须使用缓冲
- 临时文件一定及时删除
- [重要数据必须 Sync](#重要数据必须 Sync)
- 小结
- 思考与升华
-
- [Go IO 为什么这么优雅?](#Go IO 为什么这么优雅?)
- [一个简化版 Reader 实现](#一个简化版 Reader 实现)
- [为什么 Go IO 模型值得学习?](#为什么 Go IO 模型值得学习?)
- 总结
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 从来不是"读写文件",而是"操作系统资源管理"。