本文约 6,593 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:一个 42 MB 的 sitemap.xml,加载 19 秒后返回 500
站点过了三万篇文章之后,最常见的硬件式故障不是数据库慢,而是站点地图本身先崩了。Yoast/Rank Math 默认把所有 URL 塞进一组 sitemap 文件,每个 CPT 一份。当月更新频繁时,这个文件越来越大:XML 序列化耗时增加、内存占用上升(simplexml_load_string 对大文件特别吃内存)、CDN 把它当作大文件跳过缓存、Nginx 的 fastcgi_read_timeout 或 PHP 的 max_execution_time 直接把它掐断——于是 Google 隔三差五在 GSC 里报”sitemap 无法读取”。
规范上对 sitemap 有明确的硬性限制(sitemaps.org 协议):单个 sitemap 文件最多 50,000 条 URL,且解压后体积不得超过 50 MB。超过就必须拆分成 sitemap index(索引文件),由索引指向多个子 sitemap。WordPress 核心从 5.5 起自带的 WP_Sitemaps 就是这么干的,它默认每个子 sitemap 装 2000 条 URL(wp_sitemaps_get_max_urls 过滤器可改)。
本文讲怎么把大站的 sitemap 组织得既能被完整抓取、又不会拖垮服务器,以及一个常被忽略的话题:被分页腰斩的内容(<!--nextpage--> 分页文章)该怎么处理。
原理分析:索引文件的三层结构与三种拆分维度
sitemap_index.xml ← 索引,只列子 sitemap 的 URL 与 lastmod
├── post-sitemap1.xml ← 文章 1-2000
├── post-sitemap2.xml ← 文章 2001-4000
├── page-sitemap.xml
├── product-sitemap1.xml ← CPT 独立
└── image-sitemap1.xml
三种常见拆分维度,各有用武之地:
| 维度 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 按内容类型(CPT) | 所有站点必做 | 结构清晰,便于在 GSC 分别观察各类覆盖率 | 单一类型过大时仍需二级拆 |
| 按数量分页 | 单类型 >5 万条 | 符合协议上限 | URL 变化会随分页移动 |
| 按时间(年/月) | 资讯/博客类大站 | 历史数据稳定不变,可强缓存 | 需要每次发布都重建当月文件 |
第二个要点是 lastmod 的语义。协议允许的值必须是 W3C Datetime 格式(2026-10-10T08:00:00+08:00),而且它必须真实反映内容的最后修改时间。很多插件错误地把 lastmod 写成当前时间(gmdate('c')),结果搜索引擎每次抓取都认为全站内容刚更新过,抓取预算(crawl budget)被大量浪费在”重新验证其实没变”的页面上。这是一项隐藏但影响巨大的 SEO 损耗。
第三个要点是分页文章内容。<!--nextpage--> 产生的 /2/、/3/ 页面,对于有完整内容价值的文章可以考虑收录;但那些”为了增加 PV 而硬拆”的分页,把一篇文章切成 10 页,每页几百字,属于典型的低质量页面。Google 的重复内容处理策略会将其一部分折叠甚至在索引中丢失。
上一篇文章已处理多份 sitemap 的冲突问题:WordPress 站点地图冲突排查
保姆级实操
步骤 1:确认单份 sitemap 的规模
# 文件大小与 URL 条数(<url> 节点计数)
curl -s https://example.com/post-sitemap.xml -o /tmp/ps.xml
ls -lh /tmp/ps.xml
grep -o '<url>' /tmp/ps.xml | wc -l
# 索引里的子地图数量
curl -s https://example.com/sitemap_index.xml | grep -o '<loc>' | wc -l
只要单文件条数接近 5 万或体积接近 50 MB,就必须拆。
步骤 2:调整 WordPress 核心地图的分页粒度
// 把核心 sitemap 每个子文件的 URL 数从默认 2000 降到 1000,降低单次渲染压力
add_filter( 'wp_sitemaps_max_urls', function( $max_urls, $object_type ) {
if ( 'post' === $object_type ) {
return 1000;
}
return $max_urls;
}, 10, 2 );
// 剔除不想进地图的内容(比如 landing page CPT)
add_filter( 'wp_sitemaps_post_types', function( $post_types ) {
unset( $post_types['landing'] );
return $post_types;
} );
步骤 3:修正 lastmod 取值
不少插件用 Resource tags 或当前时间。自己控制时必须用文章真实修改时间:
add_filter( 'wp_sitemaps_posts_entry', function( $entry, $post ) {
if ( isset( $entry['lastmod'] ) ) {
// 用文章真实 GMT 修改时间,而不是 gmdate('c')
$entry['lastmod'] = mysql2date( 'c', $post->post_modified_gmt, false );
}
return $entry;
}, 10, 2 );
步骤 4:按年份/月份拆分(资讯大站的推荐写法)
下面是一个用 WP-CLI 定期生成的静态化方案静态文件比动态生成省资源得多:
#!/usr/bin/env bash
# gen-sitemap.sh —— 按月生成静态 sitemap,cron 每天执行一次
# 用法:0 3 * * * /usr/local/bin/gen-sitemap.sh >/dev/null 2>&1
OUT=/var/www/example.com/public/sitemaps
BASE=https://example.com
mkdir -p "$OUT"
LIMIT=45000 # 留一点余量,别顶到 50000 上限
for ym in $(wp post list --post_type=post --post_status=publish \
--format=csv --fields=ID,post_date 2>/dev/null \
| tail -n +2 | cut -d, -f2 | cut -c1-7 | sort -ru); do
y=${ym%-*}; m=${ym#*-}
file="$OUT/post-$ym.xml"
{
echo '<?xml version="1.0" encoding="UTF-8"?>'
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
while IFS=, read -r id perm mod; do
printf '<url><loc>%s</loc><lastmod>%s</lastmod></url>\n' "$perm" "$mod"
done < <(wp post list --post_type=post --post_status=publish \
--year="$y" --monthnum="$((10#${m}))" \
--fields=ID,url,post_modified_gmt --format=csv 2>/dev/null | tail -n +2)
echo '</urlset>'
} > "$file"
echo "generated $file"
done
生成的每月文件几乎不变,可以给它们配长缓存(Nginx 里 expires 7d;),这比每次动态渲染节省大量 CPU。
步骤 5:写索引文件
<?php
// gen-index.php:扫描 sitemaps/ 目录,重建 sitemap_index.xml
$out = __DIR__ . '/public/sitemap_index.xml';
$files = glob( __DIR__ . '/public/sitemaps/*.xml' );
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ( $files as $f ) {
$xml .= sprintf(
"<sitemap><loc>https://example.com/sitemaps/%s</loc><lastmod>%s</lastmod></sitemap>\n",
basename( $f ),
gmdate( 'c', filemtime( $f ) )
);
}
$xml .= '</sitemapindex>';
file_put_contents( $out, $xml );
echo "index rebuilt with " . count( $files ) . " children\n";
注意:写在 index 里的 child <loc> 必须与 index 本身同源(协议要求),跨域指向会被忽略。
步骤 6:处理被 <!--nextpage--> 分页的文章
原则:不要让所有分页都进 sitemap。合理做法只对确实有独立价值、每页内容完整的长文分页单独提交:
// 只让 karatatsu 型(>3 页且每页内容充实)的文章分页进地图,其余只提交主 URL
add_filter( 'wp_sitemaps_index_entry', function( $entry ) {
// 示例:不单独提交 comment-page 这类低价值分页
if ( false !== strpos( $entry['loc'], 'comment-page-' ) ) {
return array();
}
return $entry;
} );
步骤 7:Nginx 层面加缓存与限速保护
location ^~ /sitemap {
expires 1h;
add_header Cache-Control "public, max-age=3600";
# 防止被刷:同一 IP 每分钟最多 30 次
limit_req zone=sitemap burst=20 nodelay;
}
常见踩坑
- 索引指向了
http://而站点是https://:sitemap 协议要求 child 与 index 同协议同 host,跨协议会被”部分忽略”,GSC 里显示”无法抓取”。 lastmod用gmdate('c')导致抓取预算被榨干:见上文,务必用post_modified_gmt。- 案例:某小说站每天更新 200 章,sitemap 用插件动态生成,文件一度到 38 MB / 4 万多条。Googlebot 每次抓取平均耗时 11 秒并偶发 502,GSC 里 sitemap 状态在”成功 / 无法读取”之间反复横跳,索引量长期卡在 1.2 万。改成按月静态生成 +
expires 7d后:单文件最大降到 1.4 MB、抓取耗时降到 300 ms 以内,三个月后索引量涨到 4.6 万。教训:大站站点地图要走静态化 + 缓存,不要指望 PHP 每次现场序列化几万条数据。 - 图片 sitemap 单独却没在 robot-world 声明:图片、视频有各自的 extension 命名空间(
<image:loc>、<video:video>),必须在<urlset>里带上对应 xmlns,否则会被当作普通 URL 处理。 <?xml之前出现空格/BOM:有些主题在这个文件输出前 include 了一个带 BOM 的 php 文件,导致 XML 解析失败(”XML declaration allowed only at the start”)。用curl -s ... | head -c 20 | xxd检查开头字节。- 把 noindex 的页面还留在 sitemap 中:这是自相矛盾的信号,GSC 会报”已提交但未编入索引 → 已排除 robots meta”。Yoast 会自动剔除,自建方案要手动排除。
- 更新后没有 Ping 搜索引擎:虽然
Sitemap:写在 robots.txt 里会被定期发现,但重要更新建议主动 ping。注意百度与 Google 的 ping 端点已多次变更,优先以平台后台手动提交为准。 - CDN 把索引缓存了旧版本:见上面的
expires 1h,不要用极长 TTL 缓存 index 本身(子文件或可以用长 TTL,因为它们几乎不变)。
延伸阅读:WordPress 站点地图冲突排查
总结
大站 sitemap 的三条硬规则:①单文件 ≤50,000 条且 ≤50 MB,超了必须拆索引;②lastmod 必须是内容真实修改时间,写当前时间等于自杀式浪费抓取预算;③优先静态化 + CDN/缓存,别让 PHP 在每次爬虫请求时现场序列化几万条数据。组织方式上:CPT 独立、单类型再按数量或按月份二级拆。至于 <!--nextpage--> 分页这种伪内容,默认不要让它们单独进地图——先把精力放在让主体 URL 被完整索引上。
