From 97e788de4e62c4ae1ac70b173f37486c3948f62c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 20:24:34 +0800 Subject: [PATCH] =?UTF-8?q?docs(runbook):=20=E6=95=B0=E6=8D=AE=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E8=A7=84=E5=88=99=E3=80=81db-retention=20=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E3=80=81pg=5Fstat=5Fstatements=20=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E4=B8=8E=E6=9F=A5=E7=9C=8B=E3=80=81=E9=AB=98=E9=A2=91=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E7=BB=93=E8=AE=BA=E4=B8=8E=20PG=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=AF=84=E4=BC=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- docs/runbook/schedules.md | 2 +- docs/runbook/storage.md | 114 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/docs/runbook/schedules.md b/docs/runbook/schedules.md index 7b70eb39..bf5d3127 100644 --- a/docs/runbook/schedules.md +++ b/docs/runbook/schedules.md @@ -34,7 +34,7 @@ | `cache.sweep` | 缓存清理 | 维护 | 10 分钟 | 删 kv_cache 过期行 | | `cache.images_clean` | 图片缓存清理 | 维护 | 24 小时 | 删超过保留天数的图片 | | `cache.images_lru` | 图片缓存容量控制 | 维护 | 30 分钟 | 超过 `storage.budgets.imagesGb` 按最久未访问删到 90% | -| `storage.retention.*` | 保留清理(观察 / SSE / 任务运行 / Agent 事件 / 审计 / 能力统计 / 待识别池归档) | 维护 | 1–24 小时 | 分批删除超过保留期的流水数据,见 [storage.md](storage.md) | +| `storage.retention.*` | 保留清理(观察 / SSE / 任务运行 / Agent 事件 / 审计 / 能力统计 / 流水数据 / 待识别池归档) | 维护 | 1–24 小时(流水数据每天 04:40) | 分批删除超过保留期的流水数据,见 [storage.md](storage.md) | | `storage.analyze` | 统计信息更新 | 维护 | 7 天 | 全库 `ANALYZE` | | `metadata.backfill_languages` | 元数据语言回刷 | 维护 | 24 小时,跑完自动停用 | 部署后首次启动即运行:对已确认 TMDB 身份的作品按语言回退链 full 刷新一次(TMDB 每秒最多 2 次,走请求缓存),进度写运行记录;跑完把自己停用,可手动再执行。见 [metadata.md](metadata.md) | | `maintain.backup` | 备份 | 维护 | 每天 03:30(占位) | 未配置。备份目前由宿主机上的 `scripts/backup.sh` 执行 | diff --git a/docs/runbook/storage.md b/docs/runbook/storage.md index 3b0b74f7..7d2f27c3 100644 --- a/docs/runbook/storage.md +++ b/docs/runbook/storage.md @@ -20,11 +20,16 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}} | `retentionDays.observation` | 90 | Feed 观察记录 | | `retentionDays.sseEvent` | 7 | SSE 补读事件(发布方另按条数裁剪 10000 条) | | `retentionDays.heartbeatHours` | 24 | 进程心跳采样(小时)。历史曲线最多只能看这么久 | -| `retentionDays.taskRun` | 60 | 已结束的业务任务 `task`、引擎 run(步骤级联)、运行元数据 | +| `retentionDays.taskRun` | 60 | 已结束且没有被其它表引用的业务任务 `task`;成功的操作账本 | | `retentionDays.agentEvent` | 90 | 已结束会话的事件流与已消费消息。`agent_run` 本身保留 | | `retentionDays.audit` | 180 | 审计事件(能力调用审计除外) | | `retentionDays.capabilityStats` | 90 | 能力调用审计(`target_type=capability`)与已结束的 `capability_invocation` | | `retentionDays.identityPoolArchive` | 30 | 待识别池 unmatched 条目无新证据多少天后归档(不删) | +| `retentionDays.engineRun` | 14 | 已结束的引擎 run(步骤级联)与运行元数据 / 事实 / 信号;仍被保留中的业务任务引用的跟随任务 | +| `retentionDays.outboxEvent` | 30 | 已投递的 outbox 事件(只删无幂等键或一次性幂等键的,见「保留规则」) | +| `retentionDays.resolutionAttempt` | 30 | 识别尝试记录;每条资源至少保留最近 3 次 | +| `retentionDays.identityResolved` | 60 | 已识别(资源仍绑定作品)的待识别池条目;忽略、归档的不删 | +| `retentionDays.siteSnapshot` | 30 | 站点快照;每个站点最近一次成功的列表快照保留 | | `cacheTtl.tmdbSearchDays` | 7 | TMDB `/search/*`、`/find/*` 响应与识别缓存 | | `cacheTtl.tmdbDetailDays` | 30 | TMDB 详情 / 季 / 剧集组。关注中的作品命中时续期 | | `cacheTtl.tmdbMissDays` | 1 | TMDB 空结果与未识别负缓存 | @@ -74,15 +79,60 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}} | `storage.retention.observation` | `observation` | 6 小时 | `created_at` 早于保留期 | | `storage.retention.sse_event` | `sse_event` | 1 小时 | `at` 早于保留期 | | `system.heartbeat_cleanup` | 心跳 | 1 小时 | 进程行 24 小时无心跳;采样早于 `heartbeatHours` | -| `storage.retention.task_run` | `task`、引擎 `workflow_runs`、`tasks_run_meta` / `tasks_fact` / `tasks_op_ledger` | 6 小时 | 早于保留期且已结束(task:succeeded / failed / cancelled / dead;run:completed / succeeded / failed / canceled;账本只删 succeeded) | +| `storage.retention.task_run` | `task`、引擎 `workflow_runs`(`step_attempts` 级联)、`tasks_run_meta` / `tasks_fact` / `workflow_signals` / `tasks_op_ledger` | 6 小时 | 见下「保留规则」 | | `storage.retention.agent_event` | `agent_run_event`、`agent_run_message` | 6 小时 | 早于保留期且会话已结束;消息只删已消费的 | | `storage.retention.audit` | `audit_event` | 1 天 | `at` 早于保留期,`target_type` 不是 capability | | `storage.retention.capability_stats` | `audit_event`(capability)、`capability_invocation` | 1 天 | 早于保留期;调用记录不删 running | +| `storage.retention.pipeline` | `outbox_event`、`resolution_attempt`、`identity_candidate`(已识别)、`site_snapshot` | 每天 04:40(cron) | 见下「保留规则」 | | `storage.retention.identity_pool` | `identity_candidate` | 6 小时 | pending + unmatched,`coalesce(last_attempt_at, created_at)` 早于保留期 → 改为 archived | | `storage.analyze` | 全库 | 7 天 | `ANALYZE` | | `cache.images_lru` | 图片缓存 | 30 分钟 | 见上 | | `cache.sweep` | `kv_cache` | 10 分钟 | 已过硬过期(宽限期内的旧值保留) | +### 保留规则(2026-10-06 数据库清理) + +规则写在 `modules/system/retention.ts`(每个任务的 `rules`),定时任务、`POST /system/storage/cleanup/{target}` 与 +`scripts/db-retention.ts` 共用。原则:只删已结束、过期、没有被引用的行;拿不准的不删。 + +| 表 | 删除条件(都要同时满足) | 缺省保留 | +|---|---|---| +| `task` | 已结束(succeeded / failed / cancelled / dead)、`created_at` 早于保留期、没有被 `task.parent_task_id` / `acquisition_plan` / `asset_operation` / `agent_run` / `artifact` / `decision` / `reconcile_run` / `organization_record` 引用(这些外键是 set null,删了会断开引用) | `taskRun` 60 天 | +| 引擎 `workflow_runs` | 终态(completed / succeeded / failed / canceled);`created_at` 与结束时间都早于保留期;没有被「保留中的」`task.engine_run_id` 引用;有父 run 的,父 run 也已结束 | `engineRun` 14 天 | +| 引擎 `step_attempts` | 随 run 级联(外键 on delete cascade) | 同上 | +| `tasks_run_meta` / `tasks_fact` / 引擎 `workflow_signals` | 早于引擎保留期,且对应 run 已不存在或本次可删 | 同上 | +| `tasks_op_ledger` | `succeeded` 且 `started_at` 早于保留期(不变) | `taskRun` | +| `outbox_event` | `status = dispatched`、`at` 早于保留期,且幂等键为空或属于 `release.observed:` / `feed.due:` / `decision.created:` / `download.submitted:`(只在创建时发一次或带时间槽)。其它幂等键(生命周期事件 `media.watched/…`、`decision.updated:` 等)靠「全表唯一」做永久去重,删了会重发,保留;pending / failed / dead 不删 | `outboxEvent` 30 天 | +| `resolution_attempt` | `created_at` 早于保留期,且同一资源有至少 3 次更新的尝试(每条资源留最近 3 次) | `resolutionAttempt` 30 天 | +| `identity_candidate` | `status = resolved`、`updated_at` 早于保留期、资源仍绑定作品、没有 `metadata_enrichment` 引用、`decision_id` 不指向未结事项。忽略(dismissed,是「不再自动识别」的标记)与归档(archived,仍可手动指定)不删 | `identityResolved` 60 天 | +| `site_snapshot` | `started_at` 早于保留期、不是 running、不是该站点最近一次成功(ok / partial)的列表快照(periodic / manual / backfill;站点刷新间隔按它判断) | `siteSnapshot` 30 天 | +| `scheduled_job_run` | 不按天数:每个任务只留最近 50 次(`trimRuns`,原有) | — | +| `cache_stat` | 不按天数:写入统计时顺带删 30 天前的行(原有) | — | + +引擎语义核对(openworkflow 0.10.1):run 幂等键只在 24 小时内去重(`DEFAULT_RUN_IDEMPOTENCY_PERIOD_MS`), +认领 / 恢复只看 pending / running / sleeping,父 run 在 step 里等子 run 时读子 run 状态。 +所以删 14 天前的终态 run 不影响去重与恢复;父 run 未结束的子 run 保留。业务侧去重看 `task` 表(`kind + idempotency_key`) +与 `enqueueSweep` 的时间槽,不依赖引擎旧 run。 + +删得多(≥ 1000 行)的表,任务结束时顺带 `ANALYZE`。 + +### 脚本 `scripts/db-retention.ts` + +和定时任务同一套规则,用来对真实库先看清单再删(lessons 第 22 条): + +```sh +# 只读:每张表将删多少行(缺省) +DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/db-retention.ts +# 只看部分目标 +DATABASE_URL=… deno run -A scripts/db-retention.ts --only task_run,pipeline +# 执行(分批、每批单独事务;本次不限 5 分钟,最多 1 小时;删完 ANALYZE) +DATABASE_URL=… deno run -A scripts/db-retention.ts --apply +``` + +保留天数取库里的 `settings.storage`(缺字段按缺省)。在宿主机源码目录里跑(app 镜像是编译产物,不带脚本); +部署库的 postgres 没有映射端口,需要临时映射或在 compose 网络里起一个带 deno 的容器去连。 +引擎 schema 不是 `openworkflow` 时设 `ENGINE_SCHEMA`。不含心跳、图片、kv_cache(各自任务管)。 +测试库按部署规模造数(2.35 万 run / 1.1 万 outbox / 1.7 万识别尝试)实测:删 3.7 万行用时约 1.4 秒。 + 分批删除(`modules/system/retention.ts` 的 `deleteInBatches`): - 每批 5000 行:`delete … where ctid = any(array(select ctid … where <条件> limit 5000))`,单独事务。 @@ -108,7 +158,7 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}} | 字段 | 含义 | |---|---| | `level` | 各项最差档:ok / warn / critical,取不到的项 unknown 不参与 | -| `db` | 库体积对照 `pgDataGb` / `pgDataWarnGb`;`tables` 为体积前 15 的表(总 / 表 / 索引字节、估算行数、死元组、最近 autovacuum / analyze) | +| `db` | 库体积对照 `pgDataGb` / `pgDataWarnGb`;`tables` 为体积前 15 的表(总 / 表 / 索引字节、估算行数、死元组、最近 autovacuum / analyze);`statements` 为语句统计(见「数据库性能」) | | `kvCache[]` | 每个 region 的条数、其中已过期未清理的条数、字节 | | `images` | 图片缓存用量、文件数、预算 | | `backups` | 备份目录用量、份数(不含 `.partial`)、预算 | @@ -119,7 +169,7 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}} ### POST /system/storage/cleanup/{target}(admin) -`target`:`observation`、`sse_event`、`heartbeat`、`task_run`、`agent_event`、`audit`、`capability_stats`、`identity_pool`、`analyze`、`images`、`kv_cache`。 +`target`:`observation`、`sse_event`、`heartbeat`、`task_run`、`agent_event`、`audit`、`capability_stats`、`pipeline`、`identity_pool`、`analyze`、`images`、`kv_cache`。 和定时任务「立即执行」一样,只设置 `run_requested_at`,由调度器执行。已在排队或运行中返回 `queued:false`。 任务还没登记(调度器没同步过)返回 404。触发成功写审计 `storage.cleanup`。 @@ -150,7 +200,18 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}} | `maintenance_work_mem` | 256MB | `PG_MAINTENANCE_WORK_MEM` | VACUUM / 建索引用 | | `max_wal_size` | 2GB | `PG_MAX_WAL_SIZE` | 批量删除时减少检查点 | -另外 `shm_size: 512mb`(并行查询的动态共享内存走 /dev/shm)。 +| `random_page_cost` | 1.1 | `PG_RANDOM_PAGE_COST` | 库整个在内存里时 1.1;缺省 4 是机械盘的估值 | + +另外 `shm_size: 512mb`(并行查询的动态共享内存走 /dev/shm),`shared_preload_libraries=pg_stat_statements` +与 `pg_stat_statements.track=top`(语句统计,见下)。 + +2026-10-06 评估(部署库 172 MB): + +- `shared_buffers` 2GB 远大于库,但只是上限,页用到才占内存;容器上限 3g 也没紧张,不改。 +- `random_page_cost` 4 → 1.1:库全部命中缓存(`shared_buffers` + 页缓存),随机读的代价被高估, + 规划器在中等表上会偏向整表扫描。这是唯一调整的参数。 +- `work_mem` / `maintenance_work_mem` / `max_wal_size` 按 4C/8G 给的值,没有看到排序落盘或检查点过频,不改。 +- autovacuum 开着,按表参数见上(迁移 0050);部署库死元组都在几十行以内,不改。 按机器调整,例如 2C/4G:`PG_MEM_LIMIT=1536m PG_SHARED_BUFFERS=768MB PG_EFFECTIVE_CACHE_SIZE=2GB PG_WORK_MEM=16MB PG_MAINTENANCE_WORK_MEM=128MB`; 8C/16G:`PG_MEM_LIMIT=6g PG_SHARED_BUFFERS=4GB PG_EFFECTIVE_CACHE_SIZE=10GB PG_WORK_MEM=64MB PG_MAINTENANCE_WORK_MEM=512MB`。 @@ -158,6 +219,49 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}} `shared_buffers` 大于容器上限会在启动时 OOM,两者一起改。 +## 数据库性能(2026-10-06) + +### 语句统计 pg_stat_statements + +- 迁移 `0089_db_perf` 在镜像带这个扩展时 `create extension if not exists pg_stat_statements`(建在 public); + 扩展不存在或权限不够(非超级用户)时跳过,迁移照常成功。 +- 采集要求 postgres 以 `shared_preload_libraries=pg_stat_statements` 启动(`docker-compose.yml` 已加,`track=top` + 只记顶层语句)。**改 compose 后要重建 postgres 容器才生效**: + + ```sh + docker compose --env-file .env.deploy up -d postgres # 重建 postgres(数据在卷里不丢);app 会短暂断开数据库后自动重连 + docker compose --env-file .env.deploy exec postgres psql -U umt -c 'show shared_preload_libraries' + ``` + +- 查看:运行状态页「存储 → 数据库语句统计」(平均最慢的 5 条,只看调用 ≥ 10 次的;调用最多的 5 条), + 对应 `GET /system/storage` 的 `db.statements`;没装扩展 / 没预加载时 `enabled=false` 并在 `note` 里说明。 + 命令行: + + ```sql + select calls, round(mean_exec_time::numeric, 3) as mean_ms, round(total_exec_time::numeric) as total_ms, query + from pg_stat_statements order by total_exec_time desc limit 20; + select pg_stat_statements_reset(); -- 清零重新统计 + ``` + +- 同一节的「体积最大的表」加了死元组列(`pg_stat_all_tables.n_dead_tup`)。 + +### 高频全表扫描的结论 + +部署库的 `pg_stat_user_tables` 里 seq_scan 高的表大多只有几十到一百多行(`scheduled_job` 33、`task` 81、`admin_session` 139、 +`title_alias` 177、`external_identity` 74、`site` 13、`download_task` 4):这种表 1~5 页,整表扫描比走索引便宜, +规划器选 seq scan 是对的,对应的唯一索引 / 主键「从未使用」也是这个原因(比如会话校验本来就是按 `session_hash` +等值查,按 10 倍数据量造数后就走 `admin_session_hash_key`)。真正改了的: + +- 巡检回填(调度器每 15 秒)原来每次整扫 `scheduled_job_run`:加部分索引 `scheduled_job_run_harvest_idx`。 +- 标题模糊匹配原来写 `similarity(x, q) >= t`,函数调用用不上 trigram 索引:改为 `x % q and similarity(x, q) >= t` + (`%` 的缺省阈值 0.3 不高于各处阈值,结果相同)。`source_release` 标题搜索 8440 行从 75 ms 降到 0.7 ms; + 别名匹配(`title_alias`)现在表太小,规划器仍选整表扫描,表长大后会改走索引。 +- 调度器每秒查一次到期任务(33 行,约 0.15 ms)保留不改:它决定「立即执行」的响应延迟,成本可以忽略。 +- 删除从未使用且没有查询会用到的 `content_unit_air_date_idx`。其余从未使用的索引有用途,保留: + `source_release_title_trgm_idx`(上面的标题搜索)、`resolution_attempt_release_idx`(保留规则「每条资源留最近 3 次」)、 + `source_release_infohash_v1_idx`(按 hash 接管下载 / 浏览器核验)、`audit_event_action_idx`(能力调用统计)、 + 主键与唯一约束(保证唯一性)。 + ## 常见问题 - 面板上 PG 超预算:先看 `tables` 前几名。流水表大就临时调短对应保留期,再 `POST /system/storage/cleanup/`;