☰
从本地PostgreSQL迁移到Supabase:全流程实战与避坑指南
2026/10/5 4:17:30 网站建设 项目流程

上个月我接了个活儿,把一个跑了两年的本地项目整体搬到 Supabase 上。项目不大不小,前后加起来三十六张表,外键、触发器、视图、序列一个不少。当时想得很简单,觉得“不就是导出再导入嘛”,真动起手来才发现,从本地数据库到云平台这条路上全是细节,每一步都有可能让后面全线崩盘。这篇就把我整理出来的完整流程和踩过的坑记下来,给准备做同类型迁移的朋友当个参考。后面可能还打算做类似迁移的人,特别是后端开发和独立开发者,这篇应该能帮你省掉一大半排查时间。

Supabase 这个例子选得比较典型,它本质上是托管 PostgreSQL 加一套 BaaS 能力,所以从本地 PostgreSQL 迁过去的路子,几乎可以平移借鉴到任何云数据库平台。哪怕你的目标平台是 RDS、Neon、或者是别的什么托管 PG 服务,核心思路都是相通的。下面不废话,直接说正事。

1. 迁移前的盘点:别急着导出,先把家底摸清楚

很多人拿到迁移任务,第一反应就是打开终端执行 pg_dump。我劝你停一下。迁移这种事,前期梳理越细,后面收拾残局的时间就越少。第一步不是导出,而是把源库和目标库的差异彻底搞清楚。

1.1 确认源端与目标端的“方言”差异

先问自己一个问题:本地库到底是 PostgreSQL,还是 MySQL,又或者是别的什么?这一点直接决定了后面的迁移难度。同一个 PostgreSQL 体系内迁移,基本上就是版本兼容问题,本地是 PG 13/14,Supabase 目前是 PG 15,只要不用太冷门的扩展,问题不大。但如果是从 MySQL 迁到 Supabase,那就要做一层“翻译”,因为两边对数据类型、索引、自增主键、事务行为的处理方式截然不同。

我整理了一份常见的数据类型对照表,如果你是从 MySQL 过来,基本可以参考这个映射关系:

MySQL 类型PostgreSQL/Supabase 类型说明
INT AUTO_INCREMENTBIGSERIAL / IDENTITY显式自增,注意序列处理
TINYINT(1)BOOLEANMySQL 用长度 1 的 tinyint 表示布尔
DATETIMETIMESTAMPTZ时区处理逻辑不同,建议统一带时区
TIMESTAMPTIMESTAMPTZ同上,避免时间错乱
ENUMVARCHAR + CHECK / 自建 ENUMPG 有原生 enum,但后续加值维护麻烦
JSONJSONBJSONB 支持索引,查询性能好很多
TEXT / VARCHARTEXT / VARCHAR长度语义基本一致,注意 VARCHAR 长度定义

这里有一个非常容易踩的坑:MySQL 的标识符默认大小写敏感度取决于 lower_case_table_names 参数,而 PostgreSQL 会把所有不带引号的标识符强制转为小写。也就是说,如果原 MySQL 库里有一张表叫UserInfo,导入 PostgreSQL 之后要么变成userinfo,要么变成必须带引号才能访问的"UserInfo"。这个细节放到后面第 4 部分详细说,这里先记住一句话:迁移前统一用小写加下划线的命名风格,能省掉很多后面的麻烦。

1.2 把表、外键、触发器、序列的依赖关系画出来

这一步听着麻烦,其实是整个迁移里最有价值的一个动作。你需要弄清楚这几件事:

  • 一共有多少张表,每张表大概多少行数据
  • 哪些表之间有外键依赖,谁引用谁
  • 哪些表上挂着触发器、视图、物化视图
  • 哪些字段用的是序列自增,序列的当前值是多少
  • 有没有自定义函数、存储过程

我习惯直接跑一段 SQL 把外键关系拉出来,虽然结果比较粗糙,但梳理够用了:

SELECT conrelid::regclass AS table_name, confrelid::regclass AS referenced_table, conname AS constraint_name FROM pg_constraint WHERE contype = 'f' ORDER BY 1;

触发器可以用这条查:

SELECT event_object_table AS table_name, trigger_name, action_timing AS timing, event_manipulation AS event FROM information_schema.triggers ORDER BY event_object_table;

序列的当前值则是这样:

SELECT c.relname AS sequence_name, last_value, is_called FROM pg_sequences s JOIN pg_class c ON c.relname = s.sequencename;

为什么要做这一步?因为导入云平台的时候,外键约束的存在会导致导入顺序非常讲究。你得先导父表、再导子表,否则插入数据时外键直接报错,一条都进不去。触发器也是一样,某些触发器会在导入数据时被意外触发,导致数据被改写或拦截。把这些依赖提前摸清楚,后面导出导入的时候就能按依赖顺序排兵布阵。

1.3 目标端环境确认:别在连接串上卡壳

Supabase 本身的环境检查也不复杂,但容易被忽视。你要确认几件事:

  • 项目的 Region,也就是数据库物理位置
  • 数据库的连接串,通常长这样:postgresql://postgres:[YOUR-PASSWORD]@db.[REF].supabase.co:5432/postgres
  • 是否启用了 SSL 连接,Supabase 默认要求 SSL
  • 项目里已经预装的相关扩展,比如 pgcrypto、uuid-ossp 这些

还有一个非常关键的认知差:Supabase 的数据库账号不是超级用户。Dashboard 里那个 postgres 账号虽然有比较高的权限,但它不能执行所有本地 PG superuser 能执行的操作。这一点会在后文导数据的时候反复出现,比如某些扩展装不上、某些 SET 语句不允许执行,都需要特殊处理。

迁移前的检查我一般列成一张清单,做完一项划掉一项:

检查项状态备注
源库数据库类型与版本完成PG 14
表数量 / 数据量统计完成36 张表 / 约 70 万行
外键依赖关系列表完成31 个外键
触发器清单完成8 个
序列清单与当前值完成9 个序列
目标端连接串完成SSL 已开启
目标端预装扩展列表完成pgcrypto 存在

这张表可以按你自己的项目改。核心思路是:落袋为安,确认了再往下走,不然你会在迁移中途发现“某个依赖对象少导了”,然后回来重跑一遍。

2. 导出阶段:别一把梭,用好 pg_dump 的三个关键参数

导出阶段是整个迁移里最有技术含量的一步。很多人在这一步就犯了致命错误:拿个图形化工具,选中表,点个“转储 SQL 文件”,然后发现导出来的文件要么没有外键,要么没有序列,要么数据量一大就内存溢出。这里我直接给出我验证过的方案。

2.1 为什么主推 pg_dump,而不是图形工具或者 CSV

先说结论:pg_dump是 PostgreSQL 自带的逻辑备份工具,它能保留表结构、数据、索引、约束、触发器、函数、序列的完整依赖顺序。这个“依赖顺序”是手工导出 CSV 完全没法保证的。CSV 方式看着简单,但你要自己管理建表顺序、外键顺序、序列同步,数据量一大人很容易乱。

图形化工具其实底层调用的也是 pg_dump。例如 DBeaver 或者 Navicat 的“转储”功能,本质上是封装了 pg_dump 的参数。如果你对这些工具不够熟悉,直接命令行反而更可控、更好排查。而且命令行导出的文件是标准文本或自定义格式,放到任何环境都能重复使用。

pg_dump还有一个特别有用的特性:它默认是在导出开始时做一个一致性快照,也就是说,即便业务在读库,导出的数据也是一致的时间点数据,不会出现表 A 是上午的数据、表 B 是下午的数据这种错乱。这一点是手工导 CSV 做不到的。

2.2 推荐的三段式导出:结构、数据、对象分离

我推荐的导出方式不是一次性pg_dump一把梭,而是“三段式”。思路是先把结构导出来,再导数据,最后单独处理函数和触发器。这样做的好处是:结构文件用于建库,数据文件可以随时重导,函数和触发器单独管理更方便排错。

第一步,导出表结构,不包含数据:

pg_dump -h localhost -U postgres -d mydb \ --schema-only \ --no-owner \ --no-privileges \ -f mydb_schema.sql

第二步,导出数据,不包含结构:

pg_dump -h localhost -U postgres -d mydb \ --data-only \ --no-owner \ --no-privileges \ -f mydb_data.sql

第三步,导出函数和触发器:

pg_dump -h localhost -U postgres -d mydb \ --section=pre-data \ --section=post-data \ --no-owner \ --no-privileges \ -f mydb_objects.sql

这三个参数是我反复用下来的核心组合:

  • --schema-only:只导结构,不导数据,适合先建表
  • --data-only:只导数据,导入时不会碰已存在的表结构
  • --no-owner --no-privileges:这两个参数非常关键。本地库的 owner 一般是你的本地用户名,而 Supabase 里没有这个角色。如果不加这两个参数,执行 SQL 时就会报role "yourname" does not exist,卡在开头。

还有人问要不要用-Fc自定义格式。-Fc的好处是可以用pg_restore灵活选择要恢复哪些对象,实测在迁移到 Supabase 的场景里,直接用纯 SQL 文件搭配 psql 执行更省事。因为 Supabase 的在线 SQL 编辑器接受的就是纯 SQL 文件,你不需要额外套一层 pg_restore 的格式转换。

2.3 从 MySQL 或者其他数据库迁移过来怎么办

如果你是从 MySQL 迁过来,mysqldump 导出的 SQL 基本不能直接给 PostgreSQL 用。常见的转换工具有pgloader,不过实际我试用下来,小项目用 pgloader 够用,表一多、字段一杂,还是容易出现类型映射不准和中文注释丢失的问题。

我的实际建议是:mysqldump 导出一个中间格式,别想着一步到位。具体做法是:

  1. 先用 mysqldump 导出为 CSV,按表拆开
  2. 写一个小脚本把 MySQL 的类型定义翻译成 PostgreSQL 类型
  3. 用COPY FROM批量导入

这里贴一个 mysqldump 导出 CSV 的参考命令:

mysqldump -u root -p --tab=/tmp/data --fields-terminated-by=',' mydb

这个命令会为每张表生成一个.sql(建表语句)和.txt(数据文件)。然后你照着第 1.1 节的类型映射表,把建表 SQL 翻译成 PG 版本,再用COPY导数据:

COPY users FROM '/tmp/data/users.txt' WITH (FORMAT csv, DELIMITER ',');

需要注意表名大小写的问题。MySQL 在 Linux 下默认区分表名大小写,导到 Postgres 之后,你建的表如果用了大写字母,后续请求就会遇到我前面说的“引号表名”问题。迁移前在 MySQL 端先把表名统一改成小写下划线,比迁移后批量改容易得多。

2.4 导出之后先本地预检,别直接上云

这一步是我强烈建议的省钱省时操作。导出的 SQL 文件,先在本地跑一个空的 PostgreSQL 实例验证一遍,确认整个文件可以完整执行,再拿去线上。我已经不止一次看到有同事把带着语法错误的 dump 文件直接怼到云数据库上,结果执行到一半报错,留下一堆半建好的表,清洗起来非常痛苦。

本地预检最简单的方式是 Docker 起一个干净的库:

docker run --name pg_precheck -e POSTGRES_PASSWORD=123456 -d postgres:15

然后依次执行:

docker exec -it pg_precheck psql -U postgres -f - < mydb_schema.sql docker exec -it pg_precheck psql -U postgres -f - < mydb_data.sql docker exec -it pg_precheck psql -U postgres -f - < mydb_objects.sql

如果本地预检全绿,再往 Supabase 上导。你会在这一步提前发现很多问题,比如扩展缺失、类型不匹配、外键顺序不对,而不是到云上再反复试错。

3. 导入 Supabase:三种通道和一条铁律

导入阶段通常是大家最没底的环节。Supabase 给了你三种方式把 SQL 灌进去:Dashboard 的 SQL 编辑器、psql 命令行、supabase CLI。我挨个说清楚适用场景,并给出推荐顺序。

3.1 场景一:SQL 编辑器,只适合小文件

Supabase Dashboard 左侧有一个 “SQL Editor”,可以直接粘贴 SQL 并执行。它的优点是零门槛,在浏览器里就能搞定。缺点是:

  • 单次执行文件不宜过大,超过几 MB 就容易超时
  • 文件里有COPY ... FROM stdin这种块的时候,编辑器基本跑不动
  • 执行过程中出现错误,不支持方便的断点续传,你得手动定位

所以 SQL 编辑器只适合导入单张小表,或者执行一些简单的授权语句。正式迁移的核心文件请走下面这条路。

3.2 场景二:psql 命令行,主力方式

psql 是 PostgreSQL 自带的客户端,Supabase 连接库也是用它。这是最稳、最好排查问题的通道。连接示例:

psql "postgresql://postgres:your_password@db.xxx.supabase.co:5432/postgres?sslmode=require"

连接上之后执行:

\i mydb_schema.sql \i mydb_data.sql \i mydb_objects.sql

这里有两个小细节:

第一,一定要开启ON_ERROR_STOP。这样脚本遇到第一条错误就会停下来,不会带着错误继续执行。执行方式是:

psql "postgresql://postgres:...?...sslmode=require" -v ON_ERROR_STOP=1 -f mydb_schema.sql

第二,如果文件比较大,不要直接粘贴,用\i让 psql 自己读文件,这样终端不会卡死。文件特别大时,还可以用管道方式:

cat mydb_data.sql | psql "postgresql://postgres:...?...sslmode=require" -v ON_ERROR_STOP=1

实测比较稳妥。

3.3 场景三:supabase CLI,适合后续 Schema 变更

supabase CLI 不仅是本地开发工具,也能做db push操作把本地结构变更同步到远程。但它更适合“迁移完成后日常变更 schema”的场景,比如在本地改了一个字段类型,然后推到云上。初次全量导入大数据时,我还是推荐用 psql 而不是 CLI,因为 CLI 对大数据量的导入控制力不够,出了问题也不好定位。

初始化命令大致是这样:

supabase init supabase link --project-ref your-project-ref supabase db push

这里不展开太多,CLI 的用法可以作为后续运维手段,不塞进初次迁移的主流程里,减少变量。

3.4 导入后的数据核验:别看到零错误就觉得万事大吉

导入流程执行完了,不代表迁移成功。真实检验标准是:新库里能正常读写,业务接口能跑通,数据量和源库对得上。

我每次迁移完必做三件事:

第一,逐表核对行数:

SELECT 'users' AS tbl, count(*) FROM users UNION ALL SELECT 'orders' AS tbl, count(*) FROM orders;

第二,核对序列是否同步。这个很好理解:如果你有一张users表,主键 id 是序列生成的,数据导进来之后序列的当前位置可能还停在 1,那下次插入新记录就会报主键冲突。先把序列重置到当前最大值:

SELECT setval('users_id_seq', (SELECT max(id) FROM users));

如果你不愿意一个个表手工写,可以批量生成 setval 语句:

SELECT 'SELECT setval(' || quote_literal(sequence_name) || ', (SELECT COALESCE(max(' || column_name || '), 1) FROM ' || table_name || '));' FROM information_schema.columns WHERE column_default LIKE 'nextval%';

第三,抽查外键约束。随机找几条子表记录,带外键关联查一下父表,确认引用关系没有错位。到这一步,数据层的迁移基本就算完成了。

4. 最容易踩的坑:我从实际项目里捡出来的问题清单

这一部分是我最想写的。下面的每一个问题,都是我或者身边朋友真实遇到过、排查过、解决过的。你大概率也会碰到其中的一两个。

4.1 RLS 权限导致的“库里有数据,但 API 查不到”

这个坑太典型了。数据通过 psql 导入后,在 Supabase SQL 编辑器里查询一切正常,但通过前端 API 查询却返回空数组或者 403。

原因是什么?Supabase 的 API 层走的是 PostgREST,这个接口默认以anon角色访问数据库。你的表如果此前没有开启 Row Level Security,而且没有给anon角色授权,PostgREST 就查不到数据,因为默认情况下新表对 unauthenticated 角色是不可见的。

解决办法是在表上启用 RLS,并添加一条允许读取的 policy,或者直接给anon角色授权。我推荐用 RLS policy,因为更安全,也更符合 Supabase 的设计理念。示例:

alter table public.users enable row level security; create policy "anon_read_users" on public.users for select to anon using (true);

注意这里using (true)是在本示例的场景下允许全部读取,实际项目一定要按业务来收紧,否则就是把用户数据公开挂墙上了。这个坑导致很多人误以为数据导丢了,其实是授权没跟上。

4.2 序列不同步导致的“主键冲突”

数据导入成功,接口跑通,结果正常写一条新数据就报duplicate key value violates unique constraint "users_pkey"。这是序列没归位的老问题。

前面给过 setval 的写法,这里补充一个细节:Supabase 的序列名和本地不一定完全一样。本地序列名可能是users_id_seq,导入后还是这个名字,但你要确认一下序列到底真实存在不存在。用pg_get_serial_sequence查是最准的:

SELECT pg_get_serial_sequence('public.users', 'id');

查出来之后再 setval,不至于猜错序列名。

4.3 扩展缺失导致的“函数不存在”

本地库经常用一些第三方扩展,比如pgcrypto、uuid-ossp、citext。Supabase 预装了一部分,但并不是全部。最典型的报错是执行某条 SQL 时提示function gen_random_uuid() does not exist,这就是pgcrypto扩展没装。

在 Supabase 里补装扩展很简单:

create extension if not exists pgcrypto; create extension if not exists "uuid-ossp"; create extension if not exists citext;

需要注意的是,某些扩展需要超级用户权限,Supabase 不能给你完整的 superuser 角色,所以部分冷门的扩展装不上。遇到这种情况,替代方案是用原生能力改写,或者提前在 Supabase 工单里确认是否支持。如果你在本地预检阶段把扩展依赖查清楚,这一步基本不会踩雷。

4.4 触发器迁移后不生效,实时订阅消失

如果你的本地库里有触发器,比如“插入时自动更新时间戳”这类,pg_dump 导出的结构文件里会带上触发器定义,所以基础触发器没问题。但有一类东西不会跟着迁移:Supabase 的 Realtime API。

我遇到过的情况是:本地库里有一张表,业务代码监听 Postgres 的NOTIFY事件,迁移到 Supabase 后发现收不到事件推送了。原因是 Supabase 的 Realtime 功能需要在表上启用 replication,而这是平台层面的配置,不是单纯的数据库触发器。

启用方式可以在 Dashboard 的 Database -> Replication 里勾选表,也可以通过 SQL:

alter publication supabase_realtime add table public.orders;

排查看起来像“功能失效”,实际上是平台特性没开启。迁移后务必检查所有依赖实时订阅的表,把这层配置补上。

4.5 大小写、引号和保留字的坑

前面提到了 PostgreSQL 会强制把不带引号的标识符转成小写。如果原 MySQL 表名是OrderDetail,迁移的时候建表 SQL 里表名被 mysqldump 转成了小写,那没问题;但如果你是从本地 PG 导出,而原来的 PG 表名就带着大写字母,比如"OrderDetail",那么导出文件里每个表名都是带引号的,导入后你访问它也需要带上引号。

Supabase 的 PostgREST API 路径和表名是对应的,遇到带引号、带大写字母的表名,API 路由会非常别扭,需要 URL 编码拼接。最好的处理方式是在迁移时统一重命名表:改成全小写加下划线。这个操作放在迁移前做,比迁移后改容易太多。

还有一类是保留字问题。比如你的表里有个字段叫user或者order,这在 PostgreSQL 里都是保留字或者 JetBrains 里会给你标红的特殊关键字。建表时因为带了引号可以建,但你写查询语句、写 API 过滤器时,忘掉引号就会报语法错误。迁移时尽量顺手把这些字段名也改掉,比如user改成user_name,order改成order_info。

4.6 时间类型与布尔类型的兼容问题

从 MySQL 迁过来,这两个类型最常见:

MySQL 的datetime不含时区信息,PostgreSQL 的timestamptz是带时区的。数据导过来之后,因为时区默认是 UTC,如果你只导数据不处理类型,查询结果可能和原库差 8 个小时。建议迁移前就把类型定成timestamptz,导入后统一用set timezone或者应用层控制时区展示。

MySQL 的tinyint(1)作为布尔值的用法,在 PG 里最好转成真正的boolean。如果直接导入,PG 会把tinyint识别成smallint,字段值只能是 0 或 1,查询时where is_active = true这种写法就会报错。还是那句话:建表前照第 1.1 节的映射表转一遍类型,后面省心。

5. 迁移完成后的收尾:别把旧库立刻删掉

数据导完、接口跑通、行数核对一致,这个阶段很多人会直接关掉本地库,或者把本地服务器释放了。我建议再等等,至少留一周观察期。这期间有几个收尾动作值得做。

5.1 生成新环境下的类型定义和接口文档

Supabase 会根据数据库结构自动生成 REST API,也可以导出 OpenAPI 规范。前端可以基于这份规范生成 TypeScript 类型,整套接口的联调效率提升明显。你可以在 Supabase Dashboard 的 API 文档页面直接看到每个表对应的 CRUD 接口和示例,不用自己写文档。

5.2 定时把数据从 Supabase 拉回本地做冷备

Supabase 自带自动备份,但免费版的备份保留周期有限,而且恢复粒度不如自己掌控来得踏实。我习惯设置一个定时脚本,每天凌晨把关键表的数据用pg_dump拉回本地存储。这样就算云上出问题,手里也有一份随时能恢复的本地快照。

pg_dump "postgresql://postgres:...?...sslmode=require" \ --data-only \ --no-owner \ --no-privileges \ -t users \ -t orders \ -f /backup/supabase_daily_$(date +%F).sql

5.3 重新检查所有自定义函数和存储过程的权限点

本地 PG 的函数默认以调用者权限执行,而 Supabase 平台对安全性的要求更严格。如果你的函数里访问了auth.uid()之类的上下文函数,迁移后要么改用 security definer,要么确保调用者有足够权限。这个属于业务代码层面的适配,不算纯数据库迁移问题,但迁移后不检查,迟早会在某个页面上炸出来。

关于权限,我再补一个细节:Supabase 里postgres角色不是万能的,它不能给supabase_admin开权限,也不能操作某些系统 schema。很多本地习惯的超级用户操作在云上是做不了的。功能上能不用就不用,尽量走官方提供的 Dashboard 或 SQL 接口完成操作,少折腾底层权限。

最后说个我自己的习惯。每次做完这种迁移,我都会顺手把旧库里长期不用的无主表、断掉的序列、废弃的索引清理一遍。本地库跑了两三年,里面总有些项目早期留下的试验品,平时用不上还占着维护成本,迁移到云上之后这些“烂账”全都得带走。趁着迁移这个契机做一次架构上的瘦身,比单纯完成搬迁更有价值。数据迁移本身没什么黑魔法,无非是把家底清点清楚,然后用合适工具按正确顺序搬运。希望这篇能让你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询