公开 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 是共享缓存的兜底策略,不是发布后必须等待的刷新计时器。资源或公开数据变化时,站点按下面的顺序推进:
- 相关写入完成后,public-snapshot worker 写入一个新的不可变 R2 generation,并在 PostgreSQL 中按单调顺序推进该 URL / alias 的 publication head。旧任务不能覆盖更新的 head;被删除的 alias 会进入 tombstone 状态。
- Web 从 R2 S3 origin 读取 publication head 指向的同一个 generation,失效精确键和缓存族,并严格预热全部受影响的 Redis locale / alias 变体。
- Redis 写入带 publication order 与 generation fence。失效前已经开始的慢请求、锁等待或后台 revalidate 如果更旧,会被拒绝回写,不能把旧快照重新盖到新值上。
- 只有所需 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 | 重新验证 + ETag | 120s | 60s |
/rss/group/<slug>.xml | 重新验证 + ETag | 120s | 60s |
/rss/magnets.xml | 重新验证 + ETag | 120s | 60s |
/api/subtitle-groups | 重新验证 + ETag | 300s | 86400s |
/api/seasons/anime | 重新验证 + ETag | 600s | 86400s |
/api/anime/groups | 重新验证 + ETag | 300s | 3600s |
/api/bgm/search | 重新验证 + ETag | 300s | 300s |
/api/animes/search | 重新验证 + ETag | 90s | 3600s |
/api/animes/torrent-match | 重新验证 + ETag | 60s | 300s |
/api/anime/lookup | 重新验证 + ETag | 300s | 300s |
/api/mikan/group-lookup | 重新验证 + ETag | 600s | 3600s |
ETag 计算公式
| 端点 | 公式 |
|---|---|
/rss/anime.xml /rss/group /rss/magnets.xml | W/"r{itemCount}-{latestPublishedAtMs}-{canonicalUrlHash}" |
/api/subtitle-groups | W/"sg{count}-{maxCreatedAt}-{sumTotalReleases}-{urlHash}" |
/api/seasons/anime | W/"sa-{year}-{SEASON}[-b{bgmId}]-{count}-{lastMs}-{urlHash}" |
/api/anime/groups | W/"ag-{bgmId}-{count}-{lastMs}-{urlHash}" |
/api/bgm/search | W/"bgm-web-{queryHash}-{count}-{lastMs}" |
/api/animes/search | W/"anime-search-web-{bodyHash}" |
/api/animes/torrent-match | W/"anime-torrent-match-web-{bodyHash}" |
/api/anime/lookup | W/"anime-lookup-web-{bodyHash}" |
/api/mikan/group-lookup | W/"mikan-group-lookup-web-{bodyHash}" |
公式确定性:同一查询在数据未变时 ETag 完全相同,多副本部署也能命中。
验证 304
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/anime(max-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 是否被打 |
|---|---|---|
HIT | CDN 内存命中,仍在 fresh 期 | 否 |
STALE | CDN 给旧版本(SWR 期内)+ 后台异步刷 | 异步打 1 次 |
MISS | CDN 没缓存,同步回源 | 同步打 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 客户端最小实现
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退避