Go io.Copy 为什么复制后长度不一致
来源:17golang原创
时间:2026-10-22 01:58:14 393浏览 收藏
Go 的 io.Copy 复制后长度不一致,通常不是它“少复制了一段”,而是比较的两个长度并不属于同一层。io.Copy 返回的是实际写入目标 Writer 的字节数;源 Reader 的可读范围、当前读游标、目标文件的写游标,以及最终文件是否截断,都会让你看到不同数字。
官方资料:https://pkg.go.dev/io
n是本次实际写入的字节数,不等于“源文件曾经有多大”。- 正常读到源末尾时,
io.Copy返回的错误是nil,不是io.EOF。 - 如果目标文件复用旧文件描述符,
stat.Size()可能还包含没有被覆盖的旧尾部。 - 已知必须复制多少字节时,用
io.CopyN或显式比较n,不要只看文件名和直觉。
先确认 io.Copy 的长度到底代表什么
io.Copy(dst, src) 会从 src 读取并写入 dst,直到读到 EOF 或发生错误。返回值中的 n 是成功写进目标的字节数,不是源数据的元数据大小,也不是目标文件最后的 stat.Size()。
所以排查时先记录三个值:源端本来有多少字节、Reader 从当前位置还能提供多少字节、Writer 实际接受了多少字节。只要其中一个值的定义变了,直接比较就会得到“长度不一致”的现象。

还要注意,Reader 的约定允许一次 Read 返回“读到了一些字节,同时带有 EOF”。调用方必须先处理返回的 n,不能因为看到了错误就丢掉这批数据。标准库的 io.Copy 已经处理了这个边界,成功复制完毕时不会把 EOF 当作失败返回。
先排查源 Reader 的范围与当前位置
最常见的短复制来自 Reader 本身:你传给 io.Copy 的并不是完整文件,而是一个有边界或已经被读取过的流。
io.LimitReader只允许读取指定的最大字节数,源文件再大也不会越过这个范围。io.NewSectionReader从指定偏移开始,只暴露给定长度的区间。- 同一个文件或
bytes.Reader之前已经被读取,当前游标可能已经在中间位置。 - 解压、解码或分块 Reader 输出的是转换后的字节流,不能拿压缩包大小直接和输出大小比较。
如果你需要从文件开头重新复制,先明确地定位,而不是假定“刚打开的对象一定在开头”。下面的例子把源文件游标和复制结果一起记录出来;注释中的数字只是示例输出,不代表本机执行结果。
package main
import (
"fmt"
"io"
"os"
)
func main() {
// 以只读方式打开源文件;真实项目中应检查路径和权限错误。
src, err := os.Open("source.bin")
if err != nil {
panic(err)
}
defer src.Close()
// 显式回到文件开头,避免复用文件对象时从旧游标继续复制。
if _, err := src.Seek(0, io.SeekStart); err != nil {
panic(err)
}
// 创建目标文件会截断旧内容,避免旧尾部影响最终 stat.Size。
dst, err := os.Create("copy.bin")
if err != nil {
panic(err)
}
defer dst.Close()
written, err := io.Copy(dst, src)
// EOF 是 Copy 的正常结束条件;这里真正需要处理的是非 nil 错误。
if err != nil {
panic(err)
}
// written 只表示本次写入目标的字节数,不表示源文件的历史大小。
fmt.Printf("written=%d\\n", written)
}
如果代码中使用了 LimitReader 或 SectionReader,应把限制值也记录下来。这样看到 n 恰好等于限制值时,优先怀疑“范围生效了”,而不是怀疑 io.Copy 丢数据。
再排查目标 Writer 的游标和旧文件尾部
另一类问题发生在目标端。io.Copy 只负责把字节交给 Writer;如果 Writer 是一个打开在中间位置的文件,复制会从当前位置写入。如果文件原来更长、又没有截断,复制结束后的文件大小还可能大于本次返回的 n。
例如旧文件有 1000 字节,写游标位于开头,本次只复制 400 字节。实际写入数是 400,但如果打开方式没有 O_TRUNC,文件末尾剩下的 600 字节仍然存在,最终 stat.Size() 可能还是 1000。这个结果不是 Copy 写多了,而是旧尾部没有被删除。

需要覆盖整个目标文件时,优先使用 os.Create,或者在 os.OpenFile 中明确加入 os.O_TRUNC。需要追加时则使用 os.O_APPEND,但这时就不能把最终文件大小当成“本次复制长度”。
package main
import (
"fmt"
"io"
"os"
)
func copyAndReport(srcName, dstName string) error {
// 源文件的当前游标会影响本次可复制范围。
src, err := os.Open(srcName)
if err != nil {
return err
}
defer src.Close()
// O_TRUNC 在打开时清掉旧尾部,适合“用新内容覆盖旧文件”。
dst, err := os.OpenFile(dstName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer dst.Close()
written, err := io.Copy(dst, src)
if err != nil {
return err
}
info, err := dst.Stat()
if err != nil {
return err
}
// written 是本次 Copy 的计数;info.Size 是当前文件的最终大小。
fmt.Printf("written=%d final_size=%d\\n", written, info.Size())
return nil
}
在符合接口约定的 Writer 中,短写必须伴随非 nil 错误;因此如果 n 比预期小,应先检查返回的 err 和底层存储错误。只有自定义 Writer 违反了 Writer 契约时,才可能出现更难解释的短写行为。
用检查代码区分字节、字符和文件大小
如果复制的是文本,另一种常见误判是把字符数当成字节数。Go 的 len(string) 统计 UTF-8 编码后的字节数,而不是用户看到的字符数;一个中文字符通常占用多个字节。io.Copy 统计的永远是字节,所以不要拿“字符数量”直接和 n 比较。
package main
import (
"bytes"
"fmt"
"io"
"unicode/utf8"
)
func main() {
text := "Go 复制长度"
var dst bytes.Buffer
// Copy 的返回值按字节计数;文本的字符数是另一种单位。
written, err := io.Copy(&dst, bytes.NewBufferString(text))
if err != nil {
panic(err)
}
// len(text) 是 UTF-8 字节数,RuneCountInString 才是 Unicode 码点数。
fmt.Printf("written=%d bytes=%d runes=%d\\n", written, len(text), utf8.RuneCountInString(text))
}
排障时建议把单位写进日志字段名,例如 source_bytes、written_bytes、rune_count。字段名明确后,很多“长度不一致”会立刻变成“单位不一致”。
已知长度时用 CopyN 把边界变成结果
如果协议头、数据库字段或上游元数据已经告诉你必须复制 expected 个字节,可以使用 io.CopyN。它会复制指定长度,或者在提前遇到错误时停止;返回时只有 err == nil 才能说明 written == expected。
package main
import (
"fmt"
"io"
"strings"
)
func copyExact(expected int64, src io.Reader, dst io.Writer) error {
// CopyN 把“必须复制多少字节”写进 API 边界,适合固定长度内容。
written, err := io.CopyN(dst, src, expected)
if err != nil {
return fmt.Errorf("copy %d bytes: written=%d: %w", expected, written, err)
}
// nil 错误意味着已经满足 expected;这里的判断用于保护调用方契约。
if written != expected {
return fmt.Errorf("unexpected length: want=%d got=%d", expected, written)
}
return nil
}
func example() error {
// 示例源数据不足 8 字节,CopyN 会返回实际数量和错误。
return copyExact(8, strings.NewReader("short"), io.Discard)
}
- 先打印
n与err,不要把正常 EOF 当成失败。 - 确认源 Reader 是否被限制、是否已经前移、是否输出了转换后的字节流。
- 确认目标文件是否从正确游标写入,覆盖场景是否使用了截断。
- 统一比较单位:都用字节,或明确区分字节、字符和最终文件大小。
- 有明确长度契约时使用
io.CopyN,并检查written与err。
常见问题
为什么 io.Copy 成功时 err 不是 io.EOF?
EOF 是 Copy 判断源 Reader 已经结束的正常条件,成功返回时会被转换为 nil。只有其他读取或写入错误才应按失败处理。
为什么返回的 n 比源文件 stat.Size 小?
优先检查 Reader 当前游标、LimitReader、SectionReader 和解压/解码边界。源文件的总大小不代表本次 Reader 能提供的字节数。
为什么 n 明明是 400,目标文件却还有 1000 字节?
目标文件可能是旧文件,打开时没有截断,旧尾部仍然存在。覆盖写场景用 os.Create 或加入 os.O_TRUNC,再重新比较。
复制文本时应该比较 len 还是字符数?
和 io.Copy 返回值比较时使用字节数;如果业务关心用户看到的字符数量,再单独使用 UTF-8 的 rune 统计,不要混用两个单位。
-
502 收藏
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
244 收藏
-
431 收藏
-
250 收藏
-
334 收藏
-
229 收藏
-
139 收藏
-
500 收藏
-
149 收藏
-
319 收藏
-
457 收藏
-
496 收藏
-
321 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习