去年写了个 Windows 下载器功能不复杂多线程分段、断点续传、下完校验。协议部分两天就看明白了剩下三个月全花在断点续传上——不是难是坑多而且都藏在细节里。这篇按踩坑顺序写每个坑后面给能跑的代码。环境Node.js 18只用内置模块fs/promises、fetch。一、坑 1以为所有服务器都支持 Range第一版我直接开 8 个线程去下结果一半的站下载出来的文件是坏的。原因Range 请求头不是所有服务器都认。认的返回 206不认的返回 200 并把整个文件塞给你——你按分段偏移去写文件就乱了。所以第一件事是探测而且探测要轻只请求 1 个字节。async function checkRange(url) {const r await fetch(url, { headers: { Range: bytes0-0 } });if (r.status ! 206) {return { ok: false, reason: 服务器不支持 Range返回 r.status };}const cr r.headers.get(content-range) || ;const m /bytes\s\d-\d\/(\d)/.exec(cr);return {ok: true,size: m ? parseInt(m[1], 10) : 0,tag: r.headers.get(etag) || ,mtime: r.headers.get(last-modified) || ,};}顺手把 etag 和 last-modified 存下来。 后面判断文件换没换只能靠它们。二、坑 2相信了 Content-Length有台服务器头部写 9.4MB实际只发 9.1MB 就关了连接。我的代码认为下载完成校验时才炸。结论响应头只是参考真相只有落盘后的字节数。所以我在每个分片下载完后都做一次实际比对async function fetchPart(url, part, fh, onBytes) {const r await fetch(url, {headers: { Range: bytes${part.from part.got}-${part.to} },});if (r.status ! 206) throw new Error(分片请求失败 r.status);const buf Buffer.from(await r.arrayBuffer());const expect part.to - part.from 1 - part.got;if (buf.length ! expect) {// 少给了这次算部分成功把已到的写进去剩下的留给下一轮if (buf.length 0) {await fh.write(buf, 0, buf.length, part.from part.got);part.got buf.length;onBytes(buf.length);}throw new Error(short: buf.length / expect);}await fh.write(buf, 0, buf.length, part.from part.got);part.got buf.length;onBytes(buf.length);}注意 throw new Error(short:...) 这行——上层捕获到 short 就重试同一片而不是整包重下。三、坑 3分片数当成了并发数一开始我按 4MB 切片20G 的文件切成 5000 片然后想开 5000 个并发最快。结果IP 被限流连接被重置速度比单线程还慢。正确的关系是分片 决定「能并行到什么粒度」 → 切细一点没坏处并发 决定「同时开多少条连接」 → 受服务器限制8~16 比较稳所以切片和调度要分开片切细但 worker 数量固定。async function runPool(parts, workers, worker) {let cursor 0;const runners Array.from({ length: workers }, async () {while (cursor parts.length) {const part parts[cursor];if (part.got part.to - part.from 1) continue; // 已经完成的跳过try {await worker(part);} catch (e) {if (!/^short:/.test(e.message)) {parts.push(part); // 其它错误放回队列重试await new Promise((r) setTimeout(r, 500));}}}});await Promise.all(runners);}四、坑 4用 append 写文件这是最蠢的一个但确实犯过。多个分片并行下载落盘位置由偏移决定。用 appendFile 写等于按到达顺序拼接——文件字节顺序全乱大小还对。所以打开校验和打不开都不报错只有真正用的时候才发现坏了。正确做法open 一次所有分片用同一个句柄带 position 写。import { open, rename, writeFile } from fs/promises;const fh await open(tmpPath, r);// ...每个分片await fh.write(buf, 0, buf.length, offset)await fh.close();r 而不是 w——w 会清空文件一重启就把已下载的部分删了。五、坑 5状态文件写坏了进度文件我一开始是直接 writeFileSync 覆盖。测试时拔了一次电起来了发现进度文件是半个 JSONJSON.parse 直接抛错整个续传逻辑失效。修法是经典的「临时文件 rename」export async function saveState(path, state) {const tmp path .part;await writeFile(tmp, JSON.stringify(state), utf8);await rename(tmp, path); // 同分区原子替换}rename 在同一分区是原子操作要么是完整旧文件要么是完整新文件不会出现半截。恢复的时候还要多验一步拿存的 etag 和重新探测的 etag 比。不一样说明服务器上的文件被换了这时必须丢弃全部进度重下——否则你会得到「前半段旧文件 后半段新文件」大小对、内容错校验都救不回来。六、附几个真实存在的坑按遇到频率排七、最后这套东西要不要自己写看场景。偶尔下几十兆的文件浏览器完全够用别折腾。 真正值得写的场景只有一种文件大几个 G 以上而且要反复下——这时候能看见每一片到哪、断了能续、下完能校验省下来的时间远超写它的成本。我自己的版本是 Windows 绿色免安装的主要用来下模型文件和视频素材。代码里那些坑基本都来自真实失败不是设计出来的。想直接用的话https://turbofetch.cn/download?ccsdnZIP解压即用不用装拓展浏览器提示【不常下载文件】点旁边...保留即可如果你也在写类似的东西欢迎交流——尤其是遇到奇怪的服务器行为我这边积累了一堆样本。