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:
1 parent
8c8b252667
commit
63bbcf3cdb
5 files changed
+163
-1
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<DataTable<StatementRow>
|
||||
rows={rows}
|
||||
rowKey={(r) => r.query}
|
||||
empty={{ title: "还没有统计" }}
|
||||
columns={[
|
||||
{
|
||||
key: "query",
|
||||
header: "语句",
|
||||
cell: (r) => (
|
||||
<span className="line-clamp-2 font-mono text-meta break-all" title={r.query}>
|
||||
{r.query}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "calls",
|
||||
header: "次数",
|
||||
align: "right",
|
||||
cell: (r) => <span className="tabular-nums">{r.calls.toLocaleString("zh-CN")}</span>,
|
||||
},
|
||||
{
|
||||
key: "mean",
|
||||
header: "平均",
|
||||
align: "right",
|
||||
cell: (r) => <span className="whitespace-nowrap tabular-nums">{fmtMs(r.meanMs)}</span>,
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
header: "合计",
|
||||
align: "right",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="whitespace-nowrap tabular-nums text-muted-foreground">{fmtMs(r.totalMs)}</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 数据库语句统计(pg_stat_statements,见 docs/runbook/storage.md「数据库性能」) */
|
||||
function StatementsInfo({ st }: { st: Statements }) {
|
||||
return (
|
||||
<TechInfo title="数据库语句统计" columns={1}>
|
||||
{st.enabled ? (
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Panel title="平均最慢" note={`调用 10 次以上${st.since ? ` · 统计自 ${formatDateTime(st.since)}` : ""}`}>
|
||||
<StatementTable rows={st.slowest} />
|
||||
</Panel>
|
||||
<Panel title="调用最多">
|
||||
<StatementTable rows={st.frequent} />
|
||||
</Panel>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">语句统计未启用:{st.note}</p>
|
||||
)}
|
||||
</TechInfo>
|
||||
);
|
||||
}
|
||||
|
||||
export function StorageSection({ health }: { health: SystemHealth }) {
|
||||
const q = useSystemStorage();
|
||||
const s = q.data;
|
||||
@@ -305,6 +371,17 @@ export function StorageSection({ health }: { health: SystemHealth }) {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dead",
|
||||
header: "死元组",
|
||||
align: "right",
|
||||
hideBelow: "md",
|
||||
cell: (t) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{t.deadRows === null ? "—" : t.deadRows.toLocaleString("zh-CN")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
@@ -391,6 +468,8 @@ export function StorageSection({ health }: { health: SystemHealth }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatementsInfo st={s.db.statements} />
|
||||
|
||||
<Panel title="清理任务" note="立即清理会把对应的维护任务排入执行,结果看运行记录">
|
||||
<CleanupTable rows={s.cleanup} />
|
||||
</Panel>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -556,6 +556,16 @@ export const StorageBudgetUsage = z.object({
|
||||
});
|
||||
export type StorageBudgetUsage = z.infer<typeof StorageBudgetUsage>;
|
||||
|
||||
/** 一条语句的累计统计(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<typeof StatementStat>;
|
||||
|
||||
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 })),
|
||||
|
||||
Reference in new issue
Block a user