数据库迁移这件事,说白了就是把旧表结构变成新表结构。听起来很简单,跑个 SQL 就行了。但当你把迁移任务交给编排工具去管的时候,事情就变得有趣了——尤其是当你的应用连接池正好卡在那个间隙里。
本文聚焦一个很实际的问题:当你用 Pulumi 去管理数据库迁移任务时,如何在编排层同时处理好 schema 变更和连接池耗尽之间的联动风险。这不是纸上谈兵,而是一个在真实生产环境中很容易踩坑的坑。
一、为什么数据库迁移不能只管 SQL
传统做法是写个迁移脚本,然后在 CI/CD 流水线里跑一下。SQL 跑完了,应用更新了,完事。但这里有个隐藏的大坑:
假设你的应用跑在一个微服务里,数据库连接池默认配了 20 个连接。现在你执行一条 ALTER TABLE 语句,这个操作需要拿行锁或者表锁。如果应用正好在读写这个表,那些连接就会被阻塞。20 个连接很快就全堵住了,应用侧的线程全部挂起等待数据库响应。
这时候,连接池彻底耗尽。新的请求进不来,服务直接挂了。最要命的是,如果你用的是 Flyway 或者 Liquibase 这类迁移工具,它也是通过数据库连接来执行的。连接池一满,迁移脚本自己也拿不到连接,整个流水线卡死。
这就是 schema 变更和连接池耗尽之间的联动关系。它们不是两个独立的问题,而是互相影响的。
二、Pulumi 编排层的核心思路
Pulumi 是一个基础设施即代码工具,用编程语言(TypeScript、Python、Go 等)来定义和管理基础设施资源。它的强项在于把基础设施的声明变成可编程的——你可以写条件判断、循环、错误处理,这些在传统基础设施工具里是做不到的。
把数据库迁移放进 Pulumi 管理,好处是你可以把 schema 变更和其他步骤串起来,形成一个有状态的编排流程。具体到本文的主题,核心思路是这样的:
把 schema 变更拆成多个阶段,而不是一个大 SQL。在每个阶段之间插入连接池健康检查。如果连接池出现异常,暂停后续阶段。连接池恢复正常后,再继续执行。
说白了,就是在 schema 变更和连接池之间加一个"缓冲带",让两个操作之间有一个安全的过渡期。
2.1 Pulumi 编程模型简介
在开始写代码之前,先简单说一下 Pulumi 的几个核心概念,后面示例里会用到。
Pulumi 里的一切都是 Resource(资源),每个资源都有类型和属性。资源之间可以建立依赖关系,比如一个数据库表依赖数据库实例先创建好。Pulumi 用 Output 来包裹异步的值,因为资源创建是异步的,你不能直接访问属性,得通过 Output.apply 来操作。
// Pulumi 基础示例:理解 Output 的用法
import * as pulumi from "@pulumi/pulumi";
// 创建一个配置项
const config = new pulumi.Config();
const dbHost = config.require("dbHost");
// 这里 dbHost 是一个 Output<string>,不是直接的字符串
// 要操作它的值,需要用 apply 方法
const displayHost = dbHost.apply((host) => {
// 这个回调函数在 host 值真正解析出来后执行
console.log(`数据库地址是: ${host}`);
return host;
});
// 导出资源,Pulumi 会自动跟踪依赖关系
export const myDbHost = displayHost;
Output 这个概念是关键。因为 Pulumi 的资源创建是异步的,你写代码的时候资源可能还没创建好,所以所有资源的属性都通过 Output 来访问。后面的迁移编排逻辑会大量用到 Output.apply 和 Output.all 这些方法。
三、示例:用 Pulumi 编排一次完整的迁移流程
下面用 TypeScript 写一个完整的 Pulumi 程序,演示如何编排数据库迁移,并在过程中监控连接池状态。
技术栈:Pulumi (TypeScript) + PostgreSQL
3.1 项目初始化
# 创建项目目录并进入
mkdir db-migration-orchestrator
cd db-migration-orchestrator
# 用 Pulumi 初始化 TypeScript 项目
pulumi new typescript
# 安装需要的依赖包
npm install @pulumi/pulumi @pulumi/postgresql
初始化之后,项目结构会包含 Pulumi.yaml、index.ts 等文件。
// Pulumi.yaml - 项目配置文件
{
"name": "db-migration-orchestrator",
"runtime": "nodejs"
}
3.2 数据库连接池健康检查模块
这是整个编排流程的核心模块,负责检查连接池状态。
import * as pulumi from "@pulumi/pulumi";
import { execSync } from "child_process";
// ========== 连接池健康检查结果类型 ==========
// 定义了健康检查返回的数据结构
interface PoolHealthResult {
healthy: boolean; // 连接池是否健康
activeConnections: number; // 当前活跃连接数
maxConnections: number; // 最大连接数
checkTime: string; // 检查时间戳
}
// ========== 基础连接池健康检查函数 ==========
// 这个函数通过查询 PostgreSQL 的 pg_stat_activity 表来获取连接池状态
// pg_stat_activity 是 PostgreSQL 内置的视图,记录了所有活跃的后端进程信息
async function checkConnectionPoolHealth(
host: string,
port: number,
user: string,
password: string,
db: string
): Promise<PoolHealthResult> {
// 查询当前数据库的活跃连接数
// 排除 idle in transaction 状态的连接(这些连接已经空闲了,不算活跃)
const query = `
SELECT count(*) as active_count
FROM pg_stat_activity
WHERE datname = '${db}'
AND state != 'idle'
AND state != 'idle in transaction';
`;
try {
// 使用 psql 命令行工具执行查询
// 这里假设运行环境安装了 PostgreSQL 客户端
const result = execSync(
`PGPASSWORD=${password} psql -h ${host} -p ${port} -U ${user} -d ${db} -t -c "${query}"`,
{ encoding: "utf-8", timeout: 15000 } // 设置 15 秒超时
).trim();
const activeConnections = parseInt(result);
// 查询数据库配置的最大连接数
const maxQuery = `SHOW max_connections;`;
const maxResult = execSync(
`PGPASSWORD=${password} psql -h ${host} -p ${port} -U ${user} -d ${db} -t -c "${maxQuery}"`,
{ encoding: "utf-8", timeout: 10000 }
).trim();
const maxConnections = parseInt(maxResult);
// 健康判定标准:活跃连接数不超过最大连接数的 80%
// 留出 20% 的余量给迁移操作和其他紧急操作
const healthy = activeConnections < maxConnections * 0.8;
return {
healthy,
activeConnections,
maxConnections,
checkTime: new Date().toISOString(),
};
} catch (error) {
// 查询失败说明可能连不上数据库,或者连接池已经耗尽
return {
healthy: false,
activeConnections: -1, // -1 表示获取失败
maxConnections: 0,
checkTime: new Date().toISOString(),
};
}
}
这个函数通过查询 PostgreSQL 系统视图 pg_stat_activity 来获取当前数据库的活跃连接数。健康判定的标准是活跃连接数不超过最大连接数的 80%,留出余量给迁移操作。
3.3 完整迁移编排流程
import * as pulumi from "@pulumi/pulumi";
import * as postgres from "@pulumi/postgresql";
// ========== 读取 Pulumi 配置 ==========
const config = new pulumi.Config();
const dbHost = config.require("dbHost");
const dbPort = config.requireNumber("dbPort", 5432);
const dbUser = config.require("dbUser");
const dbPassword = config.requireSecret("dbPassword");
const dbName = config.require("dbName");
// ========== 创建数据库实例(如果不存在) ==========
const database = new postgres.Database("orchestration_db", {
name: dbName,
}, {
// 使用远程数据库,不创建新的数据库实例
// 如果是本地数据库,可以指定 host、port、username 等
provider: undefined,
});
// ========== 步骤一:迁移前连接池检查 ==========
// 在执行任何 schema 变更前,先确认连接池状态正常
const preMigrationCheck = database.name.apply(async (dbNameValue) => {
const [host, user, password] = await pulumi.output([
dbHost,
dbUser,
dbPassword,
]).promise();
return checkConnectionPoolHealth(host, dbPort, user, password, dbNameValue);
});
// 根据检查结果决定是否继续迁移
const shouldProceed = preMigrationCheck.apply((result) => {
if (!result.healthy) {
pulumi.log.warn(
`迁移前连接池状态异常:活跃连接 ${result.activeConnections}/${result.maxConnections},暂停迁移`
);
}
return result.healthy;
});
// ========== 步骤二:执行 Schema 变更 ==========
// 使用 postgres.Query 资源来执行 SQL 迁移
// 注意:每个迁移步骤独立定义,而不是一个大全量 SQL
const schemaMigration = new postgres.Query("add_email_verified_column", {
queries: [
// 先添加新列,使用 IF NOT EXISTS 避免重复添加
// 设置 DEFAULT FALSE,不影响现有数据行
"ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE;",
// 为新增列添加注释,方便后续维护
"COMMENT ON COLUMN users.email_verified IS '用户邮箱是否已验证';",
],
// autoApply 设置为 false,让 Pulumi 自动管理执行时机
autoApply: false,
}, {
// 确保数据库资源创建好之后再执行迁移
dependsOn: [database],
});
// ========== 步骤三:迁移后连接池检查 ==========
// 等待 schema 变更执行完毕,再检查连接池是否恢复正常
const postMigrationCheck = schemaMigration.id.apply(async () => {
const dbNameValue = await database.name.promise();
const [host, user, password] = await pulumi.output([
dbHost,
dbUser,
dbPassword,
]).promise();
// 等待 30 秒让数据库连接池有时间恢复
// 这个等待时间可以根据实际情况调整
// 如果迁移涉及大量数据操作,可能需要更长时间
const waitTime = 30000;
pulumi.log.info(`等待 ${waitTime / 1000} 秒让连接池稳定...`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
// 检查连接池状态
return checkConnectionPoolHealth(host, dbPort, user, password, dbNameValue);
});
// ========== 步骤四:数据回填(条件执行) ==========
// 只有连接池恢复正常,才执行耗时的数据操作
const dataBackfill = postMigrationCheck.apply(async (health) => {
if (!health.healthy) {
// 连接池未恢复,抛出异常终止迁移流程
throw new Error(
`连接池未恢复,活跃连接: ${health.activeConnections}/${health.maxConnections},暂停数据回填`
);
}
// 连接池正常,执行数据回填
// 这里更新所有用户的 email_verified 字段
return new postgres.Query("backfill_email_verified", {
queries: [
// 将 NULL 值更新为 FALSE(理论上不应该有 NULL,但做兜底)
"UPDATE users SET email_verified = FALSE WHERE email_verified IS NULL;",
],
autoApply: false,
});
});
// ========== 导出关键状态到 Pulumi 控制台 ==========
// 这些导出的值可以在 pulumi state 命令中查看
export const migrationPhase1Result = preMigrationCheck;
export const migrationPhase2Result = postMigrationCheck;
export const migrationDecision = shouldProceed;
这个编排流程把一次数据库迁移拆成了四个步骤:迁移前检查、执行 schema 变更、迁移后检查、数据回填。每个步骤之间都有连接池状态检查,防止 schema 变更把连接池堵死。
3.4 带重试机制的连接池监控
在实际生产环境中,连接池的恢复不会那么顺利。一个临时的网络抖动或者事务还没提交,都可能导致检查失败。所以需要重试机制。
import * as pulumi from "@pulumi/pulumi";
// ========== 带指数退避的连接池检查 ==========
// 最多重试指定次数,每次间隔递增(指数退避)
// 这种策略的好处是:如果是临时的连接池拥堵,几秒后就能恢复;
// 如果是持续性的问题,不会一直重试浪费时间
async function checkConnectionPoolWithRetry(
host: string,
port: number,
user: string,
password: string,
db: string,
maxRetries: number = 5,
initialWaitMs: number = 2000
): Promise<PoolHealthResult> {
let lastResult: PoolHealthResult | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// 执行一次健康检查
const result = await checkConnectionPoolHealth(host, port, user, password, db);
lastResult = result;
// 连接池健康,直接返回结果
if (result.healthy) {
if (attempt > 1) {
pulumi.log.info(`连接池在第 ${attempt} 次重试后恢复健康`);
}
return result;
}
// 连接池不健康,但不是最后一次尝试
if (attempt < maxRetries) {
// 计算等待时间:指数退避
// 第1次: 2秒, 第2次: 4秒, 第3次: 8秒, 第4次: 16秒, 第5次: 32秒
const waitTime = initialWaitMs * Math.pow(2, attempt - 1);
pulumi.log.info(
`连接池未恢复(活跃连接: ${result.activeConnections}/${result.maxConnections}),` +
`第 ${attempt}/${maxRetries} 次重试,等待 ${waitTime / 1000} 秒...`
);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
// 所有重试都失败了
pulumi.log.error(
`连接池检查失败,已重试 ${maxRetries} 次,最后状态: ` +
`活跃连接 ${lastResult?.activeConnections}/${lastResult?.maxConnections}`
);
return lastResult!;
}
这个重试函数用了指数退避策略。第一次等 2 秒,第二次等 4 秒,第三次等 8 秒,以此类推。指数退避的好处是,临时的拥堵可以快速恢复,持续性问题不会浪费太多时间。
3.5 熔断器模式:防止雪崩
当连接池反复检查失败时,需要快速失败,而不是让所有操作都卡住。这就是熔断器(Circuit Breaker)模式。
import * as pulumi from "@pulumi/pulumi";
// ========== 连接池熔断器实现 ==========
// 熔断器有三个状态:
// CLOSED(正常):所有请求都通过
// OPEN(熔断):所有请求都快速失败
// HALF_OPEN(半开):允许少量试探性请求,成功则恢复到 CLOSED
class ConnectionPoolCircuitBreaker {
private failures: number = 0;
private successes: number = 0;
private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED";
private lastFailureTime: number = 0;
private readonly failureThreshold: number; // 失败阈值
private readonly successThreshold: number; // 成功阈值
private readonly recoveryTimeout: number; // 恢复超时时间
constructor(
failureThreshold: number = 3, // 连续失败 3 次触发熔断
successThreshold: number = 2, // 连续成功 2 次恢复
recoveryTimeout: number = 60000 // 熔断 60 秒后尝试恢复
) {
this.failureThreshold = failureThreshold;
this.successThreshold = successThreshold;
this.recoveryTimeout = recoveryTimeout;
}
// 检查是否允许继续操作
async shouldAllowRequest(): Promise<{ allowed: boolean; reason: string }> {
switch (this.state) {
case "CLOSED":
// 正常状态,允许所有请求
return { allowed: true, reason: "熔断器关闭,一切正常" };
case "OPEN":
// 熔断状态,检查是否到了恢复时间
if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
// 到了恢复时间,切换到半开状态
this.state = "HALF_OPEN";
pulumi.log.warn("熔断器从 OPEN 切换到 HALF_OPEN,尝试恢复");
return { allowed: true, reason: "熔断器半开,允许试探性请求" };
}
// 还没到恢复时间,快速失败
return { allowed: false, reason: "熔断器打开,快速失败" };
case "HALF_OPEN":
// 半开状态,允许少量请求
return { allowed: true, reason: "熔断器半开,允许试探性请求" };
}
}
// 记录操作成功
recordSuccess(): void {
this.failures = 0;
this.successes++;
pulumi.log.debug(`连接池操作成功,连续成功: ${this.successes}`);
if (this.state === "HALF_OPEN" && this.successes >= this.successThreshold) {
// 半开状态下连续成功,恢复到正常状态
this.state = "CLOSED";
this.successes = 0;
pulumi.log.info("熔断器恢复到 CLOSED 状态,连接池恢复正常");
}
}
// 记录操作失败
recordFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
this.successes = 0;
pulumi.log.debug(`连接池操作失败,连续失败: ${this.failures}`);
if (this.state === "HALF_OPEN") {
// 半开状态下失败,立即回到熔断状态
this.state = "OPEN";
pulumi.log.warn("半开状态下失败,熔断器回到 OPEN 状态");
} else if (this.failures >= this.failureThreshold) {
// 达到失败阈值,触发熔断
this.state = "OPEN";
pulumi.log.warn(
`熔断器打开,连续失败 ${this.failures} 次,` +
`${this.recoveryTimeout / 1000} 秒后尝试恢复`
);
}
}
// 获取当前状态(用于日志和监控)
getState(): string {
return `${this.state} (失败: ${this.failures}, 成功: ${this.successes})`;
}
}
// ========== 使用熔断器包装连接池检查 ==========
const circuitBreaker = new ConnectionPoolCircuitBreaker(
3, // 连续失败 3 次触发熔断
2, // 连续成功 2 次恢复
60000 // 熔断 60 秒后尝试恢复
);
async function safeConnectionPoolCheck(
host: string,
port: number,
user: string,
password: string,
db: string
): Promise<PoolHealthResult> {
// 先检查熔断器状态
const { allowed, reason } = await circuitBreaker.shouldAllowRequest();
if (!allowed) {
// 熔断器打开,快速返回失败结果,不执行实际检查
pulumi.log.warn(`熔断器阻止检查: ${reason}`);
return {
healthy: false,
activeConnections: -1,
maxConnections: 0,
checkTime: new Date().toISOString(),
};
}
try {
// 执行实际的健康检查(带重试)
const result = await checkConnectionPoolWithRetry(
host, port, user, password, db, 3
);
// 记录成功
circuitBreaker.recordSuccess();
return result;
} catch (error) {
// 记录失败
circuitBreaker.recordFailure();
pulumi.log.error(
`连接池检查异常: ${error}`,
`熔断器状态: ${circuitBreaker.getState()}`
);
throw error;
}
}
熔断器模式的核心思想是:当连接池反复出问题的时候,不要每次都去重试,而是快速失败,让上层逻辑决定是回滚还是跳过。等过了一段时间(恢复超时),再允许少量试探性请求,确认连接池确实恢复了才恢复正常操作。
四、Schema 变更与连接池耗尽的联动风险详解
理解了编排流程之后,让我们深入分析一下为什么这两个问题会联动,以及背后的技术原理。
4.1 锁机制是连接的罪魁祸首
数据库的锁机制是导致联动风险的根本原因。当你对一张表执行 DDL(数据定义语言)操作时,比如 ALTER TABLE,数据库需要获取排他锁。这意味着在这把锁释放之前,所有试图访问这张表的操作都会被阻塞。
如果你用的是 PostgreSQL,ALTER TABLE 通常会获取 ACCESS EXCLUSIVE 锁,这是最严格的锁级别。它阻塞所有其他操作,包括读操作。MySQL 的 ALTER TABLE 也会获取类似的排他锁,虽然 MySQL 在 8.0 之后引入了一些在线 DDL 优化,但大表的 ALTER TABLE 仍然会持有长时间的锁。
关键在于:这些锁不会立即获取。如果表上已经有事务持有了其他级别的锁,ALTER TABLE 会先等待。而等待的过程中,数据库连接就一直被占用着。你的应用连接池里的连接,一个接一个地被锁住,最终全部耗尽。
4.2 连接池的雪崩效应
假设你的应用有 20 个数据库连接,连接池设置为最大 20。现在发生了这样一幕:
应用正常工作时,连接池里有 15 个活跃连接在处理请求。这时候你执行 ALTER TABLE,需要排他锁。排他锁要等现有事务完成,所以 ALTER TABLE 占住 1 个连接在等待。新的请求进来,拿到第 16 个连接,试图读表,发现排他锁没释放,也开始等待。请求越来越多,连接被逐渐耗尽。当第 20 个连接也被占用后,连接池满了,新请求直接报错。
这就是雪崩效应。一个 ALTER TABLE 操作,把整个连接池拖垮了。而且这个过程往往在毫秒级别就完成了,等你的监控告警出来,服务已经挂了。
4.3 迁移工具自己也变成受害者
更讽刺的是,如果你用 Flyway 或 Liquibase 来执行迁移,它们也是通过数据库连接来跑 SQL 的。当连接池满了的时候,迁移工具自己也拿不到连接,整个迁移流程就卡住了。
这就是为什么在编排层处理这个问题很重要——你需要在 schema 变更之前先评估连接池状态,在变更之后验证连接池是否恢复,而且要有一个兜底方案来处理异常情况。编排层是连接池和迁移任务之间的唯一桥梁,也是最合适的干预点。
五、如何避免联动风险:策略与实践
5.1 分批迁移而非一次性大改
不要把所有 schema 变更塞在一个事务里。把它拆成多个小步骤,每个步骤之间做连接池检查。
// 将大迁移拆分为小步骤,每步之间检查连接池
const migrationSteps: Array<{
description: string;
sql: string;
needsPoolCheck: boolean; // 执行前是否需要检查连接池
needsPoolCheckAfter: boolean; // 执行后是否需要检查连接池
}> = [
{
description: "添加新列(兼容旧代码)",
sql: "ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE;",
needsPoolCheck: true,
needsPoolCheckAfter: true,
},
{
description: "添加索引(低优先级,可以在空闲时间执行)",
sql: "CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);",
needsPoolCheck: true,
needsPoolCheckAfter: true,
},
{
description: "数据回填(耗时的 UPDATE 操作)",
sql: "UPDATE users SET email_verified = TRUE WHERE verified_date IS NOT NULL;",
needsPoolCheck: true,
needsPoolCheckAfter: true,
},
{
description: "移除旧列(确认应用已切换后再执行)",
sql: "ALTER TABLE users DROP COLUMN IF EXISTS verified_date;",
needsPoolCheck: true,
needsPoolCheckAfter: false,
},
];
// 按顺序执行每个步骤
for (let i = 0; i < migrationSteps.length; i++) {
const step = migrationSteps[i];
pulumi.log.info(`执行步骤 ${i + 1}/${migrationSteps.length}: ${step.description}`);
// 执行前检查连接池
if (step.needsPoolCheck) {
const preCheck = await safeConnectionPoolCheck(
dbHost, dbPort, dbUser, dbPassword.get(), dbName
);
if (!preCheck.healthy) {
throw new Error(
`步骤 ${i + 1} 执行前连接池不健康,暂停迁移: ` +
`活跃连接 ${preCheck.activeConnections}/${preCheck.maxConnections}`
);
}
}
// 执行当前步骤的 SQL
// 使用 postgres.Query 资源,设置 autoApply 让 Pulumi 自动执行
new postgres.Query(`migration_step_${i + 1}`, {
queries: [step.sql],
autoApply: true,
});
// 执行后检查连接池
if (step.needsPoolCheckAfter) {
// 等待连接池稳定
await new Promise((resolve) => setTimeout(resolve, 5000));
const postCheck = await safeConnectionPoolCheck(
dbHost, dbPort, dbUser, dbPassword.get(), dbName
);
if (!postCheck.healthy) {
throw new Error(
`步骤 ${i + 1} 执行后连接池未恢复,暂停迁移: ` +
`活跃连接 ${postCheck.activeConnections}/${postCheck.maxConnections}`
);
}
pulumi.log.info(`步骤 ${i + 1} 完成,连接池健康: ` +
`${postCheck.activeConnections}/${postCheck.maxConnections}`);
}
}
5.2 使用 Online Schema Change 工具
如果你的数据库支持 Online Schema Change,可以大大减少锁的持有时间。PostgreSQL 可以用 pg_partman 或者 gh-ost(MySQL 专用),这些工具的核心原理是创建一个新表,把旧数据分批复制到新表,然后快速切换表名。
// 示例:使用 gh-ost 进行在线迁移
// gh-ost 会创建一个新表,复制数据,然后切换表名
// 这个过程对旧表的锁持有时间极短(只有最终的 rename 操作需要排他锁)
import { execSync } from "child_process";
function executeOnlineMigrationWithGhOst(
table: string,
alterSql: string,
host: string,
port: number,
user: string,
password: string,
db: string
): void {
// gh-ost 命令示例
// 关键参数说明:
// --chunk-size=1000: 每次复制 1000 行,避免大批量操作锁表
// --max-lag-millis=1500: 如果主从延迟超过 1.5 秒,暂停复制,防止影响线上
// --execute: 确认执行(不加这个参数只是预检查)
const ghOstCommand = `
gh-ost \\
--host="${host}" \\
--port="${port}" \\
--user="${user}" \\
--password="${password}" \\
--database="${db}" \\
--table="${table}" \\
--alter="${alterSql}" \\
--chunk-size=1000 \\
--max-lag-millis=1500 \\
--assume-rbr \\
--execute
`;
pulumi.log.info(`开始在线迁移表 ${table}: ${alterSql}`);
execSync(ghOstCommand, {
stdio: "inherit",
timeout: 600000, // 最长 10 分钟超时
});
pulumi.log.info(`在线迁移完成: ${table}`);
}
gh-ost 的巧妙之处在于,它不是在原表上做 ALTER TABLE,而是创建一个影子表,分批复制数据,最后用 rename 操作来切换。这个 rename 操作虽然也需要排他锁,但持有时间极短(毫秒级别),对连接池的影响微乎其微。
5.3 动态调整连接池大小
在 schema 变更期间,可以临时调大连接池上限,给迁移操作留出足够的连接。
// 在 Pulumi 中管理连接池配置
// 根据迁移阶段动态调整连接池大小
const config = new pulumi.Config();
// 迁移前的连接池配置
const normalPoolSize = config.getNumber("normalPoolSize") || 20;
// 迁移时的连接池配置(增大以容纳迁移操作)
const migrationPoolSize = config.getNumber("migrationPoolSize") || 50;
// 使用 Kubernetes ConfigMap 注入连接池配置
// 假设应用部署在 K8s 上
const poolConfig = new pulumi.Output.tuple(normalPoolSize, migrationPoolSize)
.apply(([normal, migration]) => ({
// 正常状态的连接池配置
normal: {
maxPoolSize: normal,
minIdle: 5,
connectionTimeout: 10000, // 10秒获取连接超时
idleTimeout: 60000, // 空闲连接 60秒后回收
},
// 迁移期间的连接池配置
duringMigration: {
maxPoolSize: migration,
minIdle: 10,
connectionTimeout: 30000, // 30秒超时,给慢操作更多时间
idleTimeout: 120000, // 空闲连接 120秒后回收
// 增加等待队列长度,允许更多请求排队
maxWaitQueueSize: 100,
},
}));
// 导出连接池配置
export const connectionPoolConfig = poolConfig;
调大连接池不是万能药,它只是买时间。如果 schema 变更本身就有问题(比如锁等待时间过长),连接池再大也会被耗尽。所以连接池调整要和分批迁移、在线迁移工具配合使用。
六、应用场景
这套方法适用于以下几种场景:
6.1 微服务架构中的数据库迁移
微服务各自管理自己的数据库,但共享基础设施。当某个微服务需要 schema 变更时,如果连接池被占满,不仅影响自己,还可能影响依赖它连接池的其他服务。Pulumi 编排可以确保每次迁移前做健康检查,迁移后验证恢复。
6.2 大规模数据迁移
当你的表里有上亿行数据,ALTER TABLE 操作可能持续几分钟甚至更久。这种情况下,连接池被长时间占用的风险非常高。分批执行、在线迁移工具、连接池大小动态调整这些策略组合使用,可以大幅降低风险。
6.3 多环境部署
生产环境、预发布环境、测试环境各有不同的数据库。用 Pulumi 统一管理,可以在每个环境部署时自动执行对应的迁移,同时检查连接池状态。环境之间的差异也可以自动适配,比如生产环境连接池更小,检查阈值就需要相应调整。
七、技术优缺点
优点
第一,声明式管理。Pulumi 用代码描述基础设施状态,schema 迁移也是代码的一部分,方便版本控制和代码审查。团队里每个人都能看到迁移逻辑的变化历史。
第二,可编程的编排能力。相比 Terraform,Pulumi 支持完整的编程能力,可以写条件判断、循环、重试逻辑,这些都是处理联动风险所需要的。Terraform 的资源依赖是声明式的,没法写复杂的条件逻辑。
第三,自动回滚。Pulumi 内置状态管理,如果某一步失败,可以自动回滚到之前的状态。不需要手动编写回滚脚本。
第四,跨平台。不管你的数据库是 AWS RDS、阿里云 RDS、还是自建服务器,Pulumi 都能统一管理。连接池检查的逻辑可以复用,只需要调整参数。
缺点
第一,学习曲线。Pulumi 有自己的编程模型,尤其是 Output、Resource、Dependency 这些概念,新手需要花时间理解。Output 的异步模型尤其容易让人困惑。
第二,复杂度高。把数据库迁移放进基础设施编排工具里,增加了整个系统的复杂度。简单的场景用 Flyway 或 Liquibase 就够了,没必要上 Pulumi。
第三,调试困难。Pulumi 的状态管理是自动的,但当出问题的时候,调试会比传统方式复杂。你需要理解 Pulumi 的状态存储机制和部署模型。
第四,连接池检查依赖外部工具。示例中用了 psql 命令行工具来检查连接池,这要求运行环境必须安装 PostgreSQL 客户端。在生产环境中,可能需要用其他方式替代,比如用数据库 SDK 直接查询。
八、注意事项
8.1 不要在生产环境直接测试
先把编排流程在测试环境跑通,确认每个阶段的连接池检查都能正常工作,再迁移到生产环境。特别是熔断器和重试的参数,需要在测试环境中调优。
8.2 设置合理的超时时间
所有的数据库操作和连接池检查都应该设置超时。
// 连接池检查超时设置示例
// 超时时间不要设得太长,否则迁移流程会一直卡着
const CHECK_TIMEOUT = 30000; // 30秒超时
async function checkWithTimeout(
host: string,
port: number,
user: string,
password: string,
db: string,
maxConnections: number
): Promise<PoolHealthResult> {
return Promise.race([
// 正常检查流程
checkConnectionPoolWithRetry(
host, port, user, password, db, 3
),
// 超时控制:30秒后强制中断
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`连接池检查超时(${CHECK_TIMEOUT}ms)`)),
CHECK_TIMEOUT
)
),
]);
}
8.3 监控和告警要跟上
编排层的每个检查点都应该有日志输出,关键指标(活跃连接数、锁等待时间、迁移步骤耗时)应该上报到监控系统中。否则出了问题你根本不知道是在哪个环节。
8.4 注意数据库版本的兼容性
不同的数据库版本,锁的行为可能不同。PostgreSQL 13 和 16 在 ALTER TABLE 的实现上就有差异。做 schema 变更之前,先确认数据库版本支持你需要的操作。
8.5 备份永远不要省
不管编排流程设计得多好,都不要忘了在迁移前做数据库备份。这是最后一道防线。Pulumi 也可以管理备份策略,在迁移前自动触发一次全量备份。
九、文章总结
通过 Pulumi 管理数据库迁移任务,本质上是在编排层引入了一层智能调度。schema 变更和连接池耗尽不是两个独立的问题,它们通过锁机制和连接管理紧密联动。Pulumi 的编程能力让我们可以在编排流程中嵌入健康检查、重试逻辑、熔断机制,从而把这种联动风险降到最低。
核心要点回顾:
第一,拆步骤。把大迁移拆成多个小步骤,每步之间检查连接池。
第二,加缓冲。使用指数退避、熔断器来应对临时的连接池拥堵。
第三,用工具。gh-ost 等在线迁移工具可以减少锁持有时间。
第四,调配置。迁移期间动态调整连接池大小,给迁移操作留出空间。
第五,重监控。每个检查点都要有日志和监控,出了问题要能快速定位。
数据库迁移从来不是跑个 SQL 就完事那么简单。当你开始用编排工具管理它的时候,你其实在解决一个系统工程问题。Pulumi 的编排能力,加上对连接池状态的持续监控,可以让这个过程变得更可控、更安全。
记住:最复杂的 schema 变更,往往需要最简单的保护策略——先检查,再执行,出了问题能回滚。
评论
围绕“通过 Pulumi 管理数据库迁移任务:在编排层处理 schema 变更与连接池耗尽风险的联动问题”参与讨论