下载网络图片是爬虫、素材同步和图像处理流水线里最常见的操作之一。Go语言的标准库没有提供一个名为DownloadFile的现成函数,但把net/http的响应体通过io.Copy写到本地文件,就能在极低内存占用下完成这件事。难点不在写出第一版,而在于把超时、状态码、文件大小和并发控制这些边界条件处理到位。接下来从基础落盘讲起,一直延伸到一个生产可用的并发下载封装。

先看一个最小实现,它可以快速验证思路,但暂时不适合直接上生产。核心只做三件事:发起GET请求、检查HTTP状态码、创建本地文件并复制响应体。下面这段代码虽然简单,却是所有扩展的基础。
基础实现:http.Get与io.Copy流式落盘
标准库中的http.Get会返回一个响应对象,其中Body字段是实现了io.ReadCloser接口的流。只要用os.Create打开目标文件,再调用io.Copy把响应体复制过去,图片就会被完整写入磁盘。
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func downloadImage(url, filepath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
这里有一个很容易被忽略的细节:必须先检查resp.StatusCode。很多图片服务在资源不存在或触发风控时,会返回一个200之外的HTML错误页,如果不判断状态码,这段HTML就会被原样写入到以.jpg结尾的文件里。之后想再打开图片自然无法解析,排查起来非常费劲。
另一个关键点是使用io.Copy而不是io.ReadAll。io.Copy内部维护一个32KB的缓冲区,它从网络响应体读一段就向文件写一段,不会把整个响应体塞进内存。图片文件动辄几MB到几十MB,如果先用io.ReadAll把所有字节读出来再一次性写入磁盘,内存峰值会随着文件体积线性增长。批量下载时,这种峰值很可能直接拖垮程序。因此下载文件时应优先选择流式复制,这是Go处理大文件的标准做法。
os.Create默认使用0666权限,并且如果目标文件已存在会将其截断为空文件。对于图片采集任务来说这个行为通常可以接受,但如果希望保留原有文件或追加写入,就需要改用os.OpenFile并设置合适的标志位。另外,如果目标目录不存在,os.Create会直接返回错误,因此在批量下载前最好用os.MkdirAll先创建目录。
掌控下载质量:超时、重试与大小限制
直接使用http.Get其实是在使用http.DefaultClient,这个默认客户端没有设置任何超时。一旦服务端接受连接后不返回响应,或者网络出现故障导致连接假死,http.Get可能会长时间阻塞,整个下载任务就像卡住了一样。生产代码一定要替换成带有超时设置的自定义客户端。
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
http.Client.Timeout覆盖从发起请求到读取完响应体的总时长,适合作为第一道防线。如果想更细致地控制连接建立、TLS握手和响应头读取等阶段,可以在http.Transport中分别设置DialContext、TLSHandshakeTimeout和ResponseHeaderTimeout。不过对大多数下载图片的场景来说,一个总的Timeout已经足够。
网络请求天然存在偶发失败,比如短暂断连、DNS抖动或者服务端返回5xx错误。一个简单的重试机制可以显著提高下载成功率。下面这个函数在downloadImage基础上加了指数退避,每次失败后等待时间逐渐变长,避免对服务端形成连续冲击。
func downloadWithRetry(url, filepath string, maxRetries int) error {
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
time.Sleep(time.Duration(attempt) * time.Second)
}
err := downloadImage(url, filepath)
if err == nil {
return nil
}
lastErr = err
}
return fmt.Errorf("download failed after %d attempts: %w", maxRetries, lastErr)
}
重试通常会配合大小限制一起使用,因为有些错误并不是超时,而是目标URL返回了一个巨大的错误页或异常内容。如果不对响应体大小设限,一个伪装成图片的几GB文件就可能写满磁盘。借助io.LimitReader可以只允许读取指定字节数,一旦超过就返回错误并中断本次下载,方便后续重试或跳过。
const maxBytes = int64(50 << 20) // 50 MB
func downloadWithLimit(resp *http.Response, out *os.File) error {
limited := io.LimitReader(resp.Body, maxBytes)
written, err := io.Copy(out, limited)
if err != nil {
return err
}
if written >= maxBytes {
return fmt.Errorf("image exceeds %d bytes", maxBytes)
}
return nil
}
这段代码会把超过50MB的内容截断在50MB位置,但由于我们额外判断了written >= maxBytes,所以一旦发现达到上限就会返回错误,调用方可以删除这个不完整的文件并重新尝试。注意这里使用的50 << 20等价于50乘以2的20次方,也就是50MB,它是Go中常见的字节单位写法。
并发批量下载:Worker Pool与错误收集
如果需要下载成百上千张图片,串行方案会浪费大量时间等待网络往返。直接为每个URL启动一个goroutine虽然简单,但可能瞬间打开过多连接,导致本地文件描述符耗尽或触发远端限流。更稳妥的做法是使用固定数量的worker goroutine,从一个任务通道中消费下载任务。
type downloadTask struct {
index int
url string
path string
}
func downloadAll(urls []string, dir string, workers int) {
tasks := make(chan downloadTask, len(urls))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range tasks {
if err := downloadImage(task.url, task.path); err != nil {
fmt.Printf("下载失败 %s: %v\n", task.url, err)
}
}
}()
}
for i, url := range urls {
tasks <- downloadTask{
index: i,
url: url,
path: filepath.Join(dir, fmt.Sprintf("img_%d.jpg", i)),
}
}
close(tasks)
wg.Wait()
}
这段代码中,tasks是一个带缓冲的通道,缓冲大小等于URL总数,因此主goroutine可以一次性把所有任务投递进去而不会阻塞。workers决定了同时运行多少个下载协程,通常可以设置为CPU核数的2到4倍,但也要结合网络带宽和远端服务承受能力调整。每个worker从通道中取出任务并调用前面封装好的下载函数,直到通道关闭。
实际批量任务中,仅仅打印失败日志通常不够。更合理的方式是用一个错误通道把失败信息收集起来,等所有worker退出后统一处理。可以在worker内部把fmt.Errorf封装后的错误发送到errCh,主goroutine在wg.Wait()之后关闭错误通道并逐条读取,记录失败的URL、索引和原因。这样既避免了多个goroutine同时写日志造成混乱,也方便后续做失败重试或写入数据库。
另外要注意,保存文件名不能简单使用URL的最后一段,因为URL中往往带有查询参数、签名或编码后的字符。上面的示例使用索引生成顺序文件名,避免了文件名冲突和非法路径问题。如果需要保留原始文件名,应该先解析URL的路径部分,并对文件名做必要的清洗和去重。
使用context实现可取消的下载
长时间运行批量下载时,外部可能需要主动终止任务,比如用户取消操作、程序收到退出信号或整体超时。Go的context包就是为这种取消传播设计的。通过http.NewRequestWithContext创建请求,可以让一个请求在context被取消或超时时立即返回错误,而不是继续占用连接。
ctx, cancel := context.WithTimeout(context.Background(), 60 * time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 后续与 downloadImage 中的写入逻辑一致
使用context.WithTimeout可以把整个请求生命周期限制在60秒内,超时后client.Do会返回一个包含context deadline exceeded的错误。如果多个worker共享同一个父context,那么任何一处调用cancel()都会让所有正在进行的请求同时退出,这对实现全局停止按钮非常有用。
把context、超时、重试、大小限制和worker pool组合起来,就能得到一个小而完整的图片下载模块。对于需要处理不同来源、不同格式图片的项目,还可以在下载完成后增加图片解码校验步骤,用image.DecodeConfig检查文件头是否真的是合法图片,避免保存了损坏文件但后续处理时才暴露问题。这样整个链路会更加可靠,也更符合生产环境的要求。