ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

wget下载中断恢复:断点续传原理、排查与配置实践

wget下载中断恢复:断点续传原理、排查与配置实践

夜里十一点半,终端里的进度条停在 97%,紧接着一行红字:Read error (Connection reset by peer) in headers.。文件 12GB,已经跑了四个多小时,那一瞬间脑子里只有一个念头——难道要从头再来一遍?后来发现,Linux 环境下 wget 下载中断的恢复其实并不复杂,一条参数就能接着上次的位置往下跑,前提是你得知道它在什么条件下有效、在什么条件下会悄无声息地从头开始。很多人对-c的印象停留在"加了就能续传",但实际踩过的坑告诉我:-c只是一张"请求续传"的入场券,能不能续上,最终由服务器、文件本身和本地环境三方共同决定。这篇内容围绕 wget 下载中断后的恢复展开,从断点续传的底层协商过程,到续传失败的完整排查链路,再到把它做成日常可复用的配置和脚本,适合经常和不稳定网络、大文件、长时间任务打交道的运维、开发以及任何需要批量拉取资源的读者。哪怕你只是偶尔下一次镜像,看完也能在下次断掉的时候少慌十分钟。

1. 从一次 97% 的中断说起:wget 到底会在哪儿掉链子

那次中断之后我做的第一件事不是立刻重跑,而是先看了一眼 wget 的输出尾巴。它明确写了Read error,意味着连接是被对端或中间链路掐断的,而不是本地磁盘写不进去——这两类问题的处理方式完全不同。想明白这一点很重要,因为"下载中断"只是表象,底下的原因五花八门,有些能续,有些续了反而会得到一个损坏的文件。

1.1 中断的七个常见来源,以及它们对续传的影响

我把这些年遇到过的中断归了归类,大致分七种。这个分类的价值在于:它决定了你该直接-c重跑,还是必须先做点别的事。

中断来源典型报错/现象能否直接-c续传备注
链路抖动、连接被重置Read error (Connection reset by peer)可以最常见,直接续
服务器限流或临时拒绝503、429、Connection refused可以,但需退避别立刻重试,会加重限流
读超时Read error (Connection timed out)可以调大--read-timeout
本地磁盘满No space left on device清理后可以注意部分写入的文件大小是否可信
目标文件被改动续传后校验失败不可以必须删掉重下
服务器不支持 RangeServer unsupported, starting from scratch名义上可以,实际从头没用,白等
登录态/令牌过期续传回来一段 HTML不可以会得到一个"半二进制半网页"的怪文件

表格里最后两行是最坑的,因为它们不会报错,只会让你以为成功了。我见过一个同事把 8GB 的数据包下成了 8GB 里面掺了 200KB 的登录页面,直到校验才炸。

1.2 为什么"再敲一遍原命令"是个坏习惯

很多人断线之后的第一反应是上箭头、回车,把原来那条wget <URL>重跑一遍。这个动作的后果是:wget 发现本地已经存在同名文件,默认行为是生成一个带.1后缀的新文件,从零开始下载。于是你得到了file.tar.gz(97% 的残骸)和file.tar.gz.1(新的一遍),磁盘空间翻倍,时间白花。

更隐蔽的一种情况是加了-O指定输出名。这时 wget 会直接覆盖,之前那 97% 瞬间清零,连后悔的机会都没有。所以第一条经验很朴素:断线之后先别急着敲回车,先确认你要续传,而不是重下。

1.3 判断"这个残骸值不值得救"的三个快速检查

不是所有中断都值得续。我通常会花十秒钟做三个检查:

第一个是看文件大小是否合理。如果文件停在 0 字节或者几 KB,而目标是几个 GB,那多半是连接还没建立就被掐了,直接删掉重来更省事。用ls -lh看一眼就行。

第二个是看文件尾部是否有明显的截断痕迹。对于压缩包,gzip -t、tar -tzf这类命令能快速告诉你文件是否完整;不完整会报unexpected end of file,这恰恰说明它是"干净地断在中间",非常适合续传。

第三个是确认目标地址这次还能不能正常访问。如果服务器已经 404 或者需要重新认证,那续传没有意义。用wget --spider探一下,比直接上大文件稳妥得多。

# 只探测,不下载,看状态码和响应头 wget --spider -S "<下载地址>" 2>&1 | head -n 20 # 本地残骸的大小 ls -l --block-size=M file.tar.gz # 压缩包完整性粗检(不完整会报 unexpected end of file,属正常) gzip -t file.tar.gz

这三步做完,你心里基本有数了:值得救的,进下一节看-c是怎么工作的;不值得救的,删掉重来反而更快。

2.-c背后的协商过程:一次 Range 请求的完整往返

wget -c不是魔法,它的全部工作就是:读取本地已存在文件的大小,然后在 HTTP 请求头里加一行Range: bytes=<已有大小>-,告诉服务器"前面那段我有了,从这儿开始给我"。服务器如果答应,返回206 Partial Content,并在Content-Range里声明这次给的是哪一段;服务器如果不答应,返回200 OK加完整内容,wget 就只能从头写。

2.1 一次成功的续传,报文长什么样

理解这个过程最直接的方式是把响应头打出来看。-S参数会把服务器的应答头原样打印,这是排查续传问题的第一把钥匙。

# 假设本地已有 5000000 字节,看服务器如何回应 wget -c -S -O file.bin "<下载地址>" 2>&1 | grep -Ei "HTTP/|Content-Range|Content-Length|Accept-Ranges"

理想情况下你会看到类似这样的三行关键信息:

HTTP/1.1 206 Partial Content Content-Range: bytes 5000000-12345678/12345679 Content-Length: 7345679

这里的Content-Range是核心:斜杠后面是文件总长,前面是本次区间。wget 会拿它和你本地文件大小做比对,如果对不上(比如本地文件比服务器声明的还大),它会认为本地文件有问题,转成从头下载。

反过来,如果看到的是HTTP/1.1 200 OK且没有Content-Range,那基本可以断定这次续传没戏。有些服务器会带一个Accept-Ranges: none或者干脆不带这个头,都是同一个意思。

2.2 本地文件大小是怎么被"信任"的

wget 判断续传起点,靠的是对目标文件做一次stat,拿st_size。这意味着两件事。

第一,文件系统报告的尺寸必须是真的。正常情况下没问题,但如果上次是断电、容器被 kill、或者写在了某些网络文件系统上,可能出现"文件大小已经更新但数据块没落盘"的情况。这时候 wget 会从一个虚假的偏移量开始请求,最终文件中间会有一段空洞或者错位。我的做法是:非正常关机之后的残骸,先做一次完整性粗检,别盲目续。

第二,wget 是直接往目标文件里追加写的,没有独立的临时文件。这跟很多下载工具不一样。好处是续传逻辑简单;坏处是它没有办法原子性地保护你——写一半失败,目标文件就已经是"半成品"了,你必须自己管好命名和备份。我在脚本里习惯把它写成xxx.part,全部完成后再mv成正式名字,这样一眼就能看出哪些是半成品。

2.3-c与-O同时出现时的版本差异

这是我这几年被问得最多、也最容易踩的一个点。wget -c -O 文件名 <URL>这个组合,在不同版本的 wget 上行为并不一致:较新的版本能正确识别-O指定文件的现有大小并发起 Range 请求,而一些老版本会忽略-c,直接覆盖重写。

判断方法很简单,先看版本,再实测一次:

wget --version | head -n 1 # 造一个 1MB 的残骸,故意截断,然后看它是否从 1MB 处续 head -c 1048576 /dev/urandom > demo.bin wget -c -O demo.bin "<下载地址>" -S 2>&1 | grep -Ei "206|200|Length"

如果打印出206,说明这个组合在你的环境里是可用的;如果是200且demo.bin大小被重置,那就要换思路——要么不用-O,让 wget 按 URL 尾段自动命名,要么用--output-document之外的写法,把重命名交给后面的mv。

提示:如果你的目标文件名和 URL 尾段一致,最省事的做法是cd到目标目录后直接wget -c <URL>,完全绕开-O带来的不确定性。

2.4-c与-nc、--timestamping的冲突

还有一个容易被忽略的细节:-c和-nc(no-clobber,不覆盖已有文件)是互相矛盾的。同时给,wget 会以-nc为准,直接跳过下载,你看到的是"文件已经存在,跳过",然后什么都没有发生。

同理,-c和-N(时间戳模式)混用也要小心,-N会根据Last-Modified判断是否需要重新下载,逻辑上会覆盖续传的意图。日常用的组合,我一般只保留-c,其他都交给显式的判断。

3. 续传失败时的排查链路:从日志第一行往下读

真正麻烦的不是"续传没成功",而是"看起来成功了"。这一节我把自己的排查顺序完整摊开,你可以照着一步步走,不用记结论,记住链路就行。

3.1 第一步:把日志留下来,别让它滚过去

终端里刷屏的进度条是最没用的信息。我的习惯是永远带-o把日志写进文件,这样事后可以慢慢看:

wget -c -o download.log "<下载地址>" tail -n 40 download.log

日志里我会重点盯三个东西:HTTP 状态码、Length:那一行声明的总长度、以及最后停在哪个字节。状态码 206 说明续传协商成功;200 说明服务器给了完整内容;416 意味着你本地的文件比服务器上的还大,通常是上次续传出错或者文件被替换过,这时候唯一的办法是删掉重下。

3.2 第二步:搞清楚"服务器不支持续传"的四种真相

日志里出现"服务器不支持断点续传"这类提示时,别急着接受这个结论。我遇到过的情况里,真正因为服务器关闭 Range 支持的只占少数,更多是下面这几种:

第一种,重定向到了不同节点。你请求的地址是 A,A 返回 302 跳到 B,而 B 是一个独立的内容节点,不认 Range。这种情况的典型特征是:第一次下载很快,续传时服务器表现完全不一样。处理办法是先用--max-redirect=0或者-S看清楚跳到哪儿去了,然后再决定,必要时直接对最终地址发起续传。

第二种,请求带上了会改变响应内容的参数。比如带了Accept-Encoding: gzip,服务器返回的是压缩后传输的流,本地落盘的大小和Content-Length声明的大小可能对不上,Range 的偏移量就错位了。wget 默认发的是Accept-Encoding: identity,一般不会有这个问题,但如果你手动加过--header就要留意。

第三种,认证失效后返回了一个 200 的登录页。这是最阴的一种:状态码是 200,看起来正常,但内容是一个几百字节的 HTML。判断方法是看这次"续传"传输的字节数——如果只传了几百字节就"完成"了,几乎可以肯定是这个原因。打开文件尾部看一眼就能确认。

第四种,请求被中间层改写了。有些链路上的缓存设备会把 Range 请求当成完整请求转发,或者把多个分片合并成一个响应。这种问题你从客户端这边看不出来,只能通过对同一地址分别用curl -r和wget -c做对照实验来确认。

3.3 第三步:确认远端文件本身有没有变过

续传的隐含前提是"服务器上还是同一个文件"。如果这个文件在两次请求之间被替换过(比如 nightly 构建、滚动更新的镜像),你续上去的就是两个不同版本的拼接体,校验一定会失败。判断依据有三个:

  • Content-Length和第一次请求时记录的不一致;
  • ETag变了;
  • Last-Modified变了。

我在跑长时间下载任务前,习惯先把第一次的响应头存一份:

wget --spider -S "<下载地址>" 2>&1 | grep -Ei "etag|last-modified|content-length" > head.before

断线后重新探测一次,两张表一比,变了就老老实实删掉重下。这一步花不了两秒,能省掉后面几十分钟的校验和困惑。

3.4 第四步:本地环境层面的"假中断"

有一类问题看起来像网络中断,其实是本地环境打的岔。我踩过的有:

  • 磁盘 inode 耗尽。空间还剩很多,但df -i已经 100%,写入直接失败。大文件场景下尤其容易忽略。
  • 配额限制。某些托管环境对单用户有容量上限,超过之后写入会静默失败或者被截断。
  • 文件系统被重挂载为只读。磁盘出现坏块时,内核可能把分区切成只读模式,这时候所有写入都失败,报错却是Read-only file system,跟下载本身没关系。
  • 容器被驱逐。跑在容器里的下载任务,被调度器回收后进程消失,文件停在某个位置,表面看就是"断了"。

排查这些不需要什么技巧,df -h、df -i、dmesg | tail、mount | grep ro四条命令过一遍,基本能排干净。

4. 把"断线能接上"做成默认动作:参数组合与 wgetrc 配置

排查是事后补救,真正省心的是事前把参数配好。我的思路是:让 wget 自己扛住小抖动,只在真正需要人工介入时才退出来。

4.1 重试、超时、等待三个旋钮怎么拧

--tries、--timeout、--waitretry这三个参数经常被混着用,实际职责完全不同。

--tries控制的是同一个文件的重试次数,--tries=0表示无限重试。听起来很美,但要配合后面的等待时间一起考虑,否则就是在快速循环里给服务器刷请求。

--timeout是总超时,会同时影响 DNS 解析、连接建立和读取三个阶段。更精细的做法是拆开用--dns-timeout、--connect-timeout、--read-timeout。对大文件下载来说,真正需要放宽的是--read-timeout,因为持续传输过程中一旦出现短暂的停顿(比如服务端在做垃圾回收),默认的 900 秒虽然宽,但连接建立阶段的默认值可能偏紧。

--waitretry是两次重试之间的等待秒数,并且 wget 会做线性递增(第一次等 1 秒,第二次 2 秒……最多到设定值)。这个参数是对付限流的关键,设成 10 到 30 秒之间比较稳妥。

我的常用组合是这样:

wget -c \ --tries=20 \ --waitretry=15 \ --connect-timeout=20 \ --read-timeout=120 \ --retry-connrefused \ -o download.log \ "<下载地址>"

--retry-connrefused值得单独说一句:默认情况下,连接被拒绝(而不是超时)是不重试的,直接失败退出。这个行为在服务器重启、负载均衡切换的窗口期会造成大量无谓的人工重跑,加上这个参数能让脚本自己熬过去。

4.2 一份可以直接抄的 wgetrc 模板

参数写在命令行里,时间长了自己都记不住。我更推荐把稳定的部分放进~/.wgetrc,命令行只保留每次变化的部分。

# ~/.wgetrc continue = on tries = 20 waitretry = 15 connect_timeout = 20 read_timeout = 120 retry_connrefused = on progress = dot:giga timestamping = off # 限速,单位字节/秒,按自己带宽调整 # limit_rate = 2m

几个注意点。continue = on写在配置文件里意味着所有下载默认续传,这对批量任务是好事,但如果你偶尔想强制重下某个文件,记得在命令行用-nc之外的方式覆盖,比如临时--no-continue。progress = dot:giga是把进度条换成按千兆单位打点的形式,好处是日志文件里不会塞满回车符,事后好读。

系统级的配置在/etc/wgetrc,改之前先确认没有别的东西依赖它。我一般不动系统级,只维护用户级的,这样换机器的时候把文件拷过去就行。

4.3 后台运行:-b与nohup、screen的取舍

大文件下载最怕的是 SSH 断开。wget 自己提供了-b参数,直接把任务扔到后台,日志写到当前目录的wget-log。

wget -b -c -o bigfile.log "<下载地址>" tail -f bigfile.log

-b的优点是简单,缺点是它只 fork 一次,进程仍然挂在原来的会话下——如果终端被强制关闭(比如 SSH 连接被 kill 而不是正常断开),进程依然可能收到 SIGHUP。所以我更常用的是两种方式:

一是nohup加&,适合一次性任务:

nohup wget -c -o bigfile.log "<下载地址>" >/dev/null 2>&1 &

二是screen或tmux,适合需要中途回去看进度、甚至交互式调整的场景。这俩没有本质区别,用哪个看你习惯。我个人偏向tmux,因为会话管理和窗口切分更顺手,但纯粹跑下载的话screen -dmS一行就够了。

tmux new -d -s dl 'wget -c -o bigfile.log "<下载地址>"' tmux attach -t dl

4.4 限速:别把自己变成被限流的对象

--limit-rate这个参数,很多人在自己机器上下载时觉得没必要,但在共享带宽的环境里它是刚需。我这边的经验值是:如果不知道链路总带宽,先按 2MB/s 试,观察是否影响其他业务,再往上加。

wget -c --limit-rate=2m "<下载地址>"

单位可以是k(KB/s)或m(MB/s),注意是小写。限速过低反而会拉长总时长,增加中途被打断的概率,所以不是越小越安全,找到一个既能跑满又不影响别人的平衡点更实际。

5.-c也不管用的时候:三条替代路线

有些场景下 wget 的续传就是走不通,比如服务器彻底关掉了 Range、或者单个连接的速度实在没法看。这时候换工具比死磕更划算。

5.1curl -C -的差异在哪

curl的续传参数是-C -,那个短横线表示"让 curl 自己去读本地文件大小"。功能上和wget -c是一回事,但两者在几个细节上不同:

对比项wget -ccurl -C -
断点位置来源自动 stat 目标文件-表示自动,也可显式给数字
服务器不支持 Range 时提示后从头下载直接报错退出,不浪费流量
输出文件名可用-O或自动推导必须用-o显式指定
进度显示内置进度条需--progress-bar或静默
断线重试内置--tries需外部脚本循环

我实际用下来最有用的一点差异是:curl 在服务器不支持续传时会明确失败,不会像 wget 那样默默地从头下。做自动化的时候这个行为更可控,脚本能立刻感知到问题。

curl -C - -o bigfile.bin --retry 10 --retry-delay 5 "<下载地址>"

--retry系列参数是 curl 的强项,配合-C -就是一套很顺手的断线自愈组合。

5.2 分片下载:把大文件拆成若干小目标

当单连接速度上不去,或者单连接特别容易被打断时,分片是更稳的思路。原理很简单:把文件按字节区间切成 N 段,每段单独下载,最后拼接。任何一段断了只需要重下那一段,而不是整个文件。

常用的工具是aria2c,它内置了分片和续传:

aria2c -c -x 8 -s 8 -k 4M \ --file-allocation=none \ -d ./data -o bigfile.bin \ "<下载地址>"

参数含义:-x是单服务器最大连接数,-s是分片数,-k是分片大小,--file-allocation=none是关掉预分配(省时间,但会在磁盘上留下稀疏文件)。这里有个关键前提:服务器必须支持 Range,否则-x大于 1 也没用,aria2 会自动退化成单连接。

分片的坑主要有两个。一是断点信息的存放,aria2 会在同目录生成.aria2控制文件,这个文件丢了续传信息就没了,所以别手贱删它。二是分片数不是越多越好,-x 8对大多数场景够用,开到 32 往往只是给自己加压。

5.3 手动区间拼接:最后一道兜底

极端情况下,工具都搞不定,那就回到最原始的方式——自己发 Range 请求,拿到字节流追加到文件里。这种方法笨,但可控性最高,尤其是对付那些"有时候支持 Range 有时候不支持"的不稳定服务。

# 从第 N 个字节开始,追加到已有文件末尾 START=$(stat -c%s bigfile.bin) curl -r "${START}-" "<下载地址>" >> bigfile.bin

这里必须提醒一句:>>追加没有校验,一旦区间算错或者服务端返回的不是 206,你会把一段 HTML 错误页追加到二进制文件里,事后极难发现。所以我只在两种情况下用这招:一是有明确的哈希值可以事后校验,二是文件格式本身能被file、tar -t、gzip -t这类命令快速验证。

6. 下载结束之后:校验、清理,还有我踩过的那些小坑

续传成功的标志不是进度条走到 100%,而是文件真的能用。这一步很多人省掉,然后在几天后打开文件时才发现问题。

6.1 完整性校验的三道关

我一般过三道:

第一道,长度比对。用stat -c%s拿本地大小,和第一次探测时记录的Content-Length比。这一步只能发现明显的截断,对内容错位无能为力。

第二道,哈希比对。如果发布方提供了sha256或md5,这一步是终审。

sha256sum bigfile.bin # 或者,如果提供了校验文件 sha256sum -c bigfile.bin.sha256

第三道,格式自检。压缩包用tar -tzf或unzip -t,镜像用file看类型,二进制用file加ldd(如果是可执行文件)。这一步能抓住"长度对得上但内容错位"的少数情况。

三道关走完还出问题的概率很低,一旦碰上,八成是远端文件在过程中被替换了,回到 3.3 那节查ETag。

6.2 一个能自己熬过抖动的重试脚本

把前面这些东西串起来,就是我日常用的这个脚本。逻辑不复杂:循环续传,失败就退避重试,成功就校验并重命名。

#!/usr/bin/env bash set -uo pipefail url="${1:?用法: $0 <下载地址> [输出文件名]}" out="${2:-$(basename "${url%%\?*}")}" part="${out}.part" max_try=30 for i in $(seq 1 "$max_try"); do echo "[$(date '+%F %T')] 第 ${i} 次尝试,当前大小 $(stat -c%s "$part" 2>/dev/null || echo 0) 字节" if wget -c --tries=3 --waitretry=15 \ --connect-timeout=20 --read-timeout=120 \ --retry-connrefused \ -O "$part" -o "${out}.log" "$url"; then mv "$part" "$out" echo "[$(date '+%F %T')] 完成: $out" exit 0 fi sleep $(( i * 5 > 120 ? 120 : i * 5 )) done echo "重试 ${max_try} 次仍未完成,残骸保留在 $part" exit 1

几个设计上的考虑。-O "$part"配合独立的.part后缀,让半成品和正式文件在名字上就区分开,避免误用。退避时间做了上限封顶(120 秒),防止重试后期等待过长。循环次数没有设成无限,是为了避免在地址彻底失效时脚本永远挂着,占着调度资源。

需要提醒的是,这个脚本依赖-c与-O的组合在目标版本上可用。如果你的环境里这个组合不生效,把-O "$part"去掉,改成先cd到目录、把 URL 尾段当作文件名,逻辑一样成立。

6.3 几个让我印象深刻的坑

第一个坑,文件名里的查询串。很多下载地址长这样:.../file.iso?token=xxx&expire=yyy。直接用basename拿到的名字会带上一长串参数,或者被截成一个奇怪的名字。我在脚本里用${url%%\?*}先把问号后面的部分切掉,这个细节不注意,续传时目标文件名对不上,就变成了两份残骸。

第二个坑,中文或者空格文件名。wget 在自动推导文件名时,对 URL 编码的处理在不同版本上有差异,有时会得到%E4%B8%AD%E6%96%87.zip这样的名字。解决办法是显式指定输出名,别依赖推导。

第三个坑,日志没有时间戳。默认日志里只有相对时间或者干脆没有时间标记,事后想知道"到底断了多久"完全看不出来。所以我习惯在脚本里自己打时间戳,日志文件本身也可以配合logrotate按大小切分。

第四个坑,磁盘预分配行为。有的文件系统在写入前做延迟分配,ls看到的大小可能比实际已下载的还大,用它来判断续传起点会出错。判断已下载量,我优先看日志里 wget 自己报的字节数,而不是ls。

第五个坑,也就是开头那次 97% 中断教给我的:进度条接近完成的时候,反而是最不该走开的时候。文件越大,越应该在脚本层面把重试和续传兜住,而不是指望自己守在屏幕前。现在我的所有大文件任务都套在那段重试脚本里跑,哪怕是跨夜的传输,第二天早上回来基本都能看到完成两个字——偶尔没有,残骸也好好地躺在.part里,接着跑就行,不用再从头开始。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进