forked from wangziqi/gongxue-base
chore: add launch readiness smoke and tuning evidence
This commit is contained in:
259
scripts/postgres-tuning-evidence.js
Normal file
259
scripts/postgres-tuning-evidence.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const outputDir = process.env.PG_TUNING_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||||
|
||||
const settingNames = [
|
||||
'max_connections',
|
||||
'shared_buffers',
|
||||
'effective_cache_size',
|
||||
'work_mem',
|
||||
'maintenance_work_mem',
|
||||
'autovacuum_work_mem',
|
||||
'wal_buffers',
|
||||
'min_wal_size',
|
||||
'max_wal_size',
|
||||
'checkpoint_timeout',
|
||||
'checkpoint_completion_target',
|
||||
'effective_io_concurrency',
|
||||
'random_page_cost',
|
||||
'jit',
|
||||
'log_min_duration_statement',
|
||||
'idle_in_transaction_session_timeout',
|
||||
'statement_timeout',
|
||||
'lock_timeout',
|
||||
];
|
||||
|
||||
function shanghaiStamp(date = new Date()) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date).map(part => [part.type, part.value]),
|
||||
);
|
||||
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
|
||||
}
|
||||
|
||||
async function query(pool, sql, params = []) {
|
||||
const result = await pool.query(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function maybeQuery(pool, sql, params = []) {
|
||||
try {
|
||||
return await query(pool, sql, params);
|
||||
} catch (error) {
|
||||
return { unavailable: true, message: error?.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function redactUrl(value) {
|
||||
return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@');
|
||||
}
|
||||
|
||||
function explainSetting(setting) {
|
||||
const value = `${setting.setting}${setting.unit || ''}`;
|
||||
return {
|
||||
name: setting.name,
|
||||
value,
|
||||
source: setting.source,
|
||||
pendingRestart: setting.pending_restart,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluate(settingsRows) {
|
||||
const byName = new Map(settingsRows.map(row => [row.name, row]));
|
||||
const warnings = [];
|
||||
const maxConnections = Number(byName.get('max_connections')?.setting || 0);
|
||||
const jit = String(byName.get('jit')?.setting || '').toLowerCase();
|
||||
const statementTimeout = String(byName.get('statement_timeout')?.setting || '');
|
||||
const idleTimeout = String(byName.get('idle_in_transaction_session_timeout')?.setting || '');
|
||||
const lockTimeout = String(byName.get('lock_timeout')?.setting || '');
|
||||
|
||||
if (maxConnections > 150) warnings.push('max_connections is high for a 4 vCPU database; prefer API/pooler limits over direct connections.');
|
||||
if (jit === 'on') warnings.push('jit is on; short OLTP-style question-bank API queries usually start safer with jit=off.');
|
||||
if (statementTimeout === '0') warnings.push('statement_timeout is disabled; production API should have a bounded global timeout and import jobs should override per session.');
|
||||
if (idleTimeout === '0') warnings.push('idle_in_transaction_session_timeout is disabled; long idle transactions can block migrations and writes.');
|
||||
if (lockTimeout === '0') warnings.push('lock_timeout is disabled; ordinary API requests may wait too long behind locks.');
|
||||
if (settingsRows.some(row => row.pending_restart)) warnings.push('Some PostgreSQL settings have pending_restart=true; restart is required before capacity testing.');
|
||||
|
||||
return {
|
||||
status: warnings.length ? 'warn' : 'pass',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function markdown(report) {
|
||||
const lines = [
|
||||
'# PostgreSQL 调参与运行证据',
|
||||
'',
|
||||
`生成时间:${new Date(report.generatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||
'',
|
||||
`数据库:${report.databaseUrl}`,
|
||||
'',
|
||||
`评估:${report.evaluation.status}`,
|
||||
'',
|
||||
];
|
||||
if (report.evaluation.warnings.length) {
|
||||
lines.push('## 警告');
|
||||
lines.push('');
|
||||
for (const warning of report.evaluation.warnings) lines.push(`- ${warning}`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('## 关键参数');
|
||||
lines.push('');
|
||||
lines.push('| 参数 | 当前值 | 来源 | 待重启 |');
|
||||
lines.push('| --- | ---: | --- | --- |');
|
||||
for (const item of report.settings) {
|
||||
lines.push(`| ${item.name} | ${item.value} | ${item.source} | ${item.pendingRestart ? '是' : '否'} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 连接与等待');
|
||||
lines.push('');
|
||||
lines.push('| state | wait_event_type | wait_event | count |');
|
||||
lines.push('| --- | --- | --- | ---: |');
|
||||
for (const item of report.activityByWait) {
|
||||
lines.push(`| ${item.state || '-'} | ${item.wait_event_type || '-'} | ${item.wait_event || '-'} | ${item.count} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 缓存与事务');
|
||||
lines.push('');
|
||||
lines.push('| datname | commits | rollbacks | cache_hit_ratio |');
|
||||
lines.push('| --- | ---: | ---: | ---: |');
|
||||
for (const item of report.databaseStats) {
|
||||
lines.push(`| ${item.datname} | ${item.xact_commit} | ${item.xact_rollback} | ${item.cache_hit_ratio ?? '-'} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 大表规模');
|
||||
lines.push('');
|
||||
lines.push('| 表 | 估算行数 | 总大小 | 索引大小 |');
|
||||
lines.push('| --- | ---: | ---: | ---: |');
|
||||
for (const item of report.largeRelations) {
|
||||
lines.push(`| ${item.relation} | ${item.estimated_rows} | ${item.total_size} | ${item.index_size} |`);
|
||||
}
|
||||
lines.push('');
|
||||
if (Array.isArray(report.pgStatStatements)) {
|
||||
lines.push('## pg_stat_statements Top SQL');
|
||||
lines.push('');
|
||||
lines.push('| calls | total_exec_ms | mean_exec_ms | rows | query |');
|
||||
lines.push('| ---: | ---: | ---: | ---: | --- |');
|
||||
for (const item of report.pgStatStatements) {
|
||||
lines.push(`| ${item.calls} | ${item.total_exec_ms} | ${item.mean_exec_ms} | ${item.rows} | ${String(item.query || '').replaceAll('|', '\\|')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('## pg_stat_statements');
|
||||
lines.push('');
|
||||
lines.push(`未采集:${report.pgStatStatements?.message || 'extension/view unavailable'}`);
|
||||
lines.push('');
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
|
||||
try {
|
||||
const settingsRaw = await query(
|
||||
pool,
|
||||
`
|
||||
select name, setting, unit, source, pending_restart
|
||||
from pg_settings
|
||||
where name = any($1::text[])
|
||||
order by array_position($1::text[], name)
|
||||
`,
|
||||
[settingNames],
|
||||
);
|
||||
const activityByWait = await query(
|
||||
pool,
|
||||
`
|
||||
select state, wait_event_type, wait_event, count(*)::int as count
|
||||
from pg_stat_activity
|
||||
where datname = current_database()
|
||||
group by state, wait_event_type, wait_event
|
||||
order by count desc, state nulls last
|
||||
`,
|
||||
);
|
||||
const databaseStats = await query(
|
||||
pool,
|
||||
`
|
||||
select datname,
|
||||
xact_commit::text,
|
||||
xact_rollback::text,
|
||||
blks_read::text,
|
||||
blks_hit::text,
|
||||
round(blks_hit * 100.0 / nullif(blks_hit + blks_read, 0), 2)::text as cache_hit_ratio
|
||||
from pg_stat_database
|
||||
where datname = current_database()
|
||||
`,
|
||||
);
|
||||
const bgwriter = await maybeQuery(
|
||||
pool,
|
||||
`
|
||||
select checkpoints_timed::text, checkpoints_req::text,
|
||||
checkpoint_write_time::text, checkpoint_sync_time::text
|
||||
from pg_stat_bgwriter
|
||||
`,
|
||||
);
|
||||
const largeRelations = await query(
|
||||
pool,
|
||||
`
|
||||
select relid::regclass::text as relation,
|
||||
n_live_tup::bigint::text as estimated_rows,
|
||||
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
|
||||
pg_size_pretty(pg_indexes_size(relid)) as index_size
|
||||
from pg_stat_user_tables
|
||||
where schemaname = 'public'
|
||||
order by pg_total_relation_size(relid) desc
|
||||
limit 20
|
||||
`,
|
||||
);
|
||||
const pgStatStatements = await maybeQuery(
|
||||
pool,
|
||||
`
|
||||
select calls::bigint::text,
|
||||
round(total_exec_time::numeric, 2)::text as total_exec_ms,
|
||||
round(mean_exec_time::numeric, 2)::text as mean_exec_ms,
|
||||
rows::bigint::text,
|
||||
left(regexp_replace(query, '\\s+', ' ', 'g'), 180) as query
|
||||
from pg_stat_statements
|
||||
order by total_exec_time desc
|
||||
limit 10
|
||||
`,
|
||||
);
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
databaseUrl: redactUrl(databaseUrl),
|
||||
evaluation: evaluate(settingsRaw),
|
||||
settings: settingsRaw.map(explainSetting),
|
||||
activityByWait,
|
||||
databaseStats,
|
||||
bgwriter,
|
||||
largeRelations,
|
||||
pgStatStatements,
|
||||
};
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`);
|
||||
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
||||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
await fs.writeFile(mdPath, markdown(report), 'utf8');
|
||||
console.log(`[pg-evidence] status=${report.evaluation.status}`);
|
||||
for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`);
|
||||
console.log(`[pg-evidence] wrote ${jsonPath}`);
|
||||
console.log(`[pg-evidence] wrote ${mdPath}`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user