增加数据库慢查询观测与索引防线并收窄打手拼单分页查询
This commit is contained in:
@@ -20,6 +20,8 @@ DATABASE_MAX_CONNECTIONS=8
|
|||||||
DATABASE_IDLE_TIMEOUT_MS=30000
|
DATABASE_IDLE_TIMEOUT_MS=30000
|
||||||
DATABASE_CONNECTION_TIMEOUT_MS=5000
|
DATABASE_CONNECTION_TIMEOUT_MS=5000
|
||||||
DATABASE_STATEMENT_TIMEOUT_MS=15000
|
DATABASE_STATEMENT_TIMEOUT_MS=15000
|
||||||
|
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||||
|
POSTGRES_LOG_MIN_DURATION_STATEMENT_MS=500
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ DATABASE_MAX_CONNECTIONS=8
|
|||||||
DATABASE_IDLE_TIMEOUT_MS=30000
|
DATABASE_IDLE_TIMEOUT_MS=30000
|
||||||
DATABASE_CONNECTION_TIMEOUT_MS=5000
|
DATABASE_CONNECTION_TIMEOUT_MS=5000
|
||||||
DATABASE_STATEMENT_TIMEOUT_MS=15000
|
DATABASE_STATEMENT_TIMEOUT_MS=15000
|
||||||
|
# 慢查询告警阈值;超过该时长的 SQL 只记录指纹和耗时,不记录参数值
|
||||||
|
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||||
|
# PostgreSQL 原生慢查询日志阈值(毫秒);-1 关闭
|
||||||
|
POSTGRES_LOG_MIN_DURATION_STATEMENT_MS=500
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
"db:migrate:create": "tsx scripts/create-migration.ts",
|
"db:migrate:create": "tsx scripts/create-migration.ts",
|
||||||
"db:migrate:status": "tsx scripts/migration-status.ts",
|
"db:migrate:status": "tsx scripts/migration-status.ts",
|
||||||
|
"check:sql": "tsx scripts/check-sql-guard.ts",
|
||||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||||
"format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --write \"src/**/*.{ts,js,json}\" \"scripts/**/*.{ts,js}\" eslint.config.js package.json tsconfig.json tsconfig.build.json tsconfig.eslint.json",
|
"format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --write \"src/**/*.{ts,js,json}\" \"scripts/**/*.{ts,js}\" eslint.config.js package.json tsconfig.json tsconfig.build.json tsconfig.eslint.json",
|
||||||
"format:check": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"src/**/*.{ts,js,json}\" \"scripts/**/*.{ts,js}\" eslint.config.js package.json tsconfig.json tsconfig.build.json tsconfig.eslint.json",
|
"format:check": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"src/**/*.{ts,js,json}\" \"scripts/**/*.{ts,js}\" eslint.config.js package.json tsconfig.json tsconfig.build.json tsconfig.eslint.json",
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
"test:industry:gen": "tsx scripts/gen-test-cases.ts",
|
"test:industry:gen": "tsx scripts/gen-test-cases.ts",
|
||||||
"test:industry:curl": "tsx scripts/curl-send-callback.ts",
|
"test:industry:curl": "tsx scripts/curl-send-callback.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"check": "npm run format:check && npm run lint:check && npm run typecheck && npm test && npm run build",
|
"check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"start:src": "tsx src/index.ts",
|
"start:src": "tsx src/index.ts",
|
||||||
"mock:feifei": "tsx scripts/mock-feifei-claim.ts"
|
"mock:feifei": "tsx scripts/mock-feifei-claim.ts"
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 数据库 SQL 约束检查。
|
||||||
|
* 该脚本检查关键防线是否被后续重构移除,不尝试替代 EXPLAIN 和生产监控。
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const BACKEND_ROOT = path.resolve(SCRIPT_DIR, '..')
|
||||||
|
|
||||||
|
const checks: Array<{ name: string; file: string; patterns: string[] }> = [
|
||||||
|
{
|
||||||
|
name: '慢查询阈值配置',
|
||||||
|
file: path.join(BACKEND_ROOT, 'src/config/env-overrides.ts'),
|
||||||
|
patterns: ['DATABASE_SLOW_QUERY_THRESHOLD_MS', 'slowQueryThresholdMs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '慢查询日志与参数脱敏',
|
||||||
|
file: path.join(BACKEND_ROOT, 'src/db/client.ts'),
|
||||||
|
patterns: ['logSlowQuery', 'fingerprintSql', '参数值始终不写入日志'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '高频 pending 查询索引',
|
||||||
|
file: path.join(BACKEND_ROOT, 'src/db/migrations/058_database_query_guardrails.sql'),
|
||||||
|
patterns: [
|
||||||
|
'idx_worker_cancel_requests_pending_worker_order',
|
||||||
|
'idx_worker_feedbacks_pending_worker_order',
|
||||||
|
'idx_work_order_events_order_type_id',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '打手订单拼单查询限定当前页',
|
||||||
|
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-share-repo.ts'),
|
||||||
|
patterns: ['workOrderIds: number[]', 'wos.work_order_id = ANY($2::bigint[])'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures: string[] = []
|
||||||
|
for (const check of checks) {
|
||||||
|
if (!fs.existsSync(check.file)) {
|
||||||
|
failures.push(`${check.name}: 文件不存在 ${check.file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const source = fs.readFileSync(check.file, 'utf8')
|
||||||
|
for (const pattern of check.patterns) {
|
||||||
|
if (!source.includes(pattern)) {
|
||||||
|
failures.push(`${check.name}: 缺少约束 ${pattern} (${check.file})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('[sql-guard] SQL 约束检查失败')
|
||||||
|
for (const failure of failures) console.error(`[sql-guard] ${failure}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
} else {
|
||||||
|
console.info('[sql-guard] SQL 约束检查通过')
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
idleTimeoutMs: 30_000,
|
idleTimeoutMs: 30_000,
|
||||||
connectionTimeoutMs: 5_000,
|
connectionTimeoutMs: 5_000,
|
||||||
statementTimeoutMs: 15_000,
|
statementTimeoutMs: 15_000,
|
||||||
|
slowQueryThresholdMs: 500,
|
||||||
},
|
},
|
||||||
|
|
||||||
storage: {
|
storage: {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
|||||||
'MINIO_ROOT_USER',
|
'MINIO_ROOT_USER',
|
||||||
'NPM_CONFIG_REGISTRY',
|
'NPM_CONFIG_REGISTRY',
|
||||||
'POSTGRES_DB',
|
'POSTGRES_DB',
|
||||||
|
'POSTGRES_LOG_MIN_DURATION_STATEMENT_MS',
|
||||||
'POSTGRES_PASSWORD',
|
'POSTGRES_PASSWORD',
|
||||||
'POSTGRES_PORT',
|
'POSTGRES_PORT',
|
||||||
'POSTGRES_USER',
|
'POSTGRES_USER',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
integerEnv('DATABASE_IDLE_TIMEOUT_MS', ['database', 'idleTimeoutMs']),
|
integerEnv('DATABASE_IDLE_TIMEOUT_MS', ['database', 'idleTimeoutMs']),
|
||||||
integerEnv('DATABASE_CONNECTION_TIMEOUT_MS', ['database', 'connectionTimeoutMs']),
|
integerEnv('DATABASE_CONNECTION_TIMEOUT_MS', ['database', 'connectionTimeoutMs']),
|
||||||
integerEnv('DATABASE_STATEMENT_TIMEOUT_MS', ['database', 'statementTimeoutMs']),
|
integerEnv('DATABASE_STATEMENT_TIMEOUT_MS', ['database', 'statementTimeoutMs']),
|
||||||
|
integerEnv('DATABASE_SLOW_QUERY_THRESHOLD_MS', ['database', 'slowQueryThresholdMs']),
|
||||||
stringEnv('STORAGE_ENDPOINT', ['storage', 'endpoint']),
|
stringEnv('STORAGE_ENDPOINT', ['storage', 'endpoint']),
|
||||||
stringEnv('STORAGE_BUCKET', ['storage', 'bucket']),
|
stringEnv('STORAGE_BUCKET', ['storage', 'bucket']),
|
||||||
stringEnv('STORAGE_ACCESS_KEY_ID', ['storage', 'accessKeyId']),
|
stringEnv('STORAGE_ACCESS_KEY_ID', ['storage', 'accessKeyId']),
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export function validateRuntimeConfig(
|
|||||||
min: 1,
|
min: 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
requireOptionalInteger(
|
||||||
|
issues,
|
||||||
|
'database.slowQueryThresholdMs',
|
||||||
|
config.database?.slowQueryThresholdMs,
|
||||||
|
{ min: 0 },
|
||||||
|
)
|
||||||
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
|
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 100,
|
max: 100,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Pool } from 'pg'
|
|||||||
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
|
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
|
||||||
|
|
||||||
import { runtimeConfig } from '../config/runtime.js'
|
import { runtimeConfig } from '../config/runtime.js'
|
||||||
|
import { logWarn } from '../utils/logger.js'
|
||||||
|
|
||||||
let poolInstance: Pool | null = null
|
let poolInstance: Pool | null = null
|
||||||
|
|
||||||
export function getDb(): Pool {
|
export function getDb(): Pool {
|
||||||
if (!poolInstance) {
|
if (!poolInstance) {
|
||||||
poolInstance = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: String(runtimeConfig.database?.url || '').trim(),
|
connectionString: String(runtimeConfig.database?.url || '').trim(),
|
||||||
ssl: runtimeConfig.database?.ssl ? { rejectUnauthorized: false } : false,
|
ssl: runtimeConfig.database?.ssl ? { rejectUnauthorized: false } : false,
|
||||||
max: Number(runtimeConfig.database?.maxConnections || 10),
|
max: Number(runtimeConfig.database?.maxConnections || 10),
|
||||||
@@ -15,6 +16,12 @@ export function getDb(): Pool {
|
|||||||
connectionTimeoutMillis: Number(runtimeConfig.database?.connectionTimeoutMs || 5_000),
|
connectionTimeoutMillis: Number(runtimeConfig.database?.connectionTimeoutMs || 5_000),
|
||||||
statement_timeout: Number(runtimeConfig.database?.statementTimeoutMs || 15_000),
|
statement_timeout: Number(runtimeConfig.database?.statementTimeoutMs || 15_000),
|
||||||
})
|
})
|
||||||
|
pool.on('error', (error) => {
|
||||||
|
void logWarn('[db/pool]', '数据库连接池出现空闲连接错误', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
poolInstance = pool
|
||||||
}
|
}
|
||||||
|
|
||||||
return poolInstance
|
return poolInstance
|
||||||
@@ -30,7 +37,40 @@ export async function query<T extends QueryResultRow>(
|
|||||||
params: unknown[] = [],
|
params: unknown[] = [],
|
||||||
): Promise<QueryResult<T>> {
|
): Promise<QueryResult<T>> {
|
||||||
const pool = getDb()
|
const pool = getDb()
|
||||||
return pool.query<T>(text, params)
|
const startedAt = process.hrtime.bigint()
|
||||||
|
try {
|
||||||
|
const result = await pool.query<T>(text, params)
|
||||||
|
logSlowQuery(text, startedAt, result.rowCount ?? 0)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
logSlowQuery(text, startedAt, 0, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSlowQuery(text: string, startedAt: bigint, rowCount: number, error?: unknown) {
|
||||||
|
const thresholdMs = Number(runtimeConfig.database?.slowQueryThresholdMs ?? 500)
|
||||||
|
if (thresholdMs <= 0) return
|
||||||
|
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
||||||
|
if (durationMs < thresholdMs && !error) return
|
||||||
|
void logWarn('[db/query]', error ? '数据库 SQL 执行失败' : '数据库慢查询', {
|
||||||
|
durationMs: Math.round(durationMs * 100) / 100,
|
||||||
|
thresholdMs,
|
||||||
|
rowCount,
|
||||||
|
sql: fingerprintSql(text),
|
||||||
|
error: error instanceof Error ? error.message : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只保留可聚合的 SQL 形状,参数值始终不写入日志。 */
|
||||||
|
function fingerprintSql(text: string) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/--[^\r\n]*/g, ' ')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||||
|
.replace(/\$\d+/g, '?')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
export async function withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- 数据库可观测性与高频查询索引保护。
|
||||||
|
-- PostgreSQL 容器通过 shared_preload_libraries 预加载;托管数据库若无权限,跳过扩展创建并由平台侧开启。
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN insufficient_privilege OR undefined_file THEN
|
||||||
|
RAISE NOTICE '无法创建 pg_stat_statements,请在数据库平台侧启用该扩展';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 打手订单状态计算中的 pending 撤单/反馈 EXISTS。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_cancel_requests_pending_worker_order
|
||||||
|
ON worker_order_cancel_requests(worker_id, work_order_id)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_feedbacks_pending_worker_order
|
||||||
|
ON worker_order_feedbacks(worker_id, work_order_id)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- 工单公共详情和资料更新角标按工单、事件类型取最新事件。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_events_order_type_id
|
||||||
|
ON work_order_events(work_order_id, event_type, id DESC);
|
||||||
|
|
||||||
|
-- 排行榜按打手和验收时间筛选;全部周期仍需汇总表或缓存,索引不能替代聚合。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_orders_accepted_worker_at
|
||||||
|
ON work_orders(assigned_worker_id, accepted_at DESC)
|
||||||
|
WHERE status = 'accepted' AND assigned_worker_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_accepted_worker_at
|
||||||
|
ON work_order_shares(worker_id, accepted_at DESC)
|
||||||
|
WHERE status = 'accepted';
|
||||||
@@ -46,12 +46,16 @@ export async function listWorkOrderSharesByOrderIds(
|
|||||||
|
|
||||||
export async function listWorkerSharesByWorker(
|
export async function listWorkerSharesByWorker(
|
||||||
workerId: number | string,
|
workerId: number | string,
|
||||||
|
workOrderIds: number[],
|
||||||
): Promise<WorkOrderShareRow[]> {
|
): Promise<WorkOrderShareRow[]> {
|
||||||
|
if (workOrderIds.length === 0) return []
|
||||||
const result = await query<WorkOrderShareRow>(
|
const result = await query<WorkOrderShareRow>(
|
||||||
`${WORK_ORDER_SHARE_SELECT}
|
`${WORK_ORDER_SHARE_SELECT}
|
||||||
WHERE wos.worker_id = $1 AND wos.status != 'cancelled'
|
WHERE wos.worker_id = $1
|
||||||
|
AND wos.work_order_id = ANY($2::bigint[])
|
||||||
|
AND wos.status != 'cancelled'
|
||||||
ORDER BY wos.id DESC`,
|
ORDER BY wos.id DESC`,
|
||||||
[Number(workerId)],
|
[Number(workerId), workOrderIds],
|
||||||
)
|
)
|
||||||
return result.rows
|
return result.rows
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
|||||||
sort: 'worker_claimed_at_desc',
|
sort: 'worker_claimed_at_desc',
|
||||||
})
|
})
|
||||||
const permissions = resolveWorkerPermissions(worker)
|
const permissions = resolveWorkerPermissions(worker)
|
||||||
const myShares = await listWorkerSharesByWorker(worker.id)
|
|
||||||
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
|
|
||||||
const workOrderIds = items.map((item) => Number(item.id))
|
const workOrderIds = items.map((item) => Number(item.id))
|
||||||
|
const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
|
||||||
|
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
|
||||||
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] =
|
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
listWorkerWorkOrderNotes(worker.id, workOrderIds),
|
listWorkerWorkOrderNotes(worker.id, workOrderIds),
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export type RuntimeConfig = {
|
|||||||
idleTimeoutMs?: number
|
idleTimeoutMs?: number
|
||||||
connectionTimeoutMs?: number
|
connectionTimeoutMs?: number
|
||||||
statementTimeoutMs?: number
|
statementTimeoutMs?: number
|
||||||
|
/** 超过该时长的 SQL 记录慢查询告警;0 表示关闭。 */
|
||||||
|
slowQueryThresholdMs?: number
|
||||||
}
|
}
|
||||||
storage: {
|
storage: {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ x-json-log-rotation: &json-log-rotation
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: docker.m.daocloud.io/library/postgres:16-bookworm
|
image: docker.m.daocloud.io/library/postgres:16-bookworm
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"postgres",
|
||||||
|
"-c",
|
||||||
|
"shared_preload_libraries=pg_stat_statements",
|
||||||
|
"-c",
|
||||||
|
"pg_stat_statements.track=all",
|
||||||
|
"-c",
|
||||||
|
"log_min_duration_statement=${POSTGRES_LOG_MIN_DURATION_STATEMENT_MS:-500}",
|
||||||
|
]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *json-log-rotation
|
logging: *json-log-rotation
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ x-json-log-rotation: &json-log-rotation
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: docker.m.daocloud.io/library/postgres:16-bookworm
|
image: docker.m.daocloud.io/library/postgres:16-bookworm
|
||||||
|
# 开启 pg_stat_statements,便于定位生产环境累计最耗时的 SQL。
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"postgres",
|
||||||
|
"-c",
|
||||||
|
"shared_preload_libraries=pg_stat_statements",
|
||||||
|
"-c",
|
||||||
|
"pg_stat_statements.track=all",
|
||||||
|
"-c",
|
||||||
|
"log_min_duration_statement=${POSTGRES_LOG_MIN_DURATION_STATEMENT_MS:-500}",
|
||||||
|
]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *json-log-rotation
|
logging: *json-log-rotation
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 数据库 SQL 约束与运维
|
||||||
|
|
||||||
|
## 提交前约束
|
||||||
|
|
||||||
|
新增或修改 SQL 必须满足:
|
||||||
|
|
||||||
|
1. 列表查询必须有明确的分页、最大数量或日期范围;禁止无界返回历史数据。
|
||||||
|
2. `GROUP BY`、`COUNT(DISTINCT)`、窗口函数和跨表聚合必须附带 `EXPLAIN (ANALYZE, BUFFERS)` 结果。
|
||||||
|
3. 高频接口禁止在公共 `SELECT` 中叠加未必要的 LATERAL、相关子查询或全表聚合。
|
||||||
|
4. 当前页关联数据必须按当前页 ID 批量查询,不得读取用户全部历史记录再在应用层过滤。
|
||||||
|
5. 新增索引必须通过数据库 migration 提交,并说明覆盖的查询条件;大表生产索引应评估锁影响。
|
||||||
|
6. SQL 必须使用参数绑定,禁止把请求参数直接拼接到 SQL 值中。
|
||||||
|
|
||||||
|
后端 `npm run check` 会执行 `npm run check:sql`,检查关键性能防线没有被重构移除。该检查不能替代真实数据量下的 `EXPLAIN`。
|
||||||
|
|
||||||
|
## 生产观测
|
||||||
|
|
||||||
|
生产 PostgreSQL 应启用 `pg_stat_statements` 和慢查询日志。Compose 默认通过 `POSTGRES_LOG_MIN_DURATION_STATEMENT_MS`(默认 500ms)配置 PostgreSQL 原生慢查询日志;应用默认将执行超过 `DATABASE_SLOW_QUERY_THRESHOLD_MS`(默认 500ms)的 SQL 记录为慢查询,只记录 SQL 形状、耗时和返回行数,不记录参数值。
|
||||||
|
|
||||||
|
常用排查 SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT calls, total_exec_time, mean_exec_time, rows, query
|
||||||
|
FROM pg_stat_statements
|
||||||
|
ORDER BY total_exec_time DESC
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
SELECT pid, state, wait_event_type, wait_event,
|
||||||
|
now() - query_start AS duration, query
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database() AND state <> 'idle'
|
||||||
|
ORDER BY query_start;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 容量边界
|
||||||
|
|
||||||
|
- `DATABASE_STATEMENT_TIMEOUT_MS` 防止单条 SQL 长时间占用连接。
|
||||||
|
- `DATABASE_MAX_CONNECTIONS` 按后端实例数总量评估;扩容后端实例时不能简单地每实例增加连接数。
|
||||||
|
- CPU 限额只能防止数据库拖垮整机,不能替代 SQL 优化。
|
||||||
|
- 数据量持续增长后,排行榜和历史统计应迁移到汇总表、缓存或只读副本,不应继续依赖在线全量聚合。
|
||||||
Reference in New Issue
Block a user