断点续传原理:传输中断场景分析、状态持久化、断点标记与恢复策略

断点续传,说白了就是让传输任务具备「记忆能力」。

我早年做分布式文件同步时,遇到过一个大文件传了 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 是个经验值,你可以根据网络带宽和磁盘性能调整。

经验之谈:我曾经把刷盘间隔设成 64MB,结果一次中断丢了半分钟的数据。后来改成 4MB,丢的数据控制在 2 秒以内。这个 trade-off 值得你仔细权衡。

断点标记:给每个数据块一个「身份证」

断点标记,就是给每个数据块一个唯一标识。这样恢复时,服务端一看就知道「这个块传过了,下一个」。我常用的标记方式有两种:

  • 基于偏移量:记录文件中的字节偏移。简单直接,但文件结构变化时容易出错。
  • 基于块哈希:记录每个数据块的 SHA256 值。抗篡改,但计算开销大。

实际项目中,我倾向于两者结合。偏移量用于快速定位,哈希值用于校验完整性。来看一个典型的块标记结构:

type BlockMarker struct {
    BlockID   int32  // 块序号,从 0 开始
    Offset    int64  // 文件中的起始偏移
    Size      int32  // 块大小,通常固定
    Checksum  string // SHA256 哈希值
    Status    int8   // 0=未传, 1=传输中, 2=已完成
}

为什么要有「传输中」状态?嗯,这里有个坑。我遇到过客户端传完块但没来得及写检查点就崩溃了。恢复后,服务端以为没传,客户端以为传了,结果重复传输。加上「传输中」状态,配合超时机制,就能避免这种歧义。

恢复策略:从断点「接上」而不是「重来」

恢复策略是断点续传的核心。我把它拆成四个步骤:

  1. 发现断点:客户端启动时,检查本地是否有检查点文件。有,说明上次没传完。
  2. 协商恢复点:客户端把检查点信息发给服务端,服务端确认哪些块已经完成。
  3. 跳过已完成块:从第一个未完成的块开始传输,已完成的块直接跳过。
  4. 校验完整性:恢复完成后,对整个文件做一次全量校验。

这里有个容易忽略的点:服务端也要做状态持久化。我见过不少设计,只在客户端存检查点,服务端不存。结果客户端恢复时,服务端说「我没记录,你从头传吧」。那断点续传就白做了。

来看一个恢复协商的伪代码:

// 客户端发起恢复请求
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)
    }
}
核心原则:恢复时,客户端和服务端必须基于同一个「真相」做协商。这个真相就是持久化的检查点。任何一方都不能单方面决定「从哪开始」。

断点续传的完整流程

我把整个流程画成了一张图,方便你理解各个组件之间的关系:

断点续传完整流程图 客户端 服务端 1. 读取本地检查点 2. 发送恢复请求 + 检查点 3. 等待服务端确认 4. 从断点继续传输 5. 更新本地检查点 1. 接收恢复请求 2. 查询已确认块列表 3. 返回未完成块列表 4. 接收并校验数据块 5. 持久化块完成状态 关键:客户端和服务端必须基于同一个持久化状态做协商

避坑指南:我踩过的三个坑

做断点续传这么多年,我踩过的坑不少。挑三个最典型的分享给你:

  • 坑一:检查点写入不原子。我曾经用多行文本存检查点,结果写了一半进程崩溃,恢复时读到不完整的数据。后来改成单行原子写入,或者用数据库事务,问题解决。
  • 坑二:偏移量和服务端不一致。客户端说传到了 100MB,服务端说只收到 80MB。原因?客户端算的是压缩前的大小,服务端算的是压缩后的。嗯,这个坑我花了半天才定位到。解决方案:统一用原始文件偏移量。
  • 坑三:超时处理不当。客户端恢复时,服务端还在处理上一个块。结果两边状态对不上,数据乱序。后来我加了块序号和超时重试机制,才彻底解决。
特别提醒:断点续传不是银弹。如果文件在传输过程中被修改了,断点续传会出大问题。我建议在传输开始前对文件做一次快照,或者用文件 inode 和修改时间做校验。文件变了,就从头传。