diff --git a/apps/server/src/modules/system/storage.ts b/apps/server/src/modules/system/storage.ts index 5eb9aa14..4cc38321 100644 --- a/apps/server/src/modules/system/storage.ts +++ b/apps/server/src/modules/system/storage.ts @@ -248,10 +248,59 @@ export async function storageHealth(d: StorageDeps): Promise<{ level: HealthLeve return { level: l.level, alerts: l.alerts }; } +type Statements = SystemStorage["db"]["statements"]; +const STATEMENT_TOP = 5; +/** 平均耗时排行只看调用过这么多次的语句(迁移、一次性维护语句不进榜) */ +const SLOW_MIN_CALLS = 10; + +/** pg_stat_statements 前几名;没装扩展或 postgres 没预加载时 enabled=false(不报错) */ +export async function statementStats(db: Kysely): Promise { + const off = (note: string): Statements => ({ enabled: false, note, since: null, slowest: [], frequent: [] }); + const has = await sql<{ r: string | null }>`select to_regclass('public.pg_stat_statements')::text as r` + .execute(db) + .catch(() => null); + if (!has?.rows[0]?.r) return off("没有安装 pg_stat_statements 扩展"); + // 装了扩展但 postgres 没预加载时,查视图会报错 + try { + await sql`select 1 from public.pg_stat_statements limit 1`.execute(db); + } catch { + return off("postgres 没有预加载 pg_stat_statements(shared_preload_libraries),重启数据库后生效"); + } + type Row = { query: string; calls: string; total: number; mean: number; rows: string }; + const top = async (order: "mean" | "calls") => + ( + await sql` + select left(regexp_replace(query, '[[:space:]]+', ' ', 'g'), 500) as query, calls::text, + total_exec_time as total, mean_exec_time as mean, rows::text + from public.pg_stat_statements + where dbid = (select oid from pg_database where datname = current_database()) + and query not ilike '%pg_stat_statements%' + ${order === "mean" ? sql`and calls >= ${SLOW_MIN_CALLS} order by mean_exec_time desc` : sql`order by calls desc`} + limit ${STATEMENT_TOP}`.execute(db) + ).rows.map((r) => ({ + query: r.query, + calls: Number(r.calls), + totalMs: Math.round(r.total * 100) / 100, + meanMs: Math.round(r.mean * 1000) / 1000, + rows: Number(r.rows), + })); + const info = await sql<{ since: Date | null }>`select stats_reset as since from public.pg_stat_statements_info` + .execute(db) + .catch(() => null); + const since = info?.rows[0]?.since; + return { + enabled: true, + note: null, + since: since ? new Date(since).toISOString() : null, + slowest: await top("mean"), + frequent: await top("calls"), + }; +} + export async function buildStorage( d: StorageDeps & { engineSchema: string; refresh?: boolean }, ): Promise { - const [bytes, scan, tables, kv, jobs] = await Promise.all([ + const [bytes, scan, tables, kv, jobs, statements] = await Promise.all([ dbSize(d.db).catch(() => null), dirScan(d.paths, { wait: true, force: d.refresh ?? false }).catch(() => null), sql<{ @@ -287,6 +336,7 @@ export async function buildStorage( .where("id", "in", Object.values(CLEANUP_JOB_IDS)) .execute() .catch(() => []), + statementStats(d.db), ]); const lastSummaries = new Map(); if (jobs.length) { @@ -324,6 +374,7 @@ export async function buildStorage( lastAutovacuumAt: iso(t.vac), lastAnalyzeAt: iso(t.ana), })), + statements, }, kvCache: kv.rows.map((r) => ({ region: r.region, entries: r.entries, expired: r.expired, bytes: Number(r.bytes) })), images: { ...l.images, files: scan ? scan.images.files : null }, diff --git a/apps/server/test/system/storage.test.ts b/apps/server/test/system/storage.test.ts index d6035add..b3ac8070 100644 --- a/apps/server/test/system/storage.test.ts +++ b/apps/server/test/system/storage.test.ts @@ -287,6 +287,9 @@ describe.skipIf(!hasPg)("CACHE-2 存储(真实 PG)", () => { const obs = d.cleanup.find((c) => c.target === "observation")!; expect(obs).toMatchObject({ jobId: "storage.retention.observation", retention: "90 天" }); expect(d.cleanup.find((c) => c.target === "heartbeat")?.retention).toBe("24 小时"); + expect(d.cleanup.find((c) => c.target === "pipeline")).toMatchObject({ jobId: "storage.retention.pipeline" }); + // 语句统计:测试库没有预加载 pg_stat_statements 时给出原因,不报错 + if (!d.db.statements.enabled) expect(d.db.statements.note).toMatch(/pg_stat_statements/); expect(d.level).toBe("ok"); }); diff --git a/apps/web/app/components/status/storage-section.tsx b/apps/web/app/components/status/storage-section.tsx index e73bbd23..38a39d42 100644 --- a/apps/web/app/components/status/storage-section.tsx +++ b/apps/web/app/components/status/storage-section.tsx @@ -200,6 +200,72 @@ function CleanupTable({ rows }: { rows: CleanupRow[] }) { } /** 运行状态页的「存储」节 */ +type Statements = SystemStorage["db"]["statements"]; +type StatementRow = Statements["slowest"][number]; + +const fmtMs = (ms: number) => + ms >= 1000 ? `${(ms / 1000).toFixed(1)} 秒` : `${ms < 10 ? ms.toFixed(2) : Math.round(ms)} 毫秒`; + +function StatementTable({ rows }: { rows: StatementRow[] }) { + return ( + + rows={rows} + rowKey={(r) => r.query} + empty={{ title: "还没有统计" }} + columns={[ + { + key: "query", + header: "语句", + cell: (r) => ( + + {r.query} + + ), + }, + { + key: "calls", + header: "次数", + align: "right", + cell: (r) => {r.calls.toLocaleString("zh-CN")}, + }, + { + key: "mean", + header: "平均", + align: "right", + cell: (r) => {fmtMs(r.meanMs)}, + }, + { + key: "total", + header: "合计", + align: "right", + hideBelow: "sm", + cell: (r) => {fmtMs(r.totalMs)}, + }, + ]} + /> + ); +} + +/** 数据库语句统计(pg_stat_statements,见 docs/runbook/storage.md「数据库性能」) */ +function StatementsInfo({ st }: { st: Statements }) { + return ( + + {st.enabled ? ( +
+ + + + + + +
+ ) : ( +

语句统计未启用:{st.note}

+ )} +
+ ); +} + export function StorageSection({ health }: { health: SystemHealth }) { const q = useSystemStorage(); const s = q.data; @@ -305,6 +371,17 @@ export function StorageSection({ health }: { health: SystemHealth }) { ), }, + { + key: "dead", + header: "死元组", + align: "right", + hideBelow: "md", + cell: (t) => ( + + {t.deadRows === null ? "—" : t.deadRows.toLocaleString("zh-CN")} + + ), + }, ]} /> @@ -391,6 +468,8 @@ export function StorageSection({ health }: { health: SystemHealth }) { + + diff --git a/docker-compose.yml b/docker-compose.yml index c8835692..593e8843 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,14 @@ services: - maintenance_work_mem=${PG_MAINTENANCE_WORK_MEM:-256MB} - -c - max_wal_size=${PG_MAX_WAL_SIZE:-2GB} + # 库(约 200 MB)整个在 shared_buffers / 页缓存里,随机读不比顺序读贵多少;缺省 4 是机械盘的估值 + - -c + - random_page_cost=${PG_RANDOM_PAGE_COST:-1.1} + # 语句统计(运行状态页「数据库语句统计」);改了要重建 postgres 容器才生效,见 docs/runbook/storage.md + - -c + - shared_preload_libraries=pg_stat_statements + - -c + - pg_stat_statements.track=top mem_limit: ${PG_MEM_LIMIT:-3g} deploy: resources: diff --git a/packages/contracts/src/ops.ts b/packages/contracts/src/ops.ts index 4e857683..b703f4dd 100644 --- a/packages/contracts/src/ops.ts +++ b/packages/contracts/src/ops.ts @@ -556,6 +556,16 @@ export const StorageBudgetUsage = z.object({ }); export type StorageBudgetUsage = z.infer; +/** 一条语句的累计统计(pg_stat_statements,参数已归一化为 $n) */ +export const StatementStat = z.object({ + query: z.string(), + calls: NonNegInt, + totalMs: z.number().nonnegative(), + meanMs: z.number().nonnegative(), + rows: NonNegInt, +}); +export type StatementStat = z.infer; + export const SystemStorage = z.object({ /** 各项最差档(unknown 不参与) */ level: HealthLevel, @@ -578,6 +588,17 @@ export const SystemStorage = z.object({ lastAnalyzeAt: Timestamp.nullable(), }), ), + /** + * 语句统计(pg_stat_statements):enabled=false 时 note 说明原因(没装扩展 / postgres 没预加载); + * slowest 按平均耗时(调用 ≥ 10 次),frequent 按调用次数,各前 5;since 为统计起点(上次重置) + */ + statements: z.object({ + enabled: z.boolean(), + note: z.string().nullable(), + since: Timestamp.nullable(), + slowest: z.array(StatementStat), + frequent: z.array(StatementStat), + }), }), /** PG kv_cache 各 region(条数含未清理的过期行) */ kvCache: z.array(z.object({ region: z.string(), entries: NonNegInt, expired: NonNegInt, bytes: NonNegInt })),