Files
gongxue-base/scripts/postgres-tuning-evidence.js

499 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
import {
defaultPostgresTuningProfile,
getPostgresTuningProfile,
getPostgresTuningSettingNames,
} from './lib/postgres-tuning-profile.js';
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';
function parseArgs(argv) {
const options = {
profile: process.env.PG_TUNING_PROFILE || defaultPostgresTuningProfile,
strict: process.env.PG_TUNING_STRICT === 'true',
printSql: false,
json: false,
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--strict') options.strict = true;
else if (arg === '--json') options.json = true;
else if (arg === '--print-sql') options.printSql = true;
else if (arg === '--profile') {
options.profile = argv[index + 1] || options.profile;
index += 1;
} else if (arg.startsWith('--profile=')) {
options.profile = arg.slice('--profile='.length);
}
}
options.profileDefinition = getPostgresTuningProfile(options.profile);
options.settingNames = getPostgresTuningSettingNames(options.profile);
return options;
}
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 pgUnitMultiplier(unit) {
const text = String(unit || '').trim().toLowerCase();
if (!text) return 1;
if (text === 'ms') return 1;
if (text === 's') return 1000;
if (text === 'min') return 60_000;
const memoryMatch = text.match(/^(\d+)?\s*(b|kb|mb|gb|tb)$/);
if (!memoryMatch) return 1;
const blockCount = Number(memoryMatch[1] || 1);
const unitName = memoryMatch[2];
const factors = {
b: 1,
kb: 1024,
mb: 1024 ** 2,
gb: 1024 ** 3,
tb: 1024 ** 4,
};
return blockCount * factors[unitName];
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const rounded = value >= 10 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
return `${rounded}${units[unitIndex]}`;
}
function formatDuration(ms) {
if (!Number.isFinite(ms)) return '';
if (ms === 0) return '0ms';
if (ms % 60_000 === 0) return `${ms / 60_000}min`;
if (ms % 1000 === 0) return `${ms / 1000}s`;
return `${ms}ms`;
}
function normalizedSettingValue(setting) {
const number = Number(setting.setting);
if (!Number.isFinite(number)) return `${setting.setting}${setting.unit || ''}`;
const unit = String(setting.unit || '').trim();
if (!unit) return String(setting.setting);
if (/^(?:\d+)?\s*(?:b|kb|mb|gb|tb)$/i.test(unit)) {
return formatBytes(number * pgUnitMultiplier(unit));
}
if (/^(?:ms|s|min)$/i.test(unit)) {
return formatDuration(number * pgUnitMultiplier(unit));
}
return `${setting.setting}${unit}`;
}
function explainSetting(setting) {
return {
name: setting.name,
value: normalizedSettingValue(setting),
rawSetting: setting.setting,
unit: setting.unit || null,
source: setting.source,
pendingRestart: setting.pending_restart,
};
}
function parseSettingNumber(setting) {
if (!setting) return Number.NaN;
const number = Number(setting.setting);
return Number.isFinite(number) ? number : Number.NaN;
}
function parseBytes(value) {
if (typeof value === 'number') return value;
const text = String(value || '').trim().toLowerCase();
if (!text) return Number.NaN;
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
if (!match) return Number.NaN;
const amount = Number(match[1]);
const unit = match[2] || 'b';
const factors = {
b: 1,
byte: 1,
bytes: 1,
kb: 1024,
mb: 1024 ** 2,
gb: 1024 ** 3,
tb: 1024 ** 4,
};
return amount * (factors[unit] || Number.NaN);
}
function parseDurationMs(value) {
if (typeof value === 'number') return value;
const text = String(value || '').trim().toLowerCase();
if (!text) return Number.NaN;
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
if (!match) return Number.NaN;
const amount = Number(match[1]);
const unit = match[2] || 'ms';
const factors = {
ms: 1,
s: 1000,
sec: 1000,
secs: 1000,
second: 1000,
seconds: 1000,
min: 60_000,
mins: 60_000,
minute: 60_000,
minutes: 60_000,
h: 3_600_000,
hour: 3_600_000,
hours: 3_600_000,
};
return amount * (factors[unit] || Number.NaN);
}
function comparableValue(setting, rule) {
if (!setting) return Number.NaN;
if (rule.type === 'bytes') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
if (rule.type === 'duration') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
return parseSettingNumber(setting);
}
function recommendedComparator(rule, key) {
if (rule.type === 'bytes') return parseBytes(rule[key]);
if (rule.type === 'duration') return parseDurationMs(rule[key]);
return Number(rule[key]);
}
function evaluateProfile(settingsRows, profile) {
const byName = new Map(settingsRows.map(row => [row.name, row]));
const failures = [];
const warnings = [];
for (const [name, rule] of Object.entries(profile.settings)) {
const setting = byName.get(name);
if (!setting) {
failures.push(`${name} is missing from pg_settings evidence.`);
continue;
}
if (setting.pending_restart) {
failures.push(`${name} has pending_restart=true; restart PostgreSQL before capacity testing.`);
}
if (rule.exact !== undefined) {
const actual = String(setting.setting || '').toLowerCase();
if (actual !== String(rule.exact).toLowerCase()) {
failures.push(`${name} should be ${rule.exact} for ${profile.label}, got ${setting.setting}.`);
}
if (String(setting.source || '').toLowerCase() === 'default') {
warnings.push(`${name} still comes from default; recommended ${profile.name} value is ${rule.recommended}.`);
}
continue;
}
if (rule.min !== undefined) {
const actual = comparableValue(setting, rule);
const expected = recommendedComparator(rule, 'min');
if (!Number.isFinite(actual) || actual < expected) {
failures.push(`${name} is below ${rule.min} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
}
}
if (rule.max !== undefined) {
const actual = comparableValue(setting, rule);
const expected = recommendedComparator(rule, 'max');
if (!Number.isFinite(actual) || actual > expected) {
failures.push(`${name} is above ${rule.max} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
}
}
if (rule.recommended !== undefined && String(setting.source || '').toLowerCase() === 'default') {
warnings.push(`${name} still comes from default; recommended ${profile.name} value is ${rule.recommended}.`);
}
}
return { failures, warnings };
}
function evaluate(settingsRows, profile, options, pgStatStatements) {
const byName = new Map(settingsRows.map(row => [row.name, row]));
const warnings = [];
const failures = [];
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.');
if (pgStatStatements?.unavailable) warnings.push('pg_stat_statements is unavailable; enable it before production capacity acceptance.');
const profileEvaluation = evaluateProfile(settingsRows, profile);
warnings.push(...profileEvaluation.warnings);
if (options.strict) {
failures.push(...profileEvaluation.failures);
if (pgStatStatements?.unavailable) failures.push('pg_stat_statements is unavailable in strict mode.');
}
return {
status: failures.length ? 'fail' : options.strict ? 'pass' : warnings.length ? 'warn' : 'pass',
profile: profile.name,
failures,
warnings,
};
}
function markdown(report) {
const lines = [
'# PostgreSQL 调参与运行证据',
'',
`生成时间:${new Date(report.generatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
'',
`数据库:${report.databaseUrl}`,
'',
`调参 profile${report.profile.label} (${report.profile.name})`,
'',
`评估:${report.evaluation.status}`,
'',
];
if (report.evaluation.failures.length) {
lines.push('## 阻断项');
lines.push('');
for (const failure of report.evaluation.failures) lines.push(`- ${failure}`);
lines.push('');
}
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`;
}
function sqlQuote(value) {
return String(value).replaceAll("'", "''");
}
function alterSystemSql(profile) {
const lines = [
`-- PostgreSQL tuning profile: ${profile.label}`,
'-- Review current values and take a snapshot before applying this file.',
'-- Run npm run perf:postgres:evidence -- --strict after reload/restart.',
'',
];
for (const [name, rule] of Object.entries(profile.settings)) {
lines.push(`alter system set ${name} = '${sqlQuote(rule.recommended)}';`);
}
lines.push('select pg_reload_conf();');
return `${lines.join('\n')}\n`;
}
async function main() {
const options = parseArgs(process.argv);
if (options.printSql) {
console.log(alterSystemSql(options.profileDefinition));
return;
}
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)
`,
[options.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),
profile: {
name: options.profileDefinition.name,
label: options.profileDefinition.label,
description: options.profileDefinition.description,
},
evaluation: evaluate(settingsRaw, options.profileDefinition, options, pgStatStatements),
settings: settingsRaw.map(explainSetting),
activityByWait,
databaseStats,
bgwriter,
largeRelations,
pgStatStatements,
};
const pendingRestart = settingsRaw.filter(row => row.pending_restart).length;
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');
const payload = {
status: report.evaluation.status,
profile: report.profile.name,
warnings: report.evaluation.warnings.length,
failures: report.evaluation.failures.length,
pgStatStatementsAvailable: !pgStatStatements?.unavailable,
pendingRestart,
jsonPath,
mdPath,
};
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(`[pg-evidence] status=${report.evaluation.status}`);
for (const failure of report.evaluation.failures) console.log(`[pg-evidence] failure: ${failure}`);
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}`);
}
if (options.strict && report.evaluation.failures.length > 0) process.exitCode = 1;
} finally {
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});