feat(status): 启用 pg_stat_statements,运行状态页显示最慢 / 最频繁语句与各表死元组;PG 调低 random_page_cost

- docker-compose.yml 的 postgres 加 shared_preload_libraries=pg_stat_statements、pg_stat_statements.track=top;
  要重建 postgres 容器才生效(docs/runbook/storage.md)
- GET /system/storage 的 db.statements:平均最慢(调用 ≥ 10 次)与调用最多各 5 条;没装扩展 / 没预加载时
  enabled=false 并说明原因
- 运行状态页「存储」:体积最大的表加死元组列,新增「数据库语句统计」
- random_page_cost 4 → 1.1(PG_RANDOM_PAGE_COST):库整个在内存里,缺省值高估随机读

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 20:24:34 +08:00
1 parent 8c8b252667
commit 63bbcf3cdb
5 files changed
+163 -1

No files matched your search

+52 -1
View File
@@ -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<DB>): Promise<Statements> {
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<Row>`
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<SystemStorage> {
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<string, string | null>();
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 },