AniBT Wiki
公开 API

HTTP 缓存与 304

所有公开端点的 ETag、Cache-Control、stale-while-revalidate 行为详解,配 Python 客户端最小实现。

AniBT 所有公开端点(RSS + /api/*)都返回三层缓存指令,并支持 If-None-Match / If-Modified-Since 条件请求。配合标准 HTTP 客户端无需任何配置即可生效。

按 RFC 7232,If-None-Match(ETag)优先于 If-Modified-Since。如果你同时发了两个头,服务端只看 ETag。建议永远用 ETag。

响应头

公开端点通用形态
Cache-Control:    public, max-age=0, s-maxage=<N>, stale-while-revalidate=<M>, stale-if-error=<M>
CDN-Cache-Control: max-age=<N>, stale-while-revalidate=<M>, stale-if-error=<M>
ETag:             W/"..."
Last-Modified:    <RFC 1123 date>   # 仅当该端点有稳定更新时间
Vary:             Accept-Encoding

属性

类型

发布驱动的新鲜度链路

上表的 TTL 是共享缓存的兜底策略,不是发布后必须等待的刷新计时器。资源或公开数据变化时,站点按下面的顺序推进:

  1. 相关写入完成后,public-snapshot worker 写入一个新的不可变 R2 generation,并在 PostgreSQL 中按单调顺序推进该 URL / alias 的 publication head。旧任务不能覆盖更新的 head;被删除的 alias 会进入 tombstone 状态。
  2. Web 从 R2 S3 origin 读取 publication head 指向的同一个 generation,失效精确键和缓存族,并严格预热全部受影响的 Redis locale / alias 变体。
  3. Redis 写入带 publication order 与 generation fence。失效前已经开始的慢请求、锁等待或后台 revalidate 如果更旧,会被拒绝回写,不能把旧快照重新盖到新值上。
  4. 只有所需 Redis 写入全部成功后才发布 page-ready。Edge 对这些 URL 保留刚预热的 origin 值,只执行 CDN purge;URL 队列、冻结 run / batch 和带 token + generation 的 lease 让失败或重启后的工作可以继续重试,而不是靠下一次发布碰巧补刷。

这个链路保证的是所选快照 generation 的发布顺序和缓存一致性。它不把发布 API 变成同步可见性接口。generatedAt 只表示快照构建时间;HTTP 200、较新的时间戳或 cf-cache-status: HIT 都不能替代“响应正文里确实包含预期 releaseId”这一检查。

运维判定

  • 发布探针同时检查目标 releaseId / 内容、ETag 或 Last-Modified,以及 cf-cache-status;不要只检查状态码或缓存命中。
  • anibt_ops_public_snapshot_queue_age_seconds{quantile="0.95"} 的当前运维目标是 ≤ 3 秒,P99 ≤ 15 秒;anibt_ops_public_snapshot_pending_oldest_age_seconds 超过 15 秒告警、超过 60 秒严重告警。这些是站点运维 guardrail,不是公开 API 的同步 SLA。
  • anibt_ops_public_snapshot_watermark_lag、head integrity violation、Edge lease mismatch、quarantine 和 invariant violation 应保持 0;Edge URL pending age 应保持 ≤ 15 秒。
  • queue age 很高而 snapshot_redis_warm.completed.latencyMs 很低,说明瓶颈在调度 / backlog,不在 Redis。不要延长 snapshot 或 Redis TTL 来掩盖传播缺口。

各端点 TTL 一览

端点客户端缓存s-maxage (CDN)stale-while-revalidate
/rss/anime.xml重新验证 + ETag120s60s
/rss/group/<slug>.xml重新验证 + ETag120s60s
/rss/magnets.xml重新验证 + ETag120s60s
/api/subtitle-groups重新验证 + ETag300s86400s
/api/seasons/anime重新验证 + ETag600s86400s
/api/anime/groups重新验证 + ETag300s3600s
/api/bgm/search重新验证 + ETag300s300s
/api/animes/search重新验证 + ETag90s3600s
/api/animes/torrent-match重新验证 + ETag60s300s
/api/anime/lookup重新验证 + ETag300s300s
/api/mikan/group-lookup重新验证 + ETag600s3600s

ETag 计算公式

端点公式
/rss/anime.xml /rss/group /rss/magnets.xmlW/"r{itemCount}-{latestPublishedAtMs}-{canonicalUrlHash}"
/api/subtitle-groupsW/"sg{count}-{maxCreatedAt}-{sumTotalReleases}-{urlHash}"
/api/seasons/animeW/"sa-{year}-{SEASON}[-b{bgmId}]-{count}-{lastMs}-{urlHash}"
/api/anime/groupsW/"ag-{bgmId}-{count}-{lastMs}-{urlHash}"
/api/bgm/searchW/"bgm-web-{queryHash}-{count}-{lastMs}"
/api/animes/searchW/"anime-search-web-{bodyHash}"
/api/animes/torrent-matchW/"anime-torrent-match-web-{bodyHash}"
/api/anime/lookupW/"anime-lookup-web-{bodyHash}"
/api/mikan/group-lookupW/"mikan-group-lookup-web-{bodyHash}"

公式确定性:同一查询在数据未变时 ETag 完全相同,多副本部署也能命中。

验证 304

curl 验证
H=$(curl -sI "https://anibt.net/rss/anime.xml?bgmId=543360&groupSlug=kirara-fantasia")
ETAG=$(echo "$H" | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')

curl -sI -H "If-None-Match: $ETAG" \
  "https://anibt.net/rss/anime.xml?bgmId=543360&groupSlug=kirara-fantasia" | head -1
# → HTTP/2 304

时间轴:max-age + s-maxage + SWR + 304 怎么交互

很多人会问:"stale-while-revalidate=86400(24 小时)是不是意味着我可能拿到 24 小时前的旧数据?"——不会

角色

  • U:你的 HTTP/RSS 客户端(浏览器 / NetNewsWire / Inoreader / 自建脚本)
  • CDN:Cloudflare 边缘节点
  • O:origin(AniBT 服务器)

端点示例:/api/seasons/animemax-age=0, s-maxage=600, swr=86400

时间轴
T=0s    U 第一次请求
        CDN 没缓存 → 回源 O,O 拿到数据库快照 → 返回 200 + ETag=A + body
        CDN 把响应缓存 600 秒(fresh 期),再延伸 86400 秒(stale 期)
        U 拿到 200 + body + ETag=A
        U 本地记录 ETag,但因 max-age=0,下次会重新验证

T=60s   U 又来(轮询太勤)
        U 带上 If-None-Match: A 发请求
        CDN 仍在 s-maxage=600 fresh 期,可直接用边缘缓存响应 304
        (origin 0 命中)

T=700s  U 来
        U 带上 If-None-Match: A 发请求
        CDN s-maxage=600 已过期,但还在 swr=86400 内
        CDN 决策:立即把缓存里的旧版本(含 ETag=A)返回给 U;
                 同时异步发 1 个请求到 O 刷新
        U 收到响应:服务器看 If-None-Match: A == 当前 ETag → 304 Not Modified
        (用户感知:0 延迟拿到 304,知道数据没变 → 不用解析 XML)

        与此同时 CDN 异步回源:
          - 数据真没变 → O 直接返回 304 → CDN 把 fresh 期再续 600s
          - 数据有更新 → O 返回 200 + ETag=B + 新 body → CDN 替换缓存

T=900s  U 来(下一次)
        U 带 If-None-Match: A
        CDN 已有新缓存(ETag=B 或仍是 A)
        CDN 把 ETag=B 的 body 返回 → 但 U 的 If-None-Match 不匹配 → 200 + 新 body
        U 拿到新数据

swr=86400 的真正意义

不是"用户会被强制忍受 24 小时旧数据"——而是"如果 origin 当前不可用 / 回源慢,CDN 最多 用 24 小时内的旧版本兜底,避免给用户报错"。

只要 origin 健康,你永远拿到不超过 s-maxage(这里是 10 分钟)旧的数据

  • T=700s:CDN 立即吐 10 分钟前的版本(你大概率收到 304,无感)
  • T=700s 后几百毫秒:CDN 已经异步刷成最新版本
  • T=900s 你下一次来:直接拿最新

cf-cache-status 含义

状态含义origin 是否被打
HITCDN 内存命中,仍在 fresh 期
STALECDN 给旧版本(SWR 期内)+ 后台异步刷异步打 1 次
MISSCDN 没缓存,同步回源同步打 1 次
EXPIRED缓存过期且超过 SWR 窗口,必须同步回源同步打 1 次
BYPASS跳过缓存(一般不会发生在公开端点)同步打 1 次

完整 curl 演示

一次完整验证
# Step 1: 第一次请求,拿 ETag + 看缓存头
curl -sSI 'https://anibt.net/api/seasons/anime?season=2026-SUMMER' \
  | grep -iE '^(HTTP|etag|cache-control|cf-cache-status)'
# HTTP/2 200
# cache-control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400, stale-if-error=86400
# etag: W/"sa-web-2026-SUMMER-85-1782776047080-rzdksj"
# cf-cache-status: MISS               ← 第一次穿透到 origin

# Step 2: 立刻再来一次(仍在 max-age 内,但用 -H 强制触发条件请求)
ETAG='W/"sa-web-2026-SUMMER-85-1782776047080-rzdksj"'
curl -sSI -H "If-None-Match: $ETAG" \
  'https://anibt.net/api/seasons/anime?season=2026-SUMMER' \
  | grep -iE '^(HTTP|cf-cache-status)'
# HTTP/2 304                          ← 数据没变
# cf-cache-status: HIT                ← CDN 内存里直接命中

# Step 3: 等 11 分钟后再来(s-maxage 已过期,进入 SWR 期)
sleep 660
curl -sSI -H "If-None-Match: $ETAG" \
  'https://anibt.net/api/seasons/anime?season=2026-SUMMER' \
  | grep -iE '^(HTTP|cf-cache-status|age)'
# HTTP/2 304
# cf-cache-status: STALE              ← CDN 给旧版本,后台已异步刷新
# age: 367                            ← 这份缓存已存在 367 秒

Python 客户端最小实现

poll.py
import requests, time

URL = 'https://anibt.net/rss/anime.xml?bgmId=543360&groupSlug=kirara-fantasia,haruhana&language=CHS'
state = {'etag': None, 'last_modified': None}

def poll():
    headers = {}
    if state['etag']:          headers['If-None-Match']     = state['etag']
    if state['last_modified']: headers['If-Modified-Since'] = state['last_modified']

    r = requests.get(URL, headers=headers, timeout=10)
    if r.status_code == 304:
        print(f"[{time.strftime('%H:%M:%S')}] 304 not modified, body 0 bytes")
        return None  # 数据没变,不用解析
    if r.status_code == 200:
        state['etag']          = r.headers.get('ETag')
        state['last_modified'] = r.headers.get('Last-Modified')
        print(f"[{time.strftime('%H:%M:%S')}] 200 OK, body {len(r.content)} bytes, "
              f"cf-cache-status={r.headers.get('cf-cache-status')}")
        return r.content
    if r.status_code == 429:
        retry = int(r.headers.get('Retry-After', '60'))
        print(f"rate-limited, sleeping {retry}s")
        time.sleep(retry); return poll()
    r.raise_for_status()

# 每 15 分钟轮询一次
while True:
    poll()
    time.sleep(15 * 60)

这套写法满足:

  • 第一次响应缓存 ETag → 之后每次走 304,带宽几乎为零
  • CDN 给 STALE 时你也不知道(也不需要知道),用户体验完全相同
  • 触发 429 自动按 Retry-After 退避

本页目录