1. 为什么要从 mysql 迁移到 postgresql
迁移并非"换数据库"那么简单。两种数据库在设计哲学上有着根本差异,选择迁移通常基于以下驱动因素:
| 驱动因素 | mysql 现状痛点 | postgresql 优势 |
|---|---|---|
| 事务与并发 | innodb mvcc 实现不完整,读写互相阻塞 | 真正 mvcc,读写互不阻塞 |
| 复杂查询 | 优化器较弱,复杂 join 性能不稳定 | 成本优化器成熟,复杂查询更稳定 |
| json 处理 | json 函数有限,索引支持弱 | jsonb 原生支持 + gin 索引,性能优秀 |
| 全文搜索 | 需要 myisam 或额外插件 | 内置全文索引,支持多语言分词 |
| 数据完整性 | 外键性能损耗大,部分场景禁用 | 外键完整支持,性能无明显影响 |
| 扩展性 | 插件系统受限 | 丰富扩展生态(postgis、timescaledb、pgvector 等) |
| 许可与社区 | gpl 许可存在商业化风险 | postgresql license,商用零 风险 |
迁移有成本:如果你对 mysql 已经用得很顺手、业务没有复杂查询需求、团队没有 postgresql 经验,不要为了迁移而迁移。
2. mysql vs postgresql 核心架构差异
2.1 mvcc(多版本并发控制)
这是两种数据库最核心的架构差异,直接影响你的应用行为。
mysql (innodb mvcc): ┌──────────────────────────────────────────────────────┐ │ read view 在事务开始时生成,整个事务看到相同的快照 │ │ 问题:长事务会锁定大量历史版本,影响 purge │ │ 读已提交(默认)时,每次语句重新计算快照 │ └──────────────────────────────────────────────────────┘ postgresql: ┌──────────────────────────────────────────────────────┐ │ 每个查询独立生成快照(postgresql 17+ 更优化) │ │ vacuum 异步清理死亡元组,不阻塞读写 │ │ 真正实现读写不互阻塞 │ └──────────────────────────────────────────────────────┘
实测差异表现:
-- mysql: 开启两个会话
-- session 1:
begin;
select balance from accounts where id = 1; -- 看到 balance = 1000
-- session 2 (另一会话):
update accounts set balance = 2000 where id = 1;
commit;
-- session 1:
select balance from accounts where id = 1; -- mysql 读已提交:看到 2000(不可重复读)
-- postgresql repeatable read:仍看到 1000
commit;
2.2 索引机制对比
| 特性 | mysql (innodb) | postgresql |
|---|---|---|
| 默认索引类型 | b+tree | b-tree(本质上是 b+tree 变体) |
| 索引覆盖 | 支持(覆盖索引) | 支持(index only scan) |
| 表达式索引 | 有限支持(虚拟列) | ✅ 原生支持 |
| 部分索引 | 有限支持 | ✅ 原生支持 |
| 多列索引顺序 | 影响大 | 影响相对小(更智能的优化器) |
| null 索引 | 可配置(is_null 但值存或不存) | ✅ 明确索引 |
| 索引并发创建 | 不支持(阻塞 ddl) | ✅ 支持 concurrently |
| 唯一约束与索引 | 主键自动唯一索引 | 独立唯一约束 + 索引 |
2.3 json 处理
-- mysql 8.0 json 查询 select json_extract(user_data, '$.name') from users; select * from users where json_contains(user_data, '"alice"', '$.name'); -- postgresql jsonb 查询(推荐用 jsonb 而非 json,性能差异巨大) select user_data->>'name' from users; -- 文本提取 select user_data->'name' from users; -- json 类型提取 select * from users where user_data->>'name' = 'alice'; -- postgresql jsonb 索引(gin 索引,效果显著) create index idx_users_data_name on users using gin ((user_data jsonb_path_ops)); create index idx_users_name on users ((user_data->>'name'));
jsonb vs json 选择建议:
jsonb:存储时解析,适合需要频繁查询的字段(推荐默认选择)json:保持原始文本,适合只存储不查询、或需要保留格式的字段
2.4 全文搜索对比
-- mysql 全文索引(innodb,需 fulltext 索引)
alter table articles add fulltext index ft_title_content(title, content);
select * from articles
where match(title, content) against('postgresql 迁移' in natural language mode);
-- postgresql 全文搜索(内置,无需插件)
alter table articles add column search_vector tsvector;
update articles set search_vector = to_tsvector('simple', title || ' ' || content);
create index idx_articles_search on articles using gin(search_vector);
-- 多语言支持(中文需安装 zhparser 扩展)
create extension if not exists zhparser;
create text search configuration chinese_zhparser (parser = zhparser);
alter text search configuration chinese_zhparser add mapping for n,v,a,e,i,l with simple;
select * from articles where search_vector @@ to_tsquery('chinese_zhparser', 'postgresql & 迁移');
2.5 并行查询
-- postgresql 并行查询(默认启用) show max_parallel_workers_per_gather; -- 默认 2 show parallel_tuple_cost; -- 成本阈值 -- 强制使用并行(测试用) set max_parallel_workers_per_gather = 4; select * from large_table where status = 'active'; -- 可能触发 parallel seq scan -- mysql 并行查询(mysql 8.0+ 支持有限并行) -- 主要用于复制相关的并行读取,写操作无并行 set session cte_max_recursion_depth = 100000;
3. 迁移前评估与工具选型
3.1 迁移前必做清单
✅ 数据库对象盘点 - 表数量、字段类型分布 - 索引数量及类型(普通/唯一/全文/空间) - 存储过程/函数/触发器数量及复杂度 - 外键约束数量 - 分区表结构 ✅ 数据量评估 - 单表最大行数 - 总数据量(gb/tb 级迁移策略不同) - lob/blob/text 字段占比 - json 字段使用情况 ✅ sql 复杂度分析 - 统计高频查询(通过慢查询日志) - 识别 mysql 特有语法(handler、get_lock 等) - 识别不兼容 sql(需重写) ✅ 应用层适配 - 连接池(hikaricp/pgbouncer 配置) - 驱动版本(mysql connector → postgresql jdbc) - orm 框架适配(mybatis/hibernate 参数语法)
3.2 工具选型对比
| 工具 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| pgloader | 通用迁移,小到中等规模 | 一条命令完成迁移,支持源端转换规则 | 大规模数据速度一般,gui 弱 |
| ora2pg | oracle 迁移(mysql 也可用) | 规则丰富,脚本化强 | 主要为 oracle 设计,mysql 需调参 |
| aws dms | 云端大规模迁移 | 托管服务,cdt 同步,支持灰度 | 需 aws 基础设施,有额外成本 |
| debezium + kafka | 实时双写/cdc 同步 | 变更捕获,支持零停机迁移 | 架构复杂,需要 kafka 经验 |
| 手动脚本(python) | 精准控制场景 | 完全可控,灵活转换 | 开发量大,耗时 |
推荐组合策略:
场景 1: < 100gb,零停机要求 → pgloader(主) + 手动补数(增量) + 双写验证 场景 2: 100gb ~ 1tb,有零停机要求 → aws dms(持续复制) + 应用双写过渡 场景 3: > 1tb,跨云迁移 → debezium cdc + kafka + 自研消费者
3.3 用 pgloader 进行初步迁移评估
# 安装 pgloader(macos) brew install pgloader # docker 方式(推荐,无需本地安装) docker run --rm -it dimitri/pgloader:latest pgloader --help # 快速评估 mysql 数据库并生成 postgresql ddl docker run --rm -it dimitri/pgloader:latest \ pgloader mysql://root:password@localhost/source_db \ postgresql://postgres:password@localhost/target_db \ --dry-run # 仅评估,不实际迁移
4. ddl 语法差异与转换完整对照
4.1 数据类型对照表
| mysql 类型 | postgresql 类型 | 注意事项 |
|---|---|---|
tinyint | smallint 或 smallserial | 注意范围差异 |
smallint | smallint / smallserial | ✅ 直接对应 |
int / integer | integer / serial | 注意 auto_increment 处理 |
bigint | bigint / bigserial | ✅ 直接对应 |
float | real | 注意精度差异 |
double | double precision | ✅ 直接对应 |
decimal(m,n) | numeric(m,n) | ✅ 直接对应 |
varchar(255) | varchar(255) | ✅ 直接对应 |
text | text | ✅ 直接对应 |
char(10) | char(10) | ✅ 直接对应 |
datetime | timestamp 或 timestamptz | 推荐 timestamptz(带时区) |
datetime(3) | timestamp(3) | postgresql 支持小数秒精度 |
timestamp | timestamptz | ⚠️ 语义不同,mysql timestamp 有时区转换 |
date | date | ✅ 直接对应 |
time | time 或 interval | 语义略有不同 |
bool / boolean | boolean | ✅ 直接对应 |
json | jsonb | 推荐用 jsonb 替代 json |
enum('a','b') | text + check 约束 或 enum 类型 | postgresql 原生支持 enum |
set('a','b') | text[] 数组 或 多行关联表 | postgresql 无 set 类型 |
blob / tinyblob | bytea | postgresql 无 blob 类型 |
geometry | postgis geometry | 需安装 postgis 扩展 |
4.2 自增 id 转换
mysql 的 auto_increment 在 postgresql 中用 serial / bigserial 模拟:
-- mysql:
create table orders (
id bigint auto_increment primary key,
customer_id bigint not null,
created_at datetime default current_timestamp,
primary key (id)
);
-- postgresql(方式1: 序列,推荐):
create table orders (
id bigserial primary key, -- 等价于 bigint + 自动序列
customer_id bigint not null,
created_at timestamptz default now(),
constraint orders_pkey primary key (id)
);
-- postgresql(方式2: generated always as identity,更标准):
create table orders (
id bigint generated always as identity primary key,
customer_id bigint not null,
created_at timestamptz default now()
);
-- 如果需要指定自增起始值(承接 mysql 数据):
create sequence orders_id_seq start with 1000000;
alter table orders alter column id set default nextval('orders_id_seq');
重要:mysql auto_increment 的自增值在事务回滚后可能"跳号",postgresql 的序列也有类似行为,这是正常的。
4.3 索引转换
-- mysql 唯一索引
create unique index idx_user_email on users(email);
-- postgresql:
create unique index idx_user_email on users(email);
-- mysql 全文索引(无法直接转换,需重建)
create fulltext index ft_article on articles(title, body);
-- postgresql 全文索引(重建方式):
alter table articles add column search_vector tsvector
generated always as (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(body,''))) stored;
create index idx_articles_fulltext on articles using gin(search_vector);
-- mysql 前缀索引(postgresql 不支持前缀索引,使用表达式索引替代)
create index idx_user_phone on users(phone(10)); -- mysql: 只索引前10位
-- postgresql: 两种方式
create index idx_user_phone_prefix on users ((phone_text(phone))); -- 自定义函数
-- 或者直接索引整个字段(postgresql 对短字符串索引效率仍然很高)
-- mysql 多列索引
create index idx_order_status_date on orders(status, created_at);
-- postgresql: 完全相同语法,但建议将选择性高的列放前面
create index idx_order_status_date on orders(status, created_at);
-- postgresql 特有:并发创建索引(生产环境必须用,避免锁表)
create index concurrently idx_order_new on orders(customer_id);
4.4 约束转换
-- mysql 外键(注意:mysql 中外键名称可选,postgresql 必须命名)
create table order_items (
id bigint auto_increment,
order_id bigint not null,
product_id bigint,
quantity int default 1,
foreign key (order_id) references orders(id) on delete cascade,
foreign key (product_id) references products(id) on delete set null
);
-- postgresql(约束必须命名):
create table order_items (
id bigserial primary key,
order_id bigint not null,
product_id bigint,
quantity int default 1,
constraint fk_order_items_order foreign key (order_id) references orders(id) on delete cascade,
constraint fk_order_items_product foreign key (product_id) references products(id) on delete set null
);
-- mysql check 约束(mysql 8.0 才支持,之前版本会被忽略)
create table users (
id int auto_increment primary key,
age int check (age >= 0 and age <= 150), -- mysql 8.0+ 才生效
status enum('active','inactive','suspended')
);
-- postgresql(check 约束完整支持):
create table users (
id serial primary key,
age int check (age >= 0 and age <= 150),
status varchar(20) check (status in ('active','inactive','suspended'))
);
-- mysql 默认值(可使用函数)
created_at datetime default current_timestamp,
updated_at datetime default current_timestamp on update current_timestamp
-- postgresql 默认值
created_at timestamptz default now(),
updated_at timestamptz default now()
-- 动态更新触发器(见触发器迁移章节)
5. 数据迁移实战脚本
5.1 完整迁移流程(pgloader 方式)
#!/bin/bash
# migrate_mysql_to_pg.sh
# 依赖: pgloader, mysql client, postgresql client
set -e
# ========== 配置区 ==========
mysql_host="192.168.1.100"
mysql_port="3306"
mysql_user="migration"
mysql_pass="your_password"
mysql_db="source_app"
pg_host="192.168.1.101"
pg_port="5432"
pg_user="postgres"
pg_pass="your_password"
pg_db="target_app"
log_dir="./migration_logs"
date_tag=$(date +%y%m%d_%h%m%s)
mkdir -p "$log_dir"
# ========== step 1: 评估阶段(只读分析)==========
echo "[$(date)] step 1: 开始数据库评估..."
pgloader \
mysql://${mysql_user}:${mysql_pass}@${mysql_host}:${mysql_port}/${mysql_db} \
postgresql://${pg_user}:${pg_pass}@${pg_host}:${pg_port}/${pg_db} \
--dry-run \
--debug \
2>&1 | tee "${log_dir}/step1_assessment_${date_tag}.log"
# ========== step 2: 完整迁移(加并发加速)==========
echo "[$(date)] step 2: 开始数据迁移..."
pgloader \
mysql://${mysql_user}:${mysql_pass}@${mysql_host}:${mysql_port}/${mysql_db} \
postgresql://${pg_user}:${pg_pass}@${pg_host}:${pg_port}/${pg_db} \
with include drop, create tables, create indexes, foreign keys, data only \
set postgresql bytea output to 'escape' \
set mysql bigint unsigned to integer \
cast type date to timestamp drop default, \
type enum to text drop default, \
type blob to bytea drop default \
2>&1 | tee "${log_dir}/step2_migration_${date_tag}.log"
echo "[$(date)] 迁移完成,日志: ${log_dir}/step2_migration_${date_tag}.log"5.2 python 精细化迁移脚本(支持断点续传)
#!/usr/bin/env python3
"""
mysql -> postgresql 迁移脚本(支持断点续传和增量迁移)
依赖: pip install mysql-connector-python psycopg2-binary
"""
import os
import sys
import time
import logging
from datetime import datetime
from typing import optional, generator, any
import mysql.connector
import psycopg2
from psycopg2 import sql
logging.basicconfig(
level=logging.info,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.filehandler(f'migration_{datetime.now():%y%m%d_%h%m%s}.log'),
logging.streamhandler()
]
)
logger = logging.getlogger(__name__)
class mysqltopostgresmigrator:
"""mysql → postgresql 迁移器,支持断点续传"""
def __init__(
self,
mysql_conf: dict,
pg_conf: dict,
checkpoint_file: str = ".migration_checkpoint.json",
batch_size: int = 5000,
):
self.mysql_conf = mysql_conf
self.pg_conf = pg_conf
self.checkpoint_file = checkpoint_file
self.batch_size = batch_size
self.mysql_conn = none
self.pg_conn = none
self.checkpoints = self._load_checkpoints()
def _load_checkpoints(self) -> dict:
"""加载断点文件"""
if os.path.exists(self.checkpoint_file):
import json
with open(self.checkpoint_file) as f:
return json.load(f)
return {}
def _save_checkpoint(self, table: str, max_id: any):
"""保存断点"""
self.checkpoints[table] = max_id
import json
with open(self.checkpoint_file, 'w') as f:
json.dump(self.checkpoints, f)
logger.info(f"断点已保存: {table} -> {max_id}")
def connect(self):
"""建立数据库连接"""
self.mysql_conn = mysql.connector.connect(**self.mysql_conf)
self.pg_conn = psycopg2.connect(**self.pg_conf)
self.pg_conn.autocommit = false
logger.info("数据库连接已建立")
def close(self):
"""关闭连接"""
if self.mysql_conn:
self.mysql_conn.close()
if self.pg_conn:
self.pg_conn.commit()
self.pg_conn.close()
logger.info("数据库连接已关闭")
def get_tables(self) -> list[dict]:
"""获取所有需要迁移的表"""
cursor = self.mysql_conn.cursor(dictionary=true)
cursor.execute("""
select table_name, table_rows, data_length, index_length
from information_schema.tables
where table_schema = %s and table_type = 'base table'
order by data_length desc
""", (self.mysql_conf['database'],))
tables = cursor.fetchall()
cursor.close()
return tables
def get_columns(self, table: str) -> list[dict]:
"""获取表字段信息"""
cursor = self.mysql_conn.cursor(dictionary=true)
cursor.execute("""
select column_name, data_type, is_nullable, column_key,
column_default, character_maximum_length, numeric_precision,
numeric_scale, extra
from information_schema.columns
where table_schema = %s and table_name = %s
order by ordinal_position
""", (self.mysql_conf['database'], table))
columns = cursor.fetchall()
cursor.close()
return columns
def get_primary_key(self, table: str) -> optional[str]:
"""获取主键字段名"""
cursor = self.mysql_conn.cursor(dictionary=true)
cursor.execute("""
select column_name
from information_schema.key_column_usage
where table_schema = %s and table_name = %s
and constraint_name = 'primary'
""", (self.mysql_conf['database'], table))
result = cursor.fetchone()
cursor.close()
return result['column_name'] if result else none
def _convert_value(self, value: any, col_type: str) -> any:
"""数据类型转换"""
if value is none:
return none
if 'blob' in col_type.lower() or 'binary' in col_type.lower():
# mysql blob -> postgresql bytea
return psycopg2.binary(value)
return value
def migrate_table(self, table: str):
"""迁移单个表"""
start_id = self.checkpoints.get(table, 0)
pk = self.get_primary_key(table)
if not pk:
logger.warning(f"表 {table} 无主键,使用全表扫描模式")
pk = self.get_columns(table)[0]['column_name']
# 无主键时不支持断点续传
start_id = none
columns = self.get_columns(table)
col_names = [c['column_name'] for c in columns]
col_placeholders = ','.join(['%s'] * len(col_names))
col_names_str = ', '.join(col_names)
# 构建 insert 语句
insert_sql = sql.sql("insert into {table} ({cols}) values ({placeholders}) on conflict do nothing").format(
table=sql.identifier(table),
cols=sql.sql(', ').join(map(sql.identifier, col_names)),
placeholders=sql.sql(', ').join(['%s'] * len(col_names))
)
mysql_cursor = self.mysql_conn.cursor(dictionary=true)
pg_cursor = self.pg_conn.cursor()
total_rows = 0
max_id = start_id
# 分批查询 mysql
if start_id:
mysql_query = f"select {col_names_str} from {table} where {pk} > %s order by {pk} limit %s"
params = (start_id, self.batch_size)
else:
mysql_query = f"select {col_names_str} from {table} limit %s"
params = (self.batch_size,)
logger.info(f"开始迁移表: {table},起始 {pk}={start_id or 0}")
while true:
mysql_cursor.execute(mysql_query, params)
rows = mysql_cursor.fetchall()
if not rows:
break
# 数据类型转换
converted_rows = []
for row in rows:
converted = tuple(
self._convert_value(row[c], c['data_type'])
for c in zip(col_names, columns)
)
converted_rows.append(converted)
# 批量插入 postgresql
try:
pg_cursor.executemany(insert_sql.as_string(self.pg_conn), converted_rows)
self.pg_conn.commit()
except exception as e:
logger.error(f"插入失败: {e},尝试单条插入...")
for row in converted_rows:
try:
pg_cursor.execute(insert_sql.as_string(self.pg_conn), row)
except exception as row_err:
logger.error(f"单条插入失败: {row_err}")
self.pg_conn.commit()
total_rows += len(rows)
max_id = rows[-1][pk]
logger.info(f" {table}: 已迁移 {total_rows} 行,最新 {pk}={max_id}")
self._save_checkpoint(table, max_id)
if len(rows) < self.batch_size:
break
if start_id:
params = (max_id, self.batch_size)
mysql_cursor.close()
pg_cursor.close()
logger.info(f"表 {table} 迁移完成,共 {total_rows} 行")
return total_rows
def migrate_all(self):
"""迁移所有表"""
self.connect()
tables = self.get_tables()
for table_info in tables:
table = table_info['table_name']
try:
# 跳过系统表
if table.startswith('_') or table in ('migration_log',):
continue
self.migrate_table(table)
except exception as e:
logger.error(f"表 {table} 迁移失败: {e}")
continue
self.close()
logger.info("全部迁移完成!")
if __name__ == '__main__':
mysql_conf = {
'host': '192.168.1.100',
'port': 3306,
'user': 'migration',
'password': 'your_mysql_password',
'database': 'source_app',
'charset': 'utf8mb4',
}
pg_conf = {
'host': '192.168.1.101',
'port': 5432,
'user': 'postgres',
'password': 'your_pg_password',
'database': 'target_app',
}
migrator = mysqltopostgresmigrator(
mysql_conf=mysql_conf,
pg_conf=pg_conf,
checkpoint_file='.migration_checkpoint.json',
batch_size=5000,
)
migrator.migrate_all()5.3 数据校验脚本
#!/usr/bin/env python3
"""数据校验脚本:对比 mysql 和 postgresql 的行数和 checksum"""
import hashlib
import mysql.connector
import psycopg2
mysql_conf = {'host': '192.168.1.100', 'port': 3306,
'user': 'root', 'password': 'xxx', 'database': 'source_app'}
pg_conf = {'host': '192.168.1.101', 'port': 5432,
'user': 'postgres', 'password': 'xxx', 'database': 'target_app'}
def table_checksum(cursor, table: str, pk: str) -> tuple[int, str]:
"""计算表的行数和 md5 checksum"""
cursor.execute(f"select count(*) from {table}")
count = cursor.fetchone()[0]
cursor.execute(f"select {pk} from {table} order by {pk}")
ids = [str(r[0]) for r in cursor.fetchall()]
checksum = hashlib.md5('|'.join(ids).encode()).hexdigest()
return count, checksum
mysql_conn = mysql.connector.connect(**mysql_conf)
pg_conn = psycopg2.connect(**pg_conf)
mysql_cursor = mysql_conn.cursor()
pg_cursor = pg_conn.cursor()
# 获取所有表
mysql_cursor.execute("""
select table_name from information_schema.tables
where table_schema = %s and table_type = 'base table'
""", (mysql_conf['database'],))
tables = [r[0] for r in mysql_cursor.fetchall()]
errors = []
for table in tables:
# 获取主键
pg_cursor.execute("""
select a.attname from pg_index i
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
where i.indrelid = %s::regclass and i.indisprimary
""", (table,))
pk_result = pg_cursor.fetchone()
if not pk_result:
print(f"⚠ {table}: 无主键,跳过 checksum 校验")
continue
pk = pk_result[0]
mysql_count, mysql_cs = table_checksum(mysql_cursor, table, pk)
pg_count, pg_cs = table_checksum(pg_cursor, table, pk)
status = "✅" if mysql_count == pg_count and mysql_cs == pg_cs else "❌"
if status == "❌":
errors.append(table)
print(f"{status} {table}: mysql({mysql_count}, {mysql_cs[:8]}) vs pg({pg_count}, {pg_cs[:8]})")
mysql_cursor.close()
pg_cursor.close()
mysql_conn.close()
pg_conn.close()
if errors:
print(f"\n❌ 校验失败表: {errors}")
else:
print("\n✅ 所有表校验通过!")6. 存储过程与触发器迁移
6.1 mysql vs postgresql 编程模型对比
| 特性 | mysql | postgresql (pl/pgsql) |
|---|---|---|
| 存储过程 | ✅ 支持(mysql 8.0+) | ✅ 支持 |
| 自定义函数 | ✅ 支持 | ✅ 支持(更强大) |
| 触发器 | ✅ 支持 | ✅ 支持 |
| 匿名块 | ❌ 不支持 | ✅ 支持(do 块) |
| 返回表类型 | 有限 | ✅ 支持 setof |
| 异常处理 | get diagnostics | get stacked diagnostics |
| 动态 sql | prepare | execute … using |
| 数组支持 | 有限 | ✅ 原生数组 |
| cte/递归 | ✅ with recursive | ✅ 更强大的 cte |
| return next | ❌ 不用 | ✅ 用于返回多行 |
6.2 存储过程转换示例
-- ============= mysql 存储过程 =============
delimiter //
create procedure get_user_orders(
in p_user_id bigint,
in p_status varchar(20),
out p_total_amount decimal(12,2)
)
begin
declare v_count int default 0;
select count(*) into v_count from orders
where user_id = p_user_id and status = p_status;
if v_count = 0 then
select 'no orders found' as message;
else
select o.*, p.product_name
from orders o
join order_items oi on o.id = oi.order_id
join products p on oi.product_id = p.id
where o.user_id = p_user_id and o.status = p_status;
select sum(total_amount) into p_total_amount
from orders
where user_id = p_user_id and status = p_status;
end if;
end //
delimiter ;
-- ============= postgresql 等价实现 =============
-- 函数版本(推荐,postgresql 函数比过程更常用)
create or replace function get_user_orders(
p_user_id bigint,
p_status varchar(20)
) returns table (
message text,
order_id bigint,
product_name text,
total_amount numeric(12,2)
) as $$
declare
v_count int;
begin
select count(*) into v_count
from orders
where user_id = p_user_id and status = p_status;
if v_count = 0 then
return query select 'no orders found'::text, null::bigint, null::text, null::numeric(12,2);
else
return query
select null::text, o.id, p.product_name, o.total_amount
from orders o
join order_items oi on o.id = oi.order_id
join products p on oi.product_id = p.id
where o.user_id = p_user_id and o.status = p_status;
end if;
end;
$$ language plpgsql;
-- 调用
select * from get_user_orders(12345, 'completed');
-- 过程版本(postgresql 11+ 支持 create procedure)
create or replace procedure get_user_orders_proc(
p_user_id bigint,
p_status varchar(20),
out p_total_amount numeric(12,2)
) as $$
declare
v_count int;
begin
select count(*) into v_count
from orders
where user_id = p_user_id and status = p_status;
if v_count = 0 then
raise notice 'no orders found for user %', p_user_id;
else
select sum(total_amount) into p_total_amount
from orders
where user_id = p_user_id and status = p_status;
end if;
end;
$$ language plpgsql;
-- 调用(postgresql call 语法)
do $$
declare
v_total numeric(12,2);
begin
call get_user_orders_proc(12345, 'completed', v_total);
raise notice 'total amount: %', v_total;
end $$;
6.3 触发器转换
-- ============= mysql 触发器 =============
create trigger tr_order_after_insert
after insert on orders
for each row
begin
insert into order_audit (action, order_id, user_id, amount, created_at)
values ('insert', new.id, new.user_id, new.total_amount, now());
update user_stats set order_count = order_count + 1, total_spent = total_spent + new.total_amount
where user_id = new.user_id;
end;
-- ============= postgresql 等价触发器 =============
create or replace function fn_order_audit_insert()
returns trigger as $$
begin
insert into order_audit (action, order_id, user_id, amount, created_at)
values ('insert', new.id, new.user_id, new.total_amount, now());
update user_stats
set order_count = order_count + 1,
total_spent = total_spent + new.total_amount
where user_id = new.user_id;
return new;
end;
$$ language plpgsql;
create trigger tr_order_after_insert
after insert on orders
for each row
execute function fn_order_audit_insert();
-- 更新时间的自动更新触发器(mysql on update current_timestamp)
create or replace function fn_update_timestamp()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create trigger tr_users_update_timestamp
before update on users
for each row
execute function fn_update_timestamp();
6.4 高级:mysql handler / get_lock 等价实现
-- mysql get_lock(分布式锁)
-- mysql:
-- get_lock('my_lock', 10); -- 获取锁,超时10秒
-- do release_lock('my_lock');
-- postgresql: 使用 advisory lock(更强大的机制)
do $$
begin
-- 尝试获取 advisory lock(key 为 bigint)
if pg_try_advisory_lock(12345) then
raise notice 'lock acquired!';
-- 执行业务逻辑
perform pg_advisory_unlock(12345);
else
raise notice 'lock not available, waiting...';
-- 等待锁
perform pg_advisory_lock(12345);
raise notice 'lock acquired after waiting!';
perform pg_advisory_unlock(12345);
end if;
end $$;
-- postgresql advisory lock 批量模式
select pg_try_advisory_lock(1), pg_try_advisory_lock(2), pg_try_advisory_lock(3);
-- ... 业务逻辑 ...
select pg_advisory_unlock(1), pg_advisory_unlock(2), pg_advisory_unlock(3);
7. 性能调优对比:b+tree vs postgresql 索引家族
7.1 postgresql 索引类型全景图
postgresql 索引类型
├── b-tree(默认,等价 mysql innodb b+tree)
│ ├── 适用: =, <, <=, >, >=, between, is null, like 前缀匹配
│ └── 场景: 主键、唯一索引、大多数普通查询
├── hash
│ ├── 适用: = 精确匹配(仅等值查询)
│ ├── 优点: 比 b-tree 体积小,查找快
│ └── 场景: uuid、临时表、不需要范围查询的字段
├── gin(generalized inverted index)
│ ├── 适用: 数组、jsonb、全文搜索
│ ├── 优点: 一条索引覆盖多值字段
│ └── 场景: 标签查询、商品属性搜索
├── gist(generalized search tree)
│ ├── 适用: 几何类型、范围类型、全文搜索、postgis
│ └── 场景: 地理位置范围查询
├── brin(block range index)
│ ├── 适用: 物理顺序与逻辑顺序一致的大表(时序数据)
│ ├── 优点: 索引极小,适合 tb 级表
│ └── 场景: 日志表、传感器数据、订单表(按时间分区)
└── sp-gist(space-partitioned gist)
├── 适用: 非平衡数据结构(树、网格)
└── 场景: 字典树、四叉树、k-d 树
7.2 索引选择决策树
┌─────────────────────────┐
│ 什么类型的查询? │
└───────────┬─────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
等值比较 范围/排序 多值/特殊
│ │ │
┌─────┴─────┐ ┌────┴────┐ ┌────┴─────┐
│ like 'x%'?│ │between等?│ │ jsonb/数组│
└─────┬─────┘ └────┬────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
b-tree(默认) b-tree(默认) gin
或 brin
┌─────────────────────────────────────────┐
│ 时序数据 / 按时间分区的表 → 优先 brin │
│ 数据量 > 100gb 且按时间顺序插入 → brin │
│ 地理位置数据 → gist + postgis │
│ jsonb 多条件查询 → gin 索引 │
│ 只需要 = 比较 → hash 索引(体积更小) │
└─────────────────────────────────────────┘
7.3 性能 benchmark 对比
测试环境: postgresql 17 / mysql 8.0,单机 8 核 32gb,数据集 1000万行
测试 1:b+tree 基础性能(等值查询)
| 场景 | mysql innodb | postgresql b-tree | 结论 |
|---|---|---|---|
| 主键查询 (int) | 0.1ms | 0.1ms | 持平 |
| 唯一索引查询 | 0.2ms | 0.2ms | 持平 |
| 范围查询 (int) | 5ms | 5ms | 持平 |
| 前缀 like 查询 | 8ms | 7ms | 持平 |
测试 2:jsonb 查询性能
-- 测试数据: users 表 1000万行,每行有 user_data jsonb 字段 -- 查询: where user_data->>'city' = 'beijing' -- 无索引 explain (analyze, buffers) select * from users where user_data->>'city' = 'beijing'; -- 添加 gin 索引后 create index idx_users_city on users using gin ((user_data jsonb_path_ops));
| 查询方式 | 无索引 | b-tree 表达式索引 | gin 索引 |
|---|---|---|---|
| mysql json 查询 | 4200ms | n/a | n/a |
| postgresql jsonb 无索引 | 4100ms | 4100ms | 4100ms |
| postgresql jsonb + gin | n/a | n/a | 15ms |
| postgresql jsonb + b-tree | n/a | 12ms | n/a |
结论: jsonb + gin 索引性能提升 273倍,远超 mysql。
测试 3:brin vs b-tree(大表时序场景)
-- orders 表 1亿行,按 created_at 时间顺序插入 -- 数据量: 约 80gb -- b-tree 索引(常规做法) create index idx_orders_created_btree on orders(created_at); -- 索引大小: ~3.2gb -- brin 索引(利用物理顺序) create index idx_orders_created_brin on orders using brin(created_at); -- 索引大小: ~128kb(仅为 b-tree 的 0.004%) -- 查询性能对比: select * from orders where created_at between '2026-01-01' and '2026-01-31'
| 索引类型 | 索引大小 | 写入放大 | 查询耗时 |
|---|---|---|---|
| b-tree | 3.2gb | 正常 | 8ms |
| brin | 128kb | 几乎无 | 45ms |
| 无索引 | 0 | 全表扫描 | 18000ms |
结论: brin 索引体积接近零,写入零损耗,适合时序场景。查询稍慢但仍在可接受范围。
7.4 实战调优技巧
-- 1. 查看慢查询的真正执行计划
explain (analyze, buffers, format json)
select o.*, u.username from orders o
join users u on o.user_id = u.id
where o.status = 'pending' and o.created_at > now() - interval '7 days';
-- 2. postgresql 特有: 部分索引(mysql 不支持)
-- 只索引活跃订单,大幅减少索引体积
create index idx_orders_pending on orders(created_at)
where status = 'pending';
-- 3. postgresql 特有: 表达式索引
create index idx_users_email_lower on users (lower(email));
-- 4. 覆盖索引(index only scan)
create index idx_users_covering on users (user_id) include (username, email);
-- mysql: 在索引列后直接加 include
-- postgresql: 用 include(pg 11+)或表达式模拟
-- 5. 监控索引使用情况
select
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
from pg_stat_user_indexes
order by idx_scan asc;
-- 找出从未被使用的索引(可删除节省空间)
select
schemaname || '.' || tablename as table_name,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
from pg_stat_user_indexes
where idx_scan = 0
and indexrelid not in (select conindid from pg_constraint)
order by pg_relation_size(indexrelid) desc;
-- 6. 重建臃肿索引(postgresql 索引会膨胀)
reindex index concurrently idx_users_email;
vacuum analyze;
7.5 连接池与参数调优
# postgresql.conf 关键参数调优(8核32gb机器示例) # === 内存参数 === shared_buffers = 8gb # 推荐 25% 物理内存 effective_cache_size = 24gb # 推荐 75% 物理内存(留给 os 缓存) work_mem = 256mb # 每个排序/哈希操作内存上限 maintenance_work_mem = 2gb # vacuum/reindex/建索引时可用内存 # === 并行查询 === max_worker_processes = 8 max_parallel_workers_per_gather = 4 max_parallel_workers = 8 max_parallel_maintenance_workers = 4 parallel_tuple_cost = 0.1 parallel_setup_cost = 1000 # === 写入性能 === wal_buffers = 64mb checkpoint_completion_target = 0.9 max_wal_size = 4gb min_wal_size = 1gb # === 连接数(配合 pgbouncer 使用)=== max_connections = 200 # 直接连 pg 的应用连接上限 # 生产环境推荐: max_connections 设小,用 pgbouncer 分 2000 个虚拟连接 # === autovacuum(重要!mysql 无此概念)=== autovacuum_max_workers = 4 autovacuum_naptime = 30s autovacuum_vacuum_threshold = 50 autovacuum_analyze_threshold = 50
# pgbouncer pgbouncer.ini 配置(连接池) [databases] target_app = host=127.0.0.1 port=5432 dbname=target_app [pgbouncer] listen_addr = 127.0.0.1 listen_port = 6432 auth_type = md5 auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction # 事务级连接池(推荐),session 模式更保守 max_client_conn = 5000 default_pool_size = 50 # 每个 db/user 的连接数 min_pool_size = 10 reserve_pool_size = 5 reserve_pool_timeout = 3 server_idle_timeout = 600
8. 常见坑及解决方案
坑 1:字符集与排序规则
-- ⚠️ mysql 的 utf8mb4 与 postgresql 的 utf8 行为差异
-- mysql 8.0(默认排序规则 utf8mb4_0900_ai_ci)
create table users (
name varchar(50) character set utf8mb4 collate utf8mb4_0900_ai_ci
);
-- postgresql(默认排序规则继承数据库/集群级别设置)
-- 推荐创建数据库时指定:
create database target_app
with encoding = 'utf8'
lc_collate = 'en_us.utf8' -- 影响 order by 和字符串比较
lc_ctype = 'en_us.utf8' -- 影响字符分类(大写/小写/数字)
template = template0; -- 避免模板数据库污染
-- 中文环境推荐:
create database target_app
with encoding = 'utf8'
lc_collate = 'c'
lc_ctype = 'c'
template = template0;
-- 关键差异:postgresql 区分大小写!
select * from users where username = 'alice'; -- 大小写敏感!
select * from users where username ilike 'alice'; -- 不区分大小写
-- 解决方案:使用不区分大小写的排序规则或创建表达式索引
create index idx_users_username_ci on users ((lower(username)));
create extension if not exists citext; -- citext 类型,自动不区分大小写
-- 修改字段为 citext 类型
alter table users alter column username type citext using username::citext;
坑 2:大小写敏感性
-- mysql: 表名/列名在 windows/macos 不区分大小写,linux 区分
-- postgresql: 总是区分大小写(除非用双引号包裹)
-- 问题:mysql 表名 user_orders 在 pg 中必须完全匹配
select * from user_orders; -- ❌ 报错: relation "user_orders" does not exist
select * from user_orders; -- ✅ 正确
-- 解决方案:统一使用小写下划线命名
-- 建议在迁移脚本中加入: table_name.lower().replace('-', '_')
-- 函数名、索引名同理
坑 3:分页 offset 性能陷阱
-- mysql 分页(传统方式)
select * from orders order by id limit 1000000, 20;
-- mysql 在 id 上有索引时,优化器可以跳过前100万行
-- postgresql offset 性能陷阱(深分页极慢)
select * from orders order by id limit 20 offset 1000000;
-- postgresql 必须扫描 1000020 行才能返回结果!
-- 解决方案 1:游标分页(推荐,性能恒定)
select * from orders
where id > :last_seen_id
order by id
limit 20;
-- 解决方案 2:keyset 分页(适合前端无限滚动)
-- 应用层传入上一页最后一条的 id/cursor
-- 解决方案 3:如果必须用 offset,加子查询(利用索引覆盖)
select * from (
select id from orders order by id limit 20 offset 1000000
) as page_ids
join orders o using (id);
-- 解决方案 4:使用 offset + 已知边界(分批处理)
do $$
declare
offset_val bigint := 0;
batch_size int := 10000;
begin
for i in 1..100 loop
-- 每次处理 10000 行
perform do_something_with_batch(offset_val, batch_size);
offset_val := offset_val + batch_size;
raise notice 'processed offset %', offset_val;
end loop;
end $$;
坑 4:空值(null)处理差异
-- mysql: null = null 结果为 true(违反 sql 标准) -- postgresql: null = null 结果为 null(标准行为) -- 这会导致大量 where 条件失效! select * from users where deleted_at = null; -- ❌ postgresql 中永远返回空 -- 必须写成: select * from users where deleted_at is null; -- 批量替换技巧(mysql → postgresql 迁移后) update users set deleted_at = null where deleted_at = '0000-00-00 00:00:00'; -- 清掉 mysql 的 0 时间戳 update users set deleted_at = null where deleted_at = '1970-01-01'; -- 清掉 unix 纪元 -- 重要:mysql 的 group by 不要求 select 字段在 group by 中 -- postgresql 严格模式要求所有 select 字段要么在 group by 中,要么是聚合函数 -- mysql(可能报错): -- select status, count(*), created_at from orders group by status; -- postgresql(严格): select status, count(*), min(created_at) from orders group by status; -- 或关闭严格模式(不推荐): set sql_mode = '';
坑 5:自动提交与事务行为
-- mysql: 默认 autocommit=1(每条语句自动提交) set autocommit = 0; -- 手动开启事务 -- postgresql: 默认 autocommit=off(在事务块内才提交) -- 单条语句也在事务中,但无需 begin begin; -- postgresql 中 begin 是可选的(但显式写是好习惯) select * from orders where id = 1; update orders set status = 'shipped' where id = 1; commit; -- ⚠️ postgresql 没有 rollback to savepoint 以外的回滚点语法(部分支持) savepoint my_savepoint; -- ... some work ... rollback to savepoint my_savepoint; -- ✅ 支持 release savepoint my_savepoint;
坑 6:时区处理
-- mysql timestamp 在不同版本行为不一致(取决于 time_zone 设置)
-- postgresql timestamptz 明确存储 utc,转换由客户端负责
-- 推荐:统一使用 timestamptz
create table events (
id bigserial primary key,
event_time timestamptz not null default now(),
created_at timestamptz not null default now()
);
-- 统一时区显示
set time zone 'asia/shanghai';
-- 日期计算差异
-- mysql:
-- select date_sub(now(), interval 7 day);
-- postgresql:
select now() - interval '7 days';
select current_timestamp at time zone 'asia/shanghai';
坑 7:ddl 锁表问题
-- ⚠️ postgresql 大多数 ddl 操作会锁表! create index idx_orders_user_id on orders(user_id); -- 阻塞写操作 -- ✅ 解决方案:concurrently(不锁表,但速度慢 2-3 倍) create index concurrently idx_orders_user_id on orders(user_id); drop index concurrently idx_orders_user_id; -- ✅ 迁移阶段推荐:一次性重建所有索引 -- 1. 先创建所有索引为 concurrently(不加唯一约束) -- 2. 迁移数据 -- 3. 最后加唯一约束和外键 -- ⚠️ alter table 也可能锁表,pg 15+ 支持更多并发操作 alter table orders add column tracking_no varchar(50); -- pg 11+ 不锁表 alter table orders alter column status type varchar(20); -- 可能锁表 -- 替代方案(大表): alter table orders alter column status type varchar(20) using status::varchar(20); -- pg 15+ 允许 using 表达式避免全表重写(如果兼容的话)
坑 8:mysql 特有语法兼容表
| mysql 特有语法 | postgresql 替代方案 |
|---|---|
limit 10 offset 20 | limit 10 offset 20 ✅(兼容) |
select found_rows() | 窗口函数 count(*) over() 或会话变量 |
insert ignore into | insert ... on conflict do nothing |
replace into | insert ... on conflict (id) do update |
handler 语句 | pl/pgsql 游标 或 for update |
get_lock() | pg_advisory_lock() |
delimiter $$ | 不需要(语法块用 $$ 或 $body$) |
on duplicate key update | insert ... on conflict (col) do update set |
auto_increment | serial / bigserial / generated always as identity |
ifnull(col, default) | coalesce(col, default) |
group_concat | string_agg(col, ',') |
date_format | to_char(col, 'yyyy-mm-dd') |
now() + interval 1 day | now() + interval '1 day' |
select sql_calc_found_rows | 窗口函数或两次查询 |
9. 灰度上线策略
9.1 推荐方案:双写 + 灰度切流
阶段 1: 并行写入期(1-2周)
┌─────────────┐ ┌─────────────┐
│ mysql ←────│─────│ 应用层 │────→ postgresql
│ (主库) │ │ 双写逻辑 │ (新库, 只写)
└─────────────┘ └─────────────┘
↑ ↑
│ │
读取 增量校验
阶段 2: 读流量灰度(逐步切 5% → 20% → 50% → 100%)
┌─────────────┐ ┌─────────────┐
│ mysql │◄────│ 灰度网关 │────→ postgresql
│ (95%读) │ │ (权重分流) │ (5%-100%读)
└─────────────┘ └─────────────┘
阶段 3: 写流量切流(确认无误后)
┌─────────────┐ ┌─────────────┐
│ mysql │◄────│ 切流开关 │────→ postgresql
│ (备用回滚) │ │ (一键切换) │ (100%写)
└─────────────┘ └─────────────┘
阶段 4: 观察期(1周)+ 数据回填校验
9.2 应用层双写实现
#!/usr/bin/env python3
"""灰度切流控制器(伪代码)"""
from enum import enum
import random
import hashlib
import time
class dbmode(enum):
mysql = "mysql"
pg = "postgres"
dual = "dual_write"
class trafficrouter:
"""基于用户 id / 请求 id 的确定性灰度路由"""
def __init__(self, pg_read_weight: float = 0.0):
"""
pg_read_weight: postgresql 读流量占比 (0.0 ~ 1.0)
"""
self.pg_read_weight = pg_read_weight
def should_use_pg(self, user_id: str, operation: str) -> bool:
"""决定是否使用 postgresql(确定性路由,同一 user_id 结果一致)"""
if operation == "write":
# 写入期:mysql 写,postgresql 同步写
return true # 双写模式下两边都写
# 读取:根据灰度权重
key = f"{user_id}:{operation}"
hash_val = int(hashlib.md5(key.encode()).hexdigest(), 16)
return (hash_val % 100) < (self.pg_read_weight * 100)
def route_query(self, user_id: str, sql: str) -> dbmode:
"""根据路由决定查询走哪个数据库"""
if self.should_use_pg(user_id, "read"):
return dbmode.pg
return dbmode.mysql
def route_write(self, sql: str) -> list[dbmode]:
"""写入路由(双写期两边都写)"""
return [dbmode.mysql, dbmode.pg]
# 使用示例
router = trafficrouter(pg_read_weight=0.2) # 20% 读流量切 pg
def execute_read(user_id: str, sql: str):
target = router.route_query(user_id, sql)
if target == dbmode.pg:
return pg_execute(sql)
return mysql_execute(sql)
def execute_write(sql: str):
results = []
for target in router.route_write(sql):
if target == dbmode.mysql:
results.append(("mysql", mysql_execute(sql)))
else:
results.append(("pg", pg_execute(sql)))
# 校验双写结果一致性(可选)
compare_results(results)
return results[0] # 返回主库结果
# 灰度升级脚本
def upgrade_pg_read_traffic(target_weight: float, step: float = 0.1, interval: int = 3600):
"""渐进式提升 pg 读流量权重"""
current = 0.0
while current < target_weight:
current = min(current + step, target_weight)
router.pg_read_weight = current
print(f"[{time.strftime('%h:%m:%s')}] pg 读流量: {current*100:.0f}%")
time.sleep(interval)
9.3 数据一致性校验方案
-- 1. 实时数据对比视图(mysql 侧运行)
create or replace view v_data_consistency as
select
'orders' as table_name,
(select count(*) from orders) as mysql_count,
(select count(*) from orders_pg) as pg_count,
(select count(*) from orders o
left join orders_pg p on o.id = p.id
where p.id is null) as mysql_only_count,
(select count(*) from orders_pg p
left join orders o on p.id = o.id
where o.id is null) as pg_only_count
union all
select
'users',
(select count(*) from users),
(select count(*) from users_pg),
(select count(*) from users u left join users_pg p on u.id = p.id where p.id is null),
(select count(*) from users_pg p left join users u on p.id = u.id where u.id is null);
select * from v_data_consistency;
10. 总结与建议
迁移决策检查清单
□ 数据量 < 50gb,sql 复杂度低 → pgloader 即可完成 □ 数据量 50gb~1tb,零停机 → aws dms 或 pgloader + 双写 □ 数据量 > 1tb → debezium cdc 方案 □ 有大量存储过程/触发器 → 预留 40% 额外迁移时间 □ mysql 特有语法多 → 先评估重写工作量 □ json 查询密集 → postgresql jsonb 收益巨大 □ 地理位置需求 → postgis 绝对值得迁移 □ 时序数据 → timescaledb 扩展可大幅简化分区管理
核心收益回顾
| 维度 | mysql | postgresql | 收益 |
|---|---|---|---|
| json 查询 | 4200ms(无索引) | 15ms(gin) | 280x |
| 索引并发创建 | ❌ 不支持 | ✅ concurrently | 零停机 |
| 写入锁 | ddl 锁表 | concurrently | 零停机 |
| 全文搜索 | 需 fulltext | 内置多语言 | 简化架构 |
| mvcc | 不完整 | 完整 | 高并发更稳定 |
| 扩展生态 | 有限 | 丰富 | 一库多能 |
最重要的一条建议
迁移最大的成本不是数据迁移,而是应用层 sql 的兼容性调试。 建议先做 sql 审计,将所有 mysql 特有语法标记出来,评估重写工作量,再决定是否迁移、何时迁移、如何灰度。
本文测试环境:postgresql 17.3 / mysql 8.0.36 / python 3.11
以上就是mysql到postgresql完整迁移指南(含数据迁移/语法差异/性能调优)的详细内容,更多关于mysql到postgresql的迁移的资料请关注代码网其它相关文章!
发表评论