断点续传为什么会拼坏文件:用 Range 和 If-Range 判断能否追加
下载剩余部分时,如果远端文件已经更新,直接把新字节追加到旧文件会得到损坏产物。请求同时带 Range 和上次保存的强 ETag。如果服务器返回 206,验证返回的范围再追加。返回 200 时,从头替换本地下载内容。
1. 用同一个地址模拟文件更新
保存为 range_lab.mjs,运行 node range_lab.mjs。实验服务针对 GET,只演示单个开放结尾的字节范围和强标签形式的 If-Range。
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { createServer } from 'node:http';
let content = Buffer.from('ABCDEFGHIJ');
const server = createServer((req, res) => {
if (req.method !== 'GET') {
res.writeHead(405, { Allow: 'GET' }).end();
return;
}
const body = content;
const tag = '"' + createHash('sha256').update(body).digest('hex') + '"';
res.setHeader('ETag', tag);
res.setHeader('Accept-Ranges', 'bytes');
const range = /^bytes=(\d+)-$/.exec(req.headers.range ?? '');
if (range && (!req.headers['if-range'] || req.headers['if-range'] === tag)) {
const start = Number(range[1]);
if (start >= body.length) {
res.writeHead(416, { 'Content-Range': 'bytes */' + body.length }).end();
return;
}
res.writeHead(206, {
'Content-Range': 'bytes ' + start + '-' + (body.length - 1) + '/' + body.length,
'Content-Length': body.length - start,
}).end(body.subarray(start));
return;
}
res.writeHead(200, { 'Content-Length': body.length }).end(body);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const url = 'http://127.0.0.1:' + server.address().port;
try {
const initial = await fetch(url);
const tag = initial.headers.get('etag');
const prefix = (await initial.text()).slice(0, 4);
const headers = { Range: 'bytes=4-', 'If-Range': tag };
const same = await fetch(url, { headers });
assert.equal(same.status, 206);
assert.equal(same.headers.get('content-range'), 'bytes 4-9/10');
assert.equal(prefix + await same.text(), 'ABCDEFGHIJ');
console.log('same version: 206, appended bytes 4-9');
content = Buffer.from('0123456789');
const changed = await fetch(url, { headers });
assert.equal(changed.status, 200);
assert.notEqual(changed.headers.get('etag'), tag);
assert.equal(await changed.text(), '0123456789');
console.log('changed version: 200, replaced all bytes');
const beyond = await fetch(url, { headers: { Range: 'bytes=20-' } });
assert.equal(beyond.status, 416);
assert.equal(beyond.headers.get('content-range'), 'bytes */10');
await beyond.arrayBuffer();
console.log('offset out of range: 416, length=10');
} finally {
server.close();
}
本地实测输出如下。
same version: 206, appended bytes 4-9
changed version: 200, replaced all bytes
offset out of range: 416, length=10
2. 把状态码映射到写盘行为
| 响应 | 客户端处理 |
|---|---|
| 206 | 校验 Content-Range 起点等于本地字节数,检查总长和已保存的验证信息,完整读取后再推进进度 |
| 200 | 从零写入临时文件,成功后替换旧下载 |
| 416 | 读取服务器报告的长度并核对版本与本地校验结果,必要时重新下载 |
服务器可以忽略 Range 并返回 200。416 也可能只是起点越界,单凭本地字节数等于总长无法证明文件完整。最终发布前可以根据可信清单校验文件长度和哈希。
范围偏移针对选定表示中的字节。若客户端自动解压、代理变换内容,或同一个 URL 返回不同的 Content-Encoding,磁盘中的字节与服务器的范围坐标可能不同。下载器应保持表示一致,例如请求并检查未压缩响应,或按原始编码字节保存文件。弱 ETag 不用于 If-Range。
小结
把下载偏移和强标签一起保存。收到 206 校验后追加,收到 200 重写,完成后再校验整份产物。
数据来源与检索时间
检索时间 2026-11-13。
| 来源 | URL | 对应内容 |
|---|---|---|
| RFC 9110 §13.1.5 | https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.5 | If-Range 验证语义 |
| RFC 9110 §14 | https://www.rfc-editor.org/rfc/rfc9110.html#section-14 | 字节范围与 Content-Range |
核验说明
- Node.js v24.19.0 实测本地 206、200 和 416。前缀在内存模拟,实际文件写盘、多范围响应和代理压缩属于部署场景。