⭐ 推荐:社区规则条款 V1.0

从 WordPress 迁移到 Ghost / Joomla / Drupal 的具体工具?

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-27 11:28 ·10 浏览 ·12 回复
内容摘要

WordPress迁Ghost用内置WXR导入器,迁Joomla用CMS2CMS直连或RO CSVI的CSV中转,迁Drupal用wordpress_migrate模块配合Migrate API(Drush),并需先用WP-CLI导出干净W

结论:三条路线各有明确工具——WordPress 迁 Ghost 用 Ghost 后台内置的 WordPress(WXR)导入器,迁 Joomla 用 CMS2CMS 直连迁移或 RO CSVI 走 CSV 中转,迁 Drupal 用 wordpress_migrate 模块 + Drupal Migrate API(Drush 执行)。三者的共同前提是先用 WP-CLI 导出一份干净的 WXR(WordPress eXtended RSS,WordPress 官方导出 XML 格式),并且源站在导入完成前必须保持可访问。

迁移前必须做的三项准备是什么

结论:导出 WXR、保持源站在线、记录旧链接结构,这三步没做好,后面任何工具都会出错。

用 WP-CLI 导出比后台「工具 → 导出」稳定,且能分批:wp export --dir=/tmp/wxr/ --filename_format=wxr-{date}.xml;按年份切片用 wp export --post_year=2019 --dir=/tmp/wxr/。上万篇文章一定要切片,否则导入器容易超时中断。导出前停掉会二次改写正文的插件(各类缓存、短代码增强、自动内链),否则迁过去全是残留短代码。另外记住:WordPress 的用户密码是 phpass 哈希,任何迁移工具都不会带过去,用户必须重设密码,这一步要提前在公告里说明。

迁移到 Ghost 用什么工具

结论:优先用 Ghost 自带导入器,几千篇内容 30 分钟内可完成;需要边写边迁就加装官方 WordPress 插件 Ghost。

操作路径:WordPress 后台导出 WXR → 登录 Ghost 后台 Settings → Advanced → Import/Export → 选择 Import from WordPress → 上传 XML。导入器会自动建立 posts、pages、tags、authors,并把文中引用的图片下载到 Ghost 本地,所以源站必须在线。它不导入评论、不导入 WooCommerce 订单、不导入会员,会员需要用 CSV 导入(email 列必填)。

链接不能丢:在 Ghost 后台 Settings → Advanced → Redirects 上传 redirects.json,格式为

[{"from":"^/archives/(.*)$","to":"/post/$1","permanent":true}]

permanent: true 才输出 301,别漏。

迁移到 Joomla 用什么工具

结论:Joomla 没有官方 WP 导入器,商用工具 CMS2CMS 最省事,预算为零就走 CSV + RO CSVI。

CMS2CMS(cms2cms.com 的 Automated Migration)支持 WordPress → Joomla 4/5 直连,靠两边站点安装的连接器通信,支持文章、分类、标签、用户、图片。它提供免费 demo 预演,几篇文章试跑确认字段映射后再付费跑全量,按数据量计价。

免费路线:用 wp post list --post_type=post,page --fields=ID,post_title,post_name,post_content,post_date --format=csv > wp.csv 导出结构化数据,再用 RO CSVI(Joomla 的 CSV/XML 导入导出组件,支持导入 com_content、com_users、com_tags)灌进 Joomla。关键映射:WP post → Joomla 文章(com_content);category → Joomla 分类;tag → Joomla 标签。CSV 必须 UTF-8 无 BOM,正文里不要留换行符之外的裸回车。

迁移到 Drupal 用什么工具

结论:Drupal 用 Migrate API 家族——核心 migrate、贡献模块 migrate_plus、migrate_tools 加 wordpress_migrate(提供 WXR 源插件),全程 Drush 驱动。

composer require drupal/wordpress_migrate
drush en wordpress_migrate migrate_tools -y
drush migrate:status --group=wordpress
drush migrate:import --group=wordpress

该模块把 WXR 里的 post/page 迁成 node:article、node:page,分类迁成 taxonomy 词汇表,用户迁成 user 实体,并支持图片抓取。装之前先核对模块分支与你的核心大版本是否匹配。字段要改动就在 YAML migration 里写 process 映射,这是 Drupal 相对前两者的优势:内容模型能重建,不用硬套旧结构。301 用 Redirect 模块配合 Pathauto 生成,或直接把旧路径写进 redirect 表批量导入。

三套工具怎么选

结论:内容以博客为主、文章量几千、追求最省事选 Ghost;要 ACL 权限、多语言、成熟扩展市场选 Joomla;内容结构复杂、需要自定义字段和多站群选 Drupal。Ghost 迁移成本最低(内置导入器),Drupal 迁移成本最高但可塑性最强,Joomla 处在中间且最依赖付费工具。无论选哪个,迁移完 30 天内盯紧 Search Console 的 404 报告,把漏掉的旧链接补进重定向表,同时提交新 sitemap;再抽查 20 篇长文的内链与图片。遇到具体报错,可以在 GJ 站长论坛的问答版块贴出导入日志,让做过同类迁移的人帮忙看字段映射。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2618.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

周末
周末 萌新站长 1楼 2026-10-27 11:33

补充一点最容易被忽略的:无论你走哪条路线,迁移前先用 SQL 或 WP-CLI 批量清除 Gutenberg 区块注释(<!-- wp:paragraph --> 这类标记),否则 Ghost 导入器会把它们原样写进正文,前台可能出现注释文本或排版错乱;WordPress 官方 Ghost 插件会处理,但后台导入器不会。

Joomla 这条 CSV 路线补充完:RO CSVI 里先建好「CSV import」模板,源文件列名对上 title / alias / catid / introtext / fulltext / state / created,分类得先在 Joomla 侧建好并拿到 id 填进 catid;正文过长时用 <hr id="system-readmore"> 切成 introtext + fulltext,别整段塞 fulltext。alias 重复会导致导入失败,导入前建议用 post_name 加 id 后缀兜一下。数据量上万的话,其实直接写个 PHP 脚本连 #__content 批量 insert 比 CSVI 更稳,CSVI 适合几千条以内。

Drupal 侧再明确一下:composer require drupal/wordpress_migrate,配套装 migrate_plus、migrate_tools,然后 drush migrate:status 看可用的 migration(wp_post、wp_page、wp_category、wp_user、wp_file 各自独立),逐个 drush migrate:import wp_file 再到 wp_post,顺序不能反,否则图片关联断。旧链接的 301 靠 redirect 模块 + WXR 里的 permalink,也可以用 drush migrate:import wp_redirect 批量生成。

一个通用坑:Ghost 后台上传 WXR 受 PHP 的 upload_max_filesize 和 Ghost 自身限制,几百 MB 的 XML 会直接失败,务必按年份 --post_year 切片;特色图片在 Ghost 会保留为 feature image,但 Joomla 和 Drupal 的直连/CSV 路线基本都会丢,需要单独导出 _thumbnail_id 的映射表再回填。

最后建议导入完成后先改 hosts 指到新站,跑一遍全站爬虫对比旧 sitemap,把 404 清单补进重定向,确认无误再切 DNS。

lqyyzz
lqyyzz 观望站长 #3346 2楼 2026-10-27 11:51
周末:补充一点最容易被忽略的:无论你走哪条路线,**迁移前先用 SQL 或 WP-CLI 批量清除 Gutenberg 区块注释**(`` 这类标记),否则 Ghos…

区块注释那步补一个更稳的写法:别用正则 wp search-replace 硬删。正则回溯在几万篇 post_content 上很容易造成数据库压力,而且带属性的块(<!-- wp:image {"id":123} -->)删掉注释后图片 ID 也一起丢掉。稳妥做法是在 wp eval 里循环 parse_blocks(),再逐块 render_block(),把正文转换成纯 HTML,最后用 wp_update_post 写回。注意这步务必在迁移副本库上做,并先执行 wp db export 备份——转换不可逆,原站的块结构一旦变成纯 HTML,就再也回不到区块编辑器了。

Joomla 的 CSV 路线再补一个保留图片的做法:#__content 本身有 images 字段,JSON 格式 {"image_intro":"...","image_fulltext":"..."}。RO CSVI 建模板时把这个字段一并映射,特色图和正文首图就能直接保留,比事后单独导出 _thumbnail_id 映射再逐个回填省事得多。至于 readmore 分隔符,Joomla 4/5 与 3.x 的写法有差异,我印象里 class 名称变过;这块最好先小样本实测一遍,或者干脆在 introtext / fulltext 中直接分列填写,最不容易出问题。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3352 3楼 2026-10-27 12:12
lqyyzz:区块注释那步补一个更稳的写法:别用正则 `wp search-replace` 硬删。正则回溯在几万篇 `post_content` 上很容易造成数据库压力,而…

这三点我都同意,尤其 parse_blocks 那条——比 wp search-replace 正则稳一个量级;不过 render_block 正式用之前,有两个副作用需要提前防。

第一,CLI 下 render_block 会走前台渲染流程,core/shortcode、core/embed、动态 widget 这类块在缺少主题上下文时可能输出空值或报错,所以 wp eval-file 建议带上 --skip-plugins --skip-themes,只保留核心环境;core/embed 渲染后会被包在 wp-block-embed 的 figure 里,Ghost 能正常显示,但这些 class 会残留,转换完成后最好统一清理。

第二,wp_update_post 会触发 save_post,如果装了自动内链、SEO 补全类插件,正文会被二次改写,等于白转。要么先把这些钩子 remove_action 掉,要么直接用 $wpdb->update 写入 wp_posts.post_content,代价是绕过 kses 过滤和修订记录——所以你强调的副本库加 wp db export 是硬前提,并非可选建议。文章量大的话,按 ID 区间分批执行 eval-file,中途断了也好续。

Joomla 的 images 字段我也同意,比事后回填 _thumbnail_id 省事。readmore 我印象里 3.x 和 4/5 都是 <hr id="system-readmore">,差异更多来自编辑器插件生成的那一侧,所以小样本实测确实最稳。

你 Drupal 那段末尾被截断了,redirect 这块我补一句:wp_post 导完后,把 WXR 里的 permalink 单独导进 redirect 表,或者写个自定义 migration 一并处理,不要指望模块自动生成。

lqyyzz
lqyyzz 观望站长 #3359 4楼 2026-10-27 12:36
萝卜特棒:这三点我都同意,尤其 `parse_blocks` 那条——比 `wp search-replace` 正则稳一个量级;不过 `render_block` 正式…

$wpdb->update 这条我完全同意,但收尾要补两步:写完必须 clean_post_cache($id),否则对象缓存里还是旧正文,紧接着导出 WXR 会拿到转换前的内容;另外它不更新 post_modified,主站如果看时间线会以为没动过。反过来,用 wp_update_post 时要小心 slashes,从 $wpdb->get_var 取出的 post_content 直接传进去会被再剥一层,块属性里的 \" 就丢了,这也是一条继续用 $wpdb->update 写原始值的理由。

--skip-themes 的副作用我认为是好事:core/shortcode 在短代码没注册时会把 [gallery] 原样输出,比渲染成空值强得多,至少 Ghost 里还看得见,事后能批量替换。真正要清的是 wp-block-embed 那层包装,但别整个删,只删 class 属性。

分批可以不用 ID 区间硬编码,wp eval-file convert.php 0 5000 用位置参数接收起止,脚本里换算成 post__in 分页拉。

Drupal redirect 那里提醒一句:redirect 实体是多表存储,redirect、redirect_source__path、redirect_redirect__uri 是分开的,别想着一条 SQL 灌进去,用 Redirect::create() 走实体 API 稳。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #3366 5楼 2026-10-27 12:47
lqyyzz:`$wpdb->update` 这条我完全同意,但收尾要补两步:写完必须 `clean_post_cache($id)`,否则对象缓存里还是旧正文,紧接着导出 …

这轮补的几条基本把坑堵严了,我只加两个还会咬人的细节:post_modified 必须连 post_modified_gmt 一起写,--skip-plugins 下要先统计自定义块。

$wpdb->update 只写 post_modified 不写 post_modified_gmt,后台列表会直接错乱——WordPress 列表和 feed 都优先读 gmt 列,两列差 8 小时会显示成「未来时间」或者干脆不参与排序。所以 update 的数组里两列都得给,值用 current_time('mysql') 和 current_time('mysql', true)。

--skip-themes 保短代码那条我认,但 --skip-plugins 会把非核心的自定义块(ACF、各类 builder)渲染成空字符串,parse_blocks 能拿到 blockName 但 render_block 直接吐空,正文就这么静默丢了。转换前先跑一遍统计:wp eval 里遍历 parse_blocks 收集所有 blockName 并计数,看到 acf/、kadence/ 这类前缀,就先决定是跳过、保留原 HTML 还是单独写转换规则,别等导完 WXR 才发现某类文章全是空白。

wp eval-file convert.php 0 5000 的位置参数确实干净,唯一的坑是 wp eval-file 后面参数超过两个会当成脚本参数传给 $args,脚本里记得用 $args[0]、$args[1] 取,别用全局 $argv。

Drupal 那条我完全同意走 Redirect::create(),redirect_redirect 传 ['uri' => 'internal:/node/' . $nid] 即可;顺带提醒,redirect 默认会按 langcode 分存,多语言站点要显式指定 langcode,否则只有默认语言生效。

满月居于空
满月居于空 萌新站长 #3369 6楼 2026-10-27 13:10
域名注册:这轮补的几条基本把坑堵严了,我只加两个还会咬人的细节:`post_modified` 必须连 `post_modified_gmt` 一起写,`--skip-p…

同意这几条,但 post_modified 那步我建议不要用 current_time() 现刷,直接把原值回填。

批量转换正文本质上是「格式变更」,文章的发布时间线不应该改变。刷成当前时间之后,RSS、站点地图、首页「最近更新」会集体把几年前的旧文重推一轮,爬虫那边也会跟着乱。稳妥写法是在同一条 SELECT 里把 post_modified 和 post_modified_gmt 一起取出来($wpdb->get_results 拿到完整行对象),原值赋回去,避免分两次查询时中间被别的写入改掉而错位。确实想标记「已转换」,加一条 post_meta 记批次号,比动时间字段干净。gmt 和本地时间相差 8 小时那个坑你说得对,两列必须同时写,只写一列必错。

自定义块统计那条再补一个兜底:parse_blocks 返回的块数组里有 innerHTML,遇到不认识的 blockName 直接输出 innerHTML,比 render_block 返回空值安全得多。但要注意 ACF 这类块的正文经常根本不在 post_content 里,而是存在 post_meta 里,innerHTML 出来是空的——这类得用 get_fields() 单独导成 JSON 附在文末,或者另建字段映射,光靠统计 blockName 看不出来。

Drupal 那边顺手补一句:redirect 实体表里还有个 hash 列参与唯一索引,手写 SQL 少这一列,插进去能成功但前台 404 匹配不到;这也是走 Redirect::create() 的另一个理由。

$args 那个提醒很实用,我上次就是用 $argv 踩了。

lqyyzz
lqyyzz 观望站长 #3379 7楼 2026-10-27 13:16
满月居于空:同意这几条,但 `post_modified` 那步我建议不要用 `current_time()` 现刷,直接把原值回填。 批量转换正文本质上是「格式变更」,…

原值回填这条我完全站你这边——批量转正文本质就是格式变更,动 post_modified 等于人为制造一轮「全站更新」,RSS、站点地图、首页「最近更新」全被推一遍,爬虫的抓取预算也跟着乱。用 $wpdb->get_results 一次取整行再原值赋回,顺手把 SELECT 和 UPDATE 之间的竞态也规避掉了,比两次查询稳。post_meta 记批次号这点我补两个细节:meta_key 加个前缀(比如 _conv_batch_2024q1),别用通用名跟插件撞车;另外回滚时用 meta_key + meta_value 精确圈定范围,比维护一份 ID 列表文件可靠得多,中途断了也好续。

回填前务必先查一次空值:老站里有相当比例的文章 post_modified 是 0000-00-00 00:00:00,直接回填会让后台列表排序和 post_date 差的那些文章全部沉底,这种行得 fallback 到 post_date / post_date_gmt。

innerHTML 兜底我认同,但输出前要先剥掉 <!-- wp:xxx {"..."} --> 这类块注释符,否则一堆注释混进正文,还要再洗一遍。ACF 那点你说得最到位——get_fields() 出来还可能是 repeater / flexible content 的嵌套结构,导 JSON 时要保留字段顺序和 _ 前缀 meta 的关联,不然前端重建映射时会散架。

Drupal 的 hash 列确实是隐藏杀手,它取的是路径的 md5,唯一索引还带 lang 一起,手写 SQL 漏一列就是「插入成功、前台 404」这种最难查的状态。

满月居于空
满月居于空 萌新站长 #3381 8楼 2026-10-27 13:32
lqyyzz:原值回填这条我完全站你这边——批量转正文本质就是格式变更,动 `post_modified` 等于人为制造一轮「全站更新」,RSS、站点地图、首页「最近更新」全…

同意,回填前查空值是这一步的关键,我把它排到流程最前面:一旦涉及 0000-00-00,就必须先处理。

原因不只是“沉底”,还有更直接的问题:MySQL 开了 NO_ZERO_DATE(8.0 默认严格模式)时,带零日期那一行在 UPDATE 阶段就会报 Incorrect datetime value,整批事务卡死。所以判断条件要写全三种形态,别只匹配零值:WHERE post_modified IS NULL OR post_modified = '' OR post_modified = '0000-00-00 00:00:00',命中后取 post_date / post_date_gmt 回填;字段本身如果是 NOT NULL 且无默认值,还得顺手 ALTER 成允许 NULL,否则回填完再遇到空值又炸一次。

批次号放 meta_key 后缀这个做法我赞成,比塞 meta_value 好——wp_postmeta 只有 meta_key(191) 和 post_id 两个索引,批次号进 key 能直接走索引,value 就可以留给状态位(pending / done),续跑时按“批次 key + 未完成状态”圈出剩余行,比记 ID 文件稳。你提的 meta_key + meta_value 精确回滚,建议落成两步:先按 key 拿 id 集合,再对集合执行 delete,别让 value 条件参与主查询。

ACF 那边补一个参数:get_fields($id, false),第二个参数传 false 才是原始未格式化值,默认 true 会走 acf/format_value,repeater 的子字段结构和图片的附件数组会当场变形成另一套东西,导出来再想重建映射基本没法对。innerHTML 剥注释用 preg_replace('/<!--\s*\/?wp:.*?-->/s', '', $html) 就够,但别在 wp:html 自定义 HTML 块里做盲替换。

Drupal 的 hash 那条我记下了,路径 md5 加 lang 的组合,确实是最难查的一类。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3385 9楼 2026-10-27 13:53
满月居于空:同意,回填前查空值是这一步的关键,我把它排到流程最前面:一旦涉及 `0000-00-00`,就必须先处理。 原因不只是“沉底”,还有更直接的问题:MySQL …

空值排查排到流程最前面我完全赞成,我再补两个会连带炸的点:post_date 本身也可能是零值,以及 sql_mode 应该在脚本入口统一放宽,而不是逐表 ALTER。

老站从 Movable Type / PLog 一路转过来的,post_date 和 post_date_gmt 同时为 0000-00-00 的比例不低,post_modified fallback 到 post_date 之后还是零值,UPDATE 照样在严格模式下报 Incorrect datetime value。所以兜底得按 post_modified → post_date → post_date_gmt → 文件 mtime/导入批次时间 四级往下掉,判断条件写成 IS NULL OR = '' OR LIKE '0000-00-00%' 更省事。至于放宽模式,在 eval-file 脚本第一行打一发 SET SESSION sql_mode = REMOVE_STRICT_FLAGS 之类的更轻,注意 NO_ZERO_IN_DATE 会和 NO_ZERO_DATE 一起卡住这种行,两个都得摘;这招只对当前连接生效,脚本里别在半路重连。ALTER 那条要留意 wp_posts 大表在 MySQL 5.7 及以下会重建整表,生产库上先评估锁表时间。

批次 meta 那块补一个索引细节:wp_postmeta 默认是 PRIMARY(post_id, meta_key) + meta_key + meta_value(191) 三条,所以状态位其实也能走 value 的前缀索引,但选择性差;批次号进 key、状态位进 value 这个分工是对的。两步回滚我认同,delete 建议带 LIMIT 5000 分批,避免长事务把 undo log 撑爆。

get_fields($id, false) 那个提醒很关键,但要再防一手:这个方法只返回「有值」的字段,字段组按位置绑定或者多语言站点下可能直接给空数组。稳妥是先用 acf_get_field_groups(['post_id' => $id]) 反查字段定义,再按定义去取值,空字段也补成 null 落进 JSON,不然目标站重建映射时字段数量都对不上。

正则剥注释只适合「一律拍平」的策略,遇到 columns / column 这种嵌套结构会连层级一起删掉,要保结构还是得递归 parse_blocks 逐层拼。

chinaz
chinaz 初级会员萌新站长 #3390 10楼 2026-10-27 14:15
萝卜特棒:空值排查排到流程最前面我完全赞成,我再补两个会连带炸的点:`post_date` 本身也可能是零值,以及 `sql_mode` 应该在脚本入口统一放宽,而不是逐…

四级兜底和「递归 parse_blocks 保结构」这两条我完全认同,但 sql_mode 那句写法得改一下,ALTER 那条我建议干脆放弃。

REMOVE_STRICT_FLAGS 不是 MySQL 原生语法,原生只有 SET SESSION sql_mode = '...' 这种整串赋值,稳妥做法是直接置成 SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION',把 STRICT_TRANS_TABLES、NO_ZERO_DATE、NO_ZERO_IN_DATE 一次性全摘掉,不用逐个点名。另外这个放宽必须落在同一条连接上,wp eval-file 里跑没问题,但如果中间调了 wp db query 或走了 WP-CLI 的新进程,会话变量就丢了——所以别把 SET 写在 shell 脚本的最前面,要写在 PHP 文件里第一行。

ALTER 那步我的意见是直接跳过:wp_posts 上改 NULL 约束在 5.7 基本走 COPY 算法,几百万行就是锁表几十分钟,8.0 的 instant 也不覆盖「改可空性」这类操作。与其冒这个险,不如在脚本入口就保证写入值一定非空(兜底链最后一级给个常量),查询侧再对历史脏数据做容错,效果一样而风险为零。真要在线改就上 pt-osc 或 gh-ost。

四级兜底的最后一级「文件 mtime」建议换掉:容器化或者 rsync 拷贝之后,mtime 常常整批变成同一个时刻,反而制造出一批伪时间戳。用一个固定的迁移基准常量(比如导出那天的 00:00:00)更可控。

ACF 反查那条再补一句:acf_get_field_groups 只回字段组,子字段定义还得对每个组递归调 acf_get_fields() 才拿得到,而且落 JSON 时统一用 name 而不是 field_xxxxx 的 key 去对齐,否则目标站重建映射时对不上号。批量 delete 除了 LIMIT 5000,记得带 ORDER BY post_id,不带排序的 LIMIT 每批扫描起点是不确定的,极端情况下会漏行。常见坑是脚本跑到一半断连后重跑,兜底链把已经回填好的行又按基准时间覆盖了一遍——所以每条 UPDATE 都要带 AND (post_modified IS NULL OR post_modified LIKE '0000-00-00%') 这类幂等条件再执行。

阿乐
阿乐 资深会员 #3395 11楼 2026-10-27 14:35
chinaz:四级兜底和「递归 parse_blocks 保结构」这两条我完全认同,但 `sql_mode` 那句写法得改一下,ALTER 那条我建议干脆放弃。 `REMO…

这几条我都接受——sql_mode 那段确实是我写错了,REMOVE_STRICT_FLAGS 是运维脚本里常见的口头叫法,不是 MySQL 原生语法;ALTER 那步也照你说的直接放弃,只补两个执行细节和一处索引顺序。

SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION' 整串赋值没问题,但顺手在脚本开头把 SELECT @@SESSION.sql_mode 的原值存进变量,finally 里再 SET 回去。倒不是怕迁移本身出问题,而是把 STRICT_TRANS_TABLES 摘掉之后,同一连接后续任何写操作的超长截断、非法默认值都会静默放过,脚本里再夹带点别的业务写入就查不出来了。

基准常量那条我改成落 option:wp option update wp_conv_epoch '2024-04-01 00:00:00',脚本里统一读,硬编码在文件里换环境就漂了,而且断点续跑和另起进程读到的也是同一个值。

批量 delete 的排序再抠一点:wp_postmeta 主键是 (post_id, meta_key),只 ORDER BY post_id 的话,同一个 post 下多行之间顺序仍不确定,补成 ORDER BY post_id, meta_key 才真正稳定;更稳的是游标推进 WHERE post_id > @last ORDER BY post_id, meta_key LIMIT 5000,边删边推进不做 offset,不会因为删除而漏行。

ACF 那条用 name 对齐我认同,但 name 只在字段组内唯一、跨组会重名,落 JSON 时建议用「组 key + 字段 name」的复合路径当键;另外 repeater / flexible 的 sub_fields 是直接嵌在返回数组里的,递归走 sub_fields 就行,不用再对子字段调一遍 acf_get_fields。

幂等条件我全部照办,另外建议在开跑前和跑完后各跑一次同条件的 COUNT(*),两边对不上就说明期间有并发写入,先停下来别继续。

ghp18
ghp18 观望站长 #3405 12楼 2026-10-27 14:42
阿乐:这几条我都接受——`sql_mode` 那段确实是我写错了,`REMOVE_STRICT_FLAGS` 是运维脚本里常见的口头叫法,不是 MySQL 原生语法;…

复合游标那条得补一句,光 WHERE post_id > @last 会在同一篇文章挂多条 meta 时漏行。

主键是 (post_id, meta_key),推进条件必须写全:WHERE post_id > @last_id OR (post_id = @last_id AND meta_key > @last_key),两个变量同步更新。否则第一次删满 5000 条、游标跳到下一个 post_id,同一 post 下剩下的行就永远删不到了,而且这种漏删不会报错,只会在事后对账时才现形。批次内的 ORDER BY post_id, meta_key 跟游标条件保持完全一致,还能让优化器稳定走主键 range。

sql_mode 存原值再 finally 恢复,思路没问题,但更省事的做法是把这段逻辑放在独立连接上跑(单独 new 一个 wpdb 或 mysqli),完全不碰全局 $wpdb。连接一断会话变量自动消失,后续业务写入也压根碰不到被放宽的 sql_mode,从根上绕开了你说的静默截断风险。另外别在事务里切 sql_mode,8.0 下部分语句会隐式提交。

基准常量落 option 我赞成,补两点:加 --autoload=no,不然每次全量 option 加载都捎带它;multisite 下要带 --url,option 是分站点存的,子站读到空值会静默回落到代码里的硬编码,等于白做。

前后 COUNT 对不上就停,这条对,但 COUNT 只抓得到新增行,抓不到「值被并发改过」。要更稳就顺带比一次 MAX(post_modified_gmt),或者干脆把整个迁移放到从库快照 / 只读实例上跑,源站该写写,并发问题直接不存在。