最近把我的博客(基于Typecho )的数据库从 MySQL 迁移到了 PostgreSQL。迁移本身不算复杂,但真正容易出问题的地方不在“能不能导进去”,而在这些细节:

1. 迁移期间是否停止写入,避免新文章或新评论丢失
2. pgloader 默认会修改标识符大小写,可能让 Typecho 找不到驼峰字段
3. pgloader 可能把表导入到非 public schema,而 Typecho 的 PostgreSQL 适配器更适合 public schema
4. PostgreSQL 序列、主键、索引和权限需要单独确认
5. 迁移命令、备份文件和调试信息都可能泄露敏感信息

这篇文章记录一次基于 1Panel、Docker、MySQL、PostgreSQL、pgAdmin 和 pgloader 的完整迁移过程。它不是唯一做法,但会尽量把生产环境里容易踩的坑提前写清楚。

一、迁移环境

本次迁移环境如下:

服务器面板:1Panel
网站程序:Typecho,程序文件在宿主机网站目录
PHP 环境:1Panel 创建的 PHP 容器
MySQL:Docker 容器
PostgreSQL:Docker 容器
PostgreSQL 管理工具:pgAdmin
Docker 网络:1panel-network

需要特别注意:

Typecho 程序本身不是容器
真正执行 PHP 的是 1Panel 的 PHP 容器
真正需要安装 pdo_pgsql 扩展的是 PHP 容器
真正需要重启的也是 PHP 容器
pgAdmin 只是管理工具,不是 PostgreSQL 服务本体

也就是说,Typecho 文件在宿主机网站目录中,PHP 由 1Panel 的 PHP 容器提供运行环境,MySQL 和 PostgreSQL 都是 Docker 容器。

二、迁移前的安全准备

正式迁移前,建议先把站点切到维护窗口。至少要做到:

1. 暂停发布文章、评论、注册、后台修改配置等写入操作
2. 通知自己或站点用户迁移期间不要提交内容
3. 备份 MySQL 数据库和网站文件
4. 确认备份文件不放在 Web 可访问目录
5. 新建一个空的 PostgreSQL 数据库作为目标库

不要把数据库备份、迁移脚本、包含密码的文件放在 /www/sites/... 这类网站目录里。数据库备份里可能包含用户邮箱、密码哈希、登录令牌、评论 IP、插件配置等敏感信息。

三、备份 MySQL 数据库和网站文件

先备份 MySQL 数据库:

mysqldump --single-transaction --routines --triggers --events --default-character-set=utf8mb4 \
  -u root -p typecho > /root/typecho_mysql_backup.sql

其中 typecho 是原 MySQL 数据库名,根据实际情况修改。

如果 MySQL 在 Docker 容器里,也可以在容器内执行 mysqldump,再把结果重定向到宿主机文件。不要把真实密码直接写进公开笔记或博客示例里。

再备份网站文件:

tar -czvf /root/typecho_files_backup.tar.gz /www/sites/你的站点目录
chmod 600 /root/typecho_mysql_backup.sql /root/typecho_files_backup.tar.gz

重点备份这些内容:

/usr/uploads
/usr/themes
/usr/plugins
config.inc.php

建议迁移前至少确认备份文件大小正常,并保留一份可以回滚的 MySQL 配置。

四、安装 PHP PostgreSQL 扩展

Typecho 使用 PostgreSQL 时,需要 PHP 支持 PostgreSQL。1Panel 的 PHP 扩展管理中建议安装:

pdo_pgsql
pgsql

其中最关键的是:

pdo_pgsql

因为 Typecho 配置 PostgreSQL 时通常使用:

$db = new \Typecho\Db('Pdo_Pgsql', 'typecho_');

安装完成后,重启该站点实际使用的 PHP 容器。可以先查看容器名:

docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Networks}}\t{{.Ports}}"

然后进入 PHP 容器检查扩展是否生效:

docker exec -it PHP容器名 php -m | grep -iE "pdo|pgsql"

正常应该能看到类似:

PDO
pdo_pgsql
pgsql

如果没有 pdo_pgsql,说明扩展没有装到当前站点实际使用的 PHP 容器里。

五、创建 PostgreSQL 数据库和用户

可以在 pgAdmin 中创建 PostgreSQL 数据库和用户,也可以使用 SQL。下面的密码只是占位符,正式操作时请替换成强密码:

CREATE USER typecho WITH PASSWORD 'REPLACE_WITH_STRONG_PASSWORD';
CREATE DATABASE typecho OWNER typecho ENCODING 'UTF8' TEMPLATE template0;

本次示例使用:

数据库名:typecho
用户名:typecho
数据库拥有者:typecho
编码:UTF8

更安全的做法是区分迁移用户和运行用户:

迁移用户:负责建表、导入数据、调整 schema、调整序列
运行用户:只负责 Typecho 日常 SELECT / INSERT / UPDATE / DELETE

个人小站为了简单可以先使用同一个 typecho 用户,但迁移完成后仍建议收敛权限,不要额外授予不必要的 CREATE 权限。

六、用 pgloader 迁移数据

pgloader 可以把 MySQL 的表结构、数据、索引、主键、序列等迁移到 PostgreSQL。因为 MySQL 和 PostgreSQL 都在 Docker 里,运行 pgloader 时需要加入同一个 Docker 网络:

1panel-network

这里不推荐把真实密码直接写在一条命令里。命令行参数可能进入 shell history,也可能被进程列表或日志看到。建议创建一个临时目录和 pgloader 配置文件,迁移完成后删除。

mkdir -p /root/typecho-migration
chmod 700 /root/typecho-migration

创建 /root/typecho-migration/typecho.load

LOAD DATABASE
     FROM mysql://MySQL用户名:MySQL密码@MySQL容器名:3306/typecho
     INTO postgresql://typecho:PostgreSQL密码@PostgreSQL容器名:5432/typecho

 WITH include drop, create tables, create indexes, reset sequences,
      foreign keys, quote identifiers, uniquify index names

  SET maintenance_work_mem to '128MB',
      work_mem to '12MB';

然后限制文件权限:

chmod 600 /root/typecho-migration/typecho.load

这里有几个重点:

include drop:会删除目标库里同名表,只能对新建空库或确认可清空的库使用
quote identifiers:保留 MySQL 中的字段大小写,避免 authCode 被转成 authcode
reset sequences:导入后重置 PostgreSQL 序列

Typecho 的 MySQL 表中有这些驼峰字段:

authCode
screenName
authorId
ownerId
commentsNum
allowComment
allowPing
allowFeed

Typecho 的 PostgreSQL 适配器查询时也会使用带大小写的字段名。如果 pgloader 默认把字段名转成小写,后面很容易出现:

column "authCode" of relation "typecho_users" does not exist

执行迁移:

docker run --rm --network 1panel-network \
  -v /root/typecho-migration/typecho.load:/typecho.load:ro \
  dimitri/pgloader:latest \
  pgloader /typecho.load

正式写进运维文档时,建议把 dimitri/pgloader:latest 换成自己测试过的固定版本或镜像 digest。latest 方便一次性迁移,但不利于复现。

迁移完成后,重点看 pgloader 输出里的 errors 是否为 0,而不是只看最后有没有 Total import time

七、检查表所在 schema

pgloader 可能会把表导入到源 MySQL 数据库同名的 schema 下。比如源库叫 blog,导入后可能出现:

blog.typecho_contents
blog.typecho_options

而不是:

public.typecho_contents
public.typecho_options

先在 PostgreSQL 中检查:

SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_name LIKE 'typecho_%'
ORDER BY table_schema, table_name;

Typecho 的 PostgreSQL 适配器更适合在 public schema 中工作,尤其是插入数据时会查 public schema 下的主键信息。所以如果表在 blog 这类非 public schema 下,建议移动到 public

假设当前 schema 是 blog,可以执行:

DO $$
DECLARE
    source_schema text := 'blog';
    r record;
BEGIN
    FOR r IN
        SELECT tablename
        FROM pg_tables
        WHERE schemaname = source_schema
          AND tablename LIKE 'typecho\_%' ESCAPE '\'
    LOOP
        EXECUTE format('ALTER TABLE %I.%I SET SCHEMA public;', source_schema, r.tablename);
    END LOOP;
END $$;

再移动 Typecho 相关序列:

DO $$
DECLARE
    source_schema text := 'blog';
    r record;
BEGIN
    FOR r IN
        SELECT sequence_name
        FROM information_schema.sequences
        WHERE sequence_schema = source_schema
          AND sequence_name LIKE 'typecho\_%' ESCAPE '\'
    LOOP
        EXECUTE format('ALTER SEQUENCE %I.%I SET SCHEMA public;', source_schema, r.sequence_name);
    END LOOP;
END $$;

再次确认:

SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_name LIKE 'typecho_%'
ORDER BY table_schema, table_name;

理想情况下,Typecho 的核心表都在 public schema 下。

八、如果字段已经被转成小写

如果前面没有使用 quote identifiers,pgloader 可能已经把驼峰字段转成了小写。例如:

authCode    -> authcode
screenName  -> screenname
authorId    -> authorid
ownerId     -> ownerid
commentsNum -> commentsnum
allowComment -> allowcomment

可以先检查:

SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name IN ('typecho_users', 'typecho_contents', 'typecho_comments')
  AND column_name IN (
      'authcode', 'screenname', 'authorid', 'ownerid',
      'commentsnum', 'allowcomment', 'allowping', 'allowfeed'
  )
ORDER BY table_name, column_name;

如果确实存在这些小写字段,可以执行下面的补救 SQL:

DO $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_users'
          AND column_name = 'authcode'
    ) THEN
        ALTER TABLE public.typecho_users RENAME COLUMN authcode TO "authCode";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_users'
          AND column_name = 'screenname'
    ) THEN
        ALTER TABLE public.typecho_users RENAME COLUMN screenname TO "screenName";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_contents'
          AND column_name = 'authorid'
    ) THEN
        ALTER TABLE public.typecho_contents RENAME COLUMN authorid TO "authorId";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_contents'
          AND column_name = 'commentsnum'
    ) THEN
        ALTER TABLE public.typecho_contents RENAME COLUMN commentsnum TO "commentsNum";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_contents'
          AND column_name = 'allowcomment'
    ) THEN
        ALTER TABLE public.typecho_contents RENAME COLUMN allowcomment TO "allowComment";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_contents'
          AND column_name = 'allowping'
    ) THEN
        ALTER TABLE public.typecho_contents RENAME COLUMN allowping TO "allowPing";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_contents'
          AND column_name = 'allowfeed'
    ) THEN
        ALTER TABLE public.typecho_contents RENAME COLUMN allowfeed TO "allowFeed";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_comments'
          AND column_name = 'authorid'
    ) THEN
        ALTER TABLE public.typecho_comments RENAME COLUMN authorid TO "authorId";
    END IF;

    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = 'public'
          AND table_name = 'typecho_comments'
          AND column_name = 'ownerid'
    ) THEN
        ALTER TABLE public.typecho_comments RENAME COLUMN ownerid TO "ownerId";
    END IF;
END $$;

再检查一次:

SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'typecho_users'
ORDER BY ordinal_position;

应该能看到:

screenName
authCode

九、检查并修复序列

PostgreSQL 中自增字段依赖序列。迁移后如果序列值小于当前表里的最大主键,后续新增文章、评论或用户时可能出现主键冲突。

先检查 Typecho 常见自增字段:

SELECT 'contents' AS table_name, MAX("cid") AS max_id FROM public.typecho_contents
UNION ALL
SELECT 'comments', MAX("coid") FROM public.typecho_comments
UNION ALL
SELECT 'metas', MAX("mid") FROM public.typecho_metas
UNION ALL
SELECT 'users', MAX("uid") FROM public.typecho_users;

然后使用 PostgreSQL 的 pg_get_serial_sequence() 找到列绑定的序列并自动修复:

DO $$
DECLARE
    r record;
    max_id bigint;
    seq_name text;
BEGIN
    FOR r IN
        SELECT
            table_name,
            column_name
        FROM (VALUES
            ('typecho_contents', 'cid'),
            ('typecho_comments', 'coid'),
            ('typecho_metas', 'mid'),
            ('typecho_users', 'uid')
        ) AS t(table_name, column_name)
    LOOP
        seq_name := pg_get_serial_sequence(format('public.%I', r.table_name), r.column_name);

        IF seq_name IS NULL THEN
            RAISE NOTICE 'No sequence found for %.%', r.table_name, r.column_name;
            CONTINUE;
        END IF;

        EXECUTE format('SELECT COALESCE(MAX(%I), 0) FROM public.%I', r.column_name, r.table_name)
        INTO max_id;

        IF max_id < 1 THEN
            EXECUTE format('SELECT setval(%L::regclass, 1, false)', seq_name);
        ELSE
            EXECUTE format('SELECT setval(%L::regclass, %s, true)', seq_name, max_id);
        END IF;
    END LOOP;
END $$;

如果看到 No sequence found 提示,说明 pgloader 创建的序列没有和列默认值绑定,需要手动查看表结构后处理:

SELECT table_name, column_name, column_default
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name LIKE 'typecho_%'
  AND column_default LIKE 'nextval(%';

十、授权给 Typecho 用户

如果迁移和运行都使用 typecho 用户,可以执行:

GRANT USAGE ON SCHEMA public TO typecho;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO typecho;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO typecho;

如果之前为了迁移临时授予了更大的权限,迁移完成后应当收敛。日常运行的 Typecho 用户一般不需要在 public schema 中创建新表。

十一、对比源库和目标库数据量

不要只看 PostgreSQL 里有没有表。迁移完成后至少要对比核心表行数。

MySQL 中执行:

SELECT 'contents' AS table_name, COUNT(*) AS rows_count FROM typecho_contents
UNION ALL
SELECT 'comments', COUNT(*) FROM typecho_comments
UNION ALL
SELECT 'options', COUNT(*) FROM typecho_options
UNION ALL
SELECT 'users', COUNT(*) FROM typecho_users
UNION ALL
SELECT 'metas', COUNT(*) FROM typecho_metas
UNION ALL
SELECT 'fields', COUNT(*) FROM typecho_fields
UNION ALL
SELECT 'relationships', COUNT(*) FROM typecho_relationships;

PostgreSQL 中执行:

SELECT 'contents' AS table_name, COUNT(*) AS rows_count FROM public.typecho_contents
UNION ALL
SELECT 'comments', COUNT(*) FROM public.typecho_comments
UNION ALL
SELECT 'options', COUNT(*) FROM public.typecho_options
UNION ALL
SELECT 'users', COUNT(*) FROM public.typecho_users
UNION ALL
SELECT 'metas', COUNT(*) FROM public.typecho_metas
UNION ALL
SELECT 'fields', COUNT(*) FROM public.typecho_fields
UNION ALL
SELECT 'relationships', COUNT(*) FROM public.typecho_relationships;

如果行数不一致,先不要切换 Typecho 配置。应该回到 pgloader 输出和日志中查 errors,确认是否有数据类型、编码、非法日期、字段长度等问题。

十二、修改 Typecho 配置文件

Typecho 的配置文件在宿主机网站目录中,例如:

/www/sites/你的域名/index/config.inc.php

原来的 MySQL 配置可能是:

$db = new \Typecho\Db('Pdo_Mysql', 'typecho_');

改成 PostgreSQL:

$db = new \Typecho\Db('Pdo_Pgsql', 'typecho_');

$db->addServer(array (
  'host' => 'PostgreSQL容器名',
  'port' => 5432,
  'user' => 'typecho',
  'password' => '你的PostgreSQL密码',
  'charset' => 'utf8',
  'database' => 'typecho',
), \Typecho\Db::READ | \Typecho\Db::WRITE);

\Typecho\Db::set($db);

因为 PHP 是容器,PostgreSQL 也是容器,所以 host 应该填写 PHP 容器能够访问到的 PostgreSQL 容器名或 Docker 网络内服务名,例如:

'host' => 'postgresql',

不要写成 pgAdmin,也不一定能写 127.0.0.1127.0.0.1 在 PHP 容器里通常代表 PHP 容器自己,不代表 PostgreSQL 容器。

表前缀保持:

'typecho_'

不要写成:

'blog.typecho_'

schema 问题应该在 PostgreSQL 里解决,而不是把 schema 拼进表前缀。

十三、在 PHP 容器里测试 PostgreSQL 连接

真正执行 Typecho 的是 PHP 容器,所以连接测试也应该进入 PHP 容器执行:

docker exec -it PHP容器名 php -r '
try {
    $pdo = new PDO("pgsql:host=PostgreSQL容器名;port=5432;dbname=typecho", "typecho", "你的密码");
    echo "PostgreSQL connect ok\n";
} catch (Throwable $e) {
    echo $e->getMessage() . "\n";
}
'

如果输出:

PostgreSQL connect ok

说明:

PHP 容器里的 pdo_pgsql 正常
PHP 容器能访问 PostgreSQL 容器
PostgreSQL 用户名、密码、数据库名正确

十四、排查 Database Query Error

迁移后访问网站,页面可能出现:

Database Query Error

排查时不要直接在公网生产页面长时间开启错误显示。更稳妥的做法是:

1. 先查看 PHP 容器日志、1Panel 网站日志、Nginx 日志
2. 如果必须开启 Typecho 调试,只在维护窗口内临时开启
3. 最好限制访问来源,只允许自己的 IP 访问
4. 定位问题后立即关闭调试

临时调试代码如下:

define('__TYPECHO_DEBUG__', true);
ini_set('display_errors', 'On');
error_reporting(E_ALL);

如果看到:

SQLSTATE[42703]: Undefined column: 7 ERROR:
column "authCode" of relation "typecho_users" does not exist

一般说明连接已经正常,表也找到了,但字段大小写不符合 Typecho 的 PostgreSQL 查询方式。回到前面的“字段已经被转成小写”一节处理即可。

十五、重启 PHP 容器并验证站点

修改 PHP 扩展、数据库配置或 config.inc.php 后,需要重启该站点使用的 PHP 容器。

查看容器名:

docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Networks}}\t{{.Ports}}"

重启:

docker restart PHP容器名

也可以在 1Panel 面板中重启该网站对应的 PHP 运行环境。

然后逐项检查:

首页是否正常
文章页是否正常
独立页面是否正常
分类和标签是否正常
评论是否正常显示
后台是否能登录
能否新建草稿
能否发布或更新文章
能否新增评论
附件是否正常显示
主题是否正常
插件是否正常
固定链接是否正常
RSS 是否正常

重点测试“新增”操作,因为序列问题通常不会在只读浏览时暴露,只有插入新文章、新评论、新用户时才会出现。

十六、插件兼容性

如果某些插件报错,常见原因是插件里写了 MySQL 专属 SQL,例如反引号:

SELECT * FROM `typecho_contents`

PostgreSQL 不支持 MySQL 的反引号语法。这种情况需要修改插件代码,或者禁用不兼容插件。

还要留意这些 MySQL 专属写法:

反引号 `table`
INSERT IGNORE
REPLACE INTO
ON DUPLICATE KEY UPDATE
LIMIT offset, size
NOW()、FROM_UNIXTIME() 等函数差异

Typecho 核心可以使用 PostgreSQL,但插件未必都兼容。

十七、迁移后的收尾

确认网站正常后,记得做这些收尾工作:

1. 关闭 Typecho 调试模式和 display_errors
2. 删除 /root/typecho-migration/typecho.load
3. 删除或加密保存数据库备份和文件备份
4. 撤销迁移临时用户或收敛权限
5. 记录 PostgreSQL 容器名、数据库名、备份位置和回滚方案
6. 观察一段时间 PostgreSQL、PHP、Nginx 日志

删除临时迁移文件:

rm -f /root/typecho-migration/typecho.load
rmdir /root/typecho-migration

如果迁移过程中临时暴露过密码,建议迁移完成后修改 PostgreSQL 密码,并同步更新 config.inc.php

总结

本次 Typecho 从 MySQL 迁移到 PostgreSQL,主流程是:

1. 进入维护窗口,暂停写入
2. 备份 MySQL 数据库和 Typecho 网站文件
3. 在 PHP 容器中安装 pdo_pgsql
4. 创建 PostgreSQL 空库和用户
5. 使用 pgloader 迁移,并显式启用 quote identifiers
6. 检查表是否在 public schema
7. 必要时移动表和序列到 public
8. 检查字段大小写,必要时修复驼峰字段
9. 检查并修复 PostgreSQL 序列
10. 对比 MySQL 和 PostgreSQL 核心表行数
11. 修改 Typecho config.inc.php
12. 在 PHP 容器中测试 PostgreSQL 连接
13. 重启 PHP 容器并验证前台、后台、发布、评论、附件和插件
14. 关闭调试,删除迁移文件,收敛权限

这次迁移里最关键的坑有三个:

1. pgloader 默认可能把驼峰字段名转成小写
2. pgloader 可能把表导入到非 public schema
3. 迁移后必须确认序列值,否则新增内容可能主键冲突

如果只记住一句话:Typecho 迁移到 PostgreSQL,不要只验证“页面能打开”,还要验证“后台能新增内容”。前者只能证明读数据大致正常,后者才能暴露 schema、字段大小写、权限和序列这类真正容易遗漏的问题。

最后修改:2026 年 06 月 07 日
如果觉得我的文章对你有用,请随意赞赏