1. 先把"截取"这件事拆开看:从一句话需求到可落地的链路设计
项目编号 226 这个活儿,最初的需求文档只有一行字:"支持从网络文件截取指定内容到本地"。就这一句话,客户端的同学和服务端的同学各自理解出了完全不同的东西。客户端以为要做一个断点续传的下载器,服务端以为只是把文件挂到静态目录上让浏览器点右键另存为。两边都动手写完,联调那天一对齐接口,才发现连"分片"这个词的定义都不一致。所以这篇我想聊的不是某个框架怎么用,而是网络文件截取这个模块在客户端与服务端联调时,那些必须提前对齐的协议细节、参数取值和踩坑记录。
先把概念统一一下。我这里说的"网络文件截取",指的是客户端从服务端按字节区间拉取文件内容,最终在本地还原出一份完整、可校验的文件副本。它包含三层含义:一是按需分段,不是傻等一个大 HTTP 响应从头流到尾;二是可中断可恢复,网断了、进程崩了、机器重启了,都还能接着往下走;三是结果可验证,落盘之后的字节和服务端上的字节必须一模一样。适合谁看?正在做文件传输、离线包下发、大附件导出、视频缓存这类功能的后端和客户端同学,尤其是第一次把两端凑到一起联调、被各种"本地能跑线上不行"折磨过的人。
1.1 需求盘点:截什么、截多大、要多快
联调之前我们花了一个下午,把需求拆成五个必须回答的问题,每个问题都直接决定接口长什么样。第一个是文件规模,我们统计了历史数据,90% 的文件在 10MB 以下,但有 3% 的文件超过 1GB,最大单文件 2.4GB。这个分布非常关键,它意味着小文件走小文件的路子、大文件走大文件的路子,用一套逻辑硬套两种场景必然有一边难受。
第二个是网络环境,客户端所处的链路上下行差异极大,办公网里能跑到 20MB/s,弱网环境下降到 200KB/s 还频繁闪断。第三个是并发规模,峰值时单台服务端每秒要承接 300 个截取请求,其中 20% 是大文件分片请求。第四个是失败容忍度,用户能接受慢,但接受不了"下到 99% 报错重来"。第五个是文件是否可变,运营侧明确回复:同一路径的文件内容会更新,但更新后会换 URL,理论上同一个 URL 的字节内容不变。
这五个答案落到设计上就是一句话:接口必须支持按字节区间读取,并且必须能让客户端识别出"这个 URL 背后的内容有没有变过"。前半句指向 Range 请求,后半句指向 ETag 与内容校验。很多团队做联调卡住,根源就是只做了前半句,等到用户反馈"文件下下来打不开"才想起后半句。
1.2 一次完整截取在两端之间到底发生了什么
我把一次典型的截取过程拆成七步,这个时序是我们后来写进联调文档里的,两端对着这张表干活,沟通成本直接砍掉一半。
| 步骤 | 客户端动作 | 服务端动作 | 关键约定 |
|---|---|---|---|
| 1 | 发起 HEAD 或首个探测请求 | 返回元数据头 | 返回总长度、ETag、是否支持 Range |
| 2 | 比对本地 meta 记录 | 无 | ETag 不一致则丢弃本地临时数据 |
| 3 | 按分片规划并发拉取 | 按 Range 返回 206 | 每片必须严格返回 Content-Range |
| 4 | 流式写入预分配文件的对应偏移 | 无 | 写入位置 = 分片序号 × 分片大小 |
| 5 | 单片完成更新进度并落盘 meta | 无 | meta 写入必须原子 |
| 6 | 全部完成计算整文件哈希 | 提供内容哈希头 | 哈希不一致整份作废 |
| 7 | 重命名临时文件为正式文件 | 无 | 重命名是最后一步,保证可见性 |
这张表里第 4 步和第 7 步是最容易被忽略的两处。第 4 步如果不做偏移写入,而是简单地"按完成顺序追加",那并发就退化成串行,或者内容顺序全乱。第 7 步如果不做临时文件到正式文件的重命名,用户很可能在下载到一半的时候就看到一个名字正常、内容残缺的文件,然后拿去用,然后来找你。
1.3 方案选型:为什么最终没有走一次性全量下载
最初的版本非常简单,客户端发一个 GET,服务端把整个文件流回来,客户端边收边写。这个方案在小文件上表现完美,实现成本几乎为零,代码量不到三十行。但一上真实场景就崩了:2.4GB 的文件,一次流式传输要连续占用连接十几分钟,中途任何一次网络抖动都意味着从头再来;服务端那边一个慢客户端就能长期占住一个工作线程,并发能力被迅速吃光。
我们也评估过让服务端打包成压缩包再下发,但文件本身多数已经是压缩格式,再压一遍收益接近零,反而多了一次 CPU 开销和一次全量读取。最后选的是客户端主动分片 + 服务端无状态响应的方案:服务端不维护任何会话,只老老实实按 Range 返回字节;所有状态(下了哪些片、下了多少)都由客户端自己记录。这个取舍的核心逻辑是,服务端无状态才能水平扩容,客户端有状态才能灵活重试。代价是客户端要多做不少事,但对一个要被反复使用的传输模块来说,这笔账是划算的。
2. 服务端接口设计:把 Range 语义吃透
服务端的活儿看起来最轻,实际上细节最多。很多人以为静态文件服务器天然支持 Range,随便挂个目录就完事,但真到了联调阶段,各种中间件、压缩层、缓存层会把 Range 语义破坏得面目全非。我们当时的环境里,请求要穿过一层应用网关、一层反向代理、一层对象存储,任何一层处理不当,客户端收到的就不是 206 而是 200,或者是 Content-Range 对不上的 206。
2.1 响应头清单与每个字段的真实作用
联调前我们把必须返回的头字段列成一张硬性清单,缺一个就算不合格。这不是形式主义,每个字段在客户端那边都对应一段逻辑分支。
HTTP/1.1 206 Partial Content Content-Type: application/octet-stream Content-Length: 8388608 Content-Range: bytes 0-8388607/2576980377 Accept-Ranges: bytes ETag: "b7c3f1a9e2d84c05" Last-Modified: Wed, 12 Jun 2024 08:31:44 GMT X-Content-SHA256: 9f2e...(整文件哈希,仅首个请求返回) Content-Disposition: attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.binAccept-Ranges: bytes是客户端判断"能不能分片"的唯一依据,不带这个头,客户端只能降级为单流下载。Content-Range里的总长度必须和文件真实长度一致,它是客户端计算分片数量的输入,一旦不准,最后一片必然越界。ETag的作用前面说过,用来识别内容是否变化,我们用的是内容哈希截断,而不是文件系统的时间戳,原因在后面的问题排查里会详细讲。Content-Disposition里的中文文件名必须走 RFC 5987 的filename*写法,直接塞中文进去,部分客户端会拿到一堆问号。
注意:
X-Content-SHA256这类自定义头,如果链路经过跨域访问,必须显式加入Access-Control-Expose-Headers,否则前端脚本读不到,会误判成"服务端没提供校验信息"。
2.2 206 与 Content-Range 的三个边界处理
第一个边界是首字节。Range: bytes=0-8388607是合法的,但客户端如果写成bytes=-8388607,语义就变成了"最后 8388607 个字节",这是后缀范围语法,服务端必须按规范解析,不能想当然当成从头开始。我们联调时就遇到过客户端图省事,用负数后缀表示分片,结果每一片都拿到文件尾巴,拼出来的东西完全是乱的。
第二个边界是越界。客户端请求bytes=2576980000-2576989999,超出了文件总长度,服务端要么返回 416 并带上Content-Range: bytes */2576980377,要么优雅地截断到最后一位返回 206。我们选了后者,因为对下载场景更友好,但客户端仍然要按返回的Content-Range校正自己的预期,不能硬按自己算的长度去校验。
第三个边界是最后一片的长度。总长 2576980377 字节、分片 8MB 时,理论分片数是 308,前 307 片都是满的 8388608 字节,最后一片只剩 1677721 字节。这个计算必须由客户端和服务端各自独立算一遍,并且结果一致;我们当时就是客户端按 308 片算、服务端缓存层按 307 片算,最后一片请求被裁掉,文件尾部丢了 1.6MB,用户打开发现文件结构不对,排查了两个多小时。
2.3 校验信息的下发与元数据接口
校验信息不能只在第一个分片里返回,那样一旦客户端在下载中途重启,就再也拿不到哈希了。我们的做法是拆成两个接口:一个轻量的元数据查询接口,返回总长度、ETag、内容哈希、分片建议大小;另一个是纯粹按 Range 取字节的内容接口。元数据接口的响应体积在 500 字节以内,客户端每次启动截取任务先调它,成本可以忽略。
服务端算整文件哈希这件事本身有开销,2.4GB 全量读一遍算一次,如果是每次请求都算,服务端直接被打挂。我们的处理方式是启动时异步预热一份哈希索引,文件内容不变就一直复用,文件被替换时监听变更事件重新计算。这套机制在联调阶段被简化成了"按 mtime 缓存",后面果然出了问题——同一秒内替换两次文件,mtime 没变,哈希沿用旧的,客户端校验通过但内容是错的。这个坑后面单独讲。
3. 客户端实现:把字节流稳稳落到磁盘
客户端是整个联调里出问题最多的一侧,因为它要同时管网络、管磁盘、管进度、管重试,任何一块松了都会在长跑里暴露。我把它拆成请求层、调度层、落盘层三块来写,每块都有必须死守的规则。
3.1 请求层构造与连接复用
请求层的第一条规则是必须携带正确的 Range 头,并且必须校验响应状态码。如果服务端返回 200 而不是 206,说明 Range 没生效,这时候不能假装一切正常继续写,必须立刻切换到单流模式,否则客户端会把整个文件当成第一片写进去,后面的分片全乱。这个判断我们写成了一个强制分支,宁可慢也不能错。
第二条规则是连接复用。每个分片都新建连接在低并发时看不出问题,一到 6 并发以上,每次 TCP 握手加 TLS 握手的开销就摊在总耗时里了。我们把连接池大小设成并发数的 1.5 倍,开 keep-alive,实测同样 2.4GB 文件的总耗时从 143 秒降到 118 秒,差不多省了 17%。
import requests session = requests.Session() adapter = requests.adapters.HTTPAdapter( pool_connections=9, pool_maxsize=9, max_retries=0, ) session.mount("http://", adapter) session.mount("https://", adapter) def fetch_chunk(url, start, end, etag, timeout=(5, 30)): headers = { "Range": f"bytes={start}-{end}", "If-Range": etag, "Accept-Encoding": "identity", } resp = session.get(url, headers=headers, timeout=timeout, stream=True) return resp这里有两个细节值得单独说。Accept-Encoding: identity是硬性要求,不写这一条,某些中间件会给你的响应加 gzip,字节数立刻对不上,Content-Range 也随之失真。If-Range带上 ETag,让服务端在内容已变时直接返回 200 全量而不是返回一个错误的 206 片段,这样客户端能立刻感知到"文件换了"。
3.2 分片调度与并发控制
分片大小的选择不是拍脑袋。太小,请求数暴涨,头部开销和调度开销占比过高;太大,单片重试成本高,弱网下容易出现长时间无进展。我们的取值逻辑是这样的:小于 4MB 的文件直接单片拉完;4MB 到 64MB 用 2MB 分片;超过 64MB 用 8MB 分片。这个梯度的依据是"单片在目标网速下的传输时间控制在 1 到 3 秒之间",低于 1 秒说明请求太碎,高于 3 秒说明重试代价太高。
并发数也一样。我们最初设成 16,结果服务端网关的限流阈值是单 IP 20 QPS,稍微抖动一下就触发 429,客户端不停重试反而更慢。后来收到 6,总吞吐反而上去了。我的经验是,并发数不要超过服务端单连接限流阈值的一半,留出余量给重试和其他请求。6 并发配 8MB 分片,在 200Mbps 的链路上实测能跑到 22MB/s 左右,基本吃满了带宽。
调度层用一个固定大小的线程池加一个共享的任务队列,队列里存的是还没成功的分片序号。某个分片成功后从"待办"集合移除,失败则退回队列尾部,并且该分片的重试计数加一。重试计数超过 4 的分片直接标记为任务失败,不再无限重试——这一点很重要,没有上限的重试会让整个任务永远卡在一个坏分片上,用户看到进度条停在 87% 再也不动。
3.3 落盘策略与断点续传记录
落盘的核心是预分配加随机写。任务开始时按总长度预分配文件,这一步在多数文件系统上很快,能避免写入过程中不断扩展文件带来的碎片和额外元数据操作。然后每个分片算好自己的写入偏移:offset = index * chunk_size,用随机访问的方式写到对应位置。
import os CHUNK = 8 * 1024 * 1024 def prepare_file(path, total_size): with open(path, "wb") as f: f.truncate(total_size) def write_chunk(path, index, data): offset = index * CHUNK with open(path, "r+b") as f: f.seek(offset) f.write(data) f.flush() os.fsync(f.fileno())每次 open 关 open 会有额外开销,2.4GB、308 片的情况下大概多花一两秒,可以接受。如果想优化,可以用一个长生命周期的文件句柄加锁保护,但要注意多线程下的 seek 和 write 必须成对原子执行,否则两个线程交叉写会互相踩位置。我们在早期版本就踩过这个坑:两个线程共享句柄,A 线程 seek 完之后 B 线程抢先进来 seek 到别处,A 再写,结果两片数据写到了同一个位置,文件里出现大段空洞。
断点续传的记录用一个独立的 meta 文件,内容是 JSON,包含 URL、ETag、总长度、分片大小、已完成分片序号列表。每完成一片就更新一次,写入方式是先写临时文件再原子重命名,防止进程在写 meta 的瞬间被杀导致记录损坏。
{ "url": "http://example.internal/files/big.bin", "etag": "b7c3f1a9e2d84c05", "total": 2576980377, "chunk_size": 8388608, "done": [0, 1, 2, 5, 6, 7, 11], "updated_at": 1718176304 }恢复任务时,先拿本地 meta 里的 ETag 去跟服务端元数据接口比对,一致才继续,不一致就把临时文件整个删掉重来。这一步看着粗暴,但比"尽力拼接"要可靠得多,因为一旦内容变过,拼出来的文件是没法用的,与其让用户拿到一个坏文件,不如老老实实重下。
3.4 进度、限速与重试策略
进度上报的粒度要控制。每写完一个 128KB 的缓冲块就回调一次进度,对 2.4GB 文件来说就是两万次回调,如果回调里去做 UI 刷新或者数据库写入,主线程会被拖死。我们的做法是回调里只更新一个原子计数器,真正对外通知节流到每 200 毫秒一次。
限速用令牌桶实现,目标速率除以每批令牌的间隔,得到每次应放行的字节数。
import time class TokenBucket: def __init__(self, rate_bytes_per_sec, burst): self.rate = rate_bytes_per_sec self.capacity = burst self.tokens = burst self.last = time.monotonic() def consume(self, n): while True: now = time.monotonic() self.tokens = min( self.capacity, self.tokens + (now - self.last) * self.rate ) self.last = now if self.tokens >= n: self.tokens -= n return time.sleep(max(0.001, (n - self.tokens) / self.rate))按 20MB/s 的目标算,容量设成 1MB,每 128KB 消耗一次令牌,放行间隔大约 6.4 毫秒。实测下来波形比较平滑,不会出现"突然放一大波然后又卡住"的锯齿。
重试用指数退避加随机抖动:第 1 次等 1 秒,第 2 次 2 秒,第 3 次 4 秒,第 4 次 8 秒,每次再叠加 0 到 500 毫秒的随机量。加抖动的目的是避免多个分片在同一时刻集体重试,把服务端瞬间打出一个尖峰。这个技巧在 6 并发下效果很明显,我们去掉抖动做对比测试时,服务端的 429 响应数翻了将近三倍。
4. 联调实录:从跑通到跑稳的那几个坎
前面写的是设计,这一节是真实发生的事。联调一共花了三天,第一天下午跑通,后面两天半都在处理各种"看起来正常其实有问题"的情况。
4.1 环境准备与工具清单
环境上我们准备了三个服务端实例:一个本机跑的最小实现,只返回 Range,用来验证客户端的协议解析;一个部署在测试环境的完整链路,带网关、反向代理和缓存;一个准生产环境,数据和限流策略都和生产一致。三个环境对应三类问题,本机环境验证逻辑正确性,测试环境验证中间件兼容性,准生产验证容量和限流。跳过任何一个,后面都会在生产上补课。
工具方面,命令行观察响应头用 curl 最直接。
curl -s -D - -o /dev/null \ -H "Range: bytes=0-1023" \ -H "Accept-Encoding: identity" \ http://test.internal/files/big.bin这条命令打完,HTTP/1.1 206、Content-Range、Accept-Ranges、ETag一眼就能看到。本机那个最小实现一开始就漏了Accept-Ranges,客户端直接降级成单流下载,联调第一天上午就这么浪费掉了。除了 curl,还用抓包工具看完整的请求响应时间线,重点看两件事:同一连接上是否复用了多次请求,以及每片响应的首字节时间和整体耗时分别是多少。首字节时间高说明服务端处理慢,整体耗时长但首字节快说明带宽受限,两者对应完全不同的优化方向。
4.2 关键节点的时间线记录
第二天下午我们跑了第一次完整压力测试,2.4GB 文件、6 并发、8MB 分片,总耗时 118 秒,平均 21.8MB/s。这个数字看着不错,但中间出现了 4 次单片重试,全部集中在前 20 秒。翻抓包记录发现,前 20 秒正好是并发全部铺开的阶段,6 个连接同时建立,同时开始传输,把链路的瞬时缓冲打满了,触发了丢包。后来在调度层加了一个 150 毫秒的启动斜坡,就是前几个分片错开一点发出去,重试次数直接降为零。
第二个节点是断点续传验证。我们故意在第 40 秒手动断网,等 30 秒后恢复。客户端这边有 137 片已完成,恢复后从第 138 片左右开始接着下,总耗时多了大概 40 秒(含断网那 30 秒的等待),没有从头再来。但第一次做这个测试时失败了,恢复后进度直接从 0 开始。原因是客户端的 meta 文件写入用了普通 write 没加 fsync,进程被强杀后内核缓冲区里的内容丢了,重新启动读到的是一个空 meta。加上 fsync 和原子重命名之后才稳定。
第三个节点是校验环节。全部 308 片写完之后,读整个文件算 SHA-256,2.4GB 在 SSD 上花了大概 9 秒。算出来的哈希和服务端元数据接口给的值一致,说明整条链路的字节是完全对齐的。这一步千万不要省,我们有一次就是因为省了这步,把一个尾部缺失的文件交付出去,用户侧解析失败,反馈回来时已经过了两个小时。
4.3 关键参数的计算过程
把这些参数的计算过程摊开写一下,方便别人直接套用。
分片数计算:总分片数 = 向上取整(总字节数 / 分片大小)。以 2576980377 字节、8388608 字节分片为例,2576980377 / 8388608 ≈ 307.2,向上取整得 308 片。前 307 片覆盖 2575302656 字节,最后一片 = 2576980377 - 2575302656 = 1677721 字节,约 1.6MB。
超时设置:单片传输时间 = 分片大小 / 单连接实际速率。假设单连接稳定 3.5MB/s,8MB 分片约需 2.3 秒。读超时取这个值的 10 倍以上比较安全,我们定 30 秒;连接超时取 5 秒,弱网下也能接受。
连接池大小:取并发数乘以 1.5 再向上取整,6 并发得 9。这样即使有 2 个连接处于重试或半关闭状态,仍有足够空闲连接可用。
临时磁盘空间:需要 文件总大小 + meta 文件 + 缓冲余量。我们按 文件大小 × 1.2 预留,并在任务开始前做一次空间检查,剩余空间不足时直接拒绝任务并给出明确提示,而不是下到一半才报"磁盘写入失败"。
5. 常见问题速查与排查思路
这一节是我认为最有价值的部分,因为它记录的都是文档里不会写、但实际联调中一定会遇到的东西。我按现象、原因、处置整理成对照表,遇到问题先查表,能省掉大量时间。
5.1 现象与根因对照表
| 现象 | 最可能的根因 | 处置办法 |
|---|---|---|
| 收到的状态码是 200 而不是 206 | 服务端未开启 Range,或被中间件吃掉 | 检查Accept-Ranges,逐层排查代理配置 |
| Content-Range 的总长度和实际文件不符 | 缓存层做过改写,或文件被并发替换 | 用元数据接口的独立长度做交叉校验 |
| 分片大小对但字节内容对不上 | 响应被 gzip 压缩,或缓存返回旧版本 | 强制Accept-Encoding: identity,206 响应禁用缓存 |
| 续传后文件损坏 | ETag 判定失效,内容已变仍拼接 | 用内容哈希做 ETag,不要用秒级 mtime |
| 进度条长期停在某一片 | 该片重试无上限,卡死 | 单片重试设上限,超限中止并提示可续传 |
| 下发文件名是乱码 | 中文直接写进 Content-Disposition | 改用 RFC 5987 的filename*编码 |
| 并发一高就出现 429 | 客户端并发超过服务端限流阈值 | 并发降到阈值一半,读Retry-After退避 |
| 磁盘有空余但写入失败 | 单文件大小超过文件系统限制 | 检查文件系统类型,fat32 上限 4GB |
5.2 几个用时间换来的经验
ETag 一定不要用文件系统的秒级修改时间。这个坑我们踩得非常实在:运营在同一个秒内做了两次文件替换,第一次替换后客户端开始下载,第二次替换后 mtime 依然是那个秒数,客户端拿着旧 ETag 去比对,服务端也认为"没变",于是新旧内容被拼在了一起。表现出来的现象极其诡异——文件长度对、哈希不对、某些片段能正常解析某些不能。改成内容哈希的前 16 位之后,这类问题再没出现过。
分片大小不要设成刚好等于缓冲区大小。当分片大小和读缓冲一致时,最后一小块的处理容易出现"读满一块就返回但没读完整个分片"的情况,尤其是服务端流式输出时。我们统一把读缓冲设成 128KB,分片大小是缓冲的整数倍,读取循环里用"累计已读字节数小于 Content-Length"作为终止条件,而不是依赖返回值为空。
临时文件命名要能反推出源信息。我们把临时文件命名成一个带哈希和长度的形式,序列化的时候不用额外查表就能知道它是谁。这个做法在排查线上问题的时候特别省事,运维捞到一个残留的临时文件,光看名字就知道是不是该清理的垃圾。
取消和暂停必须真正中断网络读写。早期版本点了暂停,任务看着停了,实际后台线程还在收数据,只是不写盘了,结果一暂停内存就慢慢涨。正确做法是给每个工作线程一个取消标志,同时在流式读取的循环里检查这个标志,一旦置位立刻关闭响应对象,让连接真正释放。
服务端的哈希计算要和文件变更事件绑定,不能靠轮询。我们最开始用的是定时任务每 5 分钟扫一遍目录算哈希,中间那 5 分钟就是个窗口期,文件已经换了但哈希还是旧的。改成监听文件变更事件后,窗口期被压缩到毫秒级,配合 ETag 校验,基本杜绝了脏读。
联调阶段一定要模拟"坏网络"而不是"没网络"。断网是最好处理的,有明确的异常信号;真正麻烦的是极慢的链路和随机丢包,请求能发出去、响应能回来,但就是慢,超时设置不合理就会不停误杀正常的请求。我们的做法是在客户端加一个可配置的带宽限制和随机延迟注入,用一个小开关控制,联调时打开,上线时关掉。
最后分享一个我在这个项目里养成的习惯:客户端和服务端各自维护一份"协议约定清单",写清楚每个头字段的含义、每个状态码的处理分支、每个边界的取值规则,两边对着同一份清单写代码,联调时拿着清单逐条打钩。226 这个项目从第一次两端对齐到跑通完整流程只用了一个下午,后面两天半全在处理中间件和异常场景,靠的就是这份清单把"理解偏差"这类最耗时间的问题提前消灭掉了。