
简介面向C网络编程学习者使用socket实现客户端与服务器间的断点续传解决大文件传输中断后需从头开始的痛点。资源为zip压缩包共34个文件大小约3.13MB包含client和server两个完整工程的cpp/h源码、Visual Studio项目文件以及编译生成的exe、obj、pdb等便于直接打开、编译和调试。已有1232人学习下载。项目覆盖C Socket编程核心流程客户端创建套接字、连接、发送文件名与偏移量、接收数据服务器端绑定端口、监听、接受连接并从指定偏移量继续发送。代码完整演示了socket()、connect()、send()、recv()等API的配合使用并包含传输状态记录、错误处理、超时机制、多线程处理多客户端、数据校验等关键实现细节适合深入理解网络文件传输原理也可作为课程设计或实际项目参考。 上个月我在公司做内部日志下载工具遇到一个很典型的场景线上服务要拉取一个好几GB的离线数据包结果机房链路不稳定每次下载到一半就断断了下下了断。最要命的是每次重连都是从头开始试了整整两天愣是没把一个包完整拉下来。后来我把工具里加上了断点续传半小时解决问题。整个过程踩了不少坑这里把完整的 C 断点续传方案整理出来从原理到实现再到那些测试时才会暴露的坑都过一遍。先说清楚一个概念断点续传的核心不是“从中间接续”而是“先搞清楚你手里已经有什么”。很多刚接触的人会把精力都放在网络协议上其实协议侧非常简单真正的复杂度全在本地状态管理和并发写入上。只要这两块设计对了断点续传就是水到渠成的事。这篇文章适合写下载器、上传组件、网盘客户端或者任何需要做大文件传输的程序员不管你是刚入门还是已经写过几次应该都能找到有用的东西。1. 断点续传的本质不是续传是先搞清楚“你手里有什么”断点续传表面上是一个网络传输问题实际上是一个状态管理问题。你回忆一下自己手动续传时的操作下载到一半中断你打开下载目录看到那个.part文件还在于是重新发起下载软件告诉你“已下载 45%”然后直接从 45% 继续跑。这个过程中网络协议做的事情就是告诉服务器“请从第 N 个字节开始给我数据”真正解决“我从哪开始”这个问题的是本地那个记录了 N 的文件。所以断点续传要解决的事情拆开看就三件服务器支不支持按偏移量取数据、本地有没有可靠地记录“已收到哪些数据”、续传完成后能不能证明文件是完整的。三件事缺一件断点续传就是空中楼阁。这里有个常见的误区很多人觉得断点续传就是“文件已存在就跳过已有的部分直接接着写”。表面看是对的但实际实现时你会发现如果只是“接着写”你根本不知道服务器发过来的第一个字节应该塞到文件的哪个位置。你以为服务器是从你上次断掉的位置继续发但如果你无法准确地告诉服务器“我断在哪个字节”服务器只能给你从头开始的数据。那这就不是续传只是“重新下载”。再往下挖一层还有一个隐蔽的问题本地那个.part文件的大小是不是真的等于“已下载的数据量”如果下载过程中没有同步刷盘系统崩溃后文件大小可能回退如果文件是多个线程写的可能中间有空洞如果下载源服务器的内容在你下载到一半时更新了那你手里的旧数据和服务器上的新内容可能是完全不同的两份文件。所以成熟的断点续传方案光记录一个文件大小是远远不够的这就是为什么后面我会强调“状态记录”要包含文件指纹、时间戳、块列表这些信息。理解了这个本质你会发现断点续传的实现思路其实很清晰先设计一个可靠的“状态记录”再设计一个能基于这个状态继续拉数据的“传输逻辑”最后加一个“校验机制”收尾。下面按这个顺序把每个环节抠开讲。2. 断点记录与偏移量管理续传的“记忆”到底该怎么存我最早做断点续传的时候方案特别简单本地写一个文本文件里面存“已下载字节数”和一个 URL每次下载开始前读这个文件如果发现 URL 一致就从已下载字节数继续。第一个版本确实能跑但在真实网络环境下连续跑了几天之后问题一个接一个冒出来程序异常退出后记录文件是空的、下载到半截发现记录里的字节数比实际文件还大、换了下载源 URL 没变但文件内容完全不同结果拼出来一个损坏的文件。这些问题的根子都在于状态记录设计得太粗。2.1 状态记录里到底要写什么我的建议是至少包含下面几项文件总大小TotalSize用来判断源文件是否变化。已下载字节数DownloadedSize这个是最基础的断点位置。分块大小ChunkSize和已完成的块列表CompletedBlocks当你做多线程分块下载时必须有这个。源文件标识ETag 或 Last-Modified或两者都要用来判断服务器上的文件有没有变过。下载任务唯一 ID防止多个任务共用一个记录文件导致状态串了。我用一个 C 结构体来表示这些信息struct DownloadTaskState { std::string task_id; // 任务唯一ID std::string url; // 下载地址 uint64_t total_size 0; // 文件总大小 uint64_t downloaded_size 0; // 已下载字节数 uint64_t chunk_size 0; // 分块大小 std::vectorbool completed_blocks; // 每个块的完成标记 std::string etag; // 源文件标识 int64_t last_modified 0; // 源文件修改时间 int64_t update_time 0; // 状态更新时间 };这个结构体序列化成 JSON 存到本地文件里。为什么用 JSON 而不是简单的文本因为 JSON 可读性强排查问题的时候直接打开看就知道状态对不对而且扩展性也够后续加字段不会破坏老版本读取。2.2 写入时机不是越快越好而是越可靠越好这里有一个性能与可靠性的取舍问题。如果你每收到一个 TCP 包就更新一次状态文件那性能会崩磁盘 IO 会被频繁的小文件写入拖垮而且没有任何必要因为断点续传的粒度根本不需要精确到字节。如果你只在程序退出时写一次那遇到断电、进程被杀这种场景就白干了。我的做法是在每一个数据块完整写入并落盘后才更新状态文件。比如分块是 1MB那么每完成一个 1MB 的块就记录一次。这样最坏情况下丢失的进度就是 1MB完全可以接受。更新的时候先用临时文件写入再 rename 覆盖旧文件避免写一半崩溃导致记录文件损坏。void SaveState(const DownloadTaskState state) { std::string json SerializeToJson(state); std::string tmp_path state_path .tmp; std::string real_path state_path .json; { std::ofstream ofs(tmp_path, std::ios::binary); ofs.write(json.data(), json.size()); ofs.flush(); } std::filesystem::rename(tmp_path, real_path); }这个 tmprename 的套路很老但确实管用。std::filesystem::rename在同一个文件系统内是原子的不会出现“读到半截文件”的情况。另一个细节是写完数据之后要先 flush确认数据真的落到磁盘了再更新状态。如果数据还在 OS 缓存里你就把状态改成了“已完成”系统一断电文件里实际什么都没有但状态文件说你已经下完了那就成了虚假进度。2.3 启动时如何判断能不能续传程序启动时依次做这些检查本地状态文件是否存在。不存在直接全量下载。状态文件能否正确解析。解析失败删了重新下载别硬续。URL 是否一致。不一致说明是不同任务不能续传。源文件 ETag 或 Last-Modified 是否一致。可用 HEAD 请求拿这两个字段和本地记录比对不一致说明源文件变了必须重新下载。本地.part文件是否存在且大小和状态文件里的数据吻合。不吻合保守起见从头来或者只保留完成块对应的数据。这五步检查看似繁琐但每一步都是在帮你避免“拼出一个损坏文件”的灾难。我之前有过一次事故源文件在服务器上更新了但我没做 ETag 校验续传下来的文件解压时 CRC 报错排查了一整天才定位到是续传逻辑太久没更新导致。从那以后ETag 校验就成了我代码里的硬性要求。3. 多线程分块下载把“续传”和“并发”合在一起做单线程断点续传其实很简单一个 Range 请求就够了。但实际产品里没人会满足于单线程。网速快的时候单线程拉大文件带宽根本吃不满所以成熟方案基本都是多线程分块下载。多线程和断点续传一结合复杂度就上来了。3.1 为什么不能“多线程同一个文件句柄直接写”很多第一次做分块下载的人会想每个线程拿到一段数据用seekp移到指定位置再写入不就行了理论上可以但实践中有两个坑一是你无法保证多个线程各自写入的数据在内存里不交错如果同一个std::ofstream被多个线程同时用需要加锁加锁之后写入就成了串行多线程性能优势没了。二是如果某个请求失败想要续传你得知道哪些段已经写完了哪些段还是空洞这需要对文件做精细的位图管理。所以我更推荐“预分配文件 每线程独立偏移写入”这个方案先按总大小ftruncate出一个完整大小的文件在 Windows 上用_chsize_s或SetEndOfFile每个线程用pwriteLinux或WriteFileWindows配合 OVERLAPPED 结构在指定的偏移处写入。因为每次写入的偏移都是线程私有的不存在竞争不需要加锁性能可以得到最大发挥。这样文件始终是“满尺寸”的已下载的部分有数据未下载的部分是空洞或零哪个块没完成一目了然。// Linux 下使用 pwrite 按偏移写入 ssize_t WriteAt(int fd, const char* buf, size_t len, uint64_t offset) { size_t written 0; while (written len) { ssize_t n pwrite(fd, buf written, len - written, offset written); if (n 0) { if (errno EINTR) continue; return -1; } written n; } return written; }3.2 分块大小的确定与线程调度分块大小怎么定我一般用“总大小 / 线程数”再向上取整到 1MB 对齐但会设一个上下限。比如线程数是 4总大小 100MB那每块 25MB如果总大小 1GB每块 250MB 就太大了万一其中一个块失败重下要重下 250MB不划算。所以我会限制单块最大不超过 16MB最小不小于 1MB。分块和线程不是绑死的。更灵活的做法是用一个“任务队列”先按块大小把整个文件切成很多个小块每个线程从队列里取一个未完成的小块下载下载完再取下一个小块。这样即使某几个块特别慢其他线程也不会空闲整体吞吐会高不少。std::atomicuint32_t next_block_index{0}; void DownloadWorker(const std::string url, uint64_t chunk_size, std::vectorBlockStatus blocks, int fd) { while (true) { uint32_t idx next_block_index.fetch_add(1); if (idx blocks.size()) break; if (blocks[idx].completed) continue; uint64_t start idx * chunk_size; uint64_t end std::min(start chunk_size, total_size) - 1; if (!DownloadRange(url, start, end, fd, start)) { // 记录失败稍后重试 blocks[idx].status BlockStatus::kFailed; } // 每完成一个块保存一次状态 SaveState(GetTaskState()); } }这个“小任务队列”模型是分块下载里最经典的模式配合状态文件里completed_blocks的位图启动时只要扫一遍位图就知道有哪些块要重下。这也是为什么我在状态结构体里专门放一个std::vectorbool的原因。3.3 线程数怎么选不是越多越快线程数设多少我的经验是 4~8 个基本够用。原因有几个大多数服务器的单连接限速并不高多开几个连接确实能提速度但超过 8 个之后收益非常有限反而容易触发服务器的连接数限制或防火墙规则。另外每个连接都需要独立的 TCP 缓冲区、SSL 握手如果走 HTTPS内存和握手开销都会成倍增加。还有个容易被忽略的点如果你是在一个并发量很高的应用里做下载每个任务的线程数太多会拖垮整个进程的调度。如果不知道服务器能承受多少可以先做一个简单探测顺序尝试 1、2、4、8、16 个线程各跑 3 秒记录有效吞吐选吞吐最高的值。这个探测只花十几秒但比拍脑袋靠谱得多。4. HTTP Range 交互细节下载器的真正核心本地状态设计得再漂亮如果和服务器交互的协议理解错了也白搭。HTTP 断点续传的核心就是 Range 请求头。这个头很简单但周围的细节很多很多坑都藏在这些细节里。4.1 Range 请求头和服务器的几种响应请求时在 HTTP 头里带上Range: bytesstart-end注意这里的字节区间是左闭右闭bytes0-0表示请求 1 个字节不是 0 字节。服务端收到后有几种可能的响应响应状态码含义你的处理206 Partial Content服务器支持断点续传返回指定范围的字节正常处理200 OK服务器忽略了 Range返回整个文件不能续传只能全量下载或中止416 Range Not Satisfiable请求的范围无效或者超出文件大小可能是文件变了或状态记录不准确需要回退404/403资源不存在或无权限提示用户不能续传每一种非预期响应都要有对应的处理逻辑。我之前写过一版只处理了 206结果遇到一个不规范的服务器它返回 200 且直接丢了整个文件内容我的代码把整个文件内容当作一个块去解析直接崩了。4.2 响应头 Content-Range 的解析当服务器返回 206 时响应头里会带Content-Range格式是Content-Range: bytes 0-1048575/10485760意思是本次返回的是第 0 到 1048575 字节整个文件大小是 10485760 字节。一定要认真解析这个头尤其是总大小字段它可能是*当服务器不确定总大小时也可能是具体的数字。解析的时候用sscanf或正则都行我偏好在 C 里用正则因为边界情况太多std::regex pattern(R(bytes\s(\d)-(\d)/(\d|\*))); std::smatch match; if (std::regex_search(content_range, match, pattern)) { uint64_t start std::stoull(match[1].str()); uint64_t end std::stoull(match[2].str()); // match[3] 可能是 *需要单独判断 }还有一个非常容易踩的坑某些 CDN 对于特别大的 Range 范围比如一次请求超过 2GB会直接返回 416 或断开连接即使它在技术上支持 Range。所以分块大小设 16MB、最大 32MB 是合理的不要为了减少请求次数而把分块调太大。4.3 重定向与倒流续传最容易翻车的地方这是我在实际项目里踩得最深的一个坑。下载 URL 经常会做重定向尤其是走对象存储或 CDN 的场景。第一次请求可能返回 302/307然后你重定向到真正的下载地址。问题在于当你的请求设置了 Authorization 头比如私有签名 URL重定向后某些 HTTP 库不会自动携带这个头而且 Range 头在重定向过程中也可能丢失。结果就是你以为自己发了一个 Range 请求实际上服务器收到的是一个不带 Range 的普通请求返回 200 全量内容。我的建议是先发一个不带 Range 的 HEAD 或 GET 请求手动跟随重定向拿到最终的 URL 之后再用最终的 URL 构造 Range 请求。不要依赖 HTTP 库自动跟随重定向因为自动跟随的时候你无法精确控制每个跳转请求的头。另外注意 307/308 重定向是保方法、保头的301/302 在 HTTP/1.1 中大部分客户端会转成 GET 并清空请求头。所以如果服务器返回的是 301/302你的 Range 请求大概率就直接变成全量请求了要格外小心。5. 上传方向的断点续传把逻辑反过来的实现要点很多做文件传输的人只关注下载方向的断点续传忽略了上传方向同样重要。上传大文件到服务器网络中断了难道真的要重新传一遍吗上传方向的断点续传逻辑恰好是下载的镜像但实现细节有不少不同点。5.1 客户端怎么告诉服务器“我传到哪了”下载方向是客户端主动发Range服务器按需返回。上传方向没有现成的 HTTP 标准通常需要自己设计协议。一个常见的方案是客户端先发起一个查询请求问服务器这个文件以文件 ID 或文件名标识你已经收到多少字节了服务器返回offset字段表示已接收的偏移量。客户端用这个偏移量拼接剩余数据发送到服务器。有人习惯于用PUTContent-Range做上传续传这确实更贴近 HTTP 语义——请求头里带Content-Range: bytes 0-1048575/10485760表示这次上传的是文件的哪一段。但现实中很多服务端框架对 PUT 的解析支持并不好尤其是文件上传一般走 POST。我的做法是自定义一个 JSON 协议{ file_id: abc123, offset: 1048576, data_base64: ... }当然 data_base64 是比较笨的做法真实项目里应该用二进制协议或在 multipart 里塞一个偏移字段这里只是示意。重点是想强调上传续传的逻辑就是把“从哪开始”这个信息传给服务器让服务器在落盘时把数据放到正确的位置。5.2 服务端要做什么临时文件与校验服务端的难点在于同一个文件可能被多个客户端并发上传也可能一个客户端传了一半断了过会儿又突然回来了。服务器需要一个严谨的存储状态机。我的建议是上传期间不要直接写正式文件名而是先写到一个临时文件比如xxx.uploading同时用一个小状态文件记录这个临时文件已接收的长度。每收到一个数据块就校验偏移量是否是预期的防止客户端乱序发数据然后落盘更新偏移量。当偏移量等于文件总大小时做一次整体校验可以用 crc32 或 md5取决于数据安全要求通过后把临时文件重命名为正式文件。服务端还有个细节避免多个客户端重复上传同一个文件导致覆盖。合理做法是文件创建时生成一个 UUID 作为 file_id客户端每次续传都带上这个 file_id。服务端用 file_id 作为索引找到对应的临时文件和偏移量。如果客户端不带 file_id 或者带的无效就直接新建一个任务。5.3 秒传的真正原理很多人以为“秒传”是断点续传的进阶版其实是同一件事的另一面。秒传的本质是客户端在上传前先计算文件内容的哈希md5 或 sha1发给服务器服务器比对发现这个内容已经存过了就不需要真的接收文件内容直接告诉客户端“传完了”。这本质上做的是“内容去重”和断点续传用到的状态记录和校验机制是同一套底子。所以在实现断点续传的时候把哈希校验放在比较早的阶段做往往能规避很多无意义的传输流量。比如一个 10GB 的文件本地已经传了 80%如果服务器那边突然说你之前传的那 80% 校验不通过只能从头传那体验是很差的。如果每次续传都带上前一次完成数据的哈希片段服务器可以快速判断是否可以基于这份数据进行续传。6. 实测中的几个大坑与排查链路到这里主线逻辑基本讲完了。但说实话真正让断点续传代码变得健壮的不是主逻辑而是那些只有跑到真实环境中才会暴露的“边角问题”。我把印象最深的几个坑写出来每个都附上我的排查链路希望对你有用。6.1 坑一文件大小校验通过解压却报 CRC 错误现象下载完的文件文件大小和服务器上一致校验信息也一致但解压时报 CRC 错误。排查链路先用 curl 手动下载同一文件解压正常说明服务器文件没问题。接着对比本地工具下载和 curl 下载的结果用cmp逐个字节比对发现文件中间有一段字节不一致。再检查状态文件发现etag字段是空的——原来当时源 URL 没有返回 ETag我代码里就默认允许了续传结果服务器在下载过程中更新过内容导致前后两段的数据不来自同一个版本。修复方案ETag 或 Last-Modified 至少要有其一否则不做续传直接全量下载。这个坑暴露的是状态记录中必须包含“源文件版本”的概念。之前我在原理部分就强调过这里再加重一次没有版本校验的续传等于拿运气赌数据完整性。6.2 坑二Range 请求返回 416进入死循环现象某个下载任务一到 95% 左右就卡住日志里全是 416 响应然后程序会自动重试再 416就这样死循环。排查链路检查重试逻辑发现代码在 416 时会把分块大小减半再试但减到很小的分块依然 416。继续查打印出请求的 Range 值和服务器返回的 Content-Range发现请求的是bytes10485760-10485759起始大于结束是一个非法 Range服务器当然返回 416。根本原因是状态记录里downloaded_size在某个块完成时被更新了但completed_blocks位图没有同步更新恢复时用旧的位图计算出错误的块起始偏移递归地错下去。修复方案更新状态时downloaded_size由completed_blocks推导而来而不是单独维护一个可变的累加值两个数据永远不会不一致。6.3 坑三下载了一半的临时文件被下一个任务错误复用现象两个下载任务 URL 不同但文件名相同比如都叫data.zip任务 A 中途退出任务 B 启动时读状态文件发现本地已有一个data.zip.part直接尝试续传结果数据拼接错乱。排查链路看状态文件里的 URL 字段发现 B 状态文件里存的 URL 其实是 A 的 URL因为我的代码只在任务新建时写 URL续传时不更新。修复方案任务参数发生变化时URL 不同、总大小不同、目标文件名不同一律抛弃旧状态重新下载。在SaveState之前做一次全字段比对任何一项对不上就清空状态文件。这些坑共同指向一个结论断点续传的复杂性几乎全在状态管理的一致性上。网络传输本身就是不可靠的你控制不了它会怎么断能控制的是你本地这颗“大脑”——状态文件——在异常场景下能不能保持正确。从这个角度看实现断点续传最划算的投资就是写一个强健、保守的状态管理器宁可多检查几次也不要为了省几个 I/O 制造出数据损坏的隐患。7. 一套最少可用的 C 骨架代码前面讲了这么多原理这里给一个最少可用的骨架。我用 C17 写了一个ResumableDownloader类核心思路是读状态文件 - 检查能否续传 - 按照completed_blocks生成任务队列 - 多线程下载 - 每个块完成后原子更新状态。为了篇幅这里只贴最关键的几个函数完整代码已经传到我的仓库有需要可以自取。class ResumableDownloader { public: bool Start(const std::string url, const std::string save_path, const std::string state_path, uint32_t thread_count) { url_ url; save_path_ save_path; state_path_ state_path; thread_count_ std::max(1u, std::min(thread_count, 16u)); if (!LoadState()) { // 需要全量下载 if (!ProbeFileInfo()) return false; InitBlocks(); } else { // 检查源文件是否变化 if (!CheckRemoteChanged()) { ResetToFreshDownload(); } } if (!PrepareOutputFile()) return false; LaunchWorkers(); return true; } private: bool LoadState() { std::ifstream ifs(state_path_); if (!ifs.good()) return false; // 从 JSON 解析 state_解析失败返回 false return true; } bool ProbeFileInfo() { // 发送 HEAD 请求获取总大小、ETag、Last-Modified // 把信息存到 state_ return true; } bool PrepareOutputFile() { // 如果本地文件不存在或小于总大小用 ftruncate 预分配 int fd open(save_path_.c_str(), O_CREAT | O_RDWR | O_BINARY, 0644); ftruncate(fd, state_.total_size); return fd 0; } };这里的骨架非常精简重点在于给出一个可以运行的起始点。真实项目中你还需要补齐HTTP 请求的异常重试、下载完成后的完整性校验、任务取消与异常断开的清理逻辑、以及针对 Windows 平台的pwrite替代方案。8. 我后来在项目里真正改对的地方写这篇文章之前我特意把公司下载工具里那段断点续传代码又过了一遍。说实话最早一版的设计问题和我在文章里讲的坑几乎一致状态文件更新不及时、ETag 缺失导致版本错乱、多线程和续传共用一个偏移量导致边界错误。后来改到稳定可用靠的是一些非常朴素的原则第一只要本地状态有任何不确定就选择全量重下。有人觉得全量重下很亏但其实在大多数场景下状态异常的概率极低而一旦状态异常却还硬要继续传拼出损坏文件的代价才是真正不可接受的。宁可下载慢一点也不要制造出数据完整性的地雷。第二状态文件要像数据库日志一样对待。每次更新都走 tmprename每次写完数据先 fsync 再更新状态这个顺序不能反。我有一次就因为在 Windows 上省略了FlushFileBuffers结果一次断电导致数据文件和状态文件不一致程序恢复时检测出来了差点丢了好几 GB 的下载进度。第三测试不能只看“快乐路径”。正常的续传流程没人会写错错的全在异常路径下载到一半 kill 进程、服务器返回 416、响应头格式不规范、重定向丢失 Authorization。我后来给工具加了一个模拟故障的测试框架用一个小型 HTTP 服务器做测试桩专门在特定偏移位置断开连接、返回错误码、修改响应头把能想到的异常场景都跑一遍。说来惭愧第一次跑10 个用例挂了 7 个但修复完之后这个工具在线上整整跑了三个月没有再出过一次续传问题。断点续传这件事技术含量不在算法也不在网络协议它考验的是对“状态一致性”的敬畏。如果你正准备在自己的项目里加这个功能我唯一的建议是先把你手里的“断点记录”方案写得足够健壮再去考虑并发、提速之类的优化。地基打稳了楼上怎么盖都不塌。本文还有配套的精品资源点击获取