MySQL 迁移 PostgreSQL,真正麻烦的地方通常不是 CRUD 语法。

而是迁移之后发现:

连接突然满了
SQL 超时了
索引不走了
事务卡死了
程序断线了
自增 ID 冲突了
同样 SQL 跑得比 MySQL 慢
UPDATE 越跑表越大

所以这篇文章不从数据库理论开始,而是直接按线上常见问题横向对比。


一、先看总表

问题MySQLPostgreSQL迁移时最容易踩的坑
连接数可以相对较高单连接成本更高直接照搬 MySQL 连接池
连接超时connect_timeout客户端 connect timeout把连接超时误认为 SQL 慢
SQL执行超时max_execution_timestatement_timeoutPG 默认策略不同
锁等待innodb_lock_wait_timeoutlock_timeoutSQL 本身不慢,是在等锁
空闲连接wait_timeoutidle_session_timeout连接池拿到失效连接
空闲事务相对不突出idle in transaction 非常危险长事务阻塞 VACUUM
断线server has gone awayserver closed connection没设计连接池重连
类型转换比较宽松非常严格varchar = bigint 直接报错
事务错误某些错误后还能继续一条失败,事务进入 abortedcatch 后继续执行
死锁InnoDB 检测PG 检测只调大 timeout
UPSERTON DUPLICATE KEYON CONFLICTSQL 无法直接兼容
自增AUTO_INCREMENTSequence / Identity导数据后 Sequence 没同步
Booleantinyint(1)boolean0/1 字段迁移错误
JSONJSONJSONB 更常用机械迁移成 JSON
分页LIMIT offset,sizeLIMIT size OFFSET offsetSQL 方言不同
索引B-Tree 为主BTree / GIN / GiST / BRIN索引照搬但执行计划变了
UPDATEInnoDB Undo新 Tuple Version高频更新产生 Dead Tuple
垃圾回收PurgeVACUUM不理解 VACUUM
表膨胀有但感知较弱更需要关注UPDATE/DELETE 后磁盘暴涨
执行计划EXPLAINEXPLAIN ANALYZE 很强只看有没有索引,不看实际执行

二、问题 1:连接数突然打满

MySQL

以前可能是:

10 个服务
每个服务 100 个连接

= 1000 Connections

MySQL:

max_connections = 1500

还能继续跑。


PostgreSQL

如果直接迁移:

10 个服务
×
100 Connections
=
1000 PostgreSQL Connections

就可能出现:

too many clients already

或者数据库 CPU / 内存明显上升。

原因之一是 PostgreSQL 的连接模型与 MySQL 不完全相同。

错误迁移方式

MySQL

max_connections = 2000

↓

PostgreSQL

max_connections = 2000

这不是解决方案。

更合理

Application
│
↓
Go Connection Pool
│
↓
PgBouncer
│
↓
PostgreSQL

例如 Go:

db.SetMaxOpenConns(50)
db.SetMaxIdleConns(20)
db.SetConnMaxLifetime(time.Hour)
db.SetConnMaxIdleTime(10 * time.Minute)

核心区别

MySQL 思路:

连接不够
↓
max_connections 调大


PostgreSQL 思路:

连接不够
↓
先检查连接池
↓
检查服务实例数量
↓
检查慢 SQL / 长事务
↓
必要时 PgBouncer

三、问题 2:SQL Timeout,但 SQL 明明有索引

例如:

UPDATE account
SET balance = balance - 100
WHERE user_id = 10001;

正常执行:

0.3 ms

线上却:

30 seconds timeout

很多人的第一反应:

SQL 慢
↓
是不是没索引?

但真正情况可能是:

Transaction A

UPDATE account
WHERE user_id = 10001;

│
↓
持有 Row Lock
│
│
▼

Transaction B

UPDATE account
WHERE user_id = 10001;

│
↓
等待 30 秒
│
↓
Timeout

实际:

SQL执行:0.3ms

等待锁:29999ms

所以:

Timeout ≠ SQL慢。


四、MySQL / PostgreSQL Timeout 对照

       一次数据库请求
│
▼
获取连接池
│
Pool Wait Timeout
│
▼
建立连接
│
Connect Timeout
│
▼
执行 SQL
/ \
/ \
Lock Timeout Statement Timeout

对应:

类型MySQLPostgreSQL
建立连接connect timeoutconnect timeout
SQL执行max_execution_timestatement_timeout
锁等待innodb_lock_wait_timeoutlock_timeout
空闲连接wait_timeoutidle_session_timeout
空闲事务无完全对应idle_in_transaction_session_timeout

排查 Timeout 时不要直接:

Timeout
↓
加索引

应该:

Timeout
│
├─ 没拿到连接?
│
├─ 建连接失败?
│
├─ 在等锁?
│
├─ SQL 真慢?
│
└─ 网络问题?

五、问题 3:断线重连

MySQL 常见

MySQL server has gone away
Lost connection to MySQL server

PostgreSQL 常见

server closed the connection unexpectedly
connection reset by peer
broken pipe

两边本质类似:

连接建立
│
↓
放入连接池
│
↓
长时间未使用
│
↓
DB / Proxy / Network
将连接断开
│
↓
Application 再次拿到
│
↓
发现连接已失效

正确方式:

Connection Pool

├─ Connection A √
├─ Connection B √
├─ Connection C ×
│ │
│ ↓
│ Destroy
│ │
│ ↓
│ New Connection
│
└─ Connection D √

不要在业务代码里永久保存:

conn := db.Conn(...)

然后无限使用。


六、问题 4:同一条 SQL,MySQL 能跑,PostgreSQL 报错

这是迁移时最直观的问题。

MySQL:

SELECT *
FROM users
WHERE id = '10001';

假设:

id = BIGINT

MySQL 很多情况下会自动转换:

'10001'
↓
10001

然后执行。

PostgreSQL 更严格。

可能出现:

operator does not exist:
bigint = character varying

也就是:

BIGINT
=
VARCHAR

×

七、最危险的“临时修复”:CAST

开发看到报错以后,很容易:

WHERE user_id::text = '10001'

SQL 可以跑了。

但是问题来了。

原来:

user_id
│
↓
BTree Index
│
↓
Index Scan

改成:

user_id::text
│
↓
Function / Cast
│
↓
可能无法使用原索引
│
↓
Seq Scan

正确方式应该是:

WHERE user_id = 10001

即:

错误做法:

Column → CAST


正确做法:

Parameter → 正确的数据类型

这点在 Go 项目里尤其重要。


八、问题 5:事务里一个 SQL 报错,后面全部执行不了

这是 PostgreSQL 和 MySQL 使用体验差异中非常重要的一点。

例如:

BEGIN;

INSERT INTO users(id)
VALUES(1);

发生唯一键冲突:

duplicate key value violates unique constraint

代码觉得:

没事
catch(error)
继续

然后:

UPDATE users
SET name = 'karp'
WHERE id = 1;

PostgreSQL:

current transaction is aborted,
commands ignored until end of transaction block

实际事务状态:

BEGIN
│
▼
SQL 1
│
▼
ERROR
│
▼
Transaction = ABORTED
│
├──── SQL 2 ×
├──── SQL 3 ×
├──── SQL 4 ×
│
▼
ROLLBACK

所以 PostgreSQL:

事务 SQL 报错
↓
不要继续
↓
ROLLBACK

如果业务确实需要局部失败:

SAVEPOINT

九、问题 6:死锁

MySQL:

Deadlock found when trying to get lock

PostgreSQL:

deadlock detected

典型:

Transaction A             Transaction B

Lock User 1001 Lock User 1002
│ │
▼ ▼
Lock User 1002 Lock User 1001
│ │
└─────────┐ ┌─────────┘
▼ ▼
Deadlock

错误解决方式:

Deadlock
↓
把 Timeout 从 5 秒改成 30 秒

没有意义。

因为:

A 等 B

B 等 A

等 30 秒还是死锁。

正确解决:

统一加锁顺序

例如:

永远按照:

user_id ASC

所以两个事务都:

1001
↓
1002
↓
1003

同时应用层:

Deadlock
│
↓
Rollback
│
↓
Retry

十、问题 7:索引明明存在,为什么 PostgreSQL 不走?

例如:

CREATE INDEX idx_orders_user_id
ON orders(user_id);

SQL:

SELECT *
FROM orders
WHERE user_id = 10001;

可能:

Index Scan

但改成:

WHERE user_id::text = '10001'

可能:

Seq Scan

另外:

WHERE DATE(created_at) = '2026-08-13'

也可能影响索引。

建议:

WHERE created_at >= '2026-08-13 00:00:00'
AND created_at < '2026-08-14 00:00:00'

十一、不要认为 Seq Scan 一定不好

例如:

orders

总记录数:1000万

查询结果:800万

这种情况下:

Index Scan

不一定比:

Seq Scan

快。

所以迁移 PostgreSQL 后不要:

看到 Seq Scan
↓
强制加索引

而应该:

EXPLAIN ANALYZE
│
├── actual time
├── rows
├── loops
├── cost
└── Scan Type

十二、问题 8:MySQL 的联合索引照搬后,PG 为什么性能不一样?

MySQL 原来:

INDEX idx_user_status_time(
user_id,
status,
created_at
)

SQL:

SELECT *
FROM orders
WHERE user_id = 10001
AND status = 1
ORDER BY created_at DESC
LIMIT 20;

MySQL:

运行很好

迁移 PostgreSQL 后:

SQL能跑

不代表:

执行计划一样

因为:

MySQL Optimizer
≠
PostgreSQL Optimizer

所以迁移应该:

旧 MySQL SQL
│
↓
EXPLAIN
│
↓
记录计划


PG SQL
│
↓
EXPLAIN ANALYZE
│
↓
重新验证

不是:

DDL复制完成
=
索引迁移完成

十三、问题 9:UPDATE 越多,PostgreSQL 表为什么越来越大?

这是 PostgreSQL 和 MySQL 非常核心的差异。

MySQL InnoDB 可以简单理解:

Current Row
│
↓
Undo Log
│
↓
Old Version

PostgreSQL:

Tuple V1

UPDATE
│
▼

Tuple V1 → Old / Dead
Tuple V2 → New

再次 UPDATE:

Tuple V1 → Dead
Tuple V2 → Dead
Tuple V3 → Current

所以:

UPDATE
UPDATE
UPDATE
UPDATE
UPDATE

可能产生大量:

Dead Tuple

十四、这就是为什么 PostgreSQL 必须理解 VACUUM

PostgreSQL:

UPDATE / DELETE
│
▼
Dead Tuple
│
▼
Autovacuum
│
▼
清理 / 标记可复用空间

如果 Autovacuum 跟不上:

大量 UPDATE
│
▼
大量 Dead Tuple
│
▼
Table Bloat
│
▼
表越来越大
│
▼
扫描越来越慢
│
▼
IO越来越高

例如:

有效业务数据:

10 GB

实际表空间:

50 GB

这种问题在 PostgreSQL 中并不罕见。


十五、哪些业务最容易出现 Bloat?

最典型:

订单表
│
├─ pending
├─ matching
├─ filled
├─ canceled
└─ settled

一个订单不断:

UPDATE status
UPDATE filled_qty
UPDATE avg_price
UPDATE fee
UPDATE time

以及:

资产
持仓
任务状态
队列任务
风控状态

都是高频 UPDATE 场景。

所以交易系统迁移 PostgreSQL 时:

Autovacuum 不是 DBA 才需要知道的东西,核心开发也必须理解。


十六、问题 10:一个没提交的事务为什么能把数据库拖慢?

例如:

BEGIN;

执行一些 SQL。

然后程序因为 Bug:

忘记 Commit
忘记 Rollback

连接变成:

idle in transaction

PG 中:

Long Transaction
│
▼
Old Snapshot
│
▼
旧 Tuple 不能正常回收
│
▼
Vacuum 受影响
│
▼
Dead Tuple 增长
│
▼
Table Bloat

所以 MySQL 迁移 PostgreSQL 后:

连接监控

不能只看:

active connection

还要重点看:

idle in transaction

十七、问题 11:导完数据后,自增 ID 冲突

MySQL:

AUTO_INCREMENT

比如:

当前最大 ID:

100000

下一条:

100001

PostgreSQL 常用:

Identity / Sequence

迁移数据时如果只是:

COPY data

导入:

1
2
3
...
100000

但是 Sequence 仍然:

1

下一条 INSERT:

id = 1

然后:

duplicate key

所以迁移完成必须验证:

MAX(id)

VS

Sequence Current Value

必须保证:

Sequence >= MAX(id)

十八、问题 12:UPSERT 直接不能用了

MySQL:

INSERT INTO balance(user_id, amount)
VALUES(10001, 100)
ON DUPLICATE KEY UPDATE
amount = VALUES(amount);

PostgreSQL:

INSERT INTO balance(user_id, amount)
VALUES(10001, 100)
ON CONFLICT(user_id)
DO UPDATE SET
amount = EXCLUDED.amount;

迁移项目中可以直接全局搜索:

ON DUPLICATE KEY UPDATE

全部需要处理。


十九、问题 13:LIMIT 写法不同

MySQL:

LIMIT 100, 20

意思:

OFFSET = 100
LIMIT = 20

PostgreSQL:

LIMIT 20 OFFSET 100

所以:

MySQL

LIMIT offset,size

↓

PostgreSQL

LIMIT size OFFSET offset

二十、问题 14:大 OFFSET 两边都会慢

例如:

SELECT *
FROM trade
ORDER BY id DESC
LIMIT 100 OFFSET 5000000;

数据库实际上:

读取前面大量数据
│
▼
丢掉 5000000 条
│
▼
返回 100 条

更好的方式:

SELECT *
FROM trade
WHERE id < 90000000
ORDER BY id DESC
LIMIT 100;

也就是:

OFFSET Pagination

↓

Keyset Pagination

对于:

订单
成交
资金流水
账单
历史记录

尤其重要。


二十一、问题 15:MySQL 的 tinyint(1) 到 PG 怎么处理?

MySQL:

is_deleted TINYINT(1)

常见:

0 = false
1 = true

PostgreSQL:

is_deleted BOOLEAN

直接:

TRUE
FALSE

但不要把所有:

tinyint

都改成:

boolean

例如:

status = 0 Pending
status = 1 Success
status = 2 Failed

这种应该:

SMALLINT

而不是 BOOLEAN。


二十二、问题 16:JSON 应该迁成 JSON 还是 JSONB?

MySQL:

JSON

PostgreSQL:

JSON
JSONB

如果 JSON 只是:

保存
读取

JSON 可以。

但如果需要:

WHERE 查询
包含判断
索引
字段过滤

通常应该重点考虑:

JSONB

例如:

SELECT *
FROM orders
WHERE extra @> '{"symbol":"BTCUSDT"}';

再结合:

GIN Index

所以:

MySQL JSON

不要机械:

↓
PG JSON

应该:

MySQL JSON
│
├─ 只是保存 → JSON
│
└─ 需要查询 → JSONB

二十三、问题 17:时间字段最容易出现“数据没错,但业务错了”

MySQL 常见:

DATETIME
TIMESTAMP

PostgreSQL:

timestamp
timestamptz

迁移时必须确认:

Application
│
├─ UTC ?
├─ Asia/Shanghai ?
└─ Asia/Tokyo ?

Database
│
└─ TimeZone ?

Server
│
└─ TimeZone ?

否则可能出现:

数据库:

2026-08-13 08:00

应用理解:

UTC

实际业务:

UTC+8

结果差:

8 Hours

交易系统建议:

数据库内部

UTC

展示:

UTC
│
├─ Asia/Shanghai
└─ Asia/Tokyo

二十四、问题 18:Go 代码不能只换 Driver

MySQL:

db.Query(
"SELECT * FROM users WHERE id = ?",
id,
)

PostgreSQL:

db.Query(
"SELECT * FROM users WHERE id = $1",
id,
)

也就是:

MySQL

?


PostgreSQL

$1
$2
$3

如果项目有几千条手写 SQL:

Driver 替换

只是整个迁移的一小部分。


二十五、数据库错误码也要重新改

MySQL:

1062

代表:

Duplicate Entry

业务可能:

if mysqlErr.Number == 1062 {
// duplicate
}

PostgreSQL:

23505

代表:

unique_violation

常见 PG SQLSTATE:

场景PostgreSQL
唯一键冲突23505
外键冲突23503
非空约束23502
死锁40P01
序列化失败40001

最好从:

业务代码
│
↓
直接依赖 MySQL Error Code

改成:

DB Error Adapter
│
├─ Duplicate
├─ Deadlock
├─ Timeout
├─ ConnectionError
└─ SerializationFailure

这样以后数据库迁移会轻松很多。


二十六、MySQL → PostgreSQL 最常见错误迁移方式

最危险的是这种:

第一步:

Schema 转换


第二步:

数据导入


第三步:

SQL改到能运行


第四步:

上线

看起来完成了。

实际上应该:

Schema
│
▼
Data Type
│
▼
Data Migration
│
▼
SQL Compatibility
│
▼
Result Verification
│
▼
Index Verification
│
▼
EXPLAIN ANALYZE
│
▼
Transaction Test
│
▼
Lock Test
│
▼
Connection Test
│
▼
Failover Test
│
▼
Pressure Test
│
▼
Production

二十七、迁移过程中重点排查表

现象MySQL经验判断PostgreSQL 应重点检查
SQL突然30秒慢SQLLock / statement timeout
DB连接满max_connectionsPool / Long TX / PgBouncer
SQL报类型错误很少遇到bigint / varchar / uuid 类型
SQL有索引还慢索引问题EXPLAIN ANALYZE
UPDATE越来越慢锁/索引Dead Tuple / Bloat / Vacuum
DB磁盘突然变大数据增加Bloat
连接不释放Pool问题idle in transaction
SQL失败后后续全失败奇怪Transaction Aborted
主键突然重复AUTO_INCREMENTSequence 未同步
JSON查询慢JSON索引JSONB + GIN
大分页慢LIMIT优化Keyset Pagination
CPU突然升高SQL问题连接数 + Seq Scan + Vacuum
偶发死锁重试Lock Order + Retry
重启后大量报错连接失效Pool健康检查 / 重连

二十八、生产排障思路也应该改变

以前 MySQL:

数据库慢
│
▼
SHOW PROCESSLIST
│
▼
SHOW ENGINE INNODB STATUS
│
▼
EXPLAIN

迁移 PostgreSQL 后:

数据库慢
│
▼
pg_stat_activity
│
├─ Active SQL ?
├─ Long Transaction ?
├─ idle in transaction ?
└─ Lock Wait ?
│
▼
pg_locks
│
▼
EXPLAIN ANALYZE
│
▼
pg_stat_user_tables
│
├─ dead tuples ?
└─ autovacuum ?
│
▼
pg_stat_user_indexes

二十九、最后浓缩成一句话

MySQL 迁移 PostgreSQL:

不是

MySQL SQL
↓
改一下语法
↓
PostgreSQL


而是


MySQL System
│
├─ SQL
├─ Data Type
├─ Index
├─ Transaction
├─ Connection Pool
├─ Retry
├─ Timeout
└─ Operation Model
│
▼
PostgreSQL System

真正最容易踩坑的可以归纳成:

① 类型更严格

② 事务出错后必须 Rollback

③ 不要照搬 MySQL 连接数

④ Timeout 要区分 SQL / Lock / Connection

⑤ 不要为了兼容到处 CAST

⑥ 索引必须重新 EXPLAIN ANALYZE

⑦ UPDATE / DELETE 要理解 Dead Tuple

⑧ PostgreSQL 必须理解 VACUUM

⑨ 必须监控 Long Transaction

⑩ 必须监控 idle in transaction

⑪ 数据迁移后检查 Sequence

⑫ Deadlock / Serialization Failure 要 Retry

⑬ SQL 能执行 ≠ 性能没问题

⑭ Schema 迁完 ≠ 数据库迁移完成

如果原系统属于:

交易所
支付
订单
账户
资金
持仓
清算
账务

那么还需要把下面几项提高到最高优先级:

NUMERIC 精度
+
事务一致性
+
行锁竞争
+
Deadlock Retry
+
长事务
+
Connection Pool
+
Autovacuum
+
Table Bloat

这些才是 MySQL → PostgreSQL 迁移完成后,最容易在线上真正出问题的地方。