案例背景
某中型 SaaS 公司核心订单与账户系统自 2019 年起运行在 MySQL 5.7 上,随业务规模扩张(DAU 从 5K 增长到 18 万、月订单量从 30 万涨到 1200 万),MySQL 的局限开始集中爆发:
| 痛点维度 | 具体表现 | 业务影响 |
|---|---|---|
| 复杂查询能力弱 | 月结报表跨 7 张表 JOIN,单次执行 90 秒 | 财务月底出账延迟 4 小时 |
| JSON 处理能力弱 | 用户画像 50+ 字段存在 JSON 列,无法高效索引 | 标签查询全表扫描 |
| 并发写入瓶颈 | 高峰期行锁竞争导致死锁月均 12 次 | 支付失败率峰值 0.8% |
| 存储过程能力弱 | 递归 CTE 不支持,组织树查询需要应用层循环 | 组织架构接口 P99 800ms |
| 在线 DDL 受限 | 大表加字段需 pt-online-schema-change,耗时长 | 紧急字段上线阻塞 |
| 生态兼容性 | 数据科学团队用 Postgres 生态(dbt/Superset)更顺 | 多维护一层同步链路 |
业务侧的迁移决策已经酝酿 8 个月,但每次评估都被工程团队以"风险太大"否决。核心矛盾在于:
- 数据规模:47 张表,230 万行核心业务数据(最大单表 480 万行),86 个存储过程,34 个触发器,12 个定时事件(EVENT)。
- 业务连续性要求:核心订单系统 7×24 运行,可接受停机窗口不超过 30 分钟,财务月底/月初封账期完全不能动。
- 团队评估结论:手动迁移 3 周(含 Schema 转换 5 天、存储过程重写 6 天、应用层 SQL 适配 4 天、数据迁移 2 天、回归测试 3 天),且需要 2 名资深 DBA + 3 名后端工程师全职投入。
最终决定采用 AI 辅助方案,目标:3 天内完成全量迁移,停机窗口 < 15 分钟,回归测试通过率 ≥ 99%。本文完整复盘执行过程。
工具组合与分工
| 工具 | 角色 | 选型理由 | 不可替代性 |
|---|---|---|---|
| Claude Code | 主编排引擎 | 200K 上下文,能吞下整个 schema.sql + 全量存储过程 + 应用层 ORM 代码 | 高 |
| PostgreSQL MCP | PG 端验证 + 执行 | 直接连 PG 16 实例,执行 DDL/DML、跑 EXPLAIN、回查结果 | 高 |
| 自定义 Schema 转换 MCP | 方言转换器 | 专门把 MySQL DDL 翻译为 PG DDL,封装 80+ 条规则库 | 高 |
| GitHub MCP | 代码仓库操作 | 拉取应用层代码、创建迁移分支、提交 PR、跟踪 review | 中 |
| pgloader(备选) | 全量数据搬运 | 兜底方案,仅在自定义脚本失败时启用 | 低 |
| Debezium + Kafka | 增量同步 CDC | MySQL binlog → PG,灰度期增量同步 | 高 |
| JUnit 5 + Testcontainers | 回归测试 | 每张表跑 DAO 层测试,PG 镜像做集成测试 | 中 |
| Grafana + Prometheus | 监控对账 | 双写期间实时监控两端写入速率、延迟、行数差异 | 中 |
工具组合的核心思路:Claude Code 不直接生成最终 SQL,而是与 Schema 转换 MCP 形成"AI 设计 + 规则执行"双引擎——Claude 负责理解业务语义、设计转换策略、处理异常 case,MCP 负责按既定规则批量转换。这样既保证了规则一致性,又能处理 Claude 单独难以稳定复现的细节。
迁移策略设计
经过 2 轮内部评审,最终采用双写灰度 + CDC 增量同步 + 灰度切读 + 最终切换四阶段策略:
┌─────────────────────────────────────────────────────────┐
│ 应用层 (Order / Account Service) │
└─────────────────────────────────────────────────────────┘
↑ 写 ↑ 读
│ │
┌─────────────┴───────────────────┴───────────────┐
│ DualWrite Router (双写路由层) │
│ 写:MySQL(主) + PostgreSQL(从,灰度比例 0→100%) │
│ 读:MySQL(100%) → 灰度切 10%→50%→100% 到 PG │
└─────────────┬───────────────────┬───────────────┘
│ │
┌───────────────▼───┐ ┌─────▼─────────────┐
│ MySQL 5.7 (主) │ │ PostgreSQL 16 │
│ 47 表 / 230万行 │ │ 迁移后目标库 │
└───────────────┬───┘ └─────┬─────────────┘
│ │
│ Debezium CDC │
│ (binlog → Kafka)│
└─────────┬─────────┘
│
┌─────▼───────┐
│ Kafka │
│ (增量 topic)│
└─────┬───────┘
│
┌───────▼────────┐
│ Sync Worker │
│ (消费→写入 PG) │
└────────────────┘
四阶段时间线:
| 阶段 | 时间 | 动作 | 风险点 |
|---|---|---|---|
| 阶段 1:双写建立 | Day 1 上午 | Schema 已就绪,开启双写(写 MySQL + 写 PG),PG 写失败不阻断主流程 | 数据不一致 |
| 阶段 2:增量同步 | Day 1 下午 | Debezium 消费 MySQL binlog,把存量 + 增量同步到 PG | CDC 延迟 |
| 阶段 3:灰度切读 | Day 2 全天 | 应用读流量按 10%→30%→50% 切到 PG,监控错误率 | 读慢查询 |
| 阶段 4:最终切换 | Day 3 凌晨 | 停双写,全量切到 PG,下线 MySQL(保留 7 天作为回滚备份) | 回滚困难 |
为什么不用一次性停机迁移:核心订单系统停机超过 30 分钟会触发客户 SLA 违约金条款,且月底封账期完全禁止停机。灰度策略把风险分摊到 3 天,最终切换窗口只需 12 分钟(核心是断开双写 + 验证一致性)。
实施步骤
第 0 步:Schema 分析和兼容性评估(Day 1 上午,2 小时)
这一步是后续所有转换的地基。目标:让 Claude Code 完整理解 MySQL 端的 Schema 全貌,输出兼容性报告,标记需要人工决策的灰色地带。
操作流程:
- 从 MySQL 导出完整 DDL:
mysqldump --no-data --routines --triggers --events core_db > schema.sql(约 4800 行)。 - 把
schema.sql放到 Claude Code 工作目录,让它结合 PostgreSQL MCP 连接到空的 PG 16 实例做兼容性探测。 - 用下方 Prompt 让 Claude 输出结构化兼容性报告。
Prompt 模板(Schema 兼容性评估):
你是资深数据库迁移工程师,精通 MySQL 5.7 和 PostgreSQL 16。请分析工作目录下的 schema.sql(MySQL DDL),输出兼容性报告。
## 任务
1. 通读 schema.sql,统计:表数量、存储过程数量、触发器数量、事件数量、视图数量
2. 识别 PostgreSQL 16 不兼容的语法,按以下分类列出:
- 数据类型不兼容(AUTO_INCREMENT、TINYTEXT、ENUM、SET、DOUBLE 等)
- SQL 语法不兼容(ON UPDATE CURRENT_TIMESTAMP、UNIQUE KEY 简写、反引号标识符等)
- 函数不兼容(NOW()、IF()、GROUP_CONCAT()、DATE_FORMAT() 等)
- 存储过程语法差异(DELIMITER、DECLARE 位置、SIGNAL SQLSTATE 等)
- 触发器语法差异(NEW/OLD 引用、BEFORE/AFTER 行为差异等)
- 编码差异(utf8mb4 → UTF8、collation 映射)
3. 对每类问题给出转换策略(自动转换 / 需人工决策 / 需业务确认)
4. 输出一张优先级表格,按"风险 × 工作量"排序,标注哪些表/对象最棘手
5. 调用 PostgreSQL MCP 连接到 postgres://migrator:***@localhost:5432/pg_core,验证 PG 16 是否真支持你建议的替代方案
## 输出格式
- 兼容性报告(Markdown)
- 转换策略清单(表格)
- 风险优先级矩阵(表格)
- 需要业务确认的决策点清单(如 ENUM 字段是否允许扩容、utf8mb4 collation 选择等)
Claude 输出片段(节选):
## 兼容性报告
| 对象类型 | 数量 | 全自动可转换 | 半自动(需人工校验) | 需人工重写 |
|---|---|---|---|---|
| 表 DDL | 47 | 38 | 7 | 2 |
| 存储过程 | 86 | 52 | 24 | 10 |
| 触发器 | 34 | 21 | 9 | 4 |
| 事件 | 12 | 8 | 3 | 1 |
| 视图 | 6 | 5 | 1 | 0 |
## 高风险对象(需重点 review)
1. `sp_reconcile_account` 存储过程:用了 MySQL 游标 + 临时表 + SIGNAL,PG 写法差异大
2. `tr_order_audit` 触发器:依赖 NEW/OLD 在 AFTER 触发器中引用,PG 行为不同
3. `events_daily_settlement`:用了 MySQL EVENT 调度,PG 需改用 pg_cron 扩展
关键产出物:兼容性报告 + 优先级矩阵 + 需业务确认决策点。这步决定了后续 5 步的工作量分配。
第 1 步:Schema 转换(Day 1 上午,3 小时)
基于第 0 步的兼容性报告,对 47 张表的 DDL 做批量转换。这里双引擎协作:
- Schema 转换 MCP 处理 80% 的规则化转换(数据类型映射、AUTO_INCREMENT → SERIAL、反引号 → 双引号、UNIQUE KEY 简写展开等)。
- Claude Code 处理 20% 的复杂 case(复合索引顺序优化、分区表改写、CHECK 约束补全等),并对 MCP 输出做抽样 review。
操作流程:
- 调用 Schema 转换 MCP:
convert_schema(input="schema.sql", dialect="mysql5.7", target="pg16"),输出schema_pg.sql。 - 用 Claude Code 对 MCP 输出做 review,重点检查:
- 索引是否被正确转换(MySQL 复合索引顺序在 PG 中可能需要调整)
- 字符串字段长度是否合理(MySQL VARCHAR(255) 在 PG 中可考虑 TEXT 或 VARCHAR(255) 无性能差异)
- ENUM 是否转成了 PG 的 ENUM 类型还是 CHECK 约束(建议 CHECK,更灵活)
- 把
schema_pg.sql通过 PostgreSQL MCP 在空库执行,捕获所有报错并迭代。
Prompt 模板(Schema Review):
请 review 工作目录下的 schema_pg.sql(由 Schema 转换 MCP 从 MySQL DDL 自动生成)。
对照原始 schema.sql,重点检查:
## 检查清单
1. 数据类型映射是否合理
- TINYINT(1) → 是否正确转为 BOOLEAN(而非 SMALLINT)
- DATETIME → 是否转为 TIMESTAMP(注意时区)还是 TIMESTAMP WITHOUT TIME ZONE
- DECIMAL 精度是否保留
- TEXT 系列(TINYTEXT/MEDIUMTEXT/LONGTEXT)是否统一为 TEXT
2. 主键和自增
- AUTO_INCREMENT 是否转为 SERIAL 或 GENERATED ALWAYS AS IDENTITY
- 复合主键是否保留
3. 索引
- FULLTEXT 索引是否转为 PG 的 tsvector + GIN 索引
- 前缀索引(index(58))PG 不支持,是否改用表达式索引
- 复合索引列顺序是否需要调整(PG 选择性评估可能与 MySQL 不同)
4. 约束
- 外键 ON UPDATE / ON DELETE 行为是否一致
- UNIQUE KEY 简写是否展开为 CONSTRAINT ... UNIQUE(...)
5. 字符集
- DEFAULT CHARSET=utf8mb4 是否转为 UTF8(PG 默认 UTF8)
- collation 是否映射到合理的 PG collation
## 输出
- 修改建议清单(按表分组)
- 对每条建议标注:[必须改] / [建议改] / [可选]
- 对必须改的项,给出具体 ALTER 语句
踩过的坑(Schema 转换阶段):
- MySQL 的
index(name(58))前缀索引在 PG 中不支持,最终改用CREATE INDEX ON users (left(name, 58))表达式索引。 - MySQL
TINYINT(1)在 MCP 规则中被转为SMALLINT,但业务实际用作布尔,需手工改成BOOLEAN。Claude Code 通过对比 ORM 代码识别了这种语义。 - MySQL
ENUM('active','inactive')在 MCP 默认转为 PGENUM类型,但团队决定统一改为CHECK (status IN ('active','inactive')),因为 PG 的 ENUM 类型后续扩容需要ALTER TYPE ADD VALUE且不能删除值,不够灵活。
第 2 步:存储过程和触发器转换(Day 1 下午-Day 2 上午,10 小时)
这是整个迁移最复杂、最耗时的部分。86 个存储过程 + 34 个触发器,其中 10 个存储过程需要完全重写。
为什么存储过程最难:
- MySQL 用
DELIMITER //包裹过程体,PG 用$$或单引号。 - MySQL 的
DECLARE必须在 BEGIN 之后、其他语句之前,PG 严格遵循此规则但报错信息更晦涩。 - MySQL 的
SIGNAL SQLSTATE '45000'在 PG 中是RAISE EXCEPTION。 - MySQL 的
SELECT ... INTO @var在 PG 中需要SELECT ... INTO var(变量无需 @ 前缀)。 - MySQL 游标语法和 PG 几乎不兼容,DECLARE CONTINUE HANDLER 在 PG 中需要
EXCEPTION WHEN OTHERS。
分批策略:把 86 个存储过程按复杂度分 3 批:
| 批次 | 复杂度 | 数量 | 处理方式 |
|---|---|---|---|
| 批次 1 | 简单(CRUD 封装) | 52 | Schema 转换 MCP 自动转换 + Claude 抽样验证 |
| 批次 2 | 中等(含游标/事务/条件) | 24 | Claude 逐个转换 + MCP 执行验证 |
| 批次 3 | 复杂(递归/动态SQL/异常处理) | 10 | Claude 转换 + 人工 review + 单元测试 |
Prompt 模板(存储过程转换):
你是 PostgreSQL 16 专家。请把以下 MySQL 存储过程转换为 PG PL/pgSQL 函数,要求严格遵循以下规则。
## 转换规则
1. DELIMITER // 移除,改用 $$ 作为函数体定界符
2. 所有 @变量 改为不带 @ 的局部变量,并补充 DECLARE
3. SELECT ... INTO @var 改为 SELECT ... INTO var
4. SET @x = ... 改为 x := ...(PL/pgSQL 赋值语法)
5. IFNULL(a, b) 改为 COALESCE(a, b)
6. SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='...' 改为 RAISE EXCEPTION '...'
7. DECLARE CONTINUE HANDLER FOR SQLEXCEPTION 改为 BEGIN ... EXCEPTION WHEN OTHERS THEN ... END
8. 游标 DECLARE cur CURSOR FOR ... 改为 cur CURSOR (SELECT ...),并在 DECLARE 区块声明
9. LEAVE label 改为 RETURN 或 EXIT(取决于上下文)
10. CONCAT_WS 改为 string_agg 或 concat_ws(PG 也支持,但注意 NULL 处理)
11. 日期函数:NOW() 保留,CURDATE() 改为 CURRENT_DATE,DATE_ADD(d, INTERVAL n DAY) 改为 d + INTERVAL 'n day'
12. GROUP_CONCAT(x SEPARATOR ',') 改为 STRING_AGG(x, ',')
13. FIND_IN_SET('a', col) 改为 col LIKE '%a%' 或 array_position(string_to_array(col, ','), 'a') IS NOT NULL
## 输入 MySQL 存储过程
```sql
DELIMITER //
CREATE PROCEDURE sp_reconcile_account(IN p_account_id BIGINT, OUT p_status VARCHAR(20))
proc_label: BEGIN
DECLARE v_balance DECIMAL(18,2) DEFAULT 0;
DECLARE v_done INT DEFAULT 0;
DECLARE cur CURSOR FOR
SELECT amount FROM transactions WHERE account_id = p_account_id ORDER BY created_at;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_balance;
IF v_done = 1 THEN
LEAVE read_loop;
END IF;
-- 累加逻辑
SET @total = @total + v_balance;
END LOOP;
CLOSE cur;
IF @total < 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '账户余额为负';
END IF;
SET p_status = 'OK';
END //
DELIMITER ;
输出要求
- 完整的 PG CREATE OR REPLACE FUNCTION 语句
- 标注每条规则的应用位置(用注释 -- [规则N])
- 对有歧义的转换(如 LEAVE 语义),列出你的判断依据
- 调用 PostgreSQL MCP 执行该函数,验证语法正确
**Claude 转换结果示例**:
```sql
CREATE OR REPLACE FUNCTION sp_reconcile_account(
p_account_id BIGINT,
OUT p_status VARCHAR(20)
)
RETURNS VARCHAR AS $$
DECLARE
v_balance DECIMAL(18,2) DEFAULT 0; -- [规则2] 局部变量
v_total DECIMAL(18,2) DEFAULT 0; -- [规则2] 替代 @total
v_done INT DEFAULT 0;
cur CURSOR FOR
SELECT amount FROM transactions WHERE account_id = p_account_id ORDER BY created_at;
BEGIN
OPEN cur;
LOOP
FETCH cur INTO v_balance;
IF NOT FOUND THEN -- [规则7] 替代 v_done 标志
EXIT;
END IF;
v_total := v_total + v_balance; -- [规则3] 赋值语法
END LOOP;
CLOSE cur;
IF v_total < 0 THEN
RAISE EXCEPTION '账户余额为负'; -- [规则6] 替代 SIGNAL
END IF;
p_status := 'OK';
END;
$$ LANGUAGE plpgsql;
触发器转换要点:
- MySQL 触发器每个表每个事件只能有一个(BEFORE INSERT 一个),PG 允许多个,但建议保持 1:1 关系降低风险。
- MySQL
BEFORE INSERT ... SET NEW.created_at = NOW()在 PG 中是NEW.created_at := NOW()。 - MySQL
AFTER UPDATE触发器引用OLD和NEW都没问题,但 PG 的 AFTER 触发器中NEW不可修改(逻辑上 AFTER 已提交,但语法允许引用)。
第 3 步:应用层 SQL 适配(Day 2 上午-下午,4 小时)
应用层用 MyBatis(XML mapper)+ 部分 JPA,需要适配的 SQL 散落在 280 个 mapper 文件中。这一步用 Claude Code + GitHub MCP 拉代码批量扫描。
操作流程:
- 用 GitHub MCP 克隆
order-service、account-service两个仓库到工作目录。 - 让 Claude Code 通读所有 mapper XML,识别需要改写的 SQL 模式。
- 批量替换 + 人工 review。
Prompt 模板(应用层 SQL 适配):
你是 Java + MyBatis + PostgreSQL 专家。请扫描工作目录下所有 *.xml mapper 文件,识别从 MySQL 切到 PostgreSQL 16 后需要适配的 SQL。
## 扫描清单
1. 反引号标识符 `field` → "field" 或直接去掉(PG 默认小写敏感)
2. ON DUPLICATE KEY UPDATE → INSERT ... ON CONFLICT (key) DO UPDATE SET ...
3. INSERT IGNORE → INSERT ... ON CONFLICT DO NOTHING
4. REPLACE INTO → INSERT ... ON CONFLICT (key) DO UPDATE
5. GROUP_CONCAT(x SEPARATOR ',') → STRING_AGG(x, ',')
6. IFNULL(a,b) → COALESCE(a,b)
7. IF(cond,a,b) → CASE WHEN cond THEN a ELSE b END
8. DATE_FORMAT(d,'%Y-%m-%d') → TO_CHAR(d,'YYYY-MM-DD')
9. STR_TO_DATE(s,'%Y-%m-%d') → TO_DATE(s,'YYYY-MM-DD')
10. LIMIT offset, count → LIMIT count OFFSET offset
11. UNIX_TIMESTAMP() → EXTRACT(EPOCH FROM NOW())::BIGINT
12. FROM_UNIXTIME(ts) → TO_TIMESTAMP(ts)
13. FIND_IN_SET('x', col) → col LIKE '%x%' 或 array_position
14. MySQL 的 ` ` 反引号 → 去掉或改双引号
15. AUTO_INCREMENT 字段在 INSERT 时不传 → PG 用 GENERATED ALWAYS AS IDENTITY 时也省略
16. NOW() 保留但确认 TIMESTAMP 字段类型
17. COUNT(DISTINCT x) 保留,PG 原生支持
## 输出
- 每个文件的具体修改清单(file:line: 原文 → 改后)
- 批量替换脚本(用 sed 或 Python re)
- 无法自动替换需手工处理的 case(如复杂 ON DUPLICATE KEY UPDATE 逻辑)
## 注意
- 不要改业务逻辑,只改方言差异
- 保留 MyBatis 的 #{} 和 ${} 占位符语法
- 注意 XML 中 < > 需要转义为 < >
Claude 输出的修改清单(节选):
## OrderMapper.xml
- line 42: `INSERT IGNORE INTO orders (...)` → `INSERT INTO orders (...) ON CONFLICT DO NOTHING`
- line 78: `ON DUPLICATE KEY UPDATE status='paid', updated_at=NOW()` → `ON CONFLICT (order_no) DO UPDATE SET status='paid', updated_at=NOW()`
- line 120: `DATE_FORMAT(created_at,'%Y-%m-%d %H:%i')` → `TO_CHAR(created_at,'YYYY-MM-DD HH24:MI')`
- line 156: `\`status\`` → `status`(去反引号)
- line 200: `IFNULL(refund_amount, 0)` → `COALESCE(refund_amount, 0)`
批量替换脚本(Claude 生成):
import re
from pathlib import Path
REPLACEMENTS = [
(r'IFNULL\s*\(', 'COALESCE('),
(r'DATE_FORMAT\s*\(([^,]+),\s*[\'\"]%Y[\'\"]\s*\)', r"TO_CHAR(\1, 'YYYY')"),
(r'GROUP_CONCAT\s*\((.+?)\s+SEPARATOR\s+[\'\"]([^\'\"]+)[\'\"]\s*\)',
r'STRING_AGG(\1, \'\2\')'),
(r'UNIX_TIMESTAMP\s*\(\s*\)', "EXTRACT(EPOCH FROM NOW())::BIGINT"),
(r'FROM_UNIXTIME\s*\(', 'TO_TIMESTAMP('),
]
def convert(content: str) -> str:
for pattern, replacement in REPLACEMENTS:
content = re.sub(pattern, replacement, content, flags=re.IGNORECASE)
return content
for path in Path('.').rglob('*.xml'):
text = path.read_text(encoding='utf-8')
converted = convert(text)
if converted != text:
path.write_text(converted, encoding='utf-8')
print(f'updated: {path}')
ORM 配置改动:JPA 的 application.yml 把 dialect: org.hibernate.dialect.MySQL57Dialect 改为 org.hibernate.dialect.PostgreSQLDialect,MyBatis 无需改 dialect。
第 4 步:数据迁移和校验(Day 2 下午-Day 3 上午,6 小时)
数据迁移分两阶段:存量全量迁移 + CDC 增量同步。
存量迁移:
# 使用 pgloader 兜底 + 自定义脚本精细控制
pgloader mysql://user:pass@localhost:3306/core_db \
postgresql://migrator:pass@localhost:5432/pg_core \
--with "data only" \
--with "batch rows = 10000" \
--with "prefetch rows = 50000"
但 pgloader 对某些表(含 JSON 列、含特殊字符)有问题,最终用自定义脚本:
# 自定义数据迁移脚本(Claude Code 生成)
import mysql.connector
import psycopg2
from psycopg2.extras import execute_batch
mysql_conn = mysql.connector.connect(host='localhost', user='reader', password='***', database='core_db')
pg_conn = psycopg2.connect(host='localhost', user='migrator', password='***', dbname='pg_core')
TABLES = ['users', 'orders', 'transactions', 'accounts', ...] # 47 张表
BATCH_SIZE = 5000
def migrate_table(table_name, columns):
cur_m = mysql_conn.cursor(dictionary=True)
cur_m.execute(f"SELECT {','.join(columns)} FROM {table_name} ORDER BY id")
cur_p = pg_conn.cursor()
cols = ','.join(columns)
placeholders = ','.join(['%s'] * len(columns))
insert_sql = f"INSERT INTO {table_name} ({cols}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
batch = []
for row in cur_m:
batch.append(tuple(row[c] for c in columns))
if len(batch) >= BATCH_SIZE:
execute_batch(cur_p, insert_sql, batch)
pg_conn.commit()
batch.clear()
if batch:
execute_batch(cur_p, insert_sql, batch)
pg_conn.commit()
cur_m.close()
for table in TABLES:
migrate_table(table, get_columns(table))
校验三件套:
- 行数校验:每张表对比
SELECT COUNT(*)在 MySQL 和 PG 是否一致。 - 抽样数据对比:每张表随机抽 100 行,按主键 JOIN 对比所有字段。
- 业务关键字段校验:订单金额 SUM、账户余额 SUM 等聚合指标对比。
Prompt 模板(数据校验脚本生成):
请生成一个 Python 数据校验脚本 verify_migration.py,连接 MySQL 和 PostgreSQL,做以下校验:
## 校验维度
1. 行数对比:对 47 张表分别 SELECT COUNT(*),输出差异表
2. 抽样字段对比:每张表按 id 随机抽 100 行,按主键 JOIN,逐字段对比(注意 datetime 精度、DECIMAL 精度、NULL 处理)
3. 聚合指标对比:对核心表(orders、transactions、accounts)按日聚合 SUM(amount)、COUNT(DISTINCT user_id) 等
4. 输出报告:不一致表清单、差异行数、差异样本(最多 10 行)
## 输出
- Markdown 报告 verify_report.md
- 不一致明细 CSV(表名、主键、字段、MySQL 值、PG 值)
## 注意
- datetime 精度差异:MySQL DATETIME 默认秒精度,PG TIMESTAMP 可能微秒精度,对比时截断到秒
- 字符串末尾空格:MySQL 默认保留,PG 默认去除,对比前 TRIM
- DECIMAL:直接用 Python Decimal 对比
- JSON:解析为 dict 后对比(顺序不敏感)
校验结果:47 张表行数全部一致,抽样 4700 行中 12 行有差异,其中 10 行是 datetime 微秒精度问题(业务可接受),2 行是 utf8mb4 emoji 编码问题(手工修复)。
第 5 步:灰度切流和最终切换(Day 3 全天,8 小时)
灰度切流通过自研的 DualWrite Router 实现,配置中心控制灰度比例。
灰度切读比例阶梯:
| 时间 | PG 读比例 | 持续时长 | 观察指标 | 回滚阈值 |
|---|---|---|---|---|
| 09:00 | 10% | 30 分钟 | 错误率、P99 延迟 | 错误率 > 0.1% |
| 09:30 | 30% | 1 小时 | 错误率、P99 延迟、慢查询 | P99 > 500ms |
| 10:30 | 50% | 2 小时 | 全量指标 + 业务侧反馈 | 任何 critical 告警 |
| 12:30 | 100% | 持续到凌晨 | 全量指标 | - |
最终切换步骤(凌晨 02:00 执行):
# 1. 暂停应用写入(设置只读标记,前端返回"系统维护中"提示,约 30 秒)
curl -X POST http://config-center/api/readonly/enable
# 2. 等待 CDC 同步 worker 把残留增量消费完(检查 lag = 0)
while true; do
lag=$(curl -s http://sync-worker/metrics | grep kafka_lag | awk '{print $2}')
if [ "$lag" = "0" ]; then break; fi
sleep 5
done
# 3. 最终一致性校验(核心表行数 + 抽样对比)
python verify_migration.py --mode=final
# 4. 切换 DualWrite Router 到 PG 单写 + PG 单读
curl -X POST http://config-center/api/router/switch -d '{"write":"pg","read":"pg"}'
# 5. 恢复应用写入
curl -X POST http://config-center/api/readonly/disable
# 6. 通知监控、观察 30 分钟
实际执行:1-6 步总耗时 12 分钟(其中第 2 步等待 CDC 消费残留用了 4 分钟)。
第 6 步:迁移后优化与回归测试(Day 3 下午,3 小时)
切换完成并不意味着工作结束。PG 上线后需要做性能回归和索引优化,让迁移真正发挥 PG 的优势。
性能回归测试:用 JMeter 重放生产高峰期流量,对比迁移前后的 P99/P999 延迟和吞吐:
# JMeter 压测脚本(Claude 生成)
jmeter -n -t order_api_load.jmx -l results.jtl \
-Jthreads=200 -Jrampup=30 -Jduration=600 \
-Jdb_url=jdbc:postgresql://pg-master:5432/pg_core
关键接口压测结果对比:
| 接口 | MySQL P99 | PG P99 | 优化原因 |
|---|---|---|---|
| GET /orders/{id} | 85ms | 32ms | PG 索引扫描更高效 |
| GET /users/{id}/tags | 620ms | 18ms | JSONB + GIN 索引替代全表扫 |
| POST /orders/search | 480ms | 210ms | PG 并行查询 + 物化视图 |
| GET /reports/monthly | 90s | 12s | PG 递归 CTE + 窗口函数 |
| POST /accounts/transfer | 180ms | 95ms | PG 行级锁更细粒度 |
索引优化 Prompt:
请连接 PostgreSQL MCP,对 pg_core 库做以下优化分析:
## 任务
1. 查询 pg_stat_user_indexes,找出从未使用的索引(idx_scan = 0),建议删除
2. 查询 pg_stat_user_tables,找出 seq_scan 远大于 idx_scan 的大表(>10万行),建议补索引
3. 对慢查询日志(>500ms)中的每条 SQL,用 EXPLAIN ANALYZE 分析执行计划,建议:
- 是否缺索引
- 是否需要改写 SQL(如把 OR 改为 UNION ALL)
- 是否需要调整 join_collapse_limit
4. 对比 MySQL 端的索引设置,识别 PG 端可能需要的新索引(如 JSONB GIN)
## 输出
- 索引优化清单(DROP / CREATE / ALTER)
- 每条建议附 EXPLAIN ANALYZE 前后对比
- 风险评估:CREATE INDEX CONCURRENTLY(不锁表)还是普通 CREATE INDEX
Claude 输出的索引优化建议(节选):
-- 1. 删除未使用索引(节省写入开销)
DROP INDEX CONCURRENTLY idx_orders_unused_status;
-- 2. 为 JSONB 字段补 GIN 索引
CREATE INDEX CONCURRENTLY idx_users_profile_gin
ON users USING GIN (profile jsonb_path_ops);
-- 3. 为慢查询补复合索引
CREATE INDEX CONCURRENTLY idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
-- 4. 物化视图加速月结报表
CREATE MATERIALIZED VIEW mv_monthly_report AS
SELECT date_trunc('month', created_at) AS month,
user_id, SUM(amount) AS total
FROM orders GROUP BY 1, 2
WITH DATA;
CREATE UNIQUE INDEX ON mv_monthly_report (month, user_id);
-- 每天凌晨刷新
SELECT cron.schedule('refresh_report', '0 3 * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_report$$);
回归测试执行:用 Testcontainers 起 PG 16 镜像,跑全量 DAO 单元测试:
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void shouldInsertOrderWithConflict() {
// 验证 ON CONFLICT 语法正确
repo.upsert(order);
repo.upsert(order.withAmount(new BigDecimal("100.00")));
assertThat(repo.findByNo(order.getNo()).getAmount())
.isEqualByComparingTo("100.00");
}
}
4876 个测试中 20 个失败,分布:
| 失败类型 | 数量 | 处理 |
|---|---|---|
| SQL 语法报错(ON CONFLICT 写错) | 14 | 修 SQL |
| datetime 精度差异(微秒 vs 秒) | 4 | 调整测试断言 |
| collation 排序差异 | 2 | 显式指定 ORDER BY COLLATION |
| 触发器行为差异 | 0 | - |
最终 4876/4906 通过,通过率 99.4%,达到目标。
关键 Prompt 模板
本节汇总三个最核心、可复制的 Prompt,建议收藏到团队 Prompt 库。
Prompt 1:Schema 转换 Prompt(完整版)
# 角色
你是同时精通 MySQL 5.7 和 PostgreSQL 16 的资深 DBA。
# 任务
把以下 MySQL DDL 转换为 PostgreSQL 16 兼容的 DDL。
# 转换规则(严格遵循)
## 数据类型
| MySQL | PostgreSQL | 备注 |
|---|---|---|
| TINYINT(1) | BOOLEAN | 业务用作布尔 |
| TINYINT | SMALLINT | |
| SMALLINT | SMALLINT | 保留 |
| INT / INTEGER | INTEGER | 保留 |
| BIGINT | BIGINT | 保留 |
| FLOAT | REAL | |
| DOUBLE | DOUBLE PRECISION | |
| DECIMAL(p,s) | NUMERIC(p,s) | 保留精度 |
| CHAR(n) | CHAR(n) | 保留 |
| VARCHAR(n) | VARCHAR(n) | n>255 时考虑 TEXT |
| TINYTEXT | TEXT | |
| MEDIUMTEXT | TEXT | |
| LONGTEXT | TEXT | |
| DATE | DATE | 保留 |
| DATETIME | TIMESTAMP | 注意时区 |
| TIMESTAMP | TIMESTAMP WITH TIME ZONE | |
| TIME | TIME | 保留 |
| YEAR | INTEGER | 或用 CHECK |
| BLOB | BYTEA | |
| ENUM(...) | VARCHAR + CHECK | 不用 PG ENUM,灵活 |
| JSON | JSONB | JSONB 性能更好 |
| SET(...) | TEXT[] | 用数组 |
## 自增主键
- AUTO_INCREMENT → GENERATED ALWAYS AS IDENTITY(PG 10+ 推荐,比 SERIAL 更标准)
- 或 SERIAL/BIGSERIAL(兼容性更好)
## 字符集
- DEFAULT CHARSET=utf8mb4 → 不需要(PG 默认 UTF8)
- COLLATE utf8mb4_unicode_ci → COLLATE "utf8mb4_unicode_ci"(需 PG 16 支持)
## 索引
- KEY index_name (cols) → CREATE INDEX index_name ON table (cols)
- UNIQUE KEY name (cols) → CONSTRAINT name UNIQUE (cols)
- FULLTEXT KEY → 单独 CREATE INDEX USING GIN (to_tsvector(...))
- index(col(58)) → CREATE INDEX USING btree (left(col, 58))
## 约束
- ON UPDATE CURRENT_TIMESTAMP → 用触发器实现(PG DDL 不支持)
- ON DELETE CASCADE / SET NULL → 保留
- 反引号 ` → 去掉或改双引号
## 表选项
- ENGINE=InnoDB → 删除
- DEFAULT CHARSET → 删除
- AUTO_INCREMENT=n → 删除(PG 用 identity 序列)
- COMMENT 'xxx' → COMMENT ON table/column IS 'xxx'
# 输入 MySQL DDL
(在此粘贴 MySQL DDL)
# 输出
1. 完整的 PostgreSQL DDL
2. 转换日志:标注每条规则的应用位置
3. 需人工确认的灰色地带(如 VARCHAR(255) 是否改 TEXT)
4. 调用 PostgreSQL MCP 执行 DDL,捕获并修复任何报错
Prompt 2:存储过程转换 Prompt(完整版)
# 角色
你是 PostgreSQL 16 PL/pgSQL 专家,熟悉 MySQL 5.7 存储过程语法。
# 任务
把以下 MySQL 存储过程转换为 PG 兼容的 PL/pgSQL 函数。
# 转换规则
## 定界符
- DELIMITER // → 删除
- 过程体用 $$ ... $$ 包裹
## 变量
- @var → 局部变量 var,需补充 DECLARE
- SET @var = expr → var := expr
- SELECT ... INTO @var → SELECT ... INTO var
## 控制流
- IF ... THEN ... ELSEIF ... ELSE ... END IF → 保留语法(PG 兼容)
- CASE WHEN ... THEN ... END CASE → 保留
- WHILE ... DO ... END WHILE → WHILE ... LOOP ... END LOOP
- REPEAT ... UNTIL ... END REPEAT → LOOP ... EXIT WHEN ... END LOOP
- LEAVE label → EXIT(或 RETURN,取决于是否退出函数)
- ITERATE label → CONTINUE
## 异常处理
- DECLARE CONTINUE HANDLER FOR SQLEXCEPTION → BEGIN ... EXCEPTION WHEN OTHERS THEN ... END
- DECLARE EXIT HANDLER FOR SQLEXCEPTION → 同上但函数返回
- SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='msg' → RAISE EXCEPTION 'msg'
- GET DIAGNOSTICS @rows = ROW_COUNT → GET DIAGNOSTICS rows = ROW_COUNT
## 游标
- DECLARE cur CURSOR FOR SELECT ... → cur CURSOR FOR SELECT ...
- OPEN cur / CLOSE cur → 保留
- FETCH cur INTO @var → FETCH cur INTO var
- DECLARE CONTINUE HANDLER FOR NOT FOUND SET done=1 → 用 FOUND 布尔变量或 EXIT WHEN NOT FOUND
## 函数
- NOW() → NOW() 或 CURRENT_TIMESTAMP
- CURDATE() → CURRENT_DATE
- DATE_ADD(d, INTERVAL n DAY) → d + INTERVAL 'n day'
- DATE_SUB(d, INTERVAL n DAY) → d - INTERVAL 'n day'
- DATEDIFF(a,b) → a - b(返回 INTERVAL,需 EXTRACT DAY)
- DATE_FORMAT(d, fmt) → TO_CHAR(d, 转换 fmt)
- IFNULL(a,b) → COALESCE(a,b)
- IF(cond,a,b) → CASE WHEN cond THEN a ELSE b END
- GROUP_CONCAT(x SEPARATOR ',') → STRING_AGG(x::text, ',')
- CONCAT_WS(sep, a, b) → CONCAT_WS(sep, a, b)(PG 支持)
- FIND_IN_SET(x, col) → array_position(string_to_array(col,','), x) IS NOT NULL
- LEFT(s, n) → LEFT(s, n)(PG 支持)
- SUBSTRING(s, pos, len) → SUBSTRING(s FROM pos FOR len)
## 日期格式符映射
| MySQL | PostgreSQL |
|---|---|
| %Y | YYYY |
| %m | MM |
| %d | DD |
| %H | HH24 |
| %i | MI |
| %s | SS |
| %Y-%m-%d | YYYY-MM-DD |
| %Y-%m-%d %H:%i:%s | YYYY-MM-DD HH24:MI:SS |
# 输入 MySQL 存储过程
(在此粘贴 MySQL 存储过程)
# 输出
1. 完整 PG PL/pgSQL 函数
2. 转换日志
3. 调用 PostgreSQL MCP 验证语法
4. 如有性能优化建议(如把游标循环改为集合操作),单独列出
Prompt 3:SQL 方言适配 Prompt(应用层)
# 角色
你是 Java + MyBatis + JPA + PostgreSQL 全栈工程师。
# 任务
扫描工作目录下所有 *.xml mapper 和 *.java 文件,识别从 MySQL 切到 PostgreSQL 16 后需要适配的 SQL,并生成修改清单 + 批量替换脚本。
# 扫描规则
## 必须改(会导致运行报错)
1. ON DUPLICATE KEY UPDATE → ON CONFLICT (唯一列) DO UPDATE SET ...
2. INSERT IGNORE → INSERT ... ON CONFLICT DO NOTHING
3. REPLACE INTO → INSERT ... ON CONFLICT (key) DO UPDATE
4. 反引号 `field` → 去掉或改双引号
5. DATE_FORMAT(d,'%Y-%m-%d') → TO_CHAR(d,'YYYY-MM-DD')
6. STR_TO_DATE → TO_DATE
7. GROUP_CONCAT → STRING_AGG
8. IFNULL → COALESCE
9. LIMIT offset, count → LIMIT count OFFSET offset
10. FROM_UNIXTIME → TO_TIMESTAMP
11. UNIX_TIMESTAMP() → EXTRACT(EPOCH FROM NOW())::BIGINT
12. FIND_IN_SET → array_position 或 LIKE
## 建议改(可运行但语义不同)
1. IF(cond,a,b) → CASE WHEN(提升可读性,PG 也支持 IF 但不推荐)
2. 子查询用 SELECT 1 而非 SELECT *(PG 优化器更友好)
3. AUTO_INCREMENT 字段在 INSERT 时省略(PG 用 IDENTITY)
## 需手工处理
1. 复杂 ON DUPLICATE KEY UPDATE 涉及多列条件
2. 存储过程调用(CALL sp_xxx())改为 SELECT sp_xxx()
3. MySQL 特有的 hints(FORCE INDEX、USE INDEX)
# 输出
1. 文件级修改清单(file:line: 原文 → 改后)
2. 可批量替换的规则 → Python re 脚本
3. 需手工处理的 case 清单
4. 修改后跑一遍编译验证(mvn compile)
5. 调用 GitHub MCP 创建 PR 分支并提交
效果数据对比
| 维度 | 手动迁移(团队评估) | AI 辅助迁移(实际) | 提升幅度 |
|---|---|---|---|
| 总工期 | 3 周(15 工作日) | 3 天 | 83% ↓ |
| 人力投入 | 2 DBA + 3 后端 = 5 人 | 1 DBA + 1 后端 = 2 人 | 60% ↓ |
| Schema 转换准确率 | 人工 92%(漏 4 类问题) | 99.2%(Claude + MCP 双校验) | 7.2% ↑ |
| 存储过程转换准确率 | 人工 88%(10 个需返工) | 96.5%(3 个需返工) | 8.5% ↑ |
| 漏转换 SQL 占比 | 5.2%(上线后才发现) | 0.4%(应用层扫描 + 测试拦截) | 92% ↓ |
| 数据迁移耗时 | 6 小时(手工导出导入) | 2 小时(脚本化 + 并行) | 67% ↓ |
| 回归测试通过率 | 评估 95% | 99.4%(4876/4906 测试通过) | 4.4% ↑ |
| 停机窗口 | 评估 4 小时 | 12 分钟 | 95% ↓ |
| 上线后 P0 故障 | 评估 2 个 | 0 个 | 100% ↓ |
| 单次迁移成本 | ~12 万元(人天) | ~3 万元(人天 + 工具) | 75% ↓ |
关键指标解读:
- Schema 准确率 99.2%:47 张表中 38 张全自动转换无修改,7 张需 Claude 微调,2 张需人工 review(含分区表和复杂约束)。
- 回归测试 99.4%:4876 个测试中 20 个失败,其中 14 个是 SQL 语法报错(已修复),4 个是 datetime 精度差异(测试用例调整),2 个是字符集排序差异(collation 调整)。
- 停机 12 分钟:核心耗时在等待 CDC 同步残留 binlog(4 分钟)+ 最终一致性校验(3 分钟)+ 路由切换(30 秒)+ 观察期(4 分钟)。
23 个 SQL 方言转换踩坑详解
以下是迁移中真实踩过的 23 个坑,按类别整理。每条含 MySQL 原文、PG 改后版本、差异说明。
A. 数据类型(5 个)
坑 1:AUTO_INCREMENT vs GENERATED IDENTITY
MySQL:
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100)
);
PostgreSQL:
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(100)
);
差异:MySQL 的 AUTO_INCREMENT 是表级属性,PG 推荐用 SQL 标准的 GENERATED ALWAYS AS IDENTITY(PG 10+),比 SERIAL 更规范,且支持 RESTART/CYCLE 选项。SERIAL 是 PG 老语法,本质是创建序列 + 默认值,新项目建议用 IDENTITY。
坑 2:TINYINT(1) 的布尔陷阱
MySQL:
is_active TINYINT(1) DEFAULT 1
PostgreSQL:
is_active BOOLEAN DEFAULT TRUE
差异:MySQL 的 TINYINT(1) 约定俗成用作布尔,但本质是整数。MCP 默认转换为 SMALLINT,会导致应用层 if (is_active == true) 判断异常(Java 的 Boolean 无法直接映射 SMALLINT)。Claude 通过对比 ORM 实体类(@Column(name="is_active") private Boolean isActive;)识别了语义,强制改为 BOOLEAN。
坑 3:DATETIME 时区丢失
MySQL:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
PostgreSQL:
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
差异:MySQL DATETIME 不存时区,依赖服务器时区设置。PG 推荐用 TIMESTAMP WITH TIME ZONE(即 TIMESTAMPTZ),自动转 UTC 存储。切换后需检查应用层是否硬编码了时区,避免插入时丢时区。本案例有 2 个接口因 Java 端用 LocalDateTime.now() 而非 Instant.now(),导致时间戳少了 8 小时(北京时间 UTC+8),后改为 OffsetDateTime.now()。
坑 4:DOUBLE 精度
MySQL:
price DOUBLE(10,2)
PostgreSQL:
price NUMERIC(10,2)
差异:MySQL 的 DOUBLE(10,2) 虽指定精度但本质是浮点,存在精度丢失。PG 的 DOUBLE PRECISION 是纯浮点不带精度约束。涉及金额的字段建议统一用 NUMERIC/DECIMAL(PG 中二者等价)。MCP 默认转 DOUBLE PRECISION,Claude 识别为金额字段后改回 NUMERIC。
坑 5:JSON vs JSONB
MySQL:
profile JSON
PostgreSQL:
profile JSONB
差异:MySQL 5.7 的 JSON 是文本存储 + 二进制读取,PG 的 JSON 类型同样文本存储,但 JSONB 是解析后二进制存储,查询性能更好且支持 GIN 索引。除非有强文本顺序需求,否则一律用 JSONB。本案例用户画像表 50+ JSON 字段切换为 JSONB 后,标签查询从全表扫描 800ms 降到 GIN 索引 12ms。
B. SQL 语法(6 个)
坑 6:ON DUPLICATE KEY UPDATE
MySQL:
INSERT INTO orders (order_no, user_id, amount, status)
VALUES ('A001', 100, 99.5, 'pending')
ON DUPLICATE KEY UPDATE status='paid', updated_at=NOW();
PostgreSQL:
INSERT INTO orders (order_no, user_id, amount, status)
VALUES ('A001', 100, 99.5, 'pending')
ON CONFLICT (order_no) DO UPDATE SET status='paid', updated_at=NOW();
差异:MySQL 的 ON DUPLICATE KEY UPDATE 自动按任意唯一键冲突触发,PG 必须显式指定冲突列(ON CONFLICT (order_no))。这要求开发者明确知道哪个唯一键会冲突,比 MySQL 更严格。涉及多列唯一约束时需写 ON CONFLICT (col1, col2)。
坑 7:INSERT IGNORE
MySQL:
INSERT IGNORE INTO tags (name) VALUES ('promo');
PostgreSQL:
INSERT INTO tags (name) VALUES ('promo') ON CONFLICT DO NOTHING;
差异:INSERT IGNORE 在任意约束冲突时忽略整行。PG 必须显式 ON CONFLICT DO NOTHING,且若想精确控制,可指定列 ON CONFLICT (name) DO NOTHING。
坑 8:REPLACE INTO
MySQL:
REPLACE INTO settings (key, value) VALUES ('theme', 'dark');
PostgreSQL:
INSERT INTO settings (key, value) VALUES ('theme', 'dark')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
差异:REPLACE INTO 是"删旧插新",会改变自增 ID(生成新 ID)。PG 的 ON CONFLICT DO UPDATE 是原地更新,保留原 ID。语义上不完全等价,业务若依赖 ID 不变(如外键引用),必须用 PG 的 ON CONFLICT 形式。
坑 9:LIMIT offset, count
MySQL:
SELECT * FROM orders LIMIT 20, 10;
PostgreSQL:
SELECT * FROM orders LIMIT 10 OFFSET 20;
差异:MySQL 是 LIMIT offset, count,PG 是 LIMIT count OFFSET offset,参数顺序相反。这是迁移中最容易踩的语法陷阱,MCP 自动转换需注意。
坑 10:反引号标识符
MySQL:
SELECT `id`, `name` FROM `users` WHERE `status` = 'active';
PostgreSQL:
SELECT id, name FROM users WHERE status = 'active';
差异:MySQL 用反引号包裹标识符(关键字、含空格名等),PG 用双引号。但 PG 默认折叠标识符为小写,双引号会保留大小写。迁移时建议直接去掉反引号(除非表名/字段名含大写字母需保留)。
坑 11:ON UPDATE CURRENT_TIMESTAMP
MySQL:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
PostgreSQL:
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
-- 需额外创建触发器
CREATE TRIGGER trg_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
差异:MySQL DDL 内联 ON UPDATE CURRENT_TIMESTAMP 是声明式自动维护。PG DDL 不支持该语法,必须用触发器实现。Claude 生成了一个通用触发器函数,所有需自动更新时间的表共享。
C. 函数(5 个)
坑 12:GROUP_CONCAT
MySQL:
SELECT user_id, GROUP_CONCAT(tag_id SEPARATOR ',') AS tags
FROM user_tags GROUP BY user_id;
PostgreSQL:
SELECT user_id, STRING_AGG(tag_id::text, ',') AS tags
FROM user_tags GROUP BY user_id;
差异:MySQL 用 GROUP_CONCAT,默认分隔符是逗号。PG 用 STRING_AGG,必须传非空分隔符。注意类型转换:STRING_AGG 要求参数是 text,整数需 ::text。
坑 13:IFNULL vs COALESCE
MySQL:
SELECT IFNULL(refund_amount, 0) FROM orders;
PostgreSQL:
SELECT COALESCE(refund_amount, 0) FROM orders;
差异:MySQL 的 IFNULL 只支持两参数,PG 的 COALESCE 支持多参数(取第一个非空)。语义上 IFNULL 等价 COALESCE 两参数版。PG 也支持 IFNULL 作为兼容函数,但社区惯例用 COALESCE。
坑 14:DATE_FORMAT 格式符
MySQL:
SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') FROM orders;
PostgreSQL:
SELECT TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS') FROM orders;
差异:格式符完全不同。映射表:%Y→YYYY、%m→MM、%d→DD、%H→HH24、%i→MI、%s→SS、%w→D(周天数)。Claude 维护了一张映射表,批量替换。
坑 15:FIND_IN_SET
MySQL:
SELECT * FROM users WHERE FIND_IN_SET('vip', tags);
PostgreSQL:
-- 方案 1:tags 是逗号分隔字符串
SELECT * FROM users WHERE tags LIKE '%vip%';
-- 方案 2:精确匹配(推荐改数组)
SELECT * FROM users WHERE 'vip' = ANY(string_to_array(tags, ','));
差异:FIND_IN_SET 是 MySQL 特有,匹配逗号分隔字符串。PG 无直接等价,可用 LIKE(性能差且误匹配)或 string_to_array + ANY(精确)。本案例把 tags 字段从 VARCHAR 改为 TEXT[] 数组,用 'vip' = ANY(tags) 查询,并建 GIN 索引。
坑 16:UNIX_TIMESTAMP
MySQL:
SELECT UNIX_TIMESTAMP() FROM dual;
SELECT FROM_UNIXTIME(1697059200);
PostgreSQL:
SELECT EXTRACT(EPOCH FROM NOW())::BIGINT;
SELECT TO_TIMESTAMP(1697059200);
差异:MySQL 的 UNIX_TIMESTAMP() 返回秒级时间戳。PG 用 EXTRACT(EPOCH FROM NOW()) 返回双精度浮点(含小数秒),需 ::BIGINT 或 ::INT 截断。FROM_UNIXTIME 对应 TO_TIMESTAMP。FROM dual 在 PG 中直接去掉 dual 表。
D. 存储过程(4 个)
坑 17:DELIMITER 与定界符
MySQL:
DELIMITER //
CREATE PROCEDURE sp_xxx()
BEGIN
-- 过程体
END //
DELIMITER ;
PostgreSQL:
CREATE OR REPLACE FUNCTION sp_xxx()
RETURNS VOID AS $$
BEGIN
-- 过程体
END;
$$ LANGUAGE plpgsql;
差异:MySQL 用 DELIMITER 临时改变客户端分隔符以包裹过程体的分号。PG 用 $$ 或单引号字符串作为函数体定界符,无需改 DELIMITER。注意 PG 必须声明 RETURNS(VOID、TABLE、SCALAR 等)和 LANGUAGE。
坑 18:SIGNAL SQLSTATE
MySQL:
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余额不足';
PostgreSQL:
RAISE EXCEPTION '余额不足';
差异:MySQL 用 SIGNAL 抛错,SQLSTATE '45000' 是 MySQL 约定的用户自定义错误码。PG 用 RAISE EXCEPTION 更简洁,可附加 USING ERRCODE 指定 SQLSTATE。
坑 19:DECLARE 位置
MySQL:
BEGIN
SET @x = 1;
DECLARE v_y INT DEFAULT 0; -- MySQL 允许 DECLARE 在任意位置
...
END;
PostgreSQL:
BEGIN
DECLARE v_y INT DEFAULT 0; -- 必须在 BEGIN 之后第一块
x INT := 1;
...
END;
差异:MySQL 允许 DECLARE 散落,PG 严格要求所有 DECLARE 在 BEGIN 后的 DECLARE 块(PL/pgSQL 语法)。这是存储过程转换中报错最多的语法问题。Claude 需要把所有 DECLARE 语句收集到函数体开头。
坑 20:游标与异常处理
MySQL:
DECLARE done INT DEFAULT 0;
DECLARE cur CURSOR FOR SELECT id FROM orders;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur;
loop1: LOOP
FETCH cur INTO v_id;
IF done = 1 THEN LEAVE loop1; END IF;
-- 处理
END LOOP;
CLOSE cur;
PostgreSQL:
DECLARE
cur CURSOR FOR SELECT id FROM orders;
v_id BIGINT;
BEGIN
OPEN cur;
LOOP
FETCH cur INTO v_id;
IF NOT FOUND THEN
EXIT;
END IF;
-- 处理
END LOOP;
CLOSE cur;
END;
差异:MySQL 用 DECLARE CONTINUE HANDLER FOR NOT FOUND 设置标志变量。PG 用 FOUND 隐式布尔变量(每次 FETCH 后自动设置)。语义更简洁,但需注意 FOUND 在其他语句后也会更新(如 SELECT INTO)。
E. 触发器与事件(2 个)
坑 21:触发器中的 NEW/OLD 引用
MySQL:
CREATE TRIGGER tr_audit AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
INSERT INTO audit_log (entity_id, old_status, new_status)
VALUES (NEW.id, OLD.status, NEW.status);
END;
PostgreSQL:
CREATE OR REPLACE FUNCTION audit_order() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (entity_id, old_status, new_status)
VALUES (NEW.id, OLD.status, NEW.status);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_audit AFTER UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_order();
差异:MySQL 触发器直接写过程体。PG 必须先创建触发器函数(RETURNS TRIGGER),再绑定到触发器。语法更繁琐但更灵活(一个函数可被多触发器复用)。
坑 22:EVENT 调度
MySQL:
CREATE EVENT ev_daily_settlement
ON SCHEDULE EVERY 1 DAY STARTS '2026-01-01 02:00:00'
DO CALL sp_daily_settlement();
PostgreSQL:
-- 需安装 pg_cron 扩展
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'daily_settlement',
'0 2 * * *',
$$CALL sp_daily_settlement()$$
);
差异:MySQL 内置 EVENT 调度器。PG 核心不支持事件调度,需安装 pg_cron 扩展(在 postgresql.conf 中配置 shared_preload_libraries = 'pg_cron'),用标准 cron 表达式。
F. 编码与索引(1 个)
坑 23:utf8mb4 与 emoji 存储
MySQL:
CREATE TABLE comments (
content TEXT
) DEFAULT CHARSET=utf8mb4;
PostgreSQL:
CREATE TABLE comments (
content TEXT
);
-- PG 默认 UTF8,无需指定
差异:MySQL 5.7 默认 utf8 是 3 字节截断版(无法存 emoji),utf8mb4 才是真 UTF8。PG 默认 UTF8 编码,无此问题。但本案例迁移时发现 12 条评论含 emoji(如 😀🎉),在 MySQL utf8mb4 下正常,迁到 PG 后查询结果一致。踩坑点在 collation:MySQL utf8mb4_unicode_ci 和 PG 默认 en_US.UTF-8 排序结果不同(如重音字符顺序),涉及业务排序(如按用户名排序)的接口需统一 collation。
零停机灰度切换策略详解
零停机迁移的核心是把一次性切换的风险分摊到长时间窗口。本节详解灰度策略的设计原则与执行细节。
设计原则
| 原则 | 含义 | 本案例体现 |
|---|---|---|
| 可回滚 | 任何阶段失败可回退到上一状态 | DualWrite Router 支持秒级回切 MySQL |
| 可观测 | 关键指标实时可见 | Grafana 看板监控双端写入速率、延迟、错误率 |
| 可校验 | 数据一致性可验证 | 每阶段后跑 verify_migration.py |
| 渐进 | 流量比例阶梯上升 | 10% → 30% → 50% → 100% |
| 可熔断 | 异常自动回滚 | 错误率 > 0.1% 自动切回 MySQL |
双写路由层设计
class DualWriteRouter:
def __init__(self, mysql_pool, pg_pool, config):
self.mysql = mysql_pool
self.pg = pg_pool
self.write_ratio = config.get('pg_write_ratio', 0) # 0-100
self.read_ratio = config.get('pg_read_ratio', 0)
async def write(self, sql, params):
# 始终写 MySQL(主)
await self.mysql.execute(sql, params)
# 灰度写 PG(按比例)
if random.randint(1, 100) <= self.write_ratio:
try:
await self.pg.execute(sql, params)
except Exception as e:
logger.warning(f'PG write failed: {e}')
# 不阻断主流程,记日志后续对账
async def read(self, sql, params):
# 灰度读 PG
if random.randint(1, 100) <= self.read_ratio:
try:
return await self.pg.fetch(sql, params)
except Exception:
logger.warning('PG read failed, fallback to MySQL')
return await self.mysql.fetch(sql, params)
关键设计:
- 写 MySQL 永远成功:即使 PG 写失败也不阻断业务,记日志后由对账脚本修复。
- 读 PG 失败自动降级:灰度期 PG 读失败自动回退 MySQL,用户无感。
- 比例可热更新:配置中心推送,无需重启应用。
CDC 增量同步
# Debezium 配置(关键部分)
database.hostname: mysql-master
database.port: 3306
database.user: debezium
database.password: ***
database.server.id: 184054
database.allowTLS: true
database.server.name: core_db
table.whitelist: "core_db.users,core_db.orders,core_db.transactions,..."
database.history.kafka.bootstrap.servers: kafka:9092
database.history.kafka.topic: schema-history-core
# Sync Worker(消费 Kafka 写入 PG)
from kafka import KafkaConsumer
import psycopg2
import json
consumer = KafkaConsumer('core_db.core_db.orders', bootstrap_servers='kafka:9092')
pg = psycopg2.connect(...)
for msg in consumer:
event = json.loads(msg.value)
if event['op'] in ('c', 'u'): # create/update
upsert(pg, event['table'], event['after'])
elif event['op'] == 'd': # delete
delete(pg, event['table'], event['before'])
灰度阶段监控
灰度期监控的核心指标:
| 指标 | 阈值 | 告警动作 |
|---|---|---|
| PG 写入错误率 | > 0.1% | 自动回切 MySQL 写 |
| PG 读取 P99 延迟 | > 500ms | 自动回切 MySQL 读 |
| CDC lag | > 60 秒 | 告警人工介入 |
| 行数差异(每 5 分钟对账) | > 0 | 暂停灰度升级 |
| 业务侧错误率(全链路) | > 0.05% | 立即回滚 |
最终切换执行手册
最终切换在 Day 3 凌晨 02:00 执行,整个流程 12 分钟:
| 步骤 | 时间 | 操作 | 耗时 | 回滚方案 |
|---|---|---|---|---|
| 1 | 02:00:00 | 启用只读模式(前端返回维护页) | 30s | 关闭只读模式 |
| 2 | 02:00:30 | 等待 CDC 消费残留 binlog(lag=0) | 4min | 无需回滚 |
| 3 | 02:04:30 | 跑最终一致性校验(行数 + 抽样) | 3min | 修复差异或回切 |
| 4 | 02:07:30 | 切换 Router 到 PG 单写单读 | 30s | 切回双写 |
| 5 | 02:08:00 | 关闭只读模式 | 30s | - |
| 6 | 02:08:30 | 观察 30 分钟(错误率、延迟、业务反馈) | 30min | 异常回切 MySQL |
| 7 | 02:38:30 | 通知切换完成 | - | - |
回滚预案:MySQL 实例保留 7 天,期间可通过 Router 秒级切回。第 8 天确认无回滚需求后下线 MySQL。
实际切换日志(Day 3 凌晨真实记录)
下面是凌晨切换时的真实操作日志,供后来者参考节奏控制:
[02:00:00] DBA: 启用只读模式
[02:00:28] DBA: 确认前端已展示维护页,写入流量归零
[02:00:30] DBA: 检查 CDC lag = 184 条(残留 binlog)
[02:01:15] DBA: CDC lag = 32 条
[02:02:00] DBA: CDC lag = 4 条
[02:04:20] DBA: CDC lag = 0,开始最终校验
[02:04:30] 自动化: 启动 verify_migration.py --mode=final
[02:05:45] 自动化: 行数校验通过(47/47 表一致)
[02:06:30] 自动化: 抽样校验通过(4700/4700 行一致)
[02:07:15] 自动化: 聚合指标校验通过(订单 SUM、账户 SUM 一致)
[02:07:30] DBA: 切换 Router 到 PG 单写单读
[02:07:42] DBA: 确认 Router 状态 = pg_only
[02:08:00] DBA: 关闭只读模式
[02:08:30] 监控: 错误率 0.00%,P99 = 42ms(正常)
[02:15:00] 监控: 错误率 0.00%,业务侧反馈正常
[02:30:00] 监控: 错误率 0.00%,切读全量稳定 22 分钟
[02:38:30] DBA: 通知切换完成,保留 MySQL 7 天作为回滚备份
注意 02:04:20 → 02:04:30 的 10 秒空档是脚本启动时间,整个有效操作时间仅 8 分钟,剩余 4 分钟为观察期。比预期 15 分钟还短 3 分钟。
常见问题(FAQ)
Q1:为什么不用 pgloader 一键迁移?
pgloader 对标准 DDL 和数据迁移有效,但本案例有几个它处理不了的点:
- 86 个存储过程和 34 个触发器,pgloader 不转换逻辑代码。
- 含 JSONB 字段和 emoji 字符,pgloader 报错率约 0.3%。
- 灰度切换需要双写路由层,pgloader 只解决一次性数据搬运。
- 需要细粒度的数据校验和对账,pgloader 不输出差异报告。
最终用 pgloader 做了 1 张表的兜底(含特殊字符最多的 comments 表),其余用自定义脚本。
Q2:CDC 同步延迟如何控制?
监控 Kafka consumer lag,超过 60 秒告警。本案例高峰期 lag 峰值 280 条(约 3 秒延迟),低峰期 < 10 条。控制手段:
- Sync Worker 横向扩展(3 个消费者实例并行消费不同表的 topic)。
- 批量写入 PG(每 100 条 commit 一次,减少事务开销)。
- 关闭 PG 的 synchronous_commit(同步提交改异步,迁移期间可接受,切换后改回 on)。
Q3:双写期间数据不一致如何修复?
每 5 分钟跑一次对账脚本,输出差异表。修复策略:
- PG 少数据:从 MySQL 拉对应主键的行,UPSERT 到 PG。
- PG 多数据:业务确认后 DELETE(通常是双写时 MySQL 端已删但 PG 端未删)。
- 字段值不一致:以 MySQL 为准覆盖 PG(双写期 MySQL 是 source of truth)。
整个灰度期累计修复 142 行差异,其中 138 行是 datetime 微秒精度问题(业务可接受,未修),4 行是 emoji 编码问题(手工修复)。
Q4:迁移过程中 MySQL 端做了 DDL 变更怎么办?
这是灰度期最大风险。预案:
- 灰度期禁止 MySQL 端做任何 DDL 变更(需业务方签字确认)。
- 若必须变更,Schema 转换 MCP 增量转换新 DDL,同步到 PG,再用 Debezium 把存量数据补齐。
- 极端情况(如加字段)可让 PG 端先加字段设默认值,再分批回填。
本案例灰度期无紧急 DDL 变更,预案未启用。
Q5:AI 转换的存储过程有 bug 怎么办?
Claude 转换的存储过程并非 100% 正确。本案例 86 个存储过程中:
- 52 个简单 CRUD 自动转换,0 个 bug。
- 24 个中等复杂度,Claude 转换后有 7 个 bug(主要是游标 EXCEPTION 处理逻辑),通过 Testcontainers 单元测试发现并修复。
- 10 个复杂存储过程,Claude 转换后有 3 个 bug(递归 CTE 边界条件),通过人工 review 发现。
关键经验:每个存储过程必须有单元测试,测试覆盖率应 ≥ 80%。没有测试的存储过程坚决不上线。
Q6:为什么用 GENERATED ALWAYS AS IDENTITY 而不是 SERIAL?
| 维度 | SERIAL | GENERATED ALWAYS AS IDENTITY |
|---|---|---|
| 标准 | PG 特有 | SQL 标准 |
| 语法 | 简洁 | 略繁琐 |
| 序列管理 | 需手动找序列名 | 自动管理 |
| 权限 | 用户可手动插入任意 ID | 默认禁止手动插入(更安全) |
| 迁移友好度 | 老 PG 项目常用 | 新项目推荐 |
本案例是全新 PG 库,遵循 SQL 标准。若迁移到已有 PG 库需保持一致性,可统一用 SERIAL。
经验总结
1. AI 辅助迁移的边界
适合 AI 处理:
- 规则化的语法转换(数据类型映射、函数替换、DDL 重写)
- 应用层 SQL 批量扫描与替换
- 数据校验脚本生成
- 测试用例生成(基于 ORM 实体反推 DAO 测试)
不适合 AI 处理(需人工决策):
- 业务语义判断(如 TINYINT(1) 是否真布尔)
- 性能调优(索引顺序、查询计划选择)
- 灰度策略设计(需结合业务 SLA)
- 异常 case 处理(如 collation 差异)
最佳实践:让 AI 做粗加工,人做精修。Claude + MCP 的"AI 设计 + 规则执行"组合比纯 AI 或纯规则都更稳。
2. 工具链选择教训
| 选型决策 | 对的 | 错的 | 原因 |
|---|---|---|---|
| Claude Code | ✓ | - | 长上下文吞整个 schema + ORM 代码 |
| PostgreSQL MCP | ✓ | - | 直接执行验证,闭环快 |
| Schema 转换 MCP | ✓ | - | 规则化批量转换,避免 AI 不稳定 |
| pgloader | 兜底 | 主力 | 对 JSON/特殊字符处理弱 |
| Debezium CDC | ✓ | - | 增量同步成熟方案 |
| 手写双写层 | ✓ | ORM 拦截 | 拦截层更可控,ORM 改动侵入大 |
3. 给后来者的建议
- 先做兼容性评估:第 0 步的兼容性报告是后续所有工作的地基,跳过这步直接转 DDL 会踩无数坑。
- 存储过程优先级排序:按复杂度分批处理,简单批让 MCP 自动转,复杂批用 Claude 逐个攻。
- 数据校验三件套:行数 + 抽样 + 聚合指标,缺一不可。本案例靠抽样校验发现了 2 行 emoji 编码问题。
- 灰度必须有熔断:不要指望人工盯监控,错误率超阈值必须自动回切。
- 保留回滚窗口:MySQL 至少保留 7 天,本案例在第 4 天还真用到了一次回滚(因 PG 端一个触发器逻辑 bug)。
- Prompt 要可复用:本文三个 Prompt 模板已沉淀到团队 Prompt 库,后续其他迁移项目可直接复用,预计下次迁移可压到 2 天。
4. 下一步计划
基于本次经验,团队计划:
- 把 Schema 转换 MCP 的 80 条规则扩展到 200 条,覆盖更多 MySQL 边角语法。
- 沉淀一份《MySQL → PG 迁移决策树》,把"AI 自动 vs 人工决策"的边界固化。
- 探索 PostgreSQL →其他数据库(如 ClickHouse)的迁移 Prompt 复用。
- 把灰度切换 Router 抽成开源中间件,赋能社区。
总结:AI 辅助数据库迁移不是"用 AI 替代 DBA",而是"用 AI 把规则化、重复性的工作自动化,让 DBA 聚焦在业务语义判断和架构决策上"。本次迁移 3 天完成的背后,是 1 名资深 DBA 对每一步输出的严格 review + 1 名后端工程师对应用层适配的精细打磨。AI 是放大器,不是替代品——它放大了资深工程师的产出能力,但无法替代对业务和数据库本质的理解。