---
title: "断点续传为什么会拼坏文件：用 Range 和 If-Range 判断能否追加"
date: 2026-11-13
slug: http-range-if-range-resume
canonical: https://kernelranch.com/blog/2026/11/http-range-if-range-resume
markdown_url: https://kernelranch.com/blog/2026/11/http-range-if-range-resume.md
tags: ["http", "downloads", "range"]
---下载剩余部分时，如果远端文件已经更新，直接把新字节追加到旧文件会得到损坏产物。请求同时带 Range 和上次保存的强 ETag。如果服务器返回 206，验证返回的范围再追加。返回 200 时，从头替换本地下载内容。

## 1. 用同一个地址模拟文件更新

保存为 range_lab.mjs，运行 node range_lab.mjs。实验服务针对 GET，只演示单个开放结尾的字节范围和强标签形式的 If-Range。

```javascript
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();
}
```

本地实测输出如下。

```text
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。前缀在内存模拟，实际文件写盘、多范围响应和代理压缩属于部署场景。
