一个站要上线第二种、第三种语言时,团队一般会在两套结构里挑一个:一个 WordPress 装一套多语言方案,或者开多站点网络,每种语言一个子站。上线当天的成本差不了多少,拉开差距的是之后每次改版、每次加语言、每次动 URL 结构。下面按数据库结构、数据共享边界、插件冲突面、成本估算方法、迁移操作分开写,能给命令的直接给命令。
两套结构在数据库里的差别
单站多语言:一张 wp_posts、一张 wp_options、一套 wp_usermeta。语言靠分类法、自定义字段或者内容上的语言标识区分,所有内容在同一个查询空间里。
多站点网络:wp_blogs 记录每个子站,wp_site 记录网络,子站各自有 wp_2_posts、wp_2_options、wp_2_postmeta,而 wp_users 和 wp_usermeta 是全网络共用的一张表。
先看清现状:
wp site list --fields=blog_id,url,last_updated
wp db query "SELECT option_name, option_value FROM wp_2_options WHERE option_name IN ('siteurl','home','template');"
表前缀不要写死。子站 ID 不一定等于 2,用 API 取:
<?php
global $wpdb;
$blog_id = 2;
$prefix = $wpdb->get_blog_prefix( $blog_id ); // 形如 wp_2_
$sql = $wpdb->prepare(
"SELECT COUNT(*) FROM {$prefix}posts WHERE post_type = %s AND post_status = 'publish'",
'page'
);
$pages = (int) $wpdb->get_var( $sql );
用户、订单、内容分别能不能共享
用户信息是共享的。一个账号可以同时属于多个子站,角色写在 wp_2_capabilities 这个 usermeta 键里,所以同一个人在不同子站可以是不同角色。好处是注册一次全网络通用,代价是删用户、改邮箱会影响所有子站。
内容不共享。跨站取数必须切站点上下文:
<?php
$sites = get_sites( [ 'number' => 0 ] );
$rows = [];
foreach ( $sites as $site ) {
switch_to_blog( $site->blog_id );
$rows[ $site->blog_id ] = [
'url' => home_url( '/' ),
'pages' => (int) wp_count_posts( 'page' )->publish,
'inquiry' => (int) wp_count_posts( 'inquiry' )->publish, // 换成自己的文章类型
];
restore_current_blog();
}
return $rows;
订单不共享。电商订单、库存、优惠券、顾客记录都按子站存在自己的表里,做一个“统一后台看全部订单”需要自己写聚合查询,或者让订单落到外部系统里。开多站点不会自动得到这件事。
另外,跨站聚合的查询次数等于站点数。站点一多,后台列表页会明显变慢,对象缓存要按 blog_id 分开存键。
插件和主题:网络启用还是逐站启用
网络启用会把插件加载到全部子站,更新一次全网络生效;出问题时也是全网络一起出问题,而且只有超级管理员能停用,子站管理员看不到开关。逐站启用灵活,但每次更新要在每个子站验证一遍。
几个经常踩的点:
- 插件的 option 是按站点存的。网络启用只代表代码被加载,配置不会复制到新子站,新站拿到的是默认值。
- cron 也是按站点存的,定时事件数量随站点数翻倍。逐个看:
wp cron event list --url=https://example.com/ja/。 - 上传目录按站点分开,路径是
wp-content/uploads/sites/2/,备份脚本和 CDN 回源规则要跟着改。 - 自定义文章类型和分类法必须在每个子站都注册一遍,代码放 mu-plugin,或者放在网络启用的插件里。
维护成本怎么估,不要拍脑袋
分两块看:加一种语言的固定成本,和每次发版的边际成本。
| 事项 | 单站多语言 | 多站点网络 |
|---|---|---|
| 新加语言的固定工作 | 建语言目录、补翻译串、配 hreflang、检查语言切换器 | 建子站、复制主题和插件配置、重建菜单/首页/表单、单独配重定向 |
| 每次发版的边际工作 | 一处改动,检查其他语言的模板串有没有漏 | 子站逐个验证,主题和插件更新还要看网络层面 |
| 排障 | 一个库、一份日志 | 每站一份 options 和 cron,日志要按站点分开 |
| 备份与恢复 | 一个库加一个上传目录 | 各子站的表加共享用户表,要一起备、一起回滚 |
| 换主题或大改版 | 一次 | 每个子站一次,或者共用主题但每站配置不同 |
估工时的办法:拿一次真实发版,记录在现有语言上花掉的时间,拆成固定部分和每语言边际部分,边际部分乘语言数。这个数来自你们自己的流程,比任何经验值都靠谱。
URL 结构要改的时候怎么做
改 URL 结构(子目录换子域名,或者反过来)会不会影响排名,没人能保证。能控制的是把重定向、canonical、sitemap、内链做全,剩下的交给抓取频率。
第一步:冻结旧 URL 清单。
wp post list --post_type=post,page --post_status=publish --field=url --format=csv > old_urls.csv
分类法归档页、作者页、分页、搜索结果页不在这个清单里,要另外列。
第二步:建立映射,用规则不要用清单。
nginx:
map $request_uri $migrated {
~^/en/(.*)$ https://en.example.com/$1;
~^/zh/(.*)$ https://zh.example.com/$1;
default "";
}
server {
listen 443 ssl;
server_name example.com;
if ($migrated != "") {
return 301 $migrated;
}
}
Apache 的 RewriteRule 在 .htaccess 里匹配的是去掉前导斜杠的路径:
RewriteEngine On
RewriteRule ^en/(.*)$ https://en.example.com/$1 [R=301,L]
RewriteRule ^zh/(.*)$ https://zh.example.com/$1 [R=301,L]
第三步:确认 canonical 读的是当前请求地址。 301 到位之后 canonical 通常会跟着新域名走;如果某个插件是从数据库里读 siteurl 拼 canonical,迁移完要重新检查一遍,别让页面自己声明旧地址。
第四步:hreflang 互指,并且每个版本都要包含自己。 漏掉自己那一版是最常见的错。
<?php
add_action( 'wp_head', function () {
$map = [
'en' => '/en/',
'zh' => '/zh/',
];
$path = strtok( $_SERVER['REQUEST_URI'] ?? '/', '?' );
$current = 'en';
foreach ( $map as $slug => $prefix ) {
if ( strpos( $path, $prefix ) === 0 ) {
$current = $slug;
break;
}
}
$tail = ltrim( substr( $path, strlen( $map[ $current ] ) - 1 ), '/' );
foreach ( $map as $slug => $prefix ) {
printf(
'<link rel="alternate" hreflang="%s" href="%s">' . "\n",
esc_attr( $slug ),
esc_url( home_url( $prefix . $tail ) )
);
}
printf(
'<link rel="alternate" hreflang="x-default" href="%s">' . "\n",
esc_url( home_url( $map['en'] . $tail ) )
);
} );
这段只处理带语言前缀的固定路径。带查询串的列表页、分页、非语言的默认语言版本要单独接。
第五步:sitemap 分语言。 单站多语言一般是一个 sitemap 里带 hreflang 注解,或者按语言拆文件再在索引里列全。多站点网络天生每个子站一个 sitemap,但最好有一个索引把各子站的 sitemap 都列出来,别指望被发现。
第六步:处理库里的硬编码地址。 先 dry-run 看命中:
wp search-replace 'https://example.com/en/' 'https://en.example.com/' --all-tables --precise --dry-run
wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%example.com/en/%' AND post_status='publish';"
第七步:上线后逐条验证。
while read -r url; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}' -A 'redirect-check' "$url")" "$url"
done < old_urls.csv
期望是 301 且目标地址正确。出现 200 说明旧路径还在直出;出现 302 说明中间有插件插了一手;出现 404 说明映射漏了。
第八步:看日志。 按状态码和目标路径分组,看旧路径的 301 命中量是否在下降、404 有没有异常抬头。收录周期跟抓取频率有关,只能观察,不能承诺。
多站点网络的数据边界
- 用户与登录状态:天然共享。
- 订单、库存、优惠券:不共享,要自己聚合。
- 内容:不共享,用
switch_to_blog取,或者建共享自定义表。 - 媒体库:每站一份,跨站复用同一张图要手动关联。
- 权限:超级管理员在网络后台,子站管理员管自己那一站;网络启用的插件子站动不了。
如果只有一个品牌、语言版本在个位数、以后也不打算拆成独立站点,单站多语言的运维面小得多。反过来,各语言版本要独立发版、独立配插件、甚至以后交给不同主体运营,多站点网络把边界划得更干净。
我们维护的免费插件 AumLang 是按语言前缀输出 hreflang 和翻译串的,如果不想自己维护上面那段 wp_head 逻辑,可以看它的实现:AumLang。
不管最后选哪套,现在就能做的一件事:把当前所有对外 URL 导出归档。改结构那天再回头找清单,成本比现在高一个量级。