Files
rootandClaude Opus 5.5 850034b87a docs: 统一元数据缓存运行手册与 MP 差异
runbook/cache.md 重写为统一缓存说明(两级、策略表、过期先用旧值、同请求合并、pinned 与预热、跨进程失效、命中统计、清空);
storage.md 补新 TTL 字段;moviepilot-tmdb-cache.md / moviepilot-discover.md 记与 MP 的差异。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 19:37:34 +08:00

157 lines
13 KiB
Markdown
Raw Permalink 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.
# 缓存与限流运行手册
不引入 Redis / Deno KV。跨进程共享的状态都放 PG:站点限流令牌桶、缓存。缓存语义照搬 MoviePilot,
对照与偏离见 [docs/porting/moviepilot-tmdb-cache.md](../porting/moviepilot-tmdb-cache.md)。
## 跨进程站点限流
- 表 `rate_limit_bucket`(迁移 `0041_rate_limit`):key `site:<站点 id>`,tokens / capacity / refill_per_sec / blocked_until。
- `@umt/db` 的 `PgTokenBucket.acquire(key, cost, {capacity, refillPerSec, waitMs})`:事务里 `select … for update`,
按 DB 时钟补令牌后扣减;拿不到按需要等待的时间加抖动轮询,超过 waitMs 返回 `ok:false`(站点限流器会重新排队)。
- 站点适配器用 `createSharedSiteRateLimiter`:进程内先串行排队,再到 PG 扣令牌;429 的 Retry-After 写 `blocked_until`。
站点限流间隔为 0(不限流)时只走本地;PG 出错时本次退回进程内令牌桶并记 warn。
- 容量 = 站点并发(burst),速率 = 1000 / 最小间隔毫秒。改站点限流设置即时生效(每次扣令牌带上最新参数)。
排查:`select * from rate_limit_bucket where key = 'site:<id>'`;`blocked_until` 不为空说明站点要求暂停。
## 统一元数据缓存
TMDB / 豆瓣 / Bangumi 三个来源共用一套缓存层(`@umt/db` 的 `MetadataCache`,服务端入口 `apps/server/src/shared-cache.ts`)。
用户 2026-10-06 定:更积极地缓存,不上 Redis;PG `kv_cache`(UNLOGGED)作二级,进程内存作一级。
### 两级
| 级 | 存哪 | 容量 / 有效期 | 说明 |
| --- | --- | --- | --- |
| L1 | 各进程内存(api / worker / worker:agent 各一份) | 每个 region 最多 1000 条(LRU),5 分钟,且不超过条目在 L2 的有效期 | 只在跨进程失效监听在线时启用,见下 |
| L2 | PG `kv_cache`(迁移 0042,0086 加 `fresh_until` / `pinned`) | 按 region 条数上限(TLRU,先删过期再删最久未用),每条独立 TTL | 三进程共享;UNLOGGED,PG 崩溃后清空 |
读:L1 → L2 → 源;写:两级都写。L1 返回的是副本,调用方改了不影响缓存。PG 读写失败按未命中 / 不写处理,不让业务请求失败。
### 策略表(来源 × 资源类型)
TTL 取值来自 `settings.storage.cacheTtl`(见 [storage.md](storage.md);旧库缺新字段时按缺省补),代码在 `metaCachePolicy`。
| 资源类型 | TMDB | 豆瓣 / Bangumi | 缺省 TTL | 空结果 / 查无 |
| --- | --- | --- | --- | --- |
| 搜索 / 识别 | `/search/*`、`/find/*`、识别缓存 `tmdb.recognize`:`tmdbSearchDays` | 搜索请求:`metaSearchDays` | 7 天 | TMDB `tmdbMissDays`、豆瓣 / bgm `metaMissDays`,1 天 |
| 详情 / 季 / 剧集组 | `/movie/{id}`、`/tv/{id}`、季、`/tv/episode_group/*`:`tmdbDetailDays` | 条目 `douban.subject` / `bangumi.subject`、发现页匹配 `discover.match`:`metaDetailDays` | 30 天 | 1 天(TMDB `tmdbMissDays`;条目不存在 `meta.subject_miss` 用 `metaMissDays`) |
| 榜单 / 热门 / 每日放送 | trending / discover / popular 等:`tmdbListHours` | 榜单、标签、筛选、每日放送、趋势页:`metaListHours` | 6 小时 | TMDB `tmdbListEmptyHours`、豆瓣 / bgm `metaEmptyHours`,1 小时 |
| 人物 / 图片 | `/person/*`、`…/images`:`imageDays` | — | 90 天 | `tmdbMissDays` |
- 空结果:TMDB 按 `results` 为空数组(MP `EMPTY_RESULT_CACHE_TTL` 判定);豆瓣 / bgm 按各接口的列表字段为空。空结果 TTL 不超过正常 TTL。
- **失败不缓存**:网络错误、超时、鉴权失败、被拦(403、风控页)、限流、5xx 都在适配器里抛错,不写缓存(MP `skip_none` / `skip_if`)。
豆瓣 / bgm 条目查询失败(不是「不存在」)时同一条目本进程冷却 30 分钟再在线查,不写缓存。
- 条数上限:TMDB 两个 region 用 `cache.tmdbMaxSize`(缺省 5000);豆瓣 / bgm 请求 5000、条目 20000、发现匹配 20000。
- `metaSearchDays` 缺省从 1 天改为 7 天(策略表统一);豆瓣 / bgm 搜索的空结果改按 `metaMissDays`(以前按 `metaEmptyHours`)。
### 过期先用旧值(stale-while-revalidate)
- 写入时 `fresh_until = now + TTL`、`expires_at = fresh_until + 宽限期`,宽限期 = TTL 的 50%,最多 7 天。
- 过了 `fresh_until`、还没到 `expires_at` 的条目是旧值:先返回旧值,后台刷新;同一个 key 同时只刷一次。
- 后台刷新失败继续用旧值,记一次警告「缓存后台刷新失败,继续用旧值」;过了宽限期就是普通未命中,同步回源。
- `PgCache.get`(非元数据缓存与旧调用方用)只认新鲜条目;`PgCache.getEntry` 带回旧值,给 `MetadataCache` 用。
- 识别缓存、豆瓣条目这类「只读不回源」的读取(`MetadataCache.get`)只认新鲜条目。
### 同请求合并(single-flight)
同进程同 region + key 并发只发一次请求,其余等同一个结果;后台刷新也走这条,刷新进行中的 key 不再发。跨进程不合并(各进程各自一次)。
### 关注中作品:pinned 与续期
- 关注口径同 `service/watched.ts isEntityWatched`(进行中的订阅、用户搜索 / 手动识别 / 人工确认、手动下载、在下载或整理、已在库)。
集合 = 关注中作品的 TMDB 身份(`movie:<id>` / `tv:<id>`)+ 选中的剧集组(`group:<gid>`),按进程缓存 5 分钟。
- 属于关注作品的 TMDB 请求(详情、季、剧集、图片、剧集组列表、剧集组详情)写入时标 `pinned`:按条数上限淘汰时跳过(仍计入条数,到期照样过期)。
- L2 新鲜命中时续期:过期时间推到 now + 本类 TTL(只延长不缩短,保留宽限长度)。旧值不续期,该刷新就刷新。
- 作品不再关注后 `pinned` 不会立即清掉,到期自然过期。
### 进入关注时预热
建订阅、用户搜索 / 手动识别出资源、提交下载时(`enqueueWatchedRefresh`,原来补排 full 刷新的触发点),每次额外入队一次
`prewarmMetadata`(网络道,幂等键 `prewarmMetadata:<作品 id>:<日期>`,同一天重复进入只跑一次):
- 拉基础详情、富详情(含标志)、各季详情(按 `metadata.includeSpecials` 决定含不含季 0,最多 60 季)、选中的剧集组;
请求键和刷新元数据 / 刮削完全相同,之后直接命中;预热前先清关注集合,保证这次写入就 pinned;
- 海报、背景、标志的原图拉进图片缓存目录(刮削用的就是原图);已在磁盘上的不再下载;
- 走 TMDB 客户端原有的限流与重试;详情都拿不到时网络类错误交给任务重试,其余记下结束。
- 代码:`sources-identity/service/metadata-prewarm.ts`。
### 跨进程失效
- 每个进程启动时 `startMetadataCache` 开一条专用连接 `LISTEN umt_cache_invalidate`。
- `PgCache.delete / clear`(含设置页「清空」、`POST /cache/tmdb/clear`、识别缓存类型冲突删除)和手动刷新(`fresh`)写入后
`pg_notify('umt_cache_invalidate', {"r": region, "k": key})`;不带 `k` = 整个 region。各进程收到后丢掉对应 L1。
- 普通写入不通知:别的进程 L1 里的旧值最多再用 5 分钟(L1 有效期)。
- 监听在线才启用 L1;断开时立即停用并清空 L1,退避重连(最长 10 秒),重连成功后整体清一次再启用。
启动时连不上只记警告,该进程只用 L2。脚本、测试等没启动监听的进程 L1 一直关着。
### 命中统计
- 每个进程按 region 在内存里计:L1 命中、L2 命中、旧值命中(先返回旧值)、未命中、回源失败、后台刷新。
- 每分钟把增量加进表 `cache_stat`(迁移 0086,按「天 × region」一行;30 天前的行顺带删掉),进程退出时再写一次。
- 口径:`GET /api/v1/cache/regions` 每个元数据 region 带 `hits`:最近 7 天 `cache_stat` 之和 + 当前进程还没写入的计数;
别的进程最近不到一分钟的计数不在内。命中率 = (L1 + L2 + 旧值) / (L1 + L2 + 旧值 + 未命中),没读取过为 null。
合并等待(single-flight 的跟随者)按未命中计。
- 设置 → 缓存 → 缓存区表的「命中率」列显示百分比与明细(内存 / 数据库 / 旧值 / 未命中,有失败与后台刷新时附上);非元数据区显示「—」。
- 排查:`select * from cache_stat where day = current_date order by region;`
### 接到这一层的 region
| region | 内容 | 键 | 读写方式 |
|---|---|---|---|
| `tmdb.request` | TMDB 请求响应(MP `TMDb.request`) | `path?query`(参数排序,不含凭证) | `fetch`:两级 + 旧值 + 合并 + pinned |
| `tmdb.recognize` | TMDB 识别结果(MP `TmdbCache`) | `[类型][语言]{tmdbid 或名称}-{年份}-{季}` | `get` / `set`;未识别 `{id:0}` 按空结果 TTL |
| `douban.request` / `bangumi.request` | 豆瓣 / bgm 搜索与列表请求 | `方法 地址?排序后的参数 [请求体]` | `fetch` |
| `douban.subject` / `bangumi.subject` | 条目(标题 / 别名 / 年份) | 条目 id | 在线查走 `fetch`;站点详情页 / 网页摘要写入走 `set` |
| `meta.subject_miss` | 豆瓣 / bgm 条目不存在 | `来源:id` | `set` / `get` |
| `discover.match` | 豆瓣 / bgm 条目 → TMDB 作品的确定匹配 | `来源:id` | `get` / `set` |
其它 `kv_cache` 区(`site.detail`、`websearch`、`enrich*`、`identity.detail_ids`)仍直接用 `PgCache`,没有 L1 与命中统计:
| region | 内容 | 键 | TTL | 条数上限 |
|---|---|---|---|---|
| `site.detail` | 站点详情页扩展内容 | `source_release.id` | `max(站点详情缓存期, storage.cacheTtl.siteDetailDays)`(7 天) | 5000 |
| `websearch` | 网页搜索结果 | 参数 sha256 | `storage.cacheTtl.webSearchDays`(1 天) | 1000 |
### 清空与清理
- 设置 → 缓存 → 缓存区:按 region 清空(`POST /api/v1/cache/regions/{region}/clear`,仅 admin),同时通知各进程丢 L1。
- `POST /api/v1/cache/tmdb/clear`:清 `tmdb.request` 与 `tmdb.recognize`(对应 MP `clear_cache()`)。
- 定时任务 `cache.sweep`(维护组,每 10 分钟)物理删除已过硬过期(`expires_at`)的行;宽限期内的旧值不删。
- 改了 TTL:新写入的条目按新值,已有条目保持原有效期,必要时清空。
API:`GET /api/v1/cache/tmdb?kind=recognize|request&limit=` 列出条目(键 / 过期 / 大小,识别条目带 tmdbId / 标题 / 海报)。
识别缓存何时读写(`sources-identity/service/tmdb-recognize-cache.ts`):
- 识别资源时先读:命中且有标题 → 按 id 取 TMDB 详情作为唯一远端候选;命中 `{id:0}` → 不再搜索;强制复查(force)不读。
- 写:自动识别到 TMDB 条目写正常条目;确认无匹配写 `{id:0}`;TMDB 出错或有歧义不写。
- 人工认定资源身份后回填(MP `update_recognize_cache`),覆盖名称负缓存。
- "元数据是剧集、缓存是电影"视为类型冲突:读时丢弃,写时拒绝。
## 图片代理与海报
- `GET /api/v1/img/tmdb/{size}/{file}`(会话或 API Key read):只代理 `image.tmdb.org`,size 走白名单(w92…original),
file 只允许 `[A-Za-z0-9_-]+.(jpg|jpeg|png|webp)`。
- 缓存路径对照 MP `sanitize_url_path`:`DATA_DIR/cache/images/t/p/<size>/<file>`。
- `cache.globalImageCache`(缺省关闭,MP `GLOBAL_IMAGE_CACHE`)开启才读磁盘缓存;拉到的合法图片总会写盘。
刮削与关注作品预热取原图(`original`),总是先读磁盘缓存。
- 按文件头识别图片类型,非图片 / SVG 拒绝;ETag = md5(内容),`Cache-Control: public, max-age=604800`,If-None-Match 相同回 304。
- 上游超时(20s)/ 非 200 / 非图片 → 502,前端回退无彩占位。
- 清理:定时任务 `cache.images_clean`(每天)删除 mtime 早于 `cache.globalImageCacheDays`(缺省 7)天的文件。
- 容量 LRU(CACHE-2):`cache.images_lru`(30 分钟)在总量超过 `storage.budgets.imagesGb`(缺省 20 GB)时按最久未访问删到 90%;
读盘命中会刷新文件 atime。见 [storage.md](storage.md)。
- 用量 / 清空:`GET /api/v1/cache/images`、`POST /api/v1/cache/images/clear`(仅 admin);设置 → 缓存(缓存区表里的「图片」行可清空;「图片缓存」节开关与保留天数)。
前端:作品库列表、作品详情、搜索面板的 TMDB 候选在有 `posterPath`(metadata.posterPath / TMDB 候选)时显示海报,
加载失败回退原来的无彩占位。TMDB 详情刷新会把 posterPath / backdropPath / logoPath 写进 metadata_field。
## 常见问题
- TMDB 缓存不生效:确认 `select region, count(*) from kv_cache group by 1` 有 `tmdb.request`;PG 读写失败时按未命中处理并记
`缓存读取失败,按未命中处理` / `缓存写入失败`。
- 命中率一直是 0 或没有:`cache_stat` 有没有今天的行;进程日志里有没有 `缓存失效监听启动失败`(只影响 L1,L2 命中照样计)。
- 清空后别的进程还拿到旧值:看该进程有没有 `缓存失效监听断开` 日志;断线期间 L1 是停用的,不会拿旧值。
- 改了 TMDB 缓存设置:新写入的条目按新 TTL / 上限;已有条目保持原过期时间,必要时清空。
- TTL 拉长后条目更早被条数上限挤掉:`cache.tmdbMaxSize` 缺省 5000,关注作品的条目 pinned 不受影响,其余按最久未用淘汰。
- 数据库重启后缓存为空:UNLOGGED 表的正常行为。