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); });