WordPress 数据库替换网址:不破坏序列化数据的正确做法
在数据库里更改 WordPress 站点网址,同时不让小工具、菜单和主题设置变成空白。用能保住序列化数据的 WP-CLI 命令来做。
发布于
不要在 WordPress 数据库上直接跑 UPDATE ... SET column = REPLACE(column, 'oldurl', 'newurl')。它会正确地改掉文本,同时把你的设置一起毁掉——因为 WordPress 把数组和对象存成 PHP 序列化字符串,而这种格式会记录它包含的每个字符串的字节长度。原始 SQL 替换只改写文本,从来不动记录下来的长度,于是这个值就变得读不出来了。
要用支持序列化的工具。在命令行下就是 wp search-replace。先备份数据库,先跑一次 dry run,然后再真正执行。
到底坏在哪里
一个 PHP 序列化字符串长这样:
s:18:"http://example.com"
这个 18 是后面那段文本的字节长度。你可以数一下——http://example.com 正好是 18 个字符。
现在想象一个存好的主题设置:
a:1:{s:4:"logo";s:18:"http://example.com";}
把 http:// 用普通 SQL 替换成 https://,你以为会得到:
a:1:{s:4:"logo";s:19:"https://example.com";}
但你不会。你得到的是这个:
a:1:{s:4:"logo";s:18:"https://example.com";}
字符串现在是 19 字节,却仍然声称自己是 18 字节。PHP 读到声明的长度,往前走 18 个字节,在它预期的位置没找到收尾的引号和分号,于是放弃。整个数组反序列化失败。
为什么它是悄无声息地失败
这才是这个 bug 代价高昂的地方。WordPress 不会抛出错误然后停下来。它读取某个选项时会把值交给反序列化辅助函数,PHP 对格式错误的数据返回 false,然后 WordPress 交给你代码的,是一个”就像这个选项从来没设置过”的值。插件或主题于是退回它自己的默认值。
所以症状不是一个错误页面。症状是:
- 侧边栏里的小工具消失了
- 自定义器的设置被重置成主题默认值
- 页面构建器的布局数据读出来是空的,页面渲染成一片空白
- 插件设置页看起来像刚装好一样
- 菜单丢了自己指定的显示位置
后台里没有任何地方提示出了问题。数据行还在数据库里,里面还是一堆看起来完全正确的文本。只有那个长度前缀差了一两个字节。反序列化失败时 PHP 确实会发出一条通知,但生产环境关掉了错误显示,没人看得见。把 WP_DEBUG 打开,同时启用 WP_DEBUG_LOG 并把 WP_DEBUG_DISPLAY 设为 false,这些通知就会写进日志文件,你可以在那里读到。
而且损坏也不是均匀分布的。简单的标量值——siteurl、home、纯文章正文、单独占一列的网址——经历一次粗暴替换后完好无损。只有序列化的值会坏。这就是为什么一次糟糕的替换,乍看之下往往像是成功了。
动手之前先查 wp-config.php
如果 wp-config.php 里定义了 WP_HOME 或 WP_SITEURL,这些常量会盖过数据库里的一切。改 wp_options 会显得毫无作用。在断定问题出在数据库之前,先找找有没有这样的行:
define( 'WP_HOME', 'http://example.com' );
define( 'WP_SITEURL', 'http://example.com' );
要么把它们改成新网址,要么删掉、让数据库说了算。
先备份,认真备份
序列化损坏不总是好逆转的。一旦长度前缀和内容对不上,原始值就没法从损坏的值里还原出来了——不知道原来的字符串是什么,你就无从得知正确的长度该是多少。备份是唯一真正的撤销手段。
wp db export backup-before-replace.sql
如果没有 WP-CLI,就用 mysqldump:
mysqldump -u USER -p DBNAME > backup-before-replace.sql
条件允许的话,把备份放到服务器之外。放在网站根目录里的备份,既是还原点,也是安全隐患。
安全的命令
WP-CLI 的 search-replace 会把每个值反序列化,遍历解码后的结构,在里面做替换,再用正确的长度重新序列化。先跑 dry run:
wp search-replace 'http://example.com' 'https://example.com' --dry-run --all-tables --skip-columns=guid
把报告读一遍。它会告诉你每张表、每一列会发生多少次替换。如果表的列表看着不对,或者某张你不认识的表出现了成千上万条命中,先停下来看看,再真的执行。
然后正式执行:
wp search-replace 'http://example.com' 'https://example.com' --all-tables --skip-columns=guid --report-changed-only
关于这几个参数,说点实在的:
--all-tables会覆盖那些 WordPress 自己没有注册的表,而插件数据常常就在那里。不加它,有些插件表会被整个跳过。--skip-columns=guid很重要。guid是给订阅阅读器用的永久标识符,不是一个可用的网址。改写它会让订阅者把你的整个存档当成新文章。- 搜索时不要带结尾斜杠,也不要带上你并不想动的协议变体。替换裸的
example.com,会连电子邮件地址和正文里任何一次提及一起改掉。
如果你没法用命令行,支持序列化的迁移插件能在后台界面里做同样的解码工作。你要找的行为,是它明确写出自己会处理序列化数据;如果某个工具没这么说,就当它不会。
涉及哪些表和列
| 表 | 列 | 序列化风险 |
|---|---|---|
| wp_options | option_value | 高——小工具、主题设置、瞬态数据、大多数插件设置 |
| wp_postmeta | meta_value | 高——页面构建器布局、自定义字段数组 |
| wp_usermeta | meta_value | 高——用户权限、后台屏幕和仪表盘偏好 |
| wp_termmeta | meta_value | 中——取决于用了哪些插件 |
| wp_posts | post_content, guid | 正文风险低,但短代码属性和区块标记里带着网址 |
| wp_comments | comment_content, comment_author_url | 低 |
| wp_links | link_url, link_image | 低,而且通常没在用 |
多站点还要加上 wp_blogs、wp_site、wp_sitemeta,以及各子站的 wp_2_options、wp_3_options 等等。多站点改网址还有搜索替换之外的额外规则——把它当成另一件事来处理。
search-replace 解决不了的那些情况
把限制说清楚:
存在数据库里的 JSON。 有些页面构建器把布局数据存成 JSON,正斜杠是转义过的,所以你的网址长成 https:\/\/example.com 而不是 https://example.com。按正常写法去搜就会漏掉。你可能需要针对转义后的字符串再跑一遍。先在副本上测试。
Base64 编码的值。 如果某个插件在存储前把设置编码过,任何基于文本的替换都根本看不见那个网址。这类设置通常只能在插件自己的界面里重新填一遍。
写死在文件里的网址。 任何写进 functions.php、子主题模板或写死的资源路径里的东西都不在数据库里,替换碰不到它们。主题目录要另外用 grep 找一遍。
缓存和已生成的输出。 对象缓存、页面缓存和 CDN 上的副本,在替换正确完成之后仍然会继续吐出旧网址。先清掉它们再检查,别急着断定替换失败了。
如果你已经跑了那条错误的语句
还原备份。这就是答案,与其给一套多半没用的修复流程,不如把这句话直说了。
如果没有备份,能救回来的路径很窄:从没被序列化过的值没事,坏掉的序列化值只能手工重建,或者到对应的设置页面重新保存一次来重置。重新保存一个小工具区域、重新选一次菜单位置、或者把插件设置重新填一遍,都会写入一个全新的、序列化正确的值来覆盖坏掉的那个。麻烦,但有效,而且总好过把损坏的数据行留在那里,等着让下一个看这个站点的人一头雾水。
在动手之前想先把正确的语句生成出来,可以用 WordPress 搜索替换 SQL 工具 来构建——它会告诉你哪些列可以用普通 SQL 直接改、哪些必须用支持序列化的方式处理,让你在敲下命令之前就看清这条分界线。
FAQ
常见问题
为什么直接用 SQL 的 REPLACE 会把 WordPress 站点搞坏?
因为 WordPress 把数组和对象存成 PHP 序列化文本,而这种格式会记录里面每个字符串的字节长度。原始的 REPLACE 只改文本,却把旧的长度数字留在原地。PHP 于是拒绝反序列化这个值,WordPress 退回默认值,设置看起来不是出错,而是变成了空白。
更改 WordPress 数据库里的站点网址,最安全的方式是什么?
先完整备份数据库,再用 WP-CLI 的 wp search-replace 命令,第一遍先跑 dry run。WP-CLI 会把每个值反序列化,在解码后的结构内部做替换,然后用修正过的长度重新序列化。如果没有命令行权限,支持序列化的迁移插件也能在后台界面里完成同样的工作。
哪些数据表里存着旧网址?
主要是 wp_options、wp_posts、wp_postmeta、wp_usermeta、wp_termmeta 和 wp_comments。序列化的风险集中在 wp_options 以及任何 meta_value 列,因为小工具、主题设置、瞬态数据和页面构建器的数据都是以数组形式存储的。多站点环境下,wp_blogs、wp_site 和 wp_sitemeta 也会带着域名。
如果 wp-config.php 里定义了网址,改数据库还有用吗?
没用。只要 wp-config.php 里定义了 WP_HOME 或 WP_SITEURL,这两个常量就会盖过 wp_options 里的 siteurl 和 home。在常量被更新或删除之前,改数据库不会带来任何可见变化。所以先查 wp-config.php——很多迁移失败的原因就是这一行。
guid 这一列也要一起替换吗?
不要。guid 是给订阅阅读器用的永久标识符,不是可访问的网址,改写它会让订阅者把你的所有旧文章重新当成新文章看一遍。跑 WP-CLI 时用 skip-columns 把 guid 排除掉。唯一常见的例外,是从来没有对外提供过订阅源、也没有订阅者的站点。
替换出错以后,怎么判断某个值已经坏了?
坏掉的序列化值在数据库里看着完全正常,但在 WordPress 里读出来是空的。小工具消失、自定义器的设置被重置、插件选项退回默认,而且没有任何报错。开启 WP_DEBUG 和 WP_DEBUG_LOG 就能看到反序列化的通知。把声明的字符串长度和真实字节数对一下,可以直接确认。