Files
trade/data/db/pg.ts
T
Rekey e91cad79e6 feat(data): 实现配置表 CRUD 与 Schema 初始化拆分
- 新增 data/db/ 数据库访问层:pool 管理、类型定义、Zod 校验、参数化 SQL 查询
- 新增 data/db/config-crud.ts:MonitoredSymbolsRepo / ExchangeConfigRepo / AppConfigRepo 三个 CRUD 服务类
- 新增 data/config.ts:中心化配置模块,零依赖 .env 解析 + Zod 校验
- 新增 data/schema/:klines.sql + config.sql 参考 DDL
- 新增 data/exchanges/:交易所类型定义与 Binance WebSocket 封装
- 新增 data/run/:交易所连接启动入口
- 重构 data/init-db/:001_init.sql 仅保留 TimescaleDB + klines,配置表拆分至 002_config.sql
- 更新 docker-compose.yml:挂载 init-db 初始化脚本
2026-06-07 20:46:35 +08:00

365 lines
11 KiB
TypeScript
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.
// ============================================================
// db/pg.ts — PostgreSQL / TimescaleDB 连接池管理
// ============================================================
// 职责:
// 1. 基于 config.ts 的 pgsql 配置创建 pg.Pool 单例
// 2. 连接生命周期事件监听(connect / acquire / remove / error
// 3. 提供健康检查(healthCheck
// 4. 提供事务辅助函数(withTransaction
// 5. 进程退出时优雅关闭(SIGTERM / SIGINT
//
// 使用方式:
// import { pool } from "./db"; // 通过 index.ts 统一导出
// import { healthCheck } from "./db";
// const { rows } = await pool.query("SELECT NOW()");
// ============================================================
import pg from "pg";
import { readFileSync, readdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { pgsql } from "../config";
// ============================================================
// 1. 连接池创建(单例)
// ============================================================
/**
* pg.Pool 单例。
* 配置来源于 config.ts → pgsql,已包含连接数上限、超时等参数。
*
* pg.Pool 内部使用懒连接——首次 query 时才建立连接,
* 因此模块加载时不会立即连接数据库。
*/
export const pool = new pg.Pool({
host: pgsql.host,
port: pgsql.port,
database: pgsql.database,
user: pgsql.user,
password: pgsql.password,
max: pgsql.max,
idleTimeoutMillis: pgsql.idleTimeoutMillis,
connectionTimeoutMillis: pgsql.connectionTimeoutMillis,
// TimescaleDB 特有的超时设置:分析查询可能较慢
statement_timeout: 30000, // 单条 SQL 最大执行 30s
// application_name 便于在 pg_stat_activity 中识别
application_name: "trade-data",
});
// ============================================================
// 2. 连接池事件监听(可观测性)
// ============================================================
/** 新客户端连接建立 */
pool.on("connect", (client) => {
// 为每个连接设置 TimescaleDB 优化参数
// 跳过 WAL 日志可加速批量写入(仅在可接受丢失最近几秒数据的场景)
// client.query("SET timescaledb.enable_skip_scan = ON");
console.log(`[pg] 新连接建立 (total: ${pool.totalCount}, idle: ${pool.idleCount})`);
});
/** 从池中获取连接 */
pool.on("acquire", () => {
// 连接池耗尽时会频繁触发,可在此记录高负载信号
});
/** 连接归还池 */
pool.on("remove", () => {
console.log(`[pg] 连接关闭 (total: ${pool.totalCount}, idle: ${pool.idleCount})`);
});
/**
* 空闲客户端出错(如网络中断、PG 重启)。
* pg.Pool 会自动移除问题连接并创建新连接,此处仅记录日志。
*/
pool.on("error", (err: Error) => {
console.error(`[pg] 连接池错误: ${err.message}`);
// 不退出进程——连接池会自动恢复
});
// ============================================================
// 3. 健康检查
// ============================================================
/**
* 数据库连通性检查。
* 执行轻量查询 `SELECT 1`,超时 5 秒。
*
* @returns true 表示数据库可达
*/
export async function healthCheck(): Promise<boolean> {
try {
const client = await pool.connect();
try {
await client.query("SELECT 1");
return true;
} finally {
client.release();
}
} catch {
return false;
}
}
/**
* 深度健康检查:验证 TimescaleDB 扩展是否已安装。
* 仅在首次连接或定期巡检时调用。
*
* @returns TimescaleDB 版本字符串,未安装则返回 null
*/
export async function timescaleVersion(): Promise<string | null> {
try {
const { rows } = await pool.query<{ extversion: string }>(
"SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'",
);
return rows[0]?.extversion ?? null;
} catch {
return null;
}
}
// ============================================================
// 4. 事务辅助
// ============================================================
/**
* 在单连接上执行事务。
* 自动 BEGIN / COMMIT / ROLLBACK,连接用完即释放。
*
* @param fn - 事务体,接收 pg.PoolClient,返回 Promise<T>
* @returns fn 的返回值
* @throws 事务内任何异常都会触发 ROLLBACK 并向上抛出
*
* @example
* const result = await withTransaction(async (client) => {
* await client.query("INSERT INTO ...");
* await client.query("UPDATE ...");
* return { success: true };
* });
*/
export async function withTransaction<T>(
fn: (client: pg.PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// ============================================================
// 5. 优雅关闭
// ============================================================
/** 是否正在关闭(防止重复调用) */
let shuttingDown = false;
/**
* 关闭连接池。
* 先等待所有进行中的查询完成(drain),再断开所有连接。
*
* 应在进程退出前调用:SIGTERM / SIGINT 处理器中。
*/
export async function closePool(): Promise<void> {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log("[pg] 正在关闭连接池...");
// pool.end() 等待所有活跃查询完成后关闭
await pool.end();
console.log("[pg] 连接池已关闭");
}
/**
* 注册进程信号处理器——优雅关闭。
* 在应用入口调用一次即可。
*/
export function registerShutdownHandlers(): void {
const shutdown = async (signal: string) => {
console.log(`[pg] 收到 ${signal} 信号,开始优雅关闭...`);
await closePool();
process.exit(0);
};
process.once("SIGTERM", () => shutdown("SIGTERM"));
process.once("SIGINT", () => shutdown("SIGINT"));
}
// ============================================================
// 6. Schema 初始化(基于 data/schema/*.sql
// ============================================================
/**
* 将 SQL 文本按语句拆分为独立命令。
* 规则:
* - 按 `;` 分割
* - 跳过空行和纯注释行
* - 单个语句去除首尾空白后若为空则跳过
*
* 注意:此方法假设 SQL 中 `;` 仅作为语句分隔符,
* 不含存储过程/函数体内的 `;`(schema/*.sql 满足此条件)。
*/
function splitSQLStatements(sql: string): string[] {
const statements: string[] = [];
for (const raw of sql.split(";")) {
const trimmed = raw.trim();
// 跳过空语句
if (trimmed === "") {
continue;
}
// 跳过仅包含注释的行(已在上层过滤,此处兜底)
statements.push(trimmed);
}
return statements;
}
/**
* 从单个 .sql 文件初始化 Schema。
* 读取文件 → 拆分语句 → 逐条执行(同一连接,保证顺序)。
*
* 所有 SQL 均使用 IF NOT EXISTS / ON CONFLICT DO NOTHING
* 因此重复执行安全幂等。
*
* @param filePath - SQL 文件的绝对路径
* @returns 成功执行的语句数
*/
export async function initSchemaFromFile(filePath: string): Promise<number> {
const sql = readFileSync(filePath, "utf-8");
// 预处理:移除纯注释行(以 -- 开头),减少无效语句
const lines = sql
.split("\n")
.filter((line: string) => {
const trimmed = line.trim();
return trimmed !== "" && !trimmed.startsWith("--");
})
.join("\n");
const statements = splitSQLStatements(lines);
if (statements.length === 0) {
return 0;
}
let executed = 0;
// 使用单连接执行所有语句,保证 DDL 顺序(如先建表再建索引)
const client = await pool.connect();
try {
for (const stmt of statements) {
await client.query(stmt);
executed++;
}
} finally {
client.release();
}
return executed;
}
/**
* 从 data/schema/ 目录初始化所有表结构。
*
* 执行顺序(按文件名排序):
* 1. klines.sql — K 线主表(hypertable)、索引、压缩、连续聚合
* 2. config.sql — 配置表(monitored_symbols / exchange_config / app_config+ 预置数据
*
* Docker Compose 首次启动时,001_init.sql 已被自动执行。
* 此函数作为补充,确保以下场景:
* - 裸 PostgreSQL 安装(非 Docker 部署)
* - 版本升级时需要新增表/索引
* - 开发环境快速重置 Schema
*
* @param schemaDir - schema 目录路径,默认 ../schema(相对于本文件)
* @returns 各文件执行结果摘要
*/
export async function initSchema(schemaDir?: string): Promise<{
files: string[];
totalStatements: number;
errors: string[];
}> {
// 计算 schema 目录绝对路径(ESM 中 __dirname 不可用)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dir = schemaDir ?? resolve(__dirname, "..", "schema");
const result = { files: [] as string[], totalStatements: 0, errors: [] as string[] };
// 读取目录,按文件名排序保证执行顺序(klines.sql 先于 config.sql
let entries: string[];
try {
entries = readdirSync(dir)
.filter((f: string) => f.endsWith(".sql"))
.sort(); // 字母序
} catch {
result.errors.push(`无法读取 schema 目录: ${dir}`);
return result;
}
// 手动排序:klines.sql 必须在 config.sql 之前(外键/引用依赖)
const klinesFirst = ["klines.sql", "config.sql"];
entries.sort((a, b) => {
const ia = klinesFirst.indexOf(a);
const ib = klinesFirst.indexOf(b);
if (ia !== -1 && ib !== -1) return ia - ib;
if (ia !== -1) return -1;
if (ib !== -1) return 1;
return a.localeCompare(b);
});
for (const entry of entries) {
const filePath = resolve(dir, entry);
try {
const count = await initSchemaFromFile(filePath);
result.files.push(`${entry} (${count} 条)`);
result.totalStatements += count;
} catch (err) {
const msg = `${entry}: ${(err as Error).message}`;
result.errors.push(msg);
console.error(`[pg] Schema 初始化失败 — ${msg}`);
}
}
if (result.errors.length === 0) {
console.log(
`[pg] Schema 初始化完成 — ${result.files.length} 个文件, ${result.totalStatements} 条语句`,
);
}
return result;
}
/**
* 快速判断核心表是否已存在(轻量级检查,不做全量 Schema 验证)。
* 仅在执行 initSchema() 前做一次探测,已存在则跳过。
*
* @returns true 表示 klines 和 monitored_symbols 表均已存在
*/
export async function isSchemaInitialized(): Promise<boolean> {
try {
const { rows } = await pool.query<{ count: string }>(`
SELECT COUNT(*)::TEXT AS count
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('klines', 'monitored_symbols', 'exchange_config', 'app_config')
`);
// 4 张核心表全部存在
return parseInt(rows[0]?.count ?? "0", 10) >= 4;
} catch {
return false;
}
}
// ============================================================
// 7. 默认导出(便于 import pool from "./db/pg"
// ============================================================
export default pool;