Go 用 bufio.Scanner 读大文件踩坑:默认 64KB 行上限、Buffer 扩容与按 Token 切分

Go 用 bufio.Scanner 读大文件踩坑:默认 64KB 行上限、Buffer 扩容与按 Token 切分

用 Go 逐行读文件,几乎所有人第一反应都是 bufio.Scanner,写法也确实优雅:

go 复制代码
scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line := scanner.Text()
    // 处理每一行
}

小文件跑得好好的,直到某天你的程序读一个日志文件突然「读一半就停了」,还不报 panic。你以为文件读完了,其实是撞上了 bufio.Scanner 一个藏得很深的默认限制:单行超过 64KB 它就悄悄停下,而且默认吞掉错误。这篇我们把这个坑和相关的用法讲透。

复现:一行超过 64KB 就断

先造一个「有一行特别长」的文件:

go 复制代码
package main

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

func main() {
	// 写一个包含超长行的文件:100000 个 'a' 再换行
	longLine := strings.Repeat("a", 100000)
	os.WriteFile("big.txt", []byte(longLine+"\n第二行\n第三行\n"), 0644)

	f, _ := os.Open("big.txt")
	defer f.Close()

	scanner := bufio.NewScanner(f)
	count := 0
	for scanner.Scan() {
		count++
		fmt.Printf("第 %d 行,长度 %d\n", count, len(scanner.Text()))
	}
	fmt.Println("读到的行数:", count)
	fmt.Println("scanner.Err():", scanner.Err())
}

运行结果:

复制代码
读到的行数: 0
scanner.Err(): bufio.Scanner: token too long

看到了吗?一行都没读到 。第一行 10 万字节超过了默认上限,Scan() 直接返回 false,循环根本没进去。更阴险的是:如果你没检查 scanner.Err(),程序会「安静地」结束,你还以为文件是空的。

第一个铁律:循环后必须检查 scanner.Err()

for scanner.Scan() 退出有两种原因:正常读完(EOF),或者出错。Scan() 返回 false 时不区分这两者 ,你必须靠 scanner.Err() 判断:

go 复制代码
for scanner.Scan() {
	process(scanner.Text())
}
// 千万别漏这句:Scan 返回 false 可能是出错而非读完
if err := scanner.Err(); err != nil {
	log.Fatalf("扫描出错: %v", err)
}

漏了这个检查,token too long、磁盘读错误全都被你无声吞掉。这是用 bufio.Scanner 最常见的 bug 根源。

第二步:用 Buffer() 抬高上限

知道了是行太长,解决办法是用 scanner.Buffer() 显式给一个更大的缓冲区:

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

// 参数一:初始 buffer(可给 nil 让它自己分配)
// 参数二:单个 token 允许的最大字节数
buf := make([]byte, 0, 64*1024)   // 初始 64KB
scanner.Buffer(buf, 1024*1024)    // 上限抬到 1MB

for scanner.Scan() {
	fmt.Println(len(scanner.Bytes()))
}
if err := scanner.Err(); err != nil {
	log.Fatal(err)
}

Buffer(buf, max) 的第二个参数是这个 token 最多能占多少字节 。默认值是 bufio.MaxScanTokenSize,正好是 64KB(65536)。把它抬到 1MB,10 万字节的行就能读了。

注意:Buffer 必须在第一次 Scan() 之前 调用,否则会 panic(Buffer called after Scan)。

第三步:行真的可能无限长?换 bufio.Reader

如果你无法预知行有多长(比如处理别人生成的、可能有几百 MB 一行的畸形数据),盲目把上限设成 1GB 是危险的------一行就把内存吃光。这种场景应该放弃 Scanner,改用 bufio.Reader.ReadString,它按需增长、不设死上限:

go 复制代码
reader := bufio.NewReader(f)
for {
	line, err := reader.ReadString('\n') // 读到换行符为止
	if len(line) > 0 {
		process(strings.TrimRight(line, "\n"))
	}
	if err != nil {
		if err == io.EOF {
			break // 正常读完
		}
		log.Fatal(err) // 真出错了
	}
}

ReadString('\n')Scan() 的关键区别:

  • Scan() 有 token 上限,超了报错;ReadString 没有,能读多长读多长(代价是内存)。
  • ReadString 返回的 line 包含 分隔符 \n,得自己 TrimRight 掉。
  • EOF 时 ReadString 可能同时返回「最后一段没有换行的内容」和 io.EOF,所以要先处理 line 再判断 err,顺序反了会丢最后一行。

进阶:不止按行切,自定义 SplitFunc

bufio.Scanner 真正强大的地方是 Split():它不止能按行切,还能按单词、按自定义规则切。内置几个:

go 复制代码
scanner.Split(bufio.ScanWords)  // 按空白切成单词
scanner.Split(bufio.ScanRunes)  // 按 UTF-8 字符切
scanner.Split(bufio.ScanBytes)  // 按字节切

比如统计一个文本有多少个单词,一行搞定:

go 复制代码
scanner := bufio.NewScanner(f)
scanner.Split(bufio.ScanWords)
words := 0
for scanner.Scan() {
	words++
}
fmt.Println("单词数:", words)

你甚至能写自己的分隔逻辑,比如按分号 ; 切分 SQL 语句。SplitFunc 的签名是 func(data []byte, atEOF bool) (advance int, token []byte, err error),data 是当前缓冲区,你返回「消费多少字节 advance」和「切出来的 token」:

go 复制代码
// 按分号切分
func scanSemicolons(data []byte, atEOF bool) (int, []byte, error) {
	if i := bytes.IndexByte(data, ';'); i >= 0 {
		return i + 1, data[:i], nil // 消费到分号后,token 是分号前的内容
	}
	if atEOF && len(data) > 0 {
		return len(data), data, nil // 文件末尾没有分号的最后一段
	}
	return 0, nil, nil // 数据不够,要求 Scanner 再读一些
}

// 用法
scanner.Split(scanSemicolons)

注意 SplitFunc 里也受 Buffer 上限约束:如果单个 token(比如一条超长 SQL)超过上限,同样会 token too long

小结

  • bufio.Scanner 默认单行/单 token 上限是 64KB ,超了 Scan() 直接返回 false,一行都读不到。
  • 循环后必须 if err := scanner.Err(); err != nil ,否则 token too long 和读错误会被无声吞掉------这是最常见的坑。
  • 行会超 64KB 但有上界:用 scanner.Buffer(buf, max) 抬高上限,且必须在第一次 Scan() 前调用。
  • 行长度不可预知:放弃 Scanner,改用 bufio.Reader.ReadString('\n'),按需增长、无死上限,但要自己 TrimRight 分隔符、先处理内容再判 EOF。
  • Scanner.Split() 能按单词/字符/自定义规则切分,写 SplitFunc 处理非行分隔的场景。

一句话记忆:用 bufio.Scanner 读文件,永远记得两件事------检查 scanner.Err(),以及问自己「这一行会不会超过 64KB」。

相关推荐
microrain1 小时前
从协议孤岛到统一接入:SagooIoT多协议接入层的架构演进
物联网·开源·go·sagooiot
用户6083089290471 小时前
SpringBoot自定义注解校验
后端
董员外1 小时前
RAG 系统进化论(六):GraphRAG(基于知识图谱的 RAG),从相似文本走向实体关系
人工智能·后端·设计模式
Conan在掘金1 小时前
ArkTS 进阶之道(31):状态联动深水区收官边界——为啥四级状态容器 + V1/V2 双轨是状态联动深水区收官根因
后端
Conan在掘金1 小时前
鸿蒙 7.0 空间美学开篇:沉浸式毛玻璃 + 底部 Sheet 面板——一行 backgroundBlurStyle 玩出物理材质感
后端
Java编程爱好者2 小时前
R2DBC vs JDBC:Spring Boot 响应式项目该怎么选
后端
AskHarries2 小时前
Sitemap 怎么自动生成
后端
神奇小汤圆2 小时前
深入 Java 线程池:从源码原理、生产调优到故障排查全链路指南
后端
Byron Loong2 小时前
【C++】重定向是什么
开发语言·c++