Files
spider/internal/media/upload.go
toom1996 6a8b7f0704 perf: 修 spider 内存暴涨(整场图入内存 + 场次无限并发)
线上现象:spider 进程 RSS 1.3GB(33% 内存),单机 3.7G 只剩 141MB 可用,
服务器 1.5 小时内重启两次。

- media.UploadAll 改为按批(8 张)下载→上传→立即释放;
  原来先把整场 97 张(约 116MB)全读进内存,多场并发时线性叠加
- SpiderStart 对同品牌场次加 2 路并发上限(原来对全部场次无上限 go 出去)
- systemd 单元加 GOMEMLIMIT=700MiB / MemoryHigh=800M / MemoryMax=1200M 硬护栏,
  即使再退化也只 OOM 掉这一个进程,不会拖死整机
2026-10-25 18:01:46 +08:00

277 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package media 负责「媒体机直传」:把图片下载下来、算内容 sha1、
// 向后端要预签名 PUT 地址、直接传到对象存储,最后只把 key 交给后端。
//
// 为什么由 spider 做这件事:
// - spider 部署在带宽好的机器上,下载 Vogue 图 + 上传存储是带宽密集的活;
// - 后端机器只需写库,不再被出口带宽卡住;
// - 爬虫**不持有**存储密钥:预签名地址由后端签发(15 分钟有效),
// 密钥只留在后端,减少泄露面。
//
// key 采用内容寻址(runway/<sha1>.jpg),同一张图重复上传是幂等的,
// 重爬不会产生孤儿文件。
package media
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"my-spiders/internal/ingest"
)
// Uploader 媒体直传器。
type Uploader struct {
Client *ingest.Client // 复用它来签发预签名 URL(HMAC)
Prefix string // 存储前缀:runway / street
Concurrency int // 下载与上传的并发数
HTTP *http.Client
}
// Result 单张图的直传结果;Err 非空表示这张失败(调用方应回退为上报 URL)。
type Result struct {
Key string
Size int64
Err error
}
// New 创建直传器。concurrency <= 0 时取 6。
func New(client *ingest.Client, prefix string, concurrency int) *Uploader {
if concurrency <= 0 {
concurrency = 6
}
return &Uploader{
Client: client,
Prefix: prefix,
Concurrency: concurrency,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
// UploadAll 并发完成「下载 → 生成 key → 预签名 → PUT」,返回与 urls 下标一一对应的结果。
// 单张失败不影响其他张:该下标 Err 非空,调用方据此对这张回退成 URL 上报。
func (u *Uploader) UploadAll(ctx context.Context, urls []string) []Result {
results := make([]Result, len(urls))
if len(urls) == 0 {
return results
}
// 分批处理:**不能**先把整场的图全读进内存。
// 一场 100 张 × 1.2MB ≈ 120MB;而 SpiderStart 对同一品牌的多个场次是并发抓的,
// 峰值会翻好几倍——线上实测 spider 进程一度涨到 1.3GB,把小机器(3.7G)压到只剩 141MB 可用。
// 按批(默认 8 张)下载→上传→立即释放,峰值稳定在 ~10MB。
const batchSize = 8
for start := 0; start < len(urls); start += batchSize {
end := start + batchSize
if end > len(urls) {
end = len(urls)
}
u.uploadBatch(ctx, urls, results, start, end)
}
return results
}
// uploadBatch 处理 urls[start:end] 这一段:下载 → 生成 key → 预签名 → PUT。
// 返回时本批的图片字节已可回收(不再被引用)。
func (u *Uploader) uploadBatch(ctx context.Context, urls []string, results []Result, start, end int) {
n := end - start
data := make([][]byte, n)
errs := make([]error, n)
// ① 并发下载本批
u.parallel(n, func(i int) {
body, err := u.download(ctx, urls[start+i])
if err != nil {
errs[i] = err
return
}
data[i] = body
})
log.Printf("[media] 下载完成 %d 张(失败 %d)", countOK(data), n-countOK(data))
// ② 生成本批 key(内容 sha1 + 原扩展名)
for i := 0; i < n; i++ {
if errs[i] != nil {
results[start+i] = Result{Err: errs[i]}
continue
}
results[start+i] = Result{
Key: u.Prefix + "/" + sha1Hex(data[i]) + extOf(urls[start+i]),
Size: int64(len(data[i])),
}
}
// ③ 申请预签名(同一张图可能在一场里重复出现,按 key 去重)
want := make(map[string]ingest.UploadItem, n)
for i := 0; i < n; i++ {
key := results[start+i].Key
if key == "" {
continue
}
if _, ok := want[key]; ok {
continue
}
want[key] = ingest.UploadItem{Key: key, ContentType: contentTypeOf(urls[start+i])}
}
items := make([]ingest.UploadItem, 0, len(want))
for _, it := range want {
items = append(items, it)
}
presigned := make(map[string]string, len(items))
got, err := u.Client.RequestUploadURLs(ctx, items)
if err != nil {
// 申请失败:本批回退成 URL 上报(后端自己下载),不中断整场抓取。
for i := 0; i < n; i++ {
if results[start+i].Key != "" {
results[start+i] = Result{Err: fmt.Errorf("申请上传地址失败: %w", err)}
}
}
return
}
for _, g := range got {
presigned[g.Key] = g.URL
}
// ④ 并发 PUT 本批
u.parallel(n, func(i int) {
res := results[start+i]
if res.Key == "" {
return
}
putURL, ok := presigned[res.Key]
if !ok {
results[start+i] = Result{Err: fmt.Errorf("未拿到 %s 的上传地址", res.Key)}
return
}
if perr := u.put(ctx, putURL, urls[start+i], data[i]); perr != nil {
results[start+i] = Result{Err: perr}
}
})
log.Printf("[media] 本批 %d 张上传完成(%d/%d)", n, end, len(urls))
}
// parallel 以 Concurrency 路并发执行 fn(0..n-1)。
func (u *Uploader) parallel(n int, fn func(i int)) {
if n == 0 {
return
}
if u.Concurrency <= 0 {
// 兜底:并发为 0 会造成无缓冲 channel 死锁(曾把整条抓取卡住)。
u.Concurrency = 1
}
sem := make(chan struct{}, u.Concurrency)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fn(i)
}(i)
}
wg.Wait()
}
func (u *Uploader) download(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := u.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载失败 status=%d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, fmt.Errorf("下载到空文件")
}
return body, nil
}
func (u *Uploader) put(ctx context.Context, putURL, originalURL string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, bytes.NewReader(data))
if err != nil {
return err
}
// Content-Type 必须与申请预签名时一致,否则签名校验失败(403)。
req.Header.Set("Content-Type", contentTypeOf(originalURL))
resp, err := u.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return fmt.Errorf("PUT 失败 status=%d %s", resp.StatusCode, string(raw))
}
return nil
}
// countOK 数一下成功下载的张数(用于日志定位卡在哪一步)。
func countOK(data [][]byte) int {
n := 0
for i := range data {
if len(data[i]) > 0 {
n++
}
}
return n
}
func sha1Hex(b []byte) string {
sum := sha1.Sum(b)
return hex.EncodeToString(sum[:])
}
// extOf 从图片 URL 推断扩展名(取路径最后一段,忽略查询串)。
func extOf(rawURL string) string {
p := rawURL
if i := strings.IndexAny(p, "?#"); i >= 0 {
p = p[:i]
}
if i := strings.LastIndex(p, "."); i >= 0 && len(p)-i <= 6 {
ext := strings.ToLower(p[i:])
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif":
if ext == ".jpeg" {
return ".jpg"
}
return ext
}
}
return ".jpg"
}
// contentTypeOf 与 extOf 保持一致,保证预签名与 PUT 的 Content-Type 相同。
func contentTypeOf(rawURL string) string {
switch extOf(rawURL) {
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".avif":
return "image/avif"
default:
return "image/jpeg"
}
}