Files
UHD_Media_Tool/docs/runbook/cache.md
T
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

13 KiB
Raw Blame History

缓存与限流运行手册

不引入 Redis / Deno KV。跨进程共享的状态都放 PG:站点限流令牌桶、缓存。缓存语义照搬 MoviePilot, 对照与偏离见 docs/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;旧库缺新字段时按缺省补),代码在 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。
  • 用量 / 清空: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 表的正常行为。