☰
Go IO 流式处理:Reader/Writer 契约、缓冲策略与错误处理实战
2026/11/2 17:51:04 网站建设 项目流程

数据流处理这件事,在 Go 里几乎无处不在。你打开一个文件、发一个 HTTP 请求、解一个 JSON、写一行日志、做一次压缩,底层绕来绕去最后都会落到两个接口上:io.Reader和io.Writer。很多刚上手 Go 的朋友会觉得这两个接口"太简单了",一个Read(p []byte) (n int, err error),一个Write(p []byte) (n int, err error),看起来一眼就懂。但真正写起业务代码来,缓冲区大小怎么定、io.EOF什么时候算正常什么时候算异常、Read返回的n小于len(p)到底要不要继续读、Write只写了一半怎么办,这些问题一个接一个冒出来。我自己在数据流处理上踩过的坑,比在并发和内存管理上加起来还多。

这篇内容想聊的就是这两个接口背后的门道。它适合已经能写 Go 代码、但一碰到流式读写就心里没底的同学,也适合那些天天用io.Copy、bufio.Scanner却从没深究过它们内部行为的老手。我会从接口契约讲起,把Read/Write的返回值语义、短读短写的处理、缓冲策略、常见组合模式、错误处理边界,以及几个真实场景下的选型取舍,一层层拆开。核心关键词是Golang 数据流处理、Reader 接口、Writer 接口、短读短写、缓冲策略、io.Copy、流式错误处理。读完你应该能做到:看到任意一个实现了这两个接口的类型,都能判断它能不能直接拿来用、要不要包一层缓冲、出错时该怎么兜底。

1. 先把接口契约吃透:Read 和 Write 的返回值到底在说什么

很多人写 Go 好几年,对这两个接口的理解还停留在"读数据、写数据"这个层面。但接口真正的价值不在方法签名,而在契约——也就是调用方和被调用方之间约定好的行为规范。Go 标准库之所以能把文件、网络、内存缓冲、压缩流全部统一起来,靠的就是这套契约被严格遵守。你只要按契约写代码,任何实现了接口的类型都能无缝替换;反过来,如果你写的类型违反了契约,别人拿去组合就会出各种诡异问题。

1.1 Read 的三个返回值各自代表什么

Read(p []byte) (n int, err error)这个签名里,n是本次实际读到的字节数,err是本次读取的状态。关键在于:n和err是独立的两个维度,不能混为一谈。一次调用可能出现四种组合,每种的含义完全不同。

n 的值err 的值含义调用方该怎么做
n > 0nil读到了数据,流还没结束处理这 n 个字节,继续读
n > 0io.EOF读到了最后一批数据,同时流结束先处理这 n 个字节,再停止
n == 0nil这次没读到数据,但流没结束可以重试,但要防止死循环
n == 0非 nil(含 EOF)没读到数据,流结束或出错停止,按错误类型处理

最容易出错的是第二行:n > 0且err == io.EOF是完全合法的。很多实现(尤其是网络连接和某些文件系统)会在最后一次读取时把剩余数据和 EOF 一起返回。如果你写成if err != nil { break }再处理数据,就会把最后一批数据丢掉。正确的写法永远是先处理p[:n],再看err。

buf := make([]byte, 4096) for { n, err := r.Read(buf) if n > 0 { // 先处理数据,这一步不能省 process(buf[:n]) } if err != nil { if err == io.EOF { break // 正常结束 } return err // 真正的错误 } }

这段循环看起来朴素,但它就是所有流式读取的骨架。io.Copy、bufio.Reader、io.ReadAll内部都是这个结构的变体。你把这段背下来,比记任何框架 API 都有用。

1.2 短读是常态,不是异常

所谓短读(short read),就是n < len(p)。新手常有的误解是"我传了 4KB 的缓冲区,就应该读满 4KB"。实际上接口从来没承诺过这件事。Read的契约只保证:在有数据可读时,至少返回 1 个字节(除非len(p) == 0)。至于一次返回多少,完全由实现决定。

网络连接是最典型的例子。TCP 是字节流协议,一次Read能拿到多少取决于当前内核缓冲区里有多少数据、MTU 多大、对端发送节奏如何。你可能传了 64KB 缓冲,结果只回来 200 字节。这不是 bug,是设计。所以任何"读一次就假设拿全了"的代码都是错的。

提示:判断"读完了"的唯一依据是err == io.EOF,绝不是n < len(p)。把这两个概念混淆,是流式处理里最高频的 bug 来源。

1.3 Write 的契约:写一半是允许的

Write(p []byte) (n int, err error)的契约稍微反直觉一点:它允许只写一部分。如果n < len(p)且err == nil,说明只写进去了n个字节,剩下的你得自己接着写。标准库里的io.Writer文档明确说了这一点。

不过实际中大部分实现(比如*os.File、bytes.Buffer、net.Conn)要么全写成功,要么返回错误,很少出现"写一半没错误"的情况。但你不能依赖这个假设,因为一旦遇到会短写的实现(某些自定义 Writer、限流 Writer、部分管道),你的数据就会静默丢失。

稳妥的写法是用io.Copy或者自己写循环:

func writeAll(w io.Writer, p []byte) error { for len(p) > 0 { n, err := w.Write(p) if err != nil { return err } p = p[n:] } return nil }

标准库其实已经提供了io.WriteString和io.Copy帮你处理这些,但理解这个循环的存在,能让你在自定义 Writer 时知道该遵守什么。

1.4 为什么契约设计成这样

有人会问:为什么不干脆规定"Read 必须读满、Write 必须写全",省得大家写循环?原因在于流的本质是不可预测的。数据什么时候到、到多少,底层根本控制不了。如果强行要求读满,那网络场景下就得阻塞等待,延迟会变得不可控;如果要求写全,遇到对端接收慢的情况就得无限阻塞。把"部分完成"暴露给调用方,让调用方自己决定是继续等还是先处理已有数据,这才是流式处理该有的灵活性。理解了这一点,你就明白为什么 Go 的 IO 生态里到处都是循环和缓冲——它们都是在替你把这份灵活性包装成好用的抽象。

2. 缓冲不是可选项:bufio 到底帮你挡掉了什么

直接拿os.File或者net.Conn做小批量读写,性能会很难看。原因很简单:每次Read/Write都是一次系统调用,用户态和内核态来回切换的开销远大于数据拷贝本身。bufio包的存在就是为了把大量小操作攒成少量大操作。但很多人只是"听说要加 bufio",并不清楚它内部怎么工作、什么时候该用、什么时候反而添乱。

2.1 bufio.Reader 的预读机制

bufio.NewReader默认给你一个 4096 字节的内部缓冲区。当你调用Read时,它其实是这样干的:如果缓冲区里有数据,直接从缓冲区拷贝给你;如果缓冲区空了,它一次性从底层 Reader 读满整个缓冲区(或读到 EOF),然后再从缓冲区往外给。也就是说,你读 1 个字节,它可能已经从底层读了 4KB。

这个预读行为带来两个直接后果。第一,小批量读取的性能大幅提升,因为系统调用次数从"每字节一次"降到"每 4KB 一次"。第二,它会多读——底层流的位置会比你实际消费的位置更靠前。这在纯读取场景没问题,但如果你在读的同时还指望底层流停在某个精确位置,就会出问题。

f, _ := os.Open("data.bin") br := bufio.NewReader(f) b, _ := br.ReadByte() // 只读了 1 字节 // 此时 f 的偏移量可能已经是 4096,而不是 1

这个特性在解析带分隔符的协议时特别有用(比如按行读、按\n切分),因为预读的数据不会丢,都留在缓冲区里等下次取。但如果你混用bufio.Reader和直接操作底层f,就会读到错位的数据。我见过有人一边用bufio.Scanner读行,一边又对同一个文件句柄调f.Read,结果数据全乱——这就是没理解预读的代价。

2.2 bufio.Writer 的攒批与 Flush

bufio.Writer的逻辑反过来:你写的数据先攒在内部缓冲区,攒满了(默认也是 4096)才真正调用底层Write刷出去。好处是减少了系统调用,坏处是数据可能还卡在缓冲区里没落地。所以用完必须Flush,否则最后一批数据会丢。

bw := bufio.NewWriter(f) bw.WriteString("hello") bw.WriteString(" world") // 此时文件里可能还是空的 bw.Flush() // 这一步才真正写进去

Flush的时机很讲究。写日志的场景,如果每条日志都 Flush,等于没缓冲,性能白搭;如果一直不 Flush,程序崩溃时缓冲区里的日志全丢。常见做法是按时间或按条数定期 Flush,比如每 100 条或者每 1 秒刷一次,在吞吐和可靠性之间取平衡。这个策略没有标准答案,得看你的业务能容忍丢多少数据。

2.3 缓冲区大小怎么定

默认 4KB 不是金科玉律。缓冲区太小,系统调用还是频繁;太大,内存占用上去,而且预读带来的"多读"也更明显。经验值是这样的:

场景建议缓冲大小理由
普通文件读写32KB ~ 64KB接近多数文件系统的块大小,系统调用开销摊薄明显
网络流4KB ~ 16KB受 MTU 和内核缓冲限制,太大反而浪费
高并发小消息1KB ~ 4KB每连接一个缓冲区,总量要控制
大文件顺序处理256KB ~ 1MB追求吞吐,内存换速度

调大缓冲区之前先测。我见过有人把缓冲区从 4KB 调到 1MB,结果 QPS 没涨,内存先爆了——因为每个连接都开了一个 1MB 的 buffer。缓冲区是每流一份的,并发数乘以缓冲大小才是真实内存开销,这个账一定要算。

2.4 什么时候不该用 bufio

bufio不是万能药。如果底层 Reader 本身已经是带缓冲的(比如bytes.Buffer、strings.Reader),再包一层bufio.Reader就是纯浪费——多一次内存拷贝,多一层间接。同理,如果你每次都是大批量读写(比如一次读 1MB),bufio的攒批意义也不大,直接读反而少一层。

判断标准很简单:底层操作是不是系统调用或网络往返?单次数据量是不是很小?两个都是"是",才值得上bufio。否则先别加,测了再说。

3. 组合的艺术:把 Reader 和 Writer 拼成数据流水线

Go 的 IO 设计最漂亮的地方,是这些接口可以像积木一样拼。一个gzip.Reader包一个文件 Reader,就得到解压流;一个io.TeeReader包一个 Reader,就能在读取的同时把数据抄一份到别处;io.MultiWriter能把一份数据同时写到多个目标。这些组合不是花架子,而是解决实际问题的利器。但拼装的时候,顺序、错误传播、关闭时机都有讲究。

3.1 装饰器模式在 IO 里的体现

gzip.NewReader(r)返回一个*gzip.Reader,它内部持有r,每次你从它读,它就从r读压缩数据、解压后给你。这就是典型的装饰器:接口不变,行为叠加。你可以一层层套下去:

f, _ := os.Open("data.gz") gz, _ := gzip.NewReader(f) br := bufio.NewReader(gz) scanner := bufio.NewScanner(br) for scanner.Scan() { process(scanner.Text()) }

这条链上,数据从磁盘出来,经过解压、缓冲,最后按行切分。每一层只关心自己的职责,组合起来却完成了复杂的功能。这种设计的好处是每一层都可以单独替换:想换成从网络读,把os.File换成net.Conn就行,上面几层一行不用改。

但要注意关闭顺序。gzip.Reader需要Close来校验尾部校验和,os.File也需要Close释放句柄。关闭顺序应该是从外到内,也就是先关最外层的装饰器,再关底层。不过gzip.Reader.Close并不会关闭底层的f,所以两个都得关。这一点经常被忽略,导致文件句柄泄漏。

3.2 io.TeeReader:读取时顺手抄一份

io.TeeReader(r, w)返回一个新的 Reader,你从它读数据时,它会同时把读到的内容写进w。这个在需要"边读边算哈希"或者"边读边落盘"的场景特别好用。

h := sha256.New() tee := io.TeeReader(src, h) io.Copy(dst, tee) // 此时 h 里就是 src 内容的哈希,dst 里是完整拷贝

不用 Tee 的话,你得先读完算哈希,再读一遍写目标,要么就得自己维护两份缓冲。Tee 把这件事变成一行,而且内存占用恒定。它的实现也很简单,就是每次Read后顺手Write一下,但要注意:如果w写入失败,Tee 会把错误当成读错误返回。所以w最好是个不会失败的 Writer,或者你得能接受读操作因为写失败而中断。

3.3 io.MultiWriter:一份数据多处落地

io.MultiWriter(w1, w2, ...)把多个 Writer 合成一个,每次写会依次写到所有目标。日志同时输出到文件和标准输出、数据同时写本地和远端,都是这个模式。

mw := io.MultiWriter(os.Stdout, logFile) fmt.Fprintln(mw, "这条日志会同时出现在屏幕和文件里")

它的错误语义值得注意:任何一个 Writer 返回错误,整个 Write 就返回错误,但前面的 Writer 可能已经写成功了。这意味着它不是原子的,不能拿来做"要么全写要么全不写"的保证。如果某个目标失败了你不想影响其他目标,得自己包一层容错 Writer,把错误吞掉或记录后继续。

3.4 io.Pipe:把 Reader 和 Writer 接成同步管道

io.Pipe返回一对关联的*PipeReader和*PipeWriter,写进去的数据可以从读端读出来,中间没有缓冲(或者说缓冲为 0)。它的典型用途是把一个"生产者函数"伪装成 Reader,喂给需要 Reader 的 API。

pr, pw := io.Pipe() go func() { defer pw.Close() // 这里可以慢慢生成数据 fmt.Fprint(pw, "动态生成的内容") }() // pr 现在可以当普通 Reader 用 io.Copy(os.Stdout, pr)

关键点在于:PipeWriter的Write会阻塞直到有读端把数据读走。这保证了内存里不会堆积数据,但也意味着如果读端不读,写端就卡死。所以用io.Pipe一定要确保读端会被消费,或者在合适的时机CloseWithError让写端退出。我见过有人在 goroutine 里往 Pipe 写数据,主流程因为某个条件提前返回没读,结果那个 goroutine 永久阻塞——这是io.Pipe最经典的泄漏方式。

4. 错误处理的边界:EOF、超时、部分失败怎么区分

流式处理里,错误不是"有或没有"的二元问题,而是分层的:正常结束、临时性错误、永久性错误、部分成功。分不清这几类,你的重试逻辑要么疯狂重试不该重试的,要么该重试的直接放弃。

4.1 io.EOF 是正常信号,不是错误

io.EOF在 Go 里是个特殊的错误值,它表示"流正常结束"。判断方式必须是err == io.EOF或者errors.Is(err, io.EOF),不能用字符串匹配。很多包装过的 Reader 会把 EOF 包一层,所以errors.Is更稳妥。

if errors.Is(err, io.EOF) { // 正常结束,不是故障 }

把 EOF 当错误往上抛,是新手常犯的错。比如一个函数负责读完整个流返回内容,读到 EOF 时应该返回nil错误,而不是把 EOF 传出去,否则调用方会以为出事了。

4.2 临时错误与重试

网络流会遇到临时性错误(连接抖动、对端暂时不可达)。Go 早期用net.Error的Temporary()方法判断,现在更推荐用errors.Is配合具体的错误值,或者干脆在应用层做有限次重试加退避。

重试的关键是只重试幂等且可恢复的操作。读操作重试相对安全(只要你能接受重复数据),写操作重试就可能造成重复写入。所以写侧重试要么保证幂等(比如带唯一 ID),要么在重试前确认上一次到底写没写成功——而这在流式场景里往往做不到。这就是为什么很多协议要求"写失败就断开重连,不要原地重试"。

4.3 部分成功怎么处理

io.Copy返回的written字节数和err要一起看。如果written > 0且err != nil,说明传了一部分就断了。这时候你的选择取决于业务:如果是下载文件,可以记录已下载偏移,下次续传;如果是数据库导入,可能得整体回滚。

written, err := io.Copy(dst, src) if err != nil { log.Printf("传输中断,已写 %d 字节: %v", written, err) // 根据 written 决定续传还是重来 }

注意:不要假设io.Copy失败时dst是干净的。它可能已经写了一部分,清理工作得你自己做。

4.4 关闭时的错误别忽略

Close()也可能返回错误,尤其是带缓冲的 Writer 和需要刷盘的 File。bufio.Writer的Flush如果失败,数据就丢了,但如果你只defer bw.Flush()而不检查返回值,这个错误会被静默吞掉。

defer func() { if err := bw.Flush(); err != nil { log.Printf("flush 失败: %v", err) } }()

写文件、写网络这种"最后一步可能失败"的场景,关闭时的错误检查不能省。我踩过一次坑:批量导出数据,程序跑完显示成功,结果文件末尾少了几百行——就是最后一批数据卡在bufio.Writer里,Flush失败了没人管。

5. 实战选型:几个真实场景下的取舍

理论讲完,落到具体场景才有意义。下面这几个场景都是我实际处理过的,每个都涉及"用哪个 API、要不要缓冲、错误怎么兜"的决策。

5.1 大文件逐行处理:Scanner 还是 Reader

处理日志文件、CSV 这类按行组织的数据,bufio.Scanner是最顺手的。它内部处理了缓冲、按分隔符切分、跨缓冲区拼接这些细节,你只管Scan()和Text()。

scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 调大单行上限 for scanner.Scan() { line := scanner.Text() process(line) } if err := scanner.Err(); err != nil { return err }

但Scanner有个默认限制:单行最大 64KB,超了会报token too long。处理超长行(比如某些 JSON 日志一行几 MB)必须用scanner.Buffer调大上限,或者干脆改用bufio.Reader的ReadString('\n')。后者没有硬上限,但每次返回字符串会分配内存,高频调用时 GC 压力大。选型就看你的数据行长分布:绝大多数行都很短、偶尔有长行,用 Scanner 加合理上限;行长不可预测且经常很大,用 Reader 手动切。

5.2 流式转发:io.Copy 的隐藏成本

io.Copy(dst, src)看起来是最省事的转发方式,但它内部有个细节:如果src实现了WriterTo或者dst实现了ReaderFrom,它会走那条快路径,可能完全绕过你设的缓冲区。比如*os.File实现了ReaderFrom,io.Copy到文件时会用sendfile之类的零拷贝机制,性能很好。但如果你在中间包了一层自定义 Reader 做统计,快路径就失效了,退化成普通循环。

// 想要统计流量,又不想破坏快路径,可以用 TeeReader 或包装 Writer type countingWriter struct { w io.Writer n int64 } func (c *countingWriter) Write(p []byte) (int, error) { n, err := c.w.Write(p) c.n += int64(n) return n, err }

包装 Writer 比包装 Reader 更安全,因为 Writer 的快路径(ReaderFrom)判断的是目标类型,包一层就没了,但至少数据流是对的。包装 Reader 则可能让WriterTo失效。这个细节在追求极致吞吐时才重要,普通业务不用太纠结,但知道有这回事,排查性能问题时能少走弯路。

5.3 限速读写:在流里插一个节流阀

需要控制传输速率时(比如避免打满带宽),可以在 Reader 或 Writer 外面包一层限速逻辑。核心思路是记录已传输字节数和起始时间,每次读写后计算当前速率,超了就time.Sleep一会儿。

type throttleReader struct { r io.Reader rate int64 // 字节/秒 start time.Time total int64 } func (t *throttleReader) Read(p []byte) (int, error) { n, err := t.r.Read(p) t.total += int64(n) expected := time.Duration(float64(t.total) / float64(t.rate) * float64(time.Second)) elapsed := time.Since(t.start) if expected > elapsed { time.Sleep(expected - elapsed) } return n, err }

这种实现是"平均速率"控制,允许短时突发。如果要严格平滑,得用令牌桶。限速层放在 Reader 还是 Writer 上,取决于你想控制哪个方向——下载限速包 Reader,上传限速包 Writer。注意Sleep会阻塞当前 goroutine,高并发下要考虑是否值得,必要时用带缓冲的 channel 做异步节流。

5.4 内存流:bytes.Buffer 与 strings.Reader 的取舍

不是所有流都来自外部。测试、拼接、临时缓存经常需要内存里的 Reader/Writer。bytes.Buffer同时实现了 Reader 和 Writer,读写都行,但它的Read会把数据"消费"掉(读过的部分不再保留),这点和文件不同。如果你需要反复读同一份数据,用bytes.NewReader(data),它基于切片,可以Seek回开头。

strings.Reader和bytes.Reader类似,只是底层是字符串,省一次转换。它们的共同优点是零系统调用、零额外分配(除了初始数据),在纯内存处理里是最快的选择。但要注意bytes.Buffer的Bytes()返回的是内部切片,后续写入可能让它失效,需要拷贝时用buf.Bytes()后立刻复制。

6. 那些文档里不写、踩过才知道的坑

前面讲的都是"应该怎么做",这一节讲"实际会怎么翻车"。这些经验基本都来自真实的调试现场,文档里不会写,但每一条都能帮你省下几个小时。

6.1 Read 返回 0, nil 的死循环

接口契约允许Read返回(0, nil),表示"这次没数据但流没结束"。大多数实现不会这么干,但自定义 Reader 或者某些边界情况下会出现。如果你的循环写成for { n, err := r.Read(buf); if err != nil { break }; process(buf[:n]) },遇到(0, nil)就会空转,CPU 打满。

防御办法是加个计数器:连续多次(0, nil)就认为有问题,主动报错退出。标准库的io.ReadAtLeast和io.ReadFull内部就有类似保护。自己写循环时,这个防御不能省。

6.2 忘记 Close 导致的句柄泄漏

os.Open返回的文件必须Close,这个大家都知道。但装饰器链上的 Close 容易被漏:gzip.NewReader(f)之后,gz.Close()不会关f,两个都得关。更隐蔽的是http.Response.Body,不读完就 Close 会导致连接无法复用,连接池很快耗尽。

resp, _ := http.Get(url) defer resp.Body.Close() io.Copy(io.Discard, resp.Body) // 读完再关,连接才能复用

io.Discard是个"黑洞 Writer",什么都吞掉,专门用来丢弃数据。读干净再关,是 HTTP 客户端的基本功。

6.3 缓冲区复用导致的数据串味

为了减少分配,有人会复用[]byte缓冲区。但如果你把buf[:n]直接传给异步处理(比如扔进 channel 给别的 goroutine),而缓冲区又被下一次Read覆盖,数据就串了。这类 bug 极难复现,因为取决于调度时机。

规则很简单:跨 goroutine 传递的字节切片,要么拷贝,要么确保不会被复用。bufio.Scanner的Text()返回的字符串在下次Scan()后就失效,也是同样的道理——它内部复用了缓冲区。需要保留就string(b)拷贝一份。

6.4 大 buffer 的隐性内存放大

前面提过缓冲区是每流一份。一个容易被忽略的场景是连接池加缓冲:1000 个并发连接,每个bufio.Reader4KB、bufio.Writer4KB,光缓冲就 8MB。如果为了性能把缓冲调到 64KB,直接变成 128MB。这在内存紧张的环境里是致命的。

优化思路是按需分配:连接空闲时释放缓冲,活跃时才分配;或者用sync.Pool复用缓冲区对象。sync.Pool特别适合这种"短生命周期、高频分配"的场景,能显著降低 GC 压力。但要注意 Pool 里的对象可能被随时回收,不能假设放进去就一定拿得到。

6.5 并发读写同一个流

io.Reader和io.Writer的接口本身不保证并发安全。多个 goroutine 同时读同一个 Reader,或者同时写同一个 Writer,结果未定义。标准库里只有少数类型(如os.File的某些操作)在特定平台上有原子性保证,但这不是接口契约的一部分。

需要并发读写时,要么加锁,要么用 channel 把操作串行化,要么用io.Pipe做生产者-消费者解耦。千万别假设"底层是文件所以并发没事"——文件偏移量的更新在多 goroutine 下照样会乱。

7. 把流式思维用到接口之外

写到这里,其实Reader和Writer教给我们的不只是两个接口怎么用,而是一种处理数据的思维方式:数据不是一次性到齐的,而是分批流动的;每一层只做一件事,通过组合完成复杂功能;错误和结束是两种状态,要分开处理;资源有生命周期,用完必须释放。这套思维迁移到其他场景一样成立——消息队列的消费、数据库的游标、甚至前端的分块加载,底层逻辑都是相通的。

我个人在实际项目里的体会是,凡是涉及"数据量大到不能一次装进内存"或者"数据来源不可靠、可能中断"的场景,第一反应就该是设计成流式管道,而不是先ReadAll再处理。后者写起来快,但一旦数据量上来或者网络抖动,就会变成内存溢出和超时的重灾区。把io.Copy、bufio、TeeReader这些积木用熟,很多看起来复杂的传输、转换、落盘需求,几行代码就能拼出来,而且内存占用是恒定的。

最后分享一个小习惯:每次写涉及 IO 的代码,我都会问自己三个问题——这个流会不会很大?读完了吗?关干净了吗?三个问题都能答上来,这段代码基本就不会在线上出幺蛾子。缓冲区大小、要不要 Tee、用 Scanner 还是 Reader,这些细节反而可以边测边调,因为方向对了,剩下的只是参数问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询