ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

断点续传原理与实战:从HTTP Range到git clone

断点续传原理与实战:从HTTP Range到git clone 大文件传一半断网重来一次不是难受是灾难。断点续传就是为这个场景准备的让传输可以从上次中断的位置继续而不是从头开始。很多开发新人以为断点续传就是把文件切块然后编号上传实际上它要解决的问题比切块更深如何协商恢复点、如何校验完整性、如何处理源文件变化。这篇文章会用 HTTP 大文件上传下载和 git clone 三个场景把断点续传讲透覆盖原理、代码、踩坑记录适合刚接触文件传输的开发者也适合已经在写下载工具但被断线重试折磨过的人。如果你只是用迅雷、IDM 这类现成工具可能感受不到底层实现的讲究但一旦自己写服务端、自己写客户端就会知道断点续传里每一个字节都马虎不得。下面先聊原理再给可跑的代码中间穿插我个人踩过的坑。1. 断点续传的核心原理传输中断后如何精准接上1.1 Range 与 Content-RangeHTTP 里写好的“续传协议”断点续传在 HTTP 世界里的地基是 Range 请求头。客户端可以在请求里明确告诉服务端我不要整个文件我要从某个字节偏移量开始的一段数据。格式长这样Range: bytes1024-2047服务端如果支持就返回206 Partial Content并且在响应头里带上Content-Range: bytes 1024-2047/4096这里的含义是整个文件一共 4096 字节我给你的是其中第 1024 到 2047 字节。这个响应告诉客户端“你接上了而且是从你想要的位置接上的”。有了这套协商机制客户端只需要记录一个字节偏移量下次请求时把偏移量写进 Range网络中断就不再可怕。我第一次实现时犯过一个特别低级的错以为bytes0-99是 100 个字节其实 HTTP Range 的区间是闭区间0-99包含 0 和 99一共 100 个字节公式是end - start 1。服务端计算 Content-Length 时少加这个 1最后合并出来的文件总是短一截校验时怎么都对不上。这个细节几乎每篇文档都会提但该踩的人还是会踩。还有一个容易忽略的点Range 并不是只有一组start-end还支持多个区间甚至支持从尾部往前数比如bytes-500表示最后 500 字节。做断点续传不需要全部支持但服务端至少应当能解析bytesoffset-这种最常见形式否则客户端拿到 200 而不是 206下载工具只能放弃续传。1.2 恢复点不能只记“传到哪”状态里必须有校验信息很多新手写断点续传本地存一个downloaded_size下次直接从文件大小继续。这在百分之九十的情况下没问题但一旦源文件发生变化整个思路就崩了。你想一下文件从 100MB 更新成 120MB本地已经下载了 50MB下次请求Range: bytes50000000-拿到的是新文件从第 50000000 字节开始的内容拼接点前后根本不是同一个人。所以断点状态里至少要有四类信息文件唯一标识通常用 HTTP 头里的 ETag 或 Last-Modified。目标文件总大小用于判断文件是否变更。已完成的块表而不是单一游标。每个分块的校验值比如 SHA-256。这些不能只放在内存里断点续传的价值在于进程崩了、电脑重启了还能续。持久化到本地一个.part文件或者单独的元数据文件里重启后读取状态重新发起 Range 请求才算真正“续上”。如果只靠内存那充其量是“线程内重试”不是断点续传。1.3 生活化类比拼图而不是从头垒砖理解断点续传最直观的方式是拼图。整个文件是一幅大拼图网络传输是有人一块一块递给你。断网时你手里已经拼了一部分放在桌上恢复之后只需要从缺口继续接碎片不需要把拼好的部分打散重拼。但如果中途有人把拼图的图案换了你还按原来的缺口去拼结果必然是整幅图乱套。所以在恢复前先确认“还是原来那幅图”对应到技术上就是校验 ETag 和文件大小。这个类比也解释了为什么分块状态表比单一偏移量更靠谱拼图时如果只记得“我拼到了第几排”而不知道哪些块是残缺的缺口无法准确定位。只有真正落在桌上、经过校验的完整块才值得信任。2. 断点续传和完整上传的本质区别什么时候该选哪个2.1 一次提交 vs 分段协商流程差异完整上传的逻辑很简单客户端把整个文件塞进一个请求体服务端接收完落盘。优点是实现成本极低缺点也很明显网络稍一抖动几百 MB 的数据全部作废客户端只能重新构造请求服务端也要重新接收一遍。断点续传是另一种流程。以上传为例它通常不是简单发一个 Range而是把文件切成多个分片走“创建任务、分片上传、完成合并”三阶段客户端请求创建上传任务服务端返回一个 upload_id并约定分片大小。客户端对每个分片单独上传请求里带上 upload_id 和分片序号。某个分片失败时只重传这个分片其他分片不受影响。所有分片传完后客户端调用 complete 接口服务端按序号合并文件。完整上传是一次性豪赌断点续传是把风险切成了小份分别下注。在弱网环境里后者几乎必选在稳定的内网里前者反而更清爽。2.2 失败重试时的成本对比不只是多传一次的问题我用一张表把完整上传和断点续传的关键差异列出来方便对照判断维度完整上传断点续传失败成本已传数据全部作废需重新构造请求只有失败分片需要重传服务端状态基本无状态需要维护任务表、分片状态、过期清理并发控制单连接顺序发送可并发传分片但必须处理乱序到达源文件变化无感知收完就完需要通过 upload_id 绑定任务避免错乱实现复杂度低高状态机和边界情况多成本不只是“多传一次”。断点续传还会引入服务端任务状态管理比如 upload_id 存哪里、分片临时文件什么时候清、任务超过 24 小时没完成要不要回收。这些在完整上传里是不存在的。如果只是三五 MB 的小文件这些额外成本完全没必要。2.3 不是所有场景都需要断点续传断点续传听着高级但不是银弹。我现在的经验判断是超过 100MB 的文件或者要跨过弱网、移动网络传输值得上断点续传小于 10MB 的文件直接完整上传更省事。为什么小文件即使断网重试一次的成本很低而断点续传要处理的状态、并发、合并逻辑反而可能引入更多 bug。还有一个场景容易被忽略上传到对象存储。很多对象存储本身支持分片上传但如果你只是临时内部工具文件不大就别硬套断点续传。项目里最大的风险不是慢而是实现了复杂功能后没人维护。先评估文件大小和网络环境再决定要不要续传比一上来就堆代码重要得多。3. 手把手实现一个 HTTP 断点续传服务3.1 服务端用 Flask 支持 Range 下载先看下载场景。一个最简服务端可以用 Flask 实现核心是解析 Range 头返回 206。示例代码如下import os import re from flask import Flask, request, make_response, send_file app Flask(__name__) app.route(/download/path:filename) def download(filename): file_path f/data/files/{filename} if not os.path.exists(file_path): return make_response((, 404)) file_size os.path.getsize(file_path) range_header request.headers.get(Range) if not range_header: return send_file(file_path) m re.match(rbytes(\d*)-(\d*), range_header) start int(m.group(1)) if m.group(1) else 0 end int(m.group(2)) if m.group(2) else file_size - 1 if start file_size or end file_size: response make_response((, 416)) response.headers[Content-Range] fbytes */{file_size} return response length end - start 1 def generate(): with open(file_path, rb) as f: f.seek(start) remaining length while remaining 0: chunk f.read(min(64 * 1024, remaining)) if not chunk: break remaining - len(chunk) yield chunk response make_response(generate()) response.headers[Content-Range] fbytes {start}-{end}/{file_size} response.headers[Accept-Ranges] bytes response.headers[Content-Length] str(length) response.status_code 206 return response if __name__ __main__: app.run()这段代码有几个关键点。第一generate()是生成器用f.seek(start)跳到指定偏移然后按 64KB 的块读取避免大文件把内存撑爆。第二end - start 1是真正的响应长度前面说过闭区间的坑这段代码已经避开。第三当请求的 start 超出文件大小时要返回 416同时带Content-Range: bytes */file_size客户端看到这个头就知道文件变了或偏移量错了。3.2 客户端断线后如何确定恢复点下载场景的客户端续传逻辑核心就是“先看本地文件多大然后从那个字节继续请求”。最直接的做法import os import requests url https://example.com/file.zip local_file file.zip.part resp requests.head(url, allow_redirectsTrue) total int(resp.headers.get(Content-Length)) etag resp.headers.get(ETag) local_size os.path.getsize(local_file) if os.path.exists(local_file) else 0 if local_size total: print(文件可能已下载完成但建议再校验一次) exit() headers {Range: fbytes{local_size}-} with requests.get(url, headersheaders, streamTrue) as r: if r.status_code 206: with open(local_file, ab) as f: for chunk in r.iter_content(chunk_size64 * 1024): f.write(chunk)这段代码里用了head先拿 Content-Length 和 ETag再对比本地文件大小。只按文件大小判断有一个隐患如果上次写入最后一块时只写了一半os.path.getsize拿到的大小也是“半截大小”续传会把剩下半截拼在后面文件仍然损坏。更严谨的做法是记录分块状态只有完整校验过的块才推进游标这也是第 5 节要展开的坑。这里再提一个细节requests 默认会跟随重定向但如果下载服务在重定向后把 Range 头丢了r.status_code可能变成 200 而不是 206。代码里应该对 status_code 做显式判断并在 200 时考虑重新走完整下载流程或者报错提示。3.3 分块大小和并发策略怎么定做上传分片时分块大小和并发数直接影响性能和可靠性。分块太小比如 256KB每个分片都要带 header、校验、状态记录请求数量巨大服务端压力也大分块太大比如 1GB单个分片失败时重传成本又太高。我常用的起点是 8MB。一个 1GB 文件拆成 128 个分片数量适中单分片失败重传也能接受。如果客户端带宽充足、服务端支持可以放宽到 16MB如果网络极不稳定4MB 更稳。计算思路是块大小 ≈ 单块期望传输时间 × 有效带宽。比如带宽 10MB/s希望 1 秒内传完一块那 10MB 就合适但实际要考虑服务端写入速度和丢包重试8MB 是个比较均衡的默认值。并发策略上不建议把并发数拉太高。我实测在千兆内网8 并发能跑满带宽再往上增长有限反而增加服务端排序和合并压力。客户端并发上传时服务端不能假设分片按顺序到达合并文件时要用随机写入而不是追加with open(dest, rb) as f: f.seek(offset) f.write(data)append只适用于单线程顺序传输。并发场景下第 3 块可能比第 2 块先到如果盲目追加文件顺序就乱了。4. git clone 断线续传不用重新拉整个仓库4.1 git clone 的痛点没有 --resume但也不是只能删掉重来git clone 的流程分两个阶段先建立本地.git目录和 remote 配置然后下载对象库里的 commit、tree、blob。第二个阶段如果断网本地会留下一个残缺的仓库目录。很多人的第一反应是删掉重新 clone但本地其实已经有一部分对象全删非常可惜。git clone 本身没有--resume参数但它底层依赖的传输协议支持“只传缺失对象”。这个特性被git fetch继承了所以续传 git clone 的思路就是让本地残缺仓库继续 fetch 剩余对象。4.2 实测可行的 git 续传流程当你 clone 中断后进入那个不完整的仓库目录执行下面几步cd repo git remote add origin https://example.com/group/repo.git git fetch origin git checkout -b main origin/main第一步是确保.git/config里有 remote 配置。如果 clone 在非常早的阶段就断了remote 可能还没写进去就需要手动补。第二步git fetch origin会对比本地对象库和远端只下载缺失的 commit、tree、blob相当于给 git 传输加了“断点续传”。第三步把刚 fetch 到的分支检出成本地分支。我实际测试过一个大约 800MB 的开源仓库第一次 clone 到 60% 断线第二次直接 fetch只花了不到十分钟就补齐而重新 clone 大概率还是一个小时起。关键是本地.git/objects里已经存在的对象不会被重复传输这个机制和 HTTP Range 是异曲同工。4.3 更稳的做法浅克隆 逐步加深如果你从一开始就知道仓库很大或者网络不太稳定我建议别直接完整 clone改用浅克隆加逐步加深git clone --depth1 --branch main https://example.com/group/repo.git这样只下载最新一次提交对应的对象体积通常能缩小 80% 以上。后续需要更多历史时再继续加深git fetch --deepen200如果中途断线再次执行同样的git fetch --deepen200就能继续。这个方式比 clone 完整历史轻量得多也更贴近断点续传的目标每次只取自己缺的那部分。4.4 超大仓库还能怎么省流量对于特别大的 monorepo光用浅克隆可能还是不够。可以配合 partial clone 和 sparse-checkout让本地只拉你需要的目录或部分 blobgit clone --filterblob:none --sparse https://example.com/group/repo.git git sparse-checkout set backend/api这样初始传输只包含 commit、tree 以及稀疏检出的文件内容。它不算严格意义的断点续传但能显著减少首次传输量也就降低了断线的概率。我在处理超大仓库时永远先用--depth1 --filterblob:limit1m网络稳了之后再逐步 fetch。这个方法比反复重试完整 clone 舒服得多。5. 断点续传最容易踩的坑与排查清单5.1 字节偏移与服务端长度计算断点续传最容易出的问题往往不是大逻辑而是字节计算。除了前面提到的闭区间问题还有一个常见场景服务端返回的 Content-Length 与实际写入的字节数不一致会导致客户端读取不完整。排查时直接在客户端打印响应头重点看三样状态码是不是 206、Content-Range 的start-end/file_size是否合理、Content-Length 是否等于end - start 1。如果这三个都对文件还损坏再往下查写入方式。5.2 并发分片后文件拼接乱序上传场景里服务端为每个分片保存临时文件最后合并时如果按文件名排序字符串排序会坑人10.bin会被排在2.bin前面。正确做法是分片名统一按序号补零比如0001.bin、0002.bin或者在合并代码里按整型序号排序而不是按文件名。更稳的方法是每个分片一到就写入最终文件对应偏移采用seek(offset)随机写。这样即使分片乱序到达只要偏移正确最终文件也是完整的。5.3 客户端进度文件不可靠只记本地文件大小不是好主意因为意外退出可能让最后一个分片只写了一半。我最早做下载器时吃过这个亏下载一个 2GB 文件中途强杀进程本地文件大小是 1024MB 加 300KB我按这个偏移续传最后拼出来的文件里有一段垃圾数据。后来改成维护一个分块状态表只有完整写入并校验通过的分块才能算“已完成”续传时从最后一个完整块之后开始。进度文件本身也要原子更新先写临时文件再 rename避免写入一半导致状态损坏。这个小改动让我后面做下载工具再没遇过拼接错乱。5.4 断点续传常见问题速查表症状可能原因解决办法续传后文件校验和不对服务端文件变了客户端还在用旧偏移请求带 If-Range / ETag 校验不一致则重新下载下载到 100% 后提示文件损坏最后一个分块长度计算错误检查 Content-Length 是否为 end - start 1并发上传后文件乱序合并时直接 append按分片偏移 seek 写入或按序号排序续传后多了重复数据本地半截分块被当成完整分块引入分块校验表只认校验通过的分块git clone 中断后 fetch 一直报错本地 .git 配置不完整检查 .git/config或改用浅克隆重新拉取5.5 沿用至今的实操习惯做断点续传这几个月我养成了一个习惯每次下载完成除了比较文件大小还会算一次 SHA-256 和源端对比。这看起来多花几秒但能在第一时间发现静默损坏。服务端和客户端都打印关键偏移量和校验值调试时省很多事。最后分享一个我踩过的坑。最早的下载器版本为了省事把恢复点记录成百分比结果服务端换了一次文件百分比对应的字节偏移完全错乱拼出来的文件比源文件多了几 MB 垃圾数据。后来统一改成“文件大小 ETag 分块校验表”就再也没有出过这种问题。如果你只是想解决 git clone 中断直接记住第 4 节那两条命令就行如果你在做上传下载服务请一定先设计好状态存储别急着写并发代码。
返回列表