断点续传原理:传输中断场景分析、状态持久化、断点标记与恢复策略
断点续传,说白了就是让传输任务具备「记忆能力」。
我早年做分布式文件同步时,遇到过一个大文件传了 12 个小时,结果网络闪断,全部重来。那种感觉,嗯,真的很崩溃。从那以后,我设计任何传输系统,第一件事就是问自己:如果现在断了,能不能从断点继续?
传输中断场景分析
中断不是单一情况。我把它分成三类,每一类的处理策略都不一样。
| 中断类型 | 典型场景 | 影响范围 | 恢复难度 |
|---|---|---|---|
| 网络中断 | TCP 连接超时、路由器重启、WiFi 切换 | 当前连接 | 低 |
| 进程中断 | 客户端崩溃、OOM 被杀、手动重启 | 当前进程 | 中 |
| 节点中断 | 服务器宕机、磁盘故障、机房断电 | 整个节点 | 高 |
你想想看,网络中断是最常见的。TCP 断了,但进程还在,内存里的状态还在。这时候恢复,只需要重新建立连接,然后告诉服务端「我上次传到了偏移量 X」。
进程中断就麻烦一点。进程没了,内存里的状态全丢了。怎么办?必须依赖持久化存储。我习惯在每次写入一个完整数据块后,就把当前进度刷到磁盘上。
节点中断是最棘手的。服务器都挂了,你连问谁去?这时候需要分布式协调,比如用 ZooKeeper 或 etcd 记录全局进度。我在一个跨机房同步项目里就吃过这个亏——主节点挂了,从节点不知道进度,结果重复传了 200GB 数据。
状态持久化:把进度「写死」到磁盘
状态持久化是断点续传的基石。说白了,就是把「当前传到了哪里」这个信息,存到一个不会丢的地方。
我常用的持久化方案有三种:
- 本地文件:最简单,写一个 .checkpoint 文件,记录偏移量和校验值。适合单机场景。
- 数据库:用 SQLite 或嵌入式 DB,记录任务 ID、块序号、状态。适合多任务并发。
- 分布式存储:用 etcd 或 ZooKeeper,记录全局进度。适合集群场景。
来看一个本地文件持久化的例子:
// 写入检查点
func writeCheckpoint(taskID string, offset int64, checksum uint32) error {
data := fmt.Sprintf("%s|%d|%d\n", taskID, offset, checksum)
return os.WriteFile(taskID+".ckpt", []byte(data), 0644)
}
// 读取检查点
func readCheckpoint(taskID string) (offset int64, checksum uint32, err error) {
data, err := os.ReadFile(taskID + ".ckpt")
if err != nil {
return 0, 0, err
}
_, err = fmt.Sscanf(string(data), "%s|%d|%d", &taskID, &offset, &checksum)
return
}
这里有个细节:写入时机。我建议每完成一个最小数据块(比如 4MB)就刷一次盘。刷太频繁,磁盘 IO 扛不住;刷太少,中断后丢失的进度太多。4MB 是个经验值,你可以根据网络带宽和磁盘性能调整。
断点标记:给每个数据块一个「身份证」
断点标记,就是给每个数据块一个唯一标识。这样恢复时,服务端一看就知道「这个块传过了,下一个」。我常用的标记方式有两种:
- 基于偏移量:记录文件中的字节偏移。简单直接,但文件结构变化时容易出错。
- 基于块哈希:记录每个数据块的 SHA256 值。抗篡改,但计算开销大。
实际项目中,我倾向于两者结合。偏移量用于快速定位,哈希值用于校验完整性。来看一个典型的块标记结构:
type BlockMarker struct {
BlockID int32 // 块序号,从 0 开始
Offset int64 // 文件中的起始偏移
Size int32 // 块大小,通常固定
Checksum string // SHA256 哈希值
Status int8 // 0=未传, 1=传输中, 2=已完成
}
为什么要有「传输中」状态?嗯,这里有个坑。我遇到过客户端传完块但没来得及写检查点就崩溃了。恢复后,服务端以为没传,客户端以为传了,结果重复传输。加上「传输中」状态,配合超时机制,就能避免这种歧义。
恢复策略:从断点「接上」而不是「重来」
恢复策略是断点续传的核心。我把它拆成四个步骤:
- 发现断点:客户端启动时,检查本地是否有检查点文件。有,说明上次没传完。
- 协商恢复点:客户端把检查点信息发给服务端,服务端确认哪些块已经完成。
- 跳过已完成块:从第一个未完成的块开始传输,已完成的块直接跳过。
- 校验完整性:恢复完成后,对整个文件做一次全量校验。
这里有个容易忽略的点:服务端也要做状态持久化。我见过不少设计,只在客户端存检查点,服务端不存。结果客户端恢复时,服务端说「我没记录,你从头传吧」。那断点续传就白做了。
来看一个恢复协商的伪代码:
// 客户端发起恢复请求
func resumeUpload(taskID string, clientCheckpoint Checkpoint) {
// 1. 发送检查点到服务端
serverResponse := server.negotiate(taskID, clientCheckpoint)
// 2. 服务端返回已确认的块列表
confirmedBlocks := serverResponse.ConfirmedBlocks
// 3. 找出未完成的块
pendingBlocks := findPending(clientCheckpoint, confirmedBlocks)
// 4. 从第一个未完成块开始传输
for _, block := range pendingBlocks {
uploadBlock(taskID, block)
}
}
断点续传的完整流程
我把整个流程画成了一张图,方便你理解各个组件之间的关系:
避坑指南:我踩过的三个坑
做断点续传这么多年,我踩过的坑不少。挑三个最典型的分享给你:
- 坑一:检查点写入不原子。我曾经用多行文本存检查点,结果写了一半进程崩溃,恢复时读到不完整的数据。后来改成单行原子写入,或者用数据库事务,问题解决。
- 坑二:偏移量和服务端不一致。客户端说传到了 100MB,服务端说只收到 80MB。原因?客户端算的是压缩前的大小,服务端算的是压缩后的。嗯,这个坑我花了半天才定位到。解决方案:统一用原始文件偏移量。
- 坑三:超时处理不当。客户端恢复时,服务端还在处理上一个块。结果两边状态对不上,数据乱序。后来我加了块序号和超时重试机制,才彻底解决。