OpenSEO 从 Cloudflare D1 迁移到 Postgres:详细运维手册与源码级原理剖析

发布时间:2026/9/13 16:34:25
OpenSEO 从 Cloudflare D1 迁移到 Postgres:详细运维手册与源码级原理剖析 OpenSEO 从 Cloudflare D1 迁移到 Postgres详细运维手册与源码级原理剖析【免费下载链接】open-seoOpen source alternative to Semrush and Ahrefs项目地址: https://gitcode.com/GitHub_Trending/op/open-seo导读当 OpenSEO 托管实例的数据量逼近 Cloudflare D1SQLite的存储上限时需要将后端切换为 PostgresDATABASE_PROVIDERpostgres。但切换 provider 只改变应用读写的位置并不会移动已有数据——真正的数据搬迁由scripts/migrate-d1-to-postgres.ts完成。本文以 runbooks/d1-to-postgres-detailed.md 为骨架完整展开迁移的每一步操作含低停机增量同步与回滚并结合 scripts/migrate-d1-to-postgres.ts 等源码讲清楚“为什么这样迁移”以及底层的数据转换、外键排序、序列重置与幂等机制。读完后你将掌握一次可安全执行、可随时回滚的 D1→Postgres 数据搬迁全流程。适用范围说明本手册针对 OpenSEO 的生产部署——pnpm deploy:postgres硬编码绑定 alchemy stagehosted-prod、其域名与.env.production。alchemy 的自托管路径非prod阶段没有 Hyperdrive 接线因此 Postgres 目前不适用于自托管用户。一、背景为什么会出现 D1 → Postgres 迁移OpenSEO 默认运行在 Cloudflare D1SQLite之上应用代码统一经由 provider-aware 的db层访问数据库见 src/db/运行时唯一的差异是DATABASE_PROVIDER标志与连接方式。D1 的存储存在容量上限当托管实例“超出 D1 的存储天花板”outgrows D1s storage ceiling时就需要切换到 Postgres 后端。src/db/provider.ts 定义了 provider 的解析逻辑环境变量DATABASE_PROVIDERpostgres→ 返回postgresd1、未设置或空字符串 → 返回d1默认其他值 → 直接抛错Unsupported DATABASE_PROVIDER ... Expected d1 or postgres。有一个容易被忽略的关键点应用只会通过 Hyperdrive 绑定连接 Postgres不存在直连回退。src/db/provider.ts 中的getPostgresConnectionString()从HYPERDRIVE绑定的connectionString取值取不到就抛错。在本地开发时该绑定由 wrangler.jsonc 中默认注释掉的hyperdrive块的localConnectionString解析部署到 Workers 后则解析为真实 Hyperdrive。切换 provider 只是让应用换个地方读写因此数据搬迁必须由独立的脚本显式完成这正是本手册的主题。二、迁移架构直接读 D1 REST API而不是 SQL 导出再导入脚本的核心策略是逐表通过 Cloudflare REST API 直接读取 D1再写入 Postgres——全程没有 SQL dump 可下载或重新导入。这一设计源于一次真实事故早期的 dump 方案在wrangler d1 export下载时静默截断却报告成功而重新导入 400MB 的 SQL 文本本身又是一层新的失败面。改为逐表走 API 之后这类问题被整体消除并且读取到的是实时、完整的数据详见 scripts/migrate-d1-to-postgres.ts 的文件头注释。源码中d1Query()scripts/migrate-d1-to-postgres.ts实现了这个调用向https://api.cloudflare.com/client/v4/accounts/{accountId}/d1/database/{databaseId}/queryPOST 一段 SQL使用Authorization: Bearer CLOUDFLARE_API_TOKEN鉴权响应体取result[0].results作为行数据请求失败或success: false时抛出包含错误详情的异常。迁移全程不写入 D1D1 保持原样不动因此回滚极其廉价——把 provider 标志翻回d1即可。三、脚本到底转换了什么方言差异与 Drizzle 编解码“直接拷贝”行不通因为两端的列类型存在方言差异。每个原始 D1 值都会先经过 SQLite 列对应的 Drizzle codecmapFromDriverValue再通过 Postgres 列写出转换逻辑与应用自身读写数据所用的逻辑完全一致scripts/migrate-d1-to-postgres.ts。具体分两类1. better-auth 表user、session、account等SQLite 把时间戳存成整数 epoch-ms、布尔存成整数0/1Postgres 侧则是timestamptz与真正的boolean。convertRow()scripts/migrate-d1-to-postgres.ts对每一列调用column.mapFromDriverValue(raw)完成epoch-ms → Date、0/1 → boolean的解码再由 Postgres 列写出为timestamptz/boolean。2. 应用表projects、saved_keywords、billing_customer_status等这些表两端的时间戳都是 TEXT但格式不同D1 的current_timestamp默认值产生YYYY-MM-DD HH:MM:SS空格分隔、无毫秒、无 ZPostgres 代码期望并写入 ISO-8601 形式YYYY-MM-DDTHH:MM:SS.000Z。脚本用正则SPACE_TIMESTAMP /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/命中旧格式后通过toIsoText()将其重写为YYYY-MM-DDTHH:MM:SS.000Z应用已写入的 ISO 格式值带T分隔符永不匹配该正则保持原样scripts/migrate-d1-to-postgres.ts。为什么必须这样重写src/db/pg/app.schema.ts 的注释给出了深层原因Postgres 的timestamptz会被 postgres-js 解析回 JSDate即使 drizzle 用mode:string也一样从而悄悄破坏应用对时间戳做的字典序字符串比较。因此 Postgres schema 有意把时间戳定义为text并用isoNowto_char(now() AT TIME ZONE utc, ...)生成与new Date().toISOString()同格式的默认值——注意这与 SQLite 的current_timestamp并不逐字节相等所以一次性迁移必须把遗留的空格格式时间戳改写成 ISO 格式。此外表按外键安全顺序拷贝插入使用onConflictDoNothing因此脚本可以安全地从头重跑scripts/migrate-d1-to-postgres.ts。四、前置条件在运行迁移前需要准备三件事1. 已部署 provider-aware 构建即包含本分支或其父级代码的版本。2..env.local中的凭据迁移脚本会自动加载.env.localloadLocalEnv()见 scripts/cli-utils.ts依次尝试.env.local与.env已存在的环境变量不会被覆盖db:migrate:pg同样读取它CLOUDFLARE_ACCOUNT_ID... CLOUDFLARE_API_TOKEN... # needs D1 read POSTGRES_DATABASE_URLpostgres://user:passhost:5432/db # CLOUDFLARE_D1_DATABASE_ID... # optional; otherwise read from wrangler.jsoncCLOUDFLARE_ACCOUNT_ID账户 ID用wrangler whoami获取CLOUDFLARE_API_TOKEN需要 D1 读取权限POSTGRES_DATABASE_URL目标 Postgres 连接串仅 Node 侧工具读取应用本身忽略它见 docs/LOCAL_POSTGRES.mdCLOUDFLARE_D1_DATABASE_ID可选。未设置时resolveDatabaseId()scripts/migrate-d1-to-postgres.ts会从根目录 wrangler.jsonc 正则提取database_id当前为37bee90a-e1aa-404f-b01e-b0d1d479bda1提取失败则报错要求显式设置。3. 一个已建好 schema 的空 Postgres 数据库先做 schema 迁移迁移文件在drizzle-pg/下pnpm db:migrate:pg该命令对应 package.json 中的drizzle-kit migrate --config drizzle-pg.config.ts。注意Postgres schema 是手工维护的db:generate唯一不重新生成的产物双方言的 schema 一致性由src/db/schema-parity.test.ts在 CI 中把关。想先在本地演练见 docs/LOCAL_POSTGRES.md用 Docker 起一个端口5433的 Postgres 16 容器docker run --name openseo-postgres -e POSTGRES_USERopenseo -e POSTGRES_PASSWORDopenseo -e POSTGRES_DBopenseo -p 5433:5432 -d postgres:16应用db:migrate:pg再在.env.local设DATABASE_PROVIDERpostgres并取消 wrangler.jsonc 中hyperdrive块的注释即可。该块中的localConnectionString正是postgres://openseo:openseolocalhost:5433/openseo。五、标准迁移步骤1. 干跑Dry run——先看每表行数此阶段只读 D1不打开任何 Postgres 连接输出每个表的源端行数pnpm exec tsx scripts/migrate-d1-to-postgres.ts --dry-run在源码中--dry-run分支scripts/migrate-d1-to-postgres.ts只调用countD1()对每张表执行SELECT count(*)末尾打印Done. N source rows. (dry run — nothing written)。2. 正式迁移——目标必须是空库pnpm exec tsx scripts/migrate-d1-to-postgres.ts脚本自带空目标预检若目标表已有行会中止并提示“Migrate into a freshly-created Postgres, or pass--allow-nonempty”加--allow-nonempty可以强制继续且由于onConflictDoNothing脚本始终保持幂等scripts/migrate-d1-to-postgres.ts。预检在--update模式下会跳过——那本来就是“已有完整迁移、本次只补差量”的场景。迁移结束后确认输出包含All row counts match.——脚本会逐表比对 D1 与 Postgres 的行数scripts/migrate-d1-to-postgres.ts任一表不一致会打印MISMATCH table: D1 x vs postgres y最终汇总N table(s) mismatched — investigate before cutover并以非零退出码结束。3. 切换Cut over把部署指向 Postgres设置DATABASE_PROVIDERpostgres同时加上 Hyperdrive 绑定应用只经 Hyperdrive 连 Postgres然后部署pnpm deploy:postgres该命令对应 package.json先db:migrate:pg再构建最后alchemy deploy --env-file .env.production --stage hosted-prod --adopt。4. 验证对线上应用做冒烟测试——加载一个项目、保存一个关键词、检查 billing 是否正常。可选冻结写入以获得一致快照。拷贝是逐表进行的运行期间落库的写入可能被漏掉。若需要完全一致的快照可以在步骤 1–3 期间暂停定时 rank-check cron并让实例进入短暂的只读窗口之后恢复。这不是必须的——下一节的低停机方案可以绕开它。六、低停机切换--update增量补差大表keyword_metrics、audit_pages等主导拷贝耗时全程冻结可能意味着数分钟停机。低停机方案把“全量拷贝”与“切换”解耦在线全量拷贝——应用继续服务时跑完整迁移上文步骤 2。中途落库的写入允许漏掉由下面的增量补差对账。增量同步——只拷贝自全量拷贝以来发生变化的数据并用upsert冲突则更新写入pnpm exec tsx scripts/migrate-d1-to-postgres.ts --update --since-hours 12--since-hours应设为一个能舒适覆盖“全量拷贝结束到现在”的窗口。不同表在这个模式下策略不同真正庞大、以追加为主的表keyword_metrics、rank_snapshots、audit_pages、audit_lighthouse_results按其近性列过滤出近期行其余所有表——包括会被原地更新的可变表audits、rank-check runs、tracked keywords以及小型的配置/实体表——整体重新 upsert因此新注册用户、对既有行的更新刷新过的 token、归档的项目、调整过的调度、已完成的运行都会被拾取。确认输出All row counts match.。为了把窗口压到最小也可以在增量同步与切换之间短暂冻结写入但通常没有必要。增量过滤的具体实现是deltaPredicate()scripts/migrate-d1-to-postgres.tskeyword_metrics按datetime(fetched_at) cutoff过滤——该表可变但每次 upsert 都会推进fetched_at所以按近性过滤仍能捕获原地刷新rank_snapshots按datetime(checked_at)过滤audit_pages、audit_lighthouse_results自身没有时间戳改为按父表范围过滤audit_id IN (SELECT id FROM audits WHERE datetime(started_at) cutoff)其余表返回null即全表重拷。过滤时用 SQLite 的datetime()函数归一化比较对 ISO 格式与 D1 遗留的空格格式文本都成立格式无关cutoff 由脚本生成new Date(Date.now() - sinceHours * 3_600_000).toISOString()并非用户输入不存在注入风险。--update模式可安全重跑跳过空目标预检并在结束后再次推进 serial 序列。upsert 的实现细节upsert 需要冲突仲裁列conflict arbiter与ON CONFLICT DO UPDATE的 set 子句由 scripts/migrate-d1-to-postgres.ts 的conflictArbiterNames()与buildUpsert()完成优先用表的主键无主键的表如连接表saved_keyword_tag_assignments退回第一个唯一索引的列——若只认主键这类表会生成ON CONFLICT ()导致 SQL 解析失败buildUpsert()从 pg 表配置取冲突目标列携带 pg 列类型onConflictDoUpdate需要它set 子句用 JS 字段名作为键以匹配values()载荷对非仲裁列逐列写入excluded.col若某表既无主键也无唯一索引、或所有列都是仲裁列则退化为onConflictDoNothingscripts/migrate-d1-to-postgres.ts。删除不同步唯一的已知缺口--update是 insert/upsert不会同步删除——窗口期内在 D1 被删的行会留在 Postgres 中表现为验证步骤里的行数不一致例如少量过期的 session/验证行。这是预期行为若窗口内的删除必须被反映请在写入冻结下做全量拷贝。七、回滚切换后若发现任何异常把DATABASE_PROVIDER设回d1移除 Postgres 绑定并重新部署即可。D1 始终保存着原始数据、分毫未动——这正是“全程不写 D1”设计带来的红利。八、脚本实现要点分页、批处理与序列重置稳定的分页与内存边界脚本按主键排序后LIMIT ... OFFSET ...分页读取primaryKeyOrder()scripts/migrate-d1-to-postgres.ts主键列取自 SQLite 表配置含复合主键。全量冻结拷贝期间尾追加是稳定的在线拷贝时表中部发生的扰动由--update增量对账兜底。默认每页5000行--page-size N可调同一时刻内存中最多只保留一页。每批 INSERT 的绑定参数数量行数 × 列数被压到 Postgres 的 65535 参数上限之下insertBatch max(1, min(5000, floor(50000 / colCount)))scripts/migrate-d1-to-postgres.ts。外键安全顺序Kahn 算法表拷贝顺序由 scripts/migrate-d1-to-postgres.ts 的fkSafeOrder()计算对 SQLite 外键图做拓扑排序Kahn 算法保证每张表都在其外键引用表之后拷贝自引用被忽略。若出现外键环当前 schema 中不存在脚本会“响亮地失败”而非以任意顺序拷贝导致插入违反外键约束。serial 序列推进——防止新插入与迁移行冲突keyword_metrics.id、rank_snapshots.id在 SQLite 中是自增整数、被原样拷贝到 Postgres但 Postgres 的 serial 序列仍停在 1。若不推进切换后依赖 serial 默认值的新插入会与迁移行冲突——对rank_snapshots是静默丢行无目标的onConflictDoNothing对keyword_metrics则是硬性的 duplicate-key 错误。脚本在拷贝后对每个serial列执行SELECT setval(pg_get_serial_sequence(...), COALESCE(max(id), 1), max IS NOT NULL)三参数setval的第三参保证空表时 next id 从 1 开始见 scripts/migrate-d1-to-postgres.ts。表清单自动同步脚本导入的是 Node 安全的原始 barrelsrc/db/d1/schema.ts 与 src/db/pg/schema.ts二者分别 re-export 各领域 schema而不是 provider-aware 的src/db/schema.ts后者会引入cloudflare:workers。tablesByName()遍历 barrel 中所有Table实例建出表名映射因此日后新增 schema 文件表清单会自动跟上。九、全部命令行参数速查参数默认值作用--dry-run—只读 D1 报告每表行数不写任何数据也不打开 Postgres 连接--allow-nonempty关闭目标库已有行时默认中止加上此参数强制继续因onConflictDoNothing而保持幂等--page-size N5000D1 API 每页行数--update关闭增量补差模式只同步窗口内变化行并 upsert大表按近性过滤小表全量重 upsert可安全重跑跳过空目标预检结束后再次推进 serial 序列--since-hours N12配合--update的窗口小时数无任何参数运行即为一次性全量拷贝。parseArgs的实现见 scripts/cli-utils.ts支持--flagvalue与--flag value两种写法裸--flag视为布尔 true。十、注意事项与陷阱清单schema 漂移脚本创作于 2026-06-29反映的是当日的 schema通过 Drizzle 枚举全部表。此后若有 schema 变更——新增表、重命名时间戳列、出现无主键的新表——在使用前务必重读脚本尤其是deltaPredicate与conflictArbiterNames两处scripts/migrate-d1-to-postgres.ts。--update的删除缺口删除不同步是设计使然验证阶段少量行数差如过期的 session/验证行属预期必须同步删除时改用写入冻结下的全量拷贝。Hyperdrive 是唯一通道应用连 Postgres 只走 Hyperdrive 绑定部署时必须同时具备DATABASE_PROVIDERpostgres与 Hyperdrive 绑定本地开发则依赖wrangler.jsonc中hyperdrive块的localConnectionString。回滚永远可行D1 全程只读任何阶段反悔都只是把 provider 翻回d1重新部署。本地演练推荐正式操作前按 docs/LOCAL_POSTGRES.md 在 Docker Postgres 上完整排练一遍 dry-run → migrate → cutover → rollback。简化版参考只想要最小操作路径直接看 runbooks/d1-to-postgres-simple.md本文是其完整版展开。【免费下载链接】open-seoOpen source alternative to Semrush and Ahrefs项目地址: https://gitcode.com/GitHub_Trending/op/open-seo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考