保哥笔记

WordPress 标签相关文章实战:query_posts 反模式拆解、WP_Query 重写、三层缓存与 Jaccard 相关性升级

原文那段二十行的"标签相关文章"代码,是十年前 WordPress 教程站的标准答案。它的结构是:取出当前文章的所有 tag,用 query_posts() 拉同标签的最新十篇,不够十篇再用同分类补。看上去合理,跑起来也能出结果——但放在 2026 年的生产站点上,至少踩到了三个反模式、两个性能陷阱和一个 SEO 隐性扣分项。这篇文章把代码逐行拆开,把那些"为什么不能这么写"的细节、以及在 5 万文章站上 EXPLAIN 出的真实 SQL 计划写下来,然后给出从 WP_Query 重写、三层缓存、Jaccard 相关性升级、到 Gutenberg / AMP 兼容的完整方案。

原文代码的三个致命缺陷

把原文贴出来再标注问题:

$post_num = 10;
$exclude_id = $post->ID;
$posttags = get_the_tags();
$i = 0;
if ( $posttags ) {
    $tags = '';
    foreach ( $posttags as $tag )
        $tags .= $tag->term_id . ',';
    $args = array(
        'post_status'    => 'publish',
        'tag__in'        => explode(',', $tags),
        'post__not_in'   => explode(',', $exclude_id),
        'caller_get_posts' => 1,
        'orderby'        => 'comment_date',
        'posts_per_page' => $post_num,
    );
    query_posts( $args );
    while( have_posts() ) {
        the_post(); ?>
        <li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
    <?php
        $exclude_id .= ',' . $post->ID;
        $i++;
    }
    wp_reset_query();
}

缺陷 1:query_posts() 污染主循环

WordPress Codex 在 query_posts() 函数文档的最顶上有一行红框警告,原话是 "This function will completely override the main query and isn't intended for use by plugins or themes"。这不是风格建议,是物理事实——query_posts() 内部直接覆盖了 $wp_query 全局变量,等于把页面正在跑的主循环替换掉。

实际触发的 bug 我至少见过三种。第一种最经典:在 single.php 模板末尾用 query_posts() 之后,紧跟的 wp_link_pages() 输出的分页链接全错——因为 $wp_query 已经不是当前文章了。第二种:分页类插件(比如 WP-PageNavi)在 footer.php 里读 $wp_query->max_num_pages,会把"相关文章那个查询"的总页数当成主查询页数显示,于是产生出"第 1 页 共 1 页"这种诡异输出。第三种最隐蔽:用了 SEO 插件(Yoast / RankMath)的站点,head 里的 canonical / og:url 在某些版本会延迟到 footer 之前才输出,query_posts 会让 canonical 指向相关列表里的第一篇,造成大批文章互相 canonical 到一起,三个月后 GSC 里的索引覆盖率会断崖式下跌。

正确做法是 new WP_Query($args) 拿到一个独立对象,循环结束后调 wp_reset_postdata()。注意是 postdata 不是 query——后者是为 query_posts() 配套的,只在用了 query_posts() 之后才需要调。

缺陷 2:caller_get_posts 是 WordPress 3.1 起就废弃的别名

原文里的 'caller_get_posts' => 1 在 2011 年发布的 WP 3.1 之后已经改名叫 'ignore_sticky_posts'。废弃的别名虽然 14 年来仍能跑通——WP 核心保留了向后兼容——但用任何一个 WordPress lint(比如 WPCS,WordPress Coding Standards 工具集)扫描,都会爆 WordPress.WP.DeprecatedParameters.Found_replacement_caller_get_posts

更关键的是 ignore_sticky_posts 的语义:它告诉 WP_Query 不要把站点置顶文章插到结果集前面。如果站点没设置任何 sticky post,这个参数加不加都一样。如果置顶了几篇文章作为"必看推荐",那么相关文章列表里如果不加这个参数,会被同样的几篇置顶帖塞满——失去了"相关"的意义。

缺陷 3:post__not_in 接收逗号字符串是 PHP 弱类型陷阱

原文写 $exclude_id = $post->ID(整数),下面又写 $exclude_id .= ',' . $post->ID(字符串拼接),最后用 explode(',', $exclude_id) 转回数组传给 post__not_in。PHP 5/7/8 这一连串隐式类型转换不报错,但有两个问题。

第一,explode 出来的数组每一项都是字符串而不是整数,WP_Query 内部会调 array_map('intval', ...) 强转,多此一举。第二,更隐蔽:当 $exclude_id 只有一个 ID 时(比如刚进循环),explode(',', '123') 返回 ['123'];但如果 $post->ID 因为某种主题 hook 被改成了 null 或空字符串,explode(',', '') 返回 ['']——一个包含空字符串的数组。这个空字符串经 intval 变成 0,传给 post__not_in 后生成的 SQL 会变成 AND wp_posts.ID NOT IN (0),等于没过滤,于是相关文章列表里出现了当前正在阅读的这篇文章本身,看上去是"自己引用自己"的循环引用 bug。

正确的写法是用整数数组从头到尾保持类型:$exclude_ids = [(int) $post->ID],循环里 $exclude_ids[] = (int) get_the_ID()

用 WP_Query 改写的完整生产版本

把上面三个坑都修掉,加上必要的边界处理,下面是我在三个生产站点跑了至少两年的版本:

function zwb_related_posts( $post_num = 10 ) {
    $post_id   = get_the_ID();
    $post_tags = wp_get_post_tags( $post_id, [ 'fields' => 'ids' ] );
    $exclude   = [ $post_id ];
    $items     = [];

if ( ! empty( $post_tags ) ) { $q = new WP_Query( [ 'post_status' => 'publish', 'post_type' => 'post', 'tag__in' => $post_tags, 'post__not_in' => $exclude, 'ignore_sticky_posts' => 1, 'orderby' => 'date', 'order' => 'DESC', 'posts_per_page' => $post_num, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] );

foreach ( $q->posts as $p ) { $items[] = $p; $exclude[] = $p->ID; } }

if ( count( $items ) < $post_num ) { $cat_ids = wp_get_post_categories( $post_id, [ 'fields' => 'ids' ] ); if ( ! empty( $cat_ids ) ) { $q = new WP_Query( [ 'post_status' => 'publish', 'post_type' => 'post', 'category__in' => $cat_ids, 'post__not_in' => $exclude, 'ignore_sticky_posts' => 1, 'orderby' => 'date', 'order' => 'DESC', 'posts_per_page' => $post_num - count( $items ), 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] ); $items = array_merge( $items, $q->posts ); } }

return $items; }

有几个细节值得展开。fields => 'ids'wp_get_post_tags 只返回 term_id 数组,避免把整个 term 对象(含 name、slug、description、count、taxonomy 等十来个字段)从数据库 SELECT 出来。no_found_rows => true 关闭 SQL_CALC_FOUND_ROWS 计算——相关文章列表不需要分页,也就不需要总数。这个开关在 5 万文章的库上能把单次查询从 110 ms 砍到 18 ms(用 Query Monitor 实测)。update_post_meta_cacheupdate_post_term_cache 关掉,避免 WP 自动把每篇文章的所有 postmeta 和 term 关系一起拉过来——相关文章列表只用到标题和 permalink,不用 meta 不用 term。这个组合是 WP_Query 性能优化最常被忽略的细节。

另一个细节:我没用 setup_postdata( $p ); the_title(); the_permalink(),而是直接 $p->post_titleget_permalink( $p->ID )setup_postdata 会触发 the_post 钩子,包括 SEO 插件、社交分享插件挂上去的一堆回调,单次开销在 5–15 ms 之间。对相关文章这种"渲染而不交互"的列表,跳过 the_post 收益明显。

性能:tag__in 在 5 万文章站上的真实 SQL 计划

很多教程会说 "WP_Query 已经够快了",但什么叫够快?把上面那段代码挂到一个 51,800 篇文章 / 8,400 个 tag / wp_term_relationships 表 78 万行的真实站点上,开 Query Monitor 抓 SQL:

SELECT wp_posts.ID FROM wp_posts
INNER JOIN wp_term_relationships ON ( wp_posts.ID = wp_term_relationships.object_id )
WHERE 1=1
AND wp_posts.ID NOT IN (47221)
AND ( wp_term_relationships.term_taxonomy_id IN (134,256,891,1422) )
AND wp_posts.post_type = 'post'
AND ( wp_posts.post_status = 'publish' )
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
LIMIT 0, 10;

EXPLAIN 输出三行(MySQL 8.0.32,innodb_buffer_pool_size 4G):

id  select_type  table                  type    key                          rows  Extra
1   SIMPLE       wp_term_relationships  range   term_taxonomy_id             3211  Using where; Using index
1   SIMPLE       wp_posts               eq_ref  PRIMARY                      1     Using where
1   SIMPLE       (group by + sort)                                                Using temporary; Using filesort

问题在最后一行——Using temporary; Using filesort 是 MySQL 性能问题的红色信号。GROUP BY wp_posts.IDORDER BY wp_posts.post_date DESC 让 MySQL 必须先把 3211 行匹配结果生成临时表、再按 post_date 排序。这套站点单次相关文章查询平均耗时 240 ms(p95 480 ms),首页打开因为不调相关查询所以 60 ms 就出,但点进任何一篇文章 TTFB 立刻飙到 600+ ms。

三个层级的优化:

SQL 层:加复合索引。wp_posts(post_status, post_type, post_date) 复合索引:

ALTER TABLE wp_posts ADD INDEX idx_status_type_date (post_status, post_type, post_date);

WordPress 默认索引只有 PRIMARY、post_name、type_status_date(顺序是 post_type, post_status, post_date——前缀不匹配 WHERE 子句的写法)。新加的索引让 ORDER BY 能直接用索引顺序读,免去 filesort。同样 8.0.32,加完索引后单次查询从 240 ms 降到 38 ms。

WP 层:把整段 HTML 用 Transient 缓存。相关文章列表对单篇文章是恒定的(只在新发文章时可能变),完全可以用 Transient API 存 12 小时:

function zwb_related_html( $post_id, $post_num = 10 ) {
    $key   = "zwb_related_{$post_id}_{$post_num}";
    $html  = get_transient( $key );
    if ( false !== $html ) return $html;

$items = zwb_related_posts( $post_num ); if ( empty( $items ) ) { $html = '<li>暂无相关文章</li>'; } else { $html = ''; foreach ( $items as $p ) { $html .= sprintf( '<li><a href="%s" title="%s">%s</a></li>', esc_url( get_permalink( $p->ID ) ), esc_attr( $p->post_title ), esc_html( $p->post_title ) ); } } set_transient( $key, $html, 12 * HOUR_IN_SECONDS ); return $html; }

注意 cache key 里包含了 $post_num。如果某天调整了显示数量,旧的 transient 不会被命中,自然过期,无需手动清。

失效策略:发新文章时清相关 transient。WordPress 的 save_postdeleted_post 钩子触发时,把所有相关该 tag 的 transient 清一次:

add_action( 'save_post', function( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( $post->post_status !== 'publish' ) return;

$tag_ids = wp_get_post_tags( $post_id, [ 'fields' => 'ids' ] ); if ( empty( $tag_ids ) ) return;

global $wpdb; $tag_in = implode( ',', array_map( 'intval', $tag_ids ) ); $post_ids = $wpdb->get_col( " SELECT DISTINCT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN ($tag_in) " ); foreach ( $post_ids as $pid ) { delete_transient( "zwb_related_{$pid}_10" ); } }, 10, 3 );

这段代码的成本:每发一篇新文章触发一次 SELECT + N 次 delete_transient。在我的站上,单个 tag 平均关联 9 篇文章,最多关联 240 篇("WordPress" 这种万能标签),删 240 个 transient 大约 80 ms,可以接受。如果想再优化可以走 namespace cache(只删整个 namespace),需要换 object cache 后端。

三层缓存:fastcgi / object cache / transient 的边界

很多人把 cache 当成一个单维度的开关——开了就快,关了就慢。实际生产环境是三层堆叠的金字塔,每一层解决不同的问题。

第一层:fastcgi_cache(nginx 层)。整页 HTML 缓存,命中后甚至不进 PHP-FPM,TTFB 通常 5–15 ms。问题是粒度粗——任何一个页面元素变了(比如评论数 +1)整页就要失效。fastcgi cache 适合纯 GET、无登录态的页面,相关文章如果是页面的一部分自然也跟着缓存。但有个坑:fastcgi cache 默认按 $scheme$request_method$host$request_uri 做 key,不区分 cookie。如果未登录用户和管理员访问同一篇文章,且管理员看到了"编辑此篇"按钮,那个按钮可能被缓存到所有用户的页面上。配置时一定要加 fastcgi_cache_bypass $cookie_loggedin;

第二层:object cache(Redis / Memcached)。WordPress 的所有 wp_cache_get/set 调用、所有 get_option / get_post_meta / get_term,默认走的是请求级别内存缓存(请求结束即丢)。装了 Object Cache Pro 或免费版 Redis Object Cache 之后,这些调用会写到 Redis 持久化跨请求复用。对相关文章查询的影响:WP_Query 内部调的所有 get_post(拉文章对象)、get_term(拉 tag 对象)都从 Redis 出,不再触发 SQL。同样的查询,object cache 命中时单次 30 ms,不命中 240 ms。

第三层:transient(业务层)。上面那段相关文章 HTML 缓存就是 transient。Transient 在装了 object cache 时自动走 Redis(带过期时间),没装 object cache 时落 wp_options 表。落表的 transient 有个隐蔽问题:过期的 transient 不会自动清,只有调 get_transient() 才被动清。如果 cache key 高基数(比如包含 user_id),wp_options 表会膨胀到几百万行,autoload 慢到 2 秒以上。所以高基数 transient 必须配合 object cache 用,或者写定时任务定期 SQL 清理。

缓存击穿与雪崩的真实案例

有个站点的相关文章 transient 设置过期时间 1 小时,每天凌晨 4 点 cron 重建 sitemap 时会触发 save_post 钩子(因为更新了 modified 时间),把所有 transient 清空。结果是早上 7 点流量起来时所有文章页同时回源——Redis 没数据、object cache 没数据、SQL 同时跑 200 个相关查询,innodb_buffer 命中率从 99% 跌到 70%,CPU 拉满。这就是经典的缓存雪崩。

修复有两种:一种是给 transient 过期时间加随机数(比如 12h ± 1h),让缓存不在同一时刻全部失效;另一种是 probabilistic early expiration(XFetch 算法),在缓存还没过期但快过期时(比如剩 10% 寿命),按概率提前重建。后者实现:

function zwb_xfetch_get( $key, $ttl, callable $regen ) {
    $data = get_transient( $key );
    if ( false !== $data && isset( $data['expires'], $data['delta'], $data['value'] ) ) {
        $now    = microtime( true );
        $beta   = 1.0;
        $xfetch = $data['delta'] * $beta * log( mt_rand() / mt_getrandmax() );
        if ( ( $now - $xfetch ) < $data['expires'] ) {
            return $data['value'];
        }
    }
    $start = microtime( true );
    $value = $regen();
    $delta = microtime( true ) - $start;
    set_transient( $key, [
        'value'   => $value,
        'delta'   => $delta,
        'expires' => microtime( true ) + $ttl,
    ], $ttl + 60 );
    return $value;
}

用法:$html = zwb_xfetch_get( "zwb_related_$pid", 3600, fn() => build_related_html( $pid ) );。这段代码的关键是 $delta * beta * log( random )——重建越慢的缓存,越容易被提前重建,避免到期瞬间所有请求同时回源。

从纯 tag 匹配升级到 Jaccard 相关性算法

原文那种 tag__in 单纯匹配在标签稀疏的站点会退化。我跑过一个统计:6 万文章 / 1.2 万标签的站点,标签使用频次符合长尾分布——80% 的标签只关联 1–3 篇文章。当读者在那"只关联 1 篇"的文章页停留时,相关文章查询返回空,于是回退到分类查询,列表里都是"同分类最新"——和"相关"已经没什么关系。

更合理的相关性度量是 Jaccard 系数:两篇文章的标签集合交集除以并集。如果文章 A 有标签 {WP, SEO, Cache},文章 B 有 {WP, SEO, JS},Jaccard = 2/4 = 0.5。Jaccard 越高越相关。

实时算 Jaccard 不现实——每个 pageview 要扫整张文章表。可行方案是建一张 wp_related_posts 表,每天凌晨低峰期 cron 算一次:

CREATE TABLE wp_related_posts (
    post_id        BIGINT UNSIGNED NOT NULL,
    related_id     BIGINT UNSIGNED NOT NULL,
    score          DECIMAL(5,4) NOT NULL,
    rank           SMALLINT UNSIGNED NOT NULL,
    updated_at     DATETIME NOT NULL,
    PRIMARY KEY (post_id, related_id),
    INDEX idx_post_rank (post_id, rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

每篇文章只存 top 20 关联,按 score 降序 rank 1–20。查询时 SELECT related_id FROM wp_related_posts WHERE post_id = ? ORDER BY rank LIMIT 10——主键覆盖,单次 0.3 ms。

预计算的 PHP 部分:

function zwb_rebuild_related_for( $post_id ) {
    global $wpdb;
    $my_tags = wp_get_post_tags( $post_id, [ 'fields' => 'ids' ] );
    if ( empty( $my_tags ) ) return;

$tag_in = implode( ',', array_map( 'intval', $my_tags ) ); $candidates = $wpdb->get_col( " SELECT DISTINCT tr.object_id FROM {$wpdb->term_relationships} tr WHERE tr.term_taxonomy_id IN ($tag_in) AND tr.object_id != $post_id " );

$scores = []; foreach ( $candidates as $cid ) { $their_tags = wp_get_post_tags( $cid, [ 'fields' => 'ids' ] ); $intersect = count( array_intersect( $my_tags, $their_tags ) ); $union = count( array_unique( array_merge( $my_tags, $their_tags ) ) ); $scores[ $cid ] = $union > 0 ? $intersect / $union : 0; } arsort( $scores ); $scores = array_slice( $scores, 0, 20, true );

$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}related_posts WHERE post_id = %d", $post_id ) );

$rank = 1; foreach ( $scores as $rid => $score ) { $wpdb->insert( "{$wpdb->prefix}related_posts", [ 'post_id' => $post_id, 'related_id' => $rid, 'score' => $score, 'rank' => $rank++, 'updated_at' => current_time( 'mysql' ), ] ); } }

这种做法的代价是预计算窗口和增量更新。全站每篇都重算一次,6 万文章在 PHP 单线程下需要约 4 小时。所以应做增量:监听 save_post,只重算这篇文章及其所有候选文章的相关表。增量代码这里就不展开了,原理一致。

Jaccard 之外:TF-IDF / Embedding 路线

对于内容更丰富的站点(每篇 3000+ 字),可以考虑基于正文的 TF-IDF 相似度,或者直接用预训练 embedding(比如 m3e、bge-large-zh)算 cosine 相似度。后者效果显著好于 tag 匹配——比如一篇讲"Nginx 反代 WebSocket"的文章,可以正确推出"Apache 反代 WebSocket",而仅靠 tag 匹配可能两者根本没有共同标签。

Embedding 方案的实现路径:

对内容丰富、靠搜索流量的站点,这一步带来的"会话深度"收益比所有缓存优化加起来还大。我手上一个站从 tag 匹配换 embedding 后,每会话 PV 从 1.4 上升到 2.1。

SEO 维度:相关文章对站内权重传递的真实影响

相关文章列表的本质是站内内链——给每篇文章新增 10 条出链,10 条入链。这件事对 SEO 的影响有几个层面:

PageRank 传递:站内 PR 流转模型下,单页有 10 条出链时,每条出链分得的权重是 PR(page) × 0.85 / 10。把相关文章放在所有文章页等于全站任意两篇文章之间最多隔 2–3 跳就能互通。这对深层文章(发布两年以上、外链已枯竭)的复活效果显著——我跟踪的一个站,上线相关文章功能后三个月,旧文章的 GSC 展现量平均增长 47%。

anchor text 多样化:很多人在相关文章列表里把锚文本写成"点击查看 >>"或者"阅读全文"。这是浪费——锚文本应该是被链接文章的标题,因为标题里包含真实查询关键词。原文代码里 <a><?php the_title(); ?></a> 这部分是正确的。

关于 rel="bookmark" 的迷思:原文加了 rel="bookmark",很多教程站抄来抄去。rel="bookmark" 在 HTML5 规范里有定义,意思是"链接到当前文档/区块的永久链接",仅对 article 内的 self-permalink 链接有意义(比如文章标题自身的链接)。把它用在相关文章列表里语义上是错的,对 SEO 没有任何加成(Google 早就声明不区分 rel="bookmark")。建议删除。

boilerplate 内容的降权风险:2018 年后 Google 多次调整对"boilerplate"(页面间重复内容)的识别。如果相关文章列表的代码生成的 HTML 在每篇文章里都几乎相同(同样的结构、同样的列表项数量),Mueller 在多次 Office Hours 中提到搜索引擎会自动剔除这部分——也就是说,相关文章对当前文章的 keyword cloud 没有正向贡献。但如果列表里的文章标题随上下文变化大(每篇相关文章列表完全不同),那么这部分内容会被算作有效正文,对 long-tail 关键词排名有微弱正向贡献。这反过来印证了第二节里说的"用 ID 数组而非逗号字符串"——正确实现保证每篇相关列表都不同,错误实现可能在某些边界条件下推出相同的"最新文章兜底"列表。

Gutenberg / Block Editor 时代的相关文章 Block

2018 年 WP 5.0 引入 Gutenberg 之后,传统的 PHP 函数 + 主题模板调用方式仍然能用——但越来越多的站点把所有页面元素 Block 化。把相关文章封装成 Block 的好处是:编辑可以在文章编辑器里拖动、可以单独控制每篇文章是否显示、可以做 A/B 测试不同布局。

注册 Block 的最简实现(用 server-side rendering 模式):

function zwb_register_related_block() {
    register_block_type( 'zwb/related-posts', [
        'attributes' => [
            'count'  => [ 'type' => 'number', 'default' => 10 ],
            'layout' => [ 'type' => 'string', 'default' => 'list' ],
        ],
        'render_callback' => function( $attrs ) {
            $count  = max( 1, min( 30, (int) ( $attrs['count'] ?? 10 ) ) );
            $layout = $attrs['layout'] ?? 'list';
            $items  = zwb_related_posts( $count );
            if ( empty( $items ) ) return '';
            ob_start();
            ?>
            <div class="zwb-related zwb-layout-<?php echo esc_attr( $layout ); ?>">
                <h3>相关文章</h3>
                <ul>
                <?php foreach ( $items as $p ) : ?>
                    <li><a href="<?php echo esc_url( get_permalink( $p ) ); ?>">
                        <?php echo esc_html( $p->post_title ); ?>
                    </a></li>
                <?php endforeach; ?>
                </ul>
            </div>
            <?php
            return ob_get_clean();
        },
    ] );
}
add_action( 'init', 'zwb_register_related_block' );

编辑端用 edit.js 注册 placeholder 占位符,前端 server-side 渲染。这样既享受了 Block 化的灵活性,又避免了"前端额外发请求拉相关文章"的性能开销。

AMP / Mobile / 性能极限场景

如果站点开了 AMP 插件,相关文章 HTML 输出有几个限制:

对相关文章列表:如果不带缩略图,纯链接列表在 AMP 模式下原封不动可用。如果带缩略图,要写一个 AMP 专用模板,amp-img + 固定高度。AMP plugin 提供 amp_is_request() 函数判断当前是否 AMP 请求:

$tpl = function_exists( 'amp_is_request' ) && amp_is_request()
    ? 'related-amp.php'
    : 'related-html.php';

移动端首屏与 LCP

把相关文章渲染到文章末尾(在评论之前)的好处:不会进入首屏 LCP 计算范围。LCP(Largest Contentful Paint)只算视口内最大的元素,相关文章如果一开始不在视口里,它再大也不影响 LCP 分数。但要避免一种写法:把相关文章放在文章正文上方(比如 sidebar 或者文章开头)。这种布局虽然能提升 CTR,但相关文章列表的 DOM 树深、字数少(每条 30 字以下),会被搜索引擎判为"侧边栏内容"或"导航内容",参与正文相关性分析的权重低。

多语言 / Polylang / WPML 下的相关文章

装了 Polylang 或 WPML 的多语言站,相关文章查询要小心两个陷阱。

陷阱一:跨语言串扰。同一个 tag(比如"WordPress")在英文版站点和中文版站点是同一个 term_id 还是不同?Polylang 默认是不同——每个语言一组 term。WPML 默认也是分语言的 term。但如果用了某些迁移工具,term 可能被错误合并,导致英文文章的相关列表里冒出中文文章。判断方法:在 admin SQL Console 里跑 SELECT t.name, COUNT(*) FROM wp_terms t JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id GROUP BY t.name HAVING COUNT(*) > 1,看哪些 term name 重复。

陷阱二:当前语言过滤。WP_Query 在多语言插件下会被插件自动加上当前语言过滤——但只对默认 archive 查询起作用。在自定义 WP_Query 里,要么显式传 'lang' => pll_current_language()(Polylang),要么传 'suppress_filters' => false 让插件 hook 进去过滤。如果忘了,相关文章会跨语言展示,对 SEO 是双重打击:用户看不懂语言、搜索引擎判定"语言混杂"降权。

替代方案对比:自建 vs 插件

市面上常见的相关文章插件有四个:YARPP(Yet Another Related Posts Plugin)、Jetpack Related Posts、Contextual Related Posts、Related Posts for WordPress。

YARPP:历史最久(2008 年至今),算法基于全文 MyISAM FULLTEXT 索引。优点是开箱即用、相关性比纯 tag 匹配好。缺点是依赖 MyISAM——MySQL 5.6 之后默认 InnoDB 已经支持 FULLTEXT,但 YARPP 兼容代码很老,在某些托管服务(比如 Kinsta)上需要手动 ALTER 把表引擎改回 MyISAM 或者关闭 YARPP 的全文模式。另一个坑:YARPP 的 cache 表 wp_yarpp_related_cache 在 5 万文章站会膨胀到 1 GB+,定期 OPTIMIZE 必不可少。

Jetpack Related Posts:把相关文章计算外包给 WordPress.com 的 Elasticsearch 集群,本地不消耗资源。优点是性能好。缺点是数据要发出去(隐私问题)、必须连 jetpack.com(中国站点访问不稳定)、且只对发布超过 1 周的文章生效(新文章看不到相关)。

Contextual Related Posts:本地 SQL 算法,性能优化做得不错,支持 transient 缓存。最大优点是免费 + 不依赖外部服务。但配置选项多到让人头大,第一次安装要花 1 小时调参。

自建(本文方案):不到 100 行代码,可控性最高,可以做 Jaccard / Embedding 等高级算法,性能调优自如。代价是需要持续维护——WordPress 升级、PHP 升级、MySQL 升级时要跟进测试。

选型建议:日 PV < 5000 的小站直接装 Contextual Related Posts;5000–50000 的中型站建议自建 + Transient;50000+ 流量站考虑 Embedding 方案 + Redis Object Cache。

常见问题解答

query_posts() 和 WP_Query 究竟差在哪里,为什么 Codex 不推荐 query_posts?

query_posts() 内部直接覆盖 $wp_query 这个全局变量,等于把当前页面正在跑的主循环替换成自定义查询。这会导致主题 footer 里依赖 $wp_query 的所有逻辑(分页、条件判断 is_single / is_archive、SEO 插件 canonical 输出)全部错乱。WP_Query 创建独立的查询对象,不污染主循环,是推荐做法。区别一句话总结:query_posts 是"修改主查询",WP_Query 是"创建新查询"。如果你的代码不是要修改主查询,就永远不要用 query_posts。

tag__in 在大数据量站点慢,怎么加索引最有效?

WordPress 默认在 wp_term_relationships 表上有 (object_id, term_taxonomy_id) 主键和 (term_taxonomy_id) 索引。tag__in 查询的瓶颈通常不在 term_relationships 而在 wp_posts 表的 ORDER BY post_date DESC——MySQL 默认的 type_status_date 索引前缀不匹配 WHERE 子句。最有效的索引是给 wp_posts 加 (post_status, post_type, post_date) 复合索引(顺序是 status 在前 type 在后,因为 WHERE 子句通常 status='publish' 是固定值)。加完索引后 EXPLAIN 应该看不到 Using filesort。如果看到 Using temporary,多半是 GROUP BY 的副作用,可以通过 SELECT DISTINCT 或者重写 JOIN 顺序避免。

相关文章如何用 Transient API 缓存?过期时间多久合适?

核心 API 是 set_transient($key, $value, $expiration) 和 get_transient($key)。Cache key 必须包含 post_id 和所有影响输出的参数(比如条数、布局类型)。过期时间没有标准答案,平衡点是"重新计算的成本"与"内容新鲜度"。建议 12–24 小时,配合 save_post 钩子主动失效——发新文章时清掉所有同 tag 的相关 transient。给过期时间加随机扰动(比如 12h ± 2h)避免雪崩。如果用了 Redis 作为 object cache,transient 自动走 Redis;否则落 wp_options 表,要监控表大小。

没有标签或孤立文章怎么处理?

分两层兜底。第一层:原文那种"按分类补"的逻辑保留,作为标签查询返回少于 N 条时的补充。第二层:如果分类查询也返回少于 N 条,最后从全站最新文章里随机抽取——这种文章往往是新建的栏目下只有一两篇文章。第三层(推荐):在管理后台显式提示"此文章未设置相关标签,建议添加 3–5 个 tag"。从内容运营角度,孤立文章本身就是 SEO 弱点,与其用算法兜底,不如让编辑去补 tag。

AMP 模式下相关文章怎么输出?

AMP 禁用了大部分自由 JavaScript,但相关文章列表如果是纯 HTML(不带 inline JS、不带第三方 script),可以原封不动用。如果列表带缩略图,img 标签必须改成 amp-img,并且必须显式声明 width 和 height(或 layout="fixed-height" 配合 height)。如果 amp 列表带懒加载,用 amp-list 配合 JSON 数据源。检测当前是否 amp 请求用 amp_is_request() 函数(AMP plugin 提供)。简单做法:写两个模板 related-html.php 和 related-amp.php,根据 amp_is_request() 选择。

Gutenberg 时代要不要把相关文章做成 Block?

看场景。如果相关文章在所有文章末尾固定显示,主题模板里直接调用更简单——不需要 Block。如果某些专题文章想自定义相关文章的位置(比如插在第二段后)、想控制条数、想显示特定标签的相关,Block 化更灵活。Block 推荐用 server-side rendering 模式(注册 render_callback),不要用 client-side 渲染——后者会导致编辑器里看到的相关文章和前端看到的不一致,且影响 SEO(搜索引擎不爬 JS 渲染的列表)。

相关文章对 SEO 有正向贡献吗?

有,但不是无脑加。正向贡献来自三方面:站内内链密度提升(PageRank 传递更顺畅)、深层文章被发现(爬虫从相关链接 hop 到不在 sitemap 里的旧文章)、用户停留时间增长(CTR 高的相关列表能让会话深度从 1.4 涨到 2.1)。负向风险:如果相关算法很差导致每篇相关列表都几乎相同,Google 会判为 boilerplate 自动剔除——内链等于没加;如果列表项数量超过 30 条,单页出链稀释 PR、且影响 LCP。建议 8–12 条最佳,相关性算法至少做到 Jaccard 级别。

怎么测量相关文章功能上线前后的真实效果?

三个指标。第一,GSC 旧文章展现量(按发布时间分组,看 1 年以上文章的 30 天展现量);第二,GA4 平均会话页面数;第三,文章页面平均停留时间。前两者通常上线 4–8 周后能看到统计显著的变化。对照组方法:先在 50% 的文章上启用相关文章(按 post_id 奇偶分),8 周后对比两组的 GA4 数据。

因本文不是用Markdown格式的编辑器书写的,转换的页面可能不符合AMP标准。