Go 实现按行读取文件:3 种方法+避坑指南
在做日志分析工具时,一开始用ioutil.ReadFile把整个日志文件读进内存再按行分割,测试小文件没问题。
上线后遇到几个 10GB 的超大日志,直接触发内存溢出。
后来换成流式读取才解决问题——这也是 Go 按行读文件最容易踩的坑:没根据文件大小选对方法。
今天把之前总结的 3 种核心方法和避坑点整理出来,希望能够帮助大家少做弯路。
先明确核心原则:
- 小文件( 几MB 内)追求便捷,可一次性读入后分割;
- 大文件( 几十MB 以上)必须流式读取,避免内存爆炸。
一、bufio.Scanner 流式读取
这个方法最常用。
Go 标准库的bufio.Scanner是为“流式读取文本”设计的,默认按行分割,内部有缓冲区(默认 64KB ),会自动处理换行符,兼顾性能和便捷性,是大多数场景的首选。
比如读取日志文件并统计包含 “error” 的行数,核心是通过Scan()方法逐行迭代,Text()获取当前行内容:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
// 1. 打开文件(只读模式)
file, err := os.Open("app.log")
if err != nil {
// 错误处理:区分文件不存在、权限不足等场景
panic(fmt.Sprintf("打开文件失败:%v", err))
}
// 关键:延迟关闭文件,避免资源泄漏
defer func() {
if err := file.Close(); err != nil {
fmt.Printf("关闭文件失败:%v", err)
}
}()
// 2. 创建Scanner对象,绑定文件
scanner := bufio.NewScanner(file)
// 3. 逐行读取(Scan()返回false表示结束或出错)
errorCount := 0
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text() // 获取当前行文本(自动去除换行符)
// 业务逻辑:统计error行数
if strings.Contains(line, "error") {
errorCount++
fmt.Printf("第%d行存在错误:%s\n", lineNum, line)
}
}
// 4. 检查遍历过程中是否出错(如文件损坏)
if err := scanner.Err(); err != nil {
panic(fmt.Sprintf("读取文件出错:%v", err))
}
fmt.Printf("共找到%d行错误日志\n", errorCount)
}核心优势:Scan()是流式处理,每次只读取缓冲区大小的数据,即使 10GB 大文件也不会内存溢出;Text()自动剔除行尾的\n或\r\n,不用手动处理换行符。
如果文件中有超长大行(比如一行 几MB 的 JSON 数据),默认 64KB 缓冲区会导致Scan()返回错误。
解决办法是用Buffer()方法扩大缓冲区:
func main() {
file, err := os.Open("big_line.log")
if err != nil {
panic(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
// 扩大缓冲区到10MB(根据实际行大小调整)
buf := make([]byte, 10*1024*1024) // 10MB
scanner.Buffer(buf, 10*1024*1024)
for scanner.Scan() {
line := scanner.Text()
fmt.Printf("当前行长度:%d字节\n", len(line))
}
if err := scanner.Err(); err != nil {
panic(fmt.Sprintf("读取超长大行失败:%v", err))
}
}二、bufio.Reader 控制
如果需要自定义“行分隔符”(比如按|分割而非换行符),或需要获取行内容的字节切片(减少字符串拷贝),就用bufio.Reader的ReadBytes()或ReadString()方法,灵活性更高。
实现和 Scanner 类似的按行读取,适合需要字节切片的场景(比如处理二进制文本):
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Open("data.txt")
if err != nil {
panic(err)
}
defer file.Close()
// 创建Reader对象(默认缓冲区4096字节,可自定义)
reader := bufio.NewReader(file)
lineNum := 0
for {
// 按换行符'\n'读取一行(返回的字节切片包含'\n')
lineBytes, err := reader.ReadBytes('\n')
if err != nil {
// 处理结束:io.EOF表示正常结束,其他是错误
if err.Error() == "EOF" {
// 检查最后一行是否为空(避免遗漏无换行的结尾行)
if len(lineBytes) > 0 {
lineNum++
fmt.Printf("第%d行:%s", lineNum, string(lineBytes))
}
break
}
panic(fmt.Sprintf("读取失败:%v", err))
}
// 业务处理:去掉换行符后使用
lineNum++
line := string(lineBytes[:len(lineBytes)-1]) // 剔除'\n'
fmt.Printf("第%d行:%s\n", lineNum, line)
}
}自定义分隔符(特殊场景)
比如读取按|分割的配置文件,直接指定分隔符即可:
// 读取按"|"分割的文件内容
func main() {
file, err := os.Open("config.txt")
if err != nil {
panic(err)
}
defer file.Close()
reader := bufio.NewReader(file)
for {
// 按"|"分割读取
part, err := reader.ReadString('|')
if err != nil {
if err.Error() == "EOF" && len(part) > 0 {
fmt.Printf("分段:%s\n", part)
}
break
}
// 剔除分隔符后输出
fmt.Printf("分段:%s\n", part[:len(part)-1])
}
}三、ioutil.ReadFile
最便捷:ioutil.ReadFile 一次性读取(小文件专属)
如果读取的是 几MB 内的小文件(比如配置文件、小日志),用ioutil.ReadFile(Go 1.16 后建议用os.ReadFile)一次性读入内存,再按换行符分割,代码最简洁。
比如配置文件读取:
package main
import (
"fmt"
"os"
"strings"
)
func main() {
// 1. 一次性读入整个文件到字节切片
content, err := os.ReadFile("config.ini") // 替代旧版ioutil.ReadFile
if err != nil {
panic(fmt.Sprintf("读取配置文件失败:%v", err))
}
// 2. 按换行符分割成切片(支持\n和\r\n)
lines := strings.Split(string(content), "\n")
// 处理Windows换行符\r\n:先替换再分割
// lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
// 3. 遍历处理每一行
for idx, line := range lines {
// 跳过空行和注释行(业务逻辑)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fmt.Printf("第%d行配置:%s\n", idx+1, line)
}
}注意:这种方法的致命缺点是会把整个文件加载到内存,100MB 以上的文件就可能导致内存紧张,绝对不能用于大文件。
常见问题
Q1. 读取大文件时内存溢出?
这是最高频的坑,大概率是因为用了os.ReadFile一次性读入。
比如 1GB 的文件会占用 1GB 内存,远超很多服务的内存限制。
解决:
bufio.Scannerbufio.Reader所有超过 10MB 的文件,强制用或流式读取;如果不确定文件大小,优先用 Scanner,兼容性最好。
Q2. 文件最后一行没有换行符,导致读取遗漏?
比如文件内容是line1\nline2(line2后无换行),用ReadBytes('\n')或strings.Split会遗漏最后一行,Scanner 则能正常读取。
解决:
- 用 Scanner:自动处理无换行结尾的情况,无需额外处理;
- 用 Reader:循环结束后检查最后一次读取的字节切片是否非空,如前面
ReadBytes示例; - 用 ReadFile:分割后检查最后一个元素是否非空。
Q3. 读取 GBK 编码文件时出现乱码?
Go 默认按 UTF-8 解码文本,读取 GBK 编码的日志或配置文件时,会出现乱码(比如“中文”变成“䏿”)。
解决:用第三方库golang.org/x/text/encoding/simplifiedchinese解码 GBK,结合 Scanner 实现流式解码:
package main
import (
"bufio"
"fmt"
"os"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
func main() {
file, err := os.Open("gbk_log.txt")
if err != nil {
panic(err)
}
defer file.Close()
// 创建GBK解码器
decoder := simplifiedchinese.GBK.NewDecoder()
// 用解码器包装文件读取流
reader := transform.NewReader(file, decoder)
// 绑定到Scanner
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
fmt.Printf("GBK解码后:%s\n", line)
}
if err := scanner.Err(); err != nil {
panic(err)
}
}Q4. 文件路径正确却提示“文件不存在”?
本地测试正常,部署到服务器后报错,大概率是相对路径问题。
比如代码中用"app.log",实际运行时程序的工作目录和文件所在目录不一致。
解决:
- 用绝对路径:比如
"/var/log/app.log"(Linux)或"C:\\log\\app.log"(Windows); - 动态获取工作目录 + 相对路径:
import "os"
// 获取当前工作目录
workDir, err := os.Getwd()
if err != nil {
panic(err)
}
// 拼接文件路径
filePath := workDir + "/config.ini"
file, err := os.Open(filePath)Q5. 读取后文件被占用,无法删除或修改?
原因是打开文件后未关闭,导致文件句柄泄露,系统锁定文件。
尤其是循环读取多个文件时,容易出现这个问题。
解决:defer file.Close()必须用延迟关闭文件,且要放在os.Open()成功之后,避免对nil指针调用Close():
// 正确写法:先判断Open成功再defer Close
file, err := os.Open("app.log")
if err != nil {
panic(err)
}
defer file.Close() // 放在err判断之后
// 错误写法:可能对nil调用Close()
defer file.Close()
file, err := os.Open("app.log")
if err != nil {
panic(err)
}总结
- 优先用 bufio.Scanner:普适性最强,支持大/小文件,自动处理换行符,代码简洁,除了自定义分隔符场景外都推荐用;
- 自定义分隔符用 bufio.Reader:需要按非换行符分割(如|、;),或需要字节切片时使用,灵活性最高;
- 小文件用 os.ReadFile:配置文件、小日志等 几MB 内的文件,追求代码简洁时用,大文件绝对禁用。
最后再强调:
大文件必用流式读取,打开文件后必用 defer 关闭。
如果大家在处理特殊文件(比如压缩文件、网络文件)时遇到问题,欢迎在评论区交流~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/go-read-file-lines/
备用原文链接: https://blog.fiveyoboy.com/articles/go-read-file-lines/