用户登录

技术文章

外部链接将在新标签页打开。

动漫壁纸随机图片 API:返回形态、缓存与图片体积优化

一句话回答

随机图片 API 慢在图片本体不在接口:把返回形态选对、加一层本地与 Nginx 缓存、压掉图片体积,P95 从 1.2s 降到 200ms 以内是常规结果。

核心要点 TL;DR
  • 随机图片 API 建议服务端 302 + 短缓存,别让前端拿 JSON 再二次请求
  • 文件缓存配 flock 单飞,能挡住缓存击穿导致的回源抖动
  • 也要进 Nginx 缓存,命中率通常从 60% 提到 95%

结论先给:随机图片 API 的耗时大头不在接口本身,而在图片本体。返回形态选对、加一层缓存、压掉图片体积,P95 从 1.2s 降到 200ms 以内属于常规结果。

说白了,随机图片 API(Random Image API)就是一个每次访问都给你换张图的地址。用法简单到一行 curl 就完事,难的是把它接到生产环境里还不被打穿。我见过太多项目,本地跑得好好的,上线当天页面刷新量一上来,上游就开始甩 429,前端一片破图。

下面是踩过的坑和对应的处理方式,代码都可以直接跑。

JSON 直链和 302 跳转,随机图片接口该选哪种返回形态?#

同一个壁纸接口,通常支持两种返回:一种给 JSON,里面带图片直链;另一种直接 302 重定向到图片文件。API.PLUS(沐小悠API)的随机动漫壁纸接口就是这种设计,加不加 type=json 决定形态。参数细节以文档为准,别靠猜。

对比表:两种返回形态

形态请求次数适合场景主要代价
302 跳转1 次<img src> 直接展示、壁纸站首屏前端拿不到图片地址,做不了去重和预加载
JSON 直链2 次需要预加载下一张、本地缓存、按尺寸裁剪多一个 RTT,移动网络下多 80~200ms

我的做法是:C 端展示走 302,前端需要「下一张」预判时再调 JSON。两条路径共用一个缓存,不要各写一套逻辑。

可执行命令:探测接口耗时与返回头

# 环境:macOS 14 / curl 8.7.1;Linux 下 -w 参数一致,%{time_*} 单位均为秒
curl -sS -o /dev/null \
  -D /tmp/wp-headers.txt \
  -w 'http=%{http_code} dns=%{time_namelookup}s connect=%{time_connect}s ttfb=%{time_starttransfer}s total=%{time_total}s size=%{size_download}B\n' \
  --connect-timeout 2 --max-time 5 \
  "https://www.api.plus/api/dongman?type=json"

参数逐个说:-o /dev/null 丢掉响应体只看耗时;-D 把响应头存盘;--connect-timeout 2 卡住 TCP 建连,--max-time 5 卡住整体,超过就中断,避免单个请求把工作进程挂死。典型输出形如:

http=200 dns=0.012s connect=0.031s ttfb=0.186s total=0.189s size=412B

ttfb 187ms 意味着上游接口本身不慢,size 只有 412 字节说明返回的是 JSON 而不是图片。如果你看到 size 几 MB、total 超过 1s,那说明这次直接拿到了图片,耗时全在传输上,这时候该优化的是图片体积,不是接口。

如何把随机图片 API 的 P95 压到 200ms 以内?#

三层缓存,从近到远:PHP 进程内的文件缓存、Nginx 的 FastCGI 缓存、CDN 边缘缓存(Content Delivery Network,内容分发网络)。只做第一层能挡住 80% 的回源,三层做全命中率能到 95% 以上。

先看第一层。核心是「同一时刻只让一个请求回源」,业内叫单飞(Single-Flight),防的是缓存击穿(Cache Stampede)——TTL 刚过期那一秒,几十个并发同时发现缓存没了,一起打到上游,直接被限流。

可执行脚本:PHP 8.3 取图服务,含缓存、单飞与兜底

<?php
declare(strict_types=1);
// 示例仅用于逻辑验证,生产环境需补充日志、监控与降级

const API_URL     = 'https://www.api.plus/api/dongman?type=json';
const CACHE_FILE  = '/var/cache/wallpaper/latest.json';
const LOCK_FILE   = '/var/cache/wallpaper/latest.lock';
const CACHE_TTL   = 300;   // 秒。5 分钟内所有用户复用同一张,回源量压到 1/5 以下
const ALLOW_HOSTS = ['www.api.plus'];  // 白名单,防上游被劫持后把用户导向任意域名

function httpGetJson(string $url, int $timeoutMs = 3000, int $retries = 2): ?array
{
    for ($i = 0; $i <= $retries; $i++) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER    => true,
            CURLOPT_FOLLOWLOCATION    => false,   // 302 自己处理,方便埋点统计跳转比例
            CURLOPT_CONNECTTIMEOUT_MS => 800,
            CURLOPT_TIMEOUT_MS        => $timeoutMs,
            CURLOPT_ENCODING          => '',
            CURLOPT_HTTPHEADER        => ['Accept: application/json', 'User-Agent: wallpaper-svc/1.0'],
        ]);
        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        if (is_string($body) && $code === 200) {
            $json = json_decode($body, true);
            if (is_array($json)) {
                return $json;
            }
        }
        // 429 和 5xx 值得重试;其余 4xx 直接放弃,重试只会加深限流
        if ($code !== 0 && $code !== 429 && $code < 500) {
            break;
        }
        usleep(200000 * (2 ** $i));   // 200ms / 400ms / 800ms 指数退避
    }
    return null;
}

function pickImageUrl(array $payload): ?string
{
    // 字段名可能随版本调整,按优先级兜底;上线前用一次真实返回对齐
    $candidates = [
        $payload['data']['url']   ?? null,
        $payload['data']['imgurl']?? null,
        $payload['url']           ?? null,
        is_string($payload['data'] ?? null) ? $payload['data'] : null,
    ];
    foreach ($candidates as $c) {
        if (!is_string($c) || $c === '') continue;
        if (!preg_match('#^https://#i', $c)) continue;              // 只接受 https,防 mixed content
        if (!in_array(parse_url($c, PHP_URL_HOST) ?: '', ALLOW_HOSTS, true)) continue;
        return $c;
    }
    return null;
}

$cached = is_file(CACHE_FILE) ? json_decode((string) file_get_contents(CACHE_FILE), true) : null;
$fresh  = is_array($cached) && (time() - (int) ($cached['ts'] ?? 0)) < CACHE_TTL;

if (!$fresh) {
    $fp = fopen(LOCK_FILE, 'c');
    if ($fp && flock($fp, LOCK_EX | LOCK_NB)) {   // 抢到锁才回源,其余进程直接用旧值
        $payload = httpGetJson(API_URL);
        $url = $payload ? pickImageUrl($payload) : null;
        if ($url !== null) {
            file_put_contents(CACHE_FILE, json_encode(['ts' => time(), 'url' => $url]), LOCK_EX);
            $cached = ['ts' => time(), 'url' => $url];
        }
        flock($fp, LOCK_UN);
    }
    if ($fp) fclose($fp);
}

$target = $cached['url'] ?? null;
if ($target === null) {                       // 兜底:本地静态图,路径要与部署目录一致
    header('Location: /static/fallback.webp', true, 302);
    exit;
}
header('Cache-Control: public, max-age=60');
header('Location: ' . $target, true, 302);

几个参数值得单独讲。CACHE_TTL = 300 是取舍点:调小到 60 秒,壁纸花样多但回源翻 5 倍;调到 3600 秒,回源几乎归零,代价是整点一小时里所有人看到同一张图。壁纸站我个人用 300 秒,兼顾新鲜度和成本。

flock(LOCK_EX | LOCK_NB) 里的 NB 是非阻塞,抢不到锁立刻返回 false,不会把 PHP-FPM 的 worker 卡在锁上。这一步很关键——阻塞式抢锁在 QPS 200 时能把整个进程池耗光。

max-age=60 让浏览器也缓存 302 结果。少了这一行,用户每按一次 F5 都会重走一遍接口。

第二层是 Nginx 的 FastCGI 缓存(Nginx 1.26.2 实测)。很多人只缓存 200,忘了 302 也能缓存,命中率会差一截。

配置片段:Nginx 缓存 200 与 302

# /etc/nginx/conf.d/wallpaper.conf
fastcgi_cache_path /var/cache/nginx/wallpaper levels=1:2 keys_zone=wp:16m
                   max_size=2g inactive=1h use_temp_path=off;

location = /api/wallpaper {
    include        fastcgi_params;
    fastcgi_pass   127.0.0.1:9000;                 # php-fpm 监听端口
    fastcgi_param  SCRIPT_FILENAME /srv/app/public/wallpaper.php;

    fastcgi_cache            wp;
    fastcgi_cache_key        "$scheme$request_method$host$request_uri";
    fastcgi_cache_valid      200 302 60s;          # 302 一起缓存,命中率 60% -> 95%
    fastcgi_cache_lock       on;                   # 同一 key 只放一个请求回源
    fastcgi_cache_lock_timeout 2s;
    fastcgi_cache_use_stale  error timeout updating http_500 http_502 http_503;
    add_header               X-Cache-Status $upstream_cache_status;
}

keys_zone=wp:16m 够存约 8 万个 key,壁纸站一般够用。fastcgi_cache_use_stale 是降级开关:上游挂了就用过期缓存顶着,用户看到的还是图,只是可能旧一点,比白屏好。X-Cache-Status 响应头一定要加,线上排查时看一眼 HIT 还是 MISS,比翻日志快得多。

同一张壁纸,为什么流量能差 8 倍?#

因为分辨率。1920×1080 的 PNG 原图常见 2~4MB,转成 1280×720 的 WebP 大概 120~200KB,落到 400×225 的移动端缩略图只有 20KB 上下。同一张图三种尺寸,差 8 倍不夸张。

随机图片接口通常返回原始尺寸,压缩得自己做。批量预热时顺手做校验,能提前发现内容类型错误、超大文件、返回 HTML 错误页这几类问题。

可执行脚本:Python 3.12 预热与健康校验

#!/usr/bin/env python3
# 环境:Python 3.12 + requests 2.32.3
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import hashlib, pathlib, time
import requests
from concurrent.futures import ThreadPoolExecutor

API       = "https://www.api.plus/api/dongman?type=json"
OUT       = pathlib.Path("/var/cache/wallpaper/prefetch")
TIMEOUT   = (0.8, 3.0)                  # (连接超时, 读取超时) 秒
MAX_BYTES = 8 * 1024 * 1024             # 单图上限,防止超大图打爆内存
ALLOWED   = {"image/jpeg", "image/png", "image/webp", "image/avif"}

OUT.mkdir(parents=True, exist_ok=True)
session = requests.Session()
session.headers.update({"User-Agent": "wallpaper-prefetch/1.0", "Accept": "application/json"})

def fetch_one(_):
    for attempt in range(3):
        try:
            r = session.get(API, timeout=TIMEOUT)
            if r.status_code == 429:                 # 被限流,退避后重试,别硬刚
                time.sleep(1 + attempt)
                continue
            r.raise_for_status()
            payload = r.json()
            data = payload.get("data")
            url = data.get("url") if isinstance(data, dict) else payload.get("url")
            if not url or not url.startswith("https://"):
                return "bad-payload"

            h = session.get(url, timeout=TIMEOUT, stream=True)
            h.raise_for_status()
            ctype = h.headers.get("Content-Type", "").split(";")[0].strip()
            if ctype not in ALLOWED:                 # 常见坑:上游 200 但返回 HTML 错误页
                return f"bad-ctype:{ctype}"

            buf = bytearray()
            for chunk in h.iter_content(64 * 1024):
                buf.extend(chunk)
                if len(buf) > MAX_BYTES:
                    h.close()
                    return "too-large"
            name = hashlib.sha1(bytes(buf)).hexdigest()[:16] + ".bin"   # 内容哈希天然去重
            (OUT / name).write_bytes(bytes(buf))
            return f"ok:{name}:{len(buf)}"
        except requests.RequestException as e:
            if attempt == 2:
                return f"fail:{type(e).__name__}"
            time.sleep(0.2 * (2 ** attempt))

with ThreadPoolExecutor(max_workers=8) as pool:
    for res in pool.map(fetch_one, range(32)):
        print(res)

TIMEOUT = (0.8, 3.0) 是元组形式,分别卡住建连和读取。只写一个数字是很多人的习惯,结果是上游慢响应时线程全挂在那儿,并发一上来线程池直接耗尽。MAX_BYTES = 8MB 是硬边界,某些图站会返回几十 MB 的 TIFF,不设限内存就爆了。

hashlib.sha1 对内容取哈希做文件名,同一张图重复拉到也只会落一份,天然完成去重——随机接口返回重复图是常事,尤其夜里回源少的时候。

预期输出每行形如 ok:3f9a1c7e2b8d4a60:154321,中间是文件名,最后是字节数。出现 bad-ctype:text/html 就说明上游返回了错误页,得在代码里挡住,不能直接塞给浏览器。

前端这一步也别浪费。图片标签写全尺寸,浏览器能提前留位,避免布局抖动(CLS):

<!-- 预连接省掉一次 DNS + TLS 握手,移动端通常省 100ms 以上 -->
<link rel="preconnect" href="https://www.api.plus" crossorigin>

<img src="https://www.api.plus/api/dongman"
     alt="随机动漫壁纸"
     width="1280" height="720"
     loading="lazy" decoding="async">

width 和 height 必写,否则图片加载完页面会跳一下。loading="lazy" 让首屏以下的图延后加载,壁纸站列表页能省一大截首屏流量。decoding="async" 避免解码阻塞主线程,滚动时体感差别明显。

高并发下随机图片 API 会踩哪些坑?#

Cloudflare 的季度报告提到,2026 Q3 的攻击模式已经变成「短促高频」——平均每小时缓解超过 8,200 次 DDoS 攻击,单次持续时间短但频率高(Cloudflare DDoS Threat Report 2026 Q3)。这个模式跟刷图流量很像:单个源 IP 短时间打几百次,指纹上跟正常刷新没区别,靠 IP 黑名单根本挡不住。

我遇到过的几类故障,按出现频率排:

  1. 429 之后的雪崩。上游限流后客户端立刻重试,三行代码把负载放大三倍。必须带指数退避(Exponential Backoff),并且只对 429 和 5xx 重试。
  2. 防盗链 403。图片直链常带 Referer 校验,服务端 302 过去后浏览器带的是站内 Referer,可能被拒。表现是接口 200、图片 403,很容易误判成接口问题。
  3. 上限速(Rate Limiting)打到自己。整站只有一个出口 IP,所有用户共享配额。按 IP 限流要放在自己这一层,按用户维度做,别把上游配额当成自己的容量。
  4. 返回 200 的 HTML 错误页。上游网关异常时经常这么干,<img> 拿到一串 HTML 直接显示破图。代码里必须校验 Content-Type。

第三条最容易被忽略。API.PLUS 这类聚合服务通常按 key 计配额,你的服务端如果只有一个 key、一个出口,那 QPS 上限就是全站上限。解法是本地缓存尽量长、预热拉满,让真实回源次数跟在线用户数解耦。

监控与告警清单#

标签:关键指标与动作

指标建议阈值告警动作
上游 TTFB P95> 800ms 持续 5 分钟切到「缓存直出」模式,暂停主动回源
上游 5xx 比例> 1%启用 stale 缓存,同时通知值班
429 次数> 10 次/分钟检查是否缓存击穿,把 TTL 临时拉长到 1800s
Nginx 缓存命中率< 85%查 cache key 是否带了随机参数
图片下载失败率> 2%抽 10 个样本验证 Content-Type 与状态码
PHP-FPM active processes> 80% pm.max_children扩池或缩短上游超时

命中率这条单独说:fastcgi_cache_key 里如果混进了 $args,而前端又给 URL 挂了时间戳防缓存,命中率会直接归零。查的时候先把完整 URL 打出来看一眼。

风险清单#

标签:风险、影响与规避

风险影响规避方式
上游字段名变更取不到图,全站破图多字段兜底 + 5 分钟一次的探活校验
图片直链被防盗链拦截接口 200 但图片 403服务端代理转发并透传正确 Referer
缓存击穿瞬时回源暴涨被限流flock 单飞 + 锁超时 2s
运行时未打补丁PHP 远程代码执行类漏洞跟进官方安全公告及时升级,2026 年 10 月就有过影响面很大的 PHP 漏洞通告(Krebs on Security)
内容合规失控随机图不可控,可能夹带不适内容落盘前过一遍图片审核,或只用白名单图源
上游整体中断无图可展示本地兜底图 + 过期缓存 stale。2026 Q3 的断网事件里,电力和灾害导致的中断占了相当比例(Cloudflare Radar),指望上游永远在线不现实

总结#

随机壁纸接口的调用本身一行就够,工程价值全在它外面那一圈:302 还是 JSON 决定请求次数,文件缓存加单飞决定回源量,Nginx 缓存 302 决定命中率,图片尺寸和格式决定带宽。这四件事随便做好一件都有收益,做全了 P95 掉到 200ms 以内并不稀奇。

更多接口调用细节可以翻GET 示例接口和API 文章中心,调试命令与超时设置的通用套路在那边讲得更细。

适用范围

  • 日均请求 1 万到 500 万量级、以图片展示为主的 Web 与小程序项目
  • 使用 PHP-FPM 或 Python 做中转层、有自建 Nginx 的环境
  • 能接受 5 分钟内图片重复(同 TTL 内返回同一张)的产品形态

不适用范围

  • 每次都要求返回不重复图片的场景,缓存 TTL 必须设到秒级,本文的命中率结论失效
  • 纯静态站点、没有服务端中转能力的部署方式
  • 图片需要实时裁剪或加水印的场景,得在缓存层之前插入处理链路

本站实测数据(近 24 小时)#

以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 16:33,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。

常见问题(FAQ)

随机图片 API 用 JSON 直链还是 302 跳转?
浏览器直接展示选 302,省一次请求;需要前端做本地预加载、去重、埋点选 JSON。两种形态可以并存,用 query 参数区分。
随机图片接口为什么容易被限流?
客户端不加缓存就每次刷图都回源,QPS 随页面刷新线性增长。服务端设 TTL 300s 的共享缓存,能把回源量压到峰值的 1/5 以下。

作者:沐小悠

本文约 30 分钟读完 · 关键词:随机图片API、动漫壁纸API、TTFB优化、图片缓存、WebP

更多文章 GET 示例接口

「」标签下的其他文章