一些 wp-discourse + cloudflare 的经验教训

以下是我过去几个月在使用 Discourse、wp-discourse 和 Cloudflare 时总结的一些针对个人环境的经验教训。分享出来,希望能对其他人有所帮助。

环境配置:

  • Discourse 和 WordPress 分别托管在同一个 VPC 内的独立 AWS EC2 实例上
  • WordPress 技术栈:nginx + fastCGI 缓存 + php8.3-fpm + redis + mariadb
  • 使用 wp-discourse 插件将 WordPress 与 Discourse 关联,并在博客文章下方嵌入 Discourse 评论
  • Cloudflare 代理了 Discourse 和 WordPress
  • WordPress 启用了 APO(自动平台优化)

1) 在 VPC 层面稍微“耍个小聪明”

最初部署时,我很快遇到了 Cloudflare 试图对 Web 服务器与 Discourse 服务器之间的 API 流量进行速率限制或拦截的问题。我本打算研究一些排除规则,但既然两台服务器都在同一个 AWS VPC 内,更简单的做法是直接在每台服务器上添加 hosts 文件条目,将各自的域名指向其 VPC 内部地址,而不是公共 DNS 地址。这样就不必再担心 Cloudflare 插手 WordPress 和 Discourse 之间的通信了。这个小改动,大大减少了头痛。

2) 处理 wp-discourse 与 Apple News 之间的竞态条件

在设置好 Discourse 与 WordPress 集成后,我遇到过一个奇怪且间歇性的问题:有时(虽然不频繁,但确实会发生),新发布的 WordPress 文章在访客看来,底部显示的仍然是 WordPress 原生的评论框,而不是 Discourse 评论。实际上,文章内容本身是正确的,Discourse 评论也已正确加载,但 Cloudflare 的边缘缓存却在 Discourse 评论加载完成前的那一两秒内捕获了该页面,并一直保留着这个过时的版本。

我绞尽脑汁也找不到导致这种行为的具体原因或触发机制。它看起来完全随机,没有任何明显的规律。我仔细检查了日志,但没发现任何异常。

于是,我将此归咎于各层缓存之间的复杂交互,并利用 ChatGPT 编写了一个小型 mu-plugin(必须加载插件),通过强制 WordPress 在新文章发布但 Discourse 链接尚未建立时大喊“不要缓存此页面”,从而绕过该问题。随后,在评论线程成功附加后,再强制在 Cloudflare 侧进行一次额外的缓存清除,确保没有任何过时内容残留在 CF 缓存中。

discourse-cloudflare-purge.php
<?php
/**
 * Plugin Name: Discourse → Cloudflare Safe Cache for Comments
 * Description: Prevents caching of single-post HTML until Discourse linkage exists; also purges Cloudflare when linkage lands.
 * Author: Lee Hutchinson + ChatGPT
 * Version: 1.6.1
 *
 * Changelog:
 * 1.6.1 - Added timeout failsafe so that we're not emitting no-cache headers forever
 * 1.6.0 — ADD pre-linkage no-cache gate (template_redirect) so edge/origin never cache the WP-native-comments HTML.
 * 1.4.0 — Purge on added/updated postmeta + status transition; purge slash/no-slash variants.
 * 1.3.0 — Credentials via wp-config.php constants only.
 */

if (!defined('ABSPATH')) exit;

/** 从 wp-config.php 解析 Cloudflare 凭据 */
function scw_dcfp_get_creds(): array {
    $zone  = defined('SCW_CF_ZONE_ID')   ? trim((string) SCW_CF_ZONE_ID)   : '';
    $token = defined('SCW_CF_API_TOKEN') ? trim((string) SCW_CF_API_TOKEN) : '';
    return ['zone_id' => $zone, 'api_token' => $token];
}

/** Discourse 关联元数据键(可过滤) */
function scw_dcfp_meta_keys(): array {
    $keys = ['discourse_topic_id', 'discourse_post_id', 'discourse_permalink'];
    return apply_filters('scw_dcfp_meta_keys', $keys);
}

/** 为文章构建要清除的 URL(含斜杠/无斜杠变体,以及主页和 RSS) */
function scw_dcfp_build_urls(int $post_id): array {
    $urls = [];
    $permalink = get_permalink($post_id);
    if ($permalink) {
        $urls[] = $permalink;
        $urls[] = (substr($permalink, -1) === '/') ? rtrim($permalink, '/') : trailingslashit($permalink);
    }
    $urls[] = home_url('/');
    $urls[] = get_bloginfo('rss2_url');
    $urls = array_unique(array_filter($urls));
    return apply_filters('scw_dcfp_urls', $urls, $post_id);
}

/** 发布后经过的分钟数(UTC),未知则返回 null */
function scw_dcfp_minutes_since_publish(int $post_id): ?int {
    $post_time = get_post_time('U', true, $post_id);
    if (!$post_time) return null;
    return (int) floor((time() - (int)$post_time) / 60);
}

/** 底层 Cloudflare 按 URL 清除缓存 */
function scw_dcfp_purge_urls(array $urls): void {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '' || empty($urls)) return;

    $endpoint = sprintf('https://api.cloudflare.com/client/v4/zones/%s/purge_cache', $creds['zone_id']);
    $response = wp_remote_post($endpoint, [
        'headers' => [
            'Authorization' => 'Bearer ' . $creds['api_token'],
            'Content-Type'  => 'application/json',
        ],
        'body'    => wp_json_encode(['files' => array_values($urls)]),
        'timeout' => 10,
    ]);

    if (is_wp_error($response)) {
        error_log('[SCW CF Purge] WP_Error: ' . $response->get_error_message());
        return;
    }
    $code = (int) wp_remote_retrieve_response_code($response);
    if ($code < 200 || $code >= 300) {
        $body = wp_remote_retrieve_body($response);
        error_log(sprintf('[SCW CF Purge] HTTP %d. Response: %s', $code, $body));
    }
}

/** 通用检查:仅在已写入关联元数据且文章为公开状态时执行 */
function scw_dcfp_should_trigger($post_id, $meta_key, $meta_value): bool {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') return false;
    if (empty($meta_value)) return false;
    if (!in_array($meta_key, scw_dcfp_meta_keys(), true)) return false;
    return (get_post_status($post_id) === 'publish');
}

/** 当 Discourse 关联元数据被添加或更新时清除缓存 */
function scw_dcfp_on_post_meta_change($meta_id, $post_id, $meta_key, $meta_value): void {
    if (!scw_dcfp_should_trigger($post_id, $meta_key, $meta_value)) return;
    $urls = scw_dcfp_build_urls((int) $post_id);
    scw_dcfp_purge_urls($urls);
}
add_action('added_post_meta',   'scw_dcfp_on_post_meta_change', 10, 4);
add_action('updated_post_meta', 'scw_dcfp_on_post_meta_change', 10, 4);

/** 当文章状态转为 publish 且关联元数据已存在时清除缓存 */
function scw_dcfp_on_transition_post_status($new_status, $old_status, $post): void {
    if ($new_status !== 'publish' || !($post instanceof WP_Post)) return;
    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) {
            $urls = scw_dcfp_build_urls((int) $post->ID);
            scw_dcfp_purge_urls($urls);
            break;
        }
    }
}
add_action('transition_post_status', 'scw_dcfp_on_transition_post_status', 10, 3);

/**
 * 新增:关联建立前的“无缓存”门禁。
 * 在已发布的单篇文章上尚未建立任何 Discourse 关联元数据之前,
 * 发送强“无缓存”头,确保源站(FastCGI)和 Cloudflare 都不会缓存
 * 显示 WordPress 原生评论表单的过时 HTML。
 */
function scw_dcfp_prelinkage_nocache(): void {
    if (!is_singular('post')) return;

    $post = get_queried_object();
    if (!($post instanceof WP_Post) || get_post_status($post) !== 'publish') return;

    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) return; // 关联已存在,正常缓存
    }

    // 可在 wp-config.php 中设置:define('SCW_CF_LINKAGE_GRACE_MIN', 10);
    $grace = defined('SCW_CF_LINKAGE_GRACE_MIN') ? (int) SCW_CF_LINKAGE_GRACE_MIN : 0;
    if ($grace > 0) {
        $mins = scw_dcfp_minutes_since_publish($post->ID);
        if ($mins !== null && $mins >= $grace) {
            // 等待时间已足够,停止门禁,允许缓存恢复。
            // (可选:触发一次清除以刷新边缘缓存中的当前内容。)
            // scw_dcfp_purge_urls(scw_dcfp_build_urls((int)$post->ID));
            return;
        }
    }

    // 阻止对此响应的缓存(源站 + 边缘 + 浏览器)
    if (!headers_sent()) {
        nocache_headers();                    // Cache-Control: no-store, no-cache, must-revalidate 等
        header('cf-edge-cache: no-cache');    // 给 Cloudflare/APO 的额外提示
    }
    if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);

    // 可选调试:发布几篇文章后可取消注释
    // error_log('[SCW CF Gate] Pre-linkage no-cache for post ' . $post->ID);
}
add_action('template_redirect', 'scw_dcfp_prelinkage_nocache', 0);

/** 可选:若未配置则显示管理员通知 */
add_action('admin_notices', function () {
    if (!current_user_can('manage_options')) return;
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') {
        echo '<div class="notice notice-error"><p><strong>SCW Discourse → Cloudflare:</strong> 缺少凭据。请在 <code>wp-config.php</code> 中定义 <code>SCW_CF_ZONE_ID</code> 和 <code>SCW_CF_API_TOKEN</code>。</p></div>';
    }
});

注意,此 mu-plugin 会调用 Cloudflare API,因此需要在 wp-config.php 中定义一些常量,包括您的 CF 区域 ID(SCW_CF_ZONE_ID)和 API 密钥(SCW_CF_API_TOKEN)。您也可以考虑重命名这些常量。

这个 mu-plugin 彻底消除了问题行为——在我部署 discourse-cloudflare-purge.php 后,问题立即消失。但这仍然让我非常困惑:为什么会出现这种情况?只要禁用该 mu-plugin,几天内问题就会重现,说明根本原因依然存在。

最终,在仔细分析日志后,我找到了问题所在:

我运行的 WordPress 站点通过 这个插件 将内容同步发布到 Apple News。该插件更新频繁,多年来一直运行良好。然而,Apple News 的发布流程会在文章发布时立即触发,有时就会出现以下 nginx 日志片段所示的行为。该片段始于作者点击 WordPress 中的“发布”按钮触发的“POST”事件:

杂乱的 nginx 访问日志片段
[22/Dec/2025:06:15:40 -0600] spacecityweather.com [-] [Poster IP] | POST /wp-admin/admin-ajax.php HTTP/2.0 200 Ref: "https://spacecityweather.com/wp-admin/post.php?post=53600&action=edit" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:40 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET / HTTP/2.0 200 Ref: "-" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-admin/admin.php?page=stats&noheader&proxy&chart=admin-bar-hours-scale HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-json/jetpack/v4/scan HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.197 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.183 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [-] [Discourse server IP] | POST /wp-json/wp-discourse/v1/update-topic-content HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.175 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/image-29.png HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/change.jpg HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] 2a01:b747:3003:206::32 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.182 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.181 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[Poster IP] 是 WordPress 文章作者的脱敏 IP 地址,[Discourse server IP] 是 Discourse 服务器的 VPC 地址。

[HIT][MISS][BYPASS] 表示该请求在本地 nginx fastcgi 缓存中的命中状态,是一个很好的辅助指标。

新文章的 URL 是 /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2

简而言之,日志显示:文章创建后,在 Discourse 插件来得及处理之前,AppleNewsBot(由文章发布触发)就抢先访问了新发布的永久链接。(您会在下方看到 Discourse 正在构建文章并抓取图片,其他 Apple News 服务器也在抓取副本。)

这次竞态几乎可以肯定是我在 Cloudflare 边缘缓存中看到过时页面的根源。不一致性是因为:有时 Discourse 赢得竞态,文章首次进入缓存时已包含正确的评论;有时 Apple News 赢得竞态,文章进入缓存时还没有评论。WordPress 的 Cloudflare 插件有自己的缓存清除逻辑,但看起来它在 Apple News 之前就被触发了。

无论如何,现在知道了问题的原因,我感到安心多了。我正在考虑向 Apple News 插件维护者提交问题报告,但我不确定这个问题是否普遍到值得开发者关注。此外,该 mu-plugin(配合 nginx 自身的智能缓存配置)已经完全为我解决了这个问题。

3) 试图鱼与熊掌兼得:将 Ajax 响应缓存 60 秒

wp-discourse 插件有多种处理 WordPress 文章/页面上评论缓存的方式,但为了符合网站所有者的要求,我选择了 Ajax 方式显示评论。这几乎完全符合我的需求,效果极佳,能在 Discourse 端发布新评论后几乎实时地在 WordPress 文章上显示出来。

但我始终担心高流量事件的发生——这是一个天气预报网站,有时预报会引发巨大关注。如果突然有大量用户同时查看嵌入的评论,从而产生海量 Ajax 请求,该怎么办?

事实证明,我们可以稍微“滥用”一下 Cloudflare,在 Cloudflare 边缘对 Ajax 响应进行短时间的缓存,以分担部分潜在负载。这需要另一个 mu-plugin 来告诉 WordPress 该做什么,然后再设置一条 Cloudflare 缓存规则来告诉 CF 该怎么做。

discourse-rest-edge-cache.php
<?php
/**
 * Plugin Name: Discourse REST Edge Cache
 * Description: Sets cache headers on WP-Discourse REST responses so Cloudflare can cache them (~60s at edge) while browsers revalidate.
 * Version: 1.0.3
 * Author: Lee Hutchinson + ChatGPT
 */

if (!defined('ABSPATH')) exit;

add_filter('rest_post_dispatch', function($result, $server, $request){
    if (!($request instanceof WP_REST_Request)) return $result;

    // 匹配路由开头的 WP-Discourse 命名空间
    $route = $request->get_route(); // 例如 /wp-discourse/v1/...
    $attrs = method_exists($request, 'get_attributes') ? (array) $request->get_attributes() : [];
    $ns    = $attrs['namespace'] ?? '';

    $is_discourse = ($ns === 'wp-discourse') || (is_string($route) && preg_match('#^/wp-discourse(?:/|$)#', $route));
    if (!$is_discourse) return $result;

    // 仅处理安全方法
    $method = strtoupper($request->get_method() ?: 'GET');
    if ($method !== 'GET' && $method !== 'HEAD') return $result;

    // 不缓存个性化/已认证请求
    if (is_user_logged_in()
        || $request->get_header('authorization')
        || $request->get_header('x-wp-nonce')
        || $request->get_header('cookie')) {
        return $result;
    }

    // 仅缓存成功响应
    if (is_wp_error($result)) return $result;
    $status = ($result instanceof WP_HTTP_Response) ? (int) $result->get_status() : 200;
    if ($status < 200 || $status >= 400) return $result;

    // 边缘 TTL 约 60 秒,浏览器重新验证;SWR 平滑刷新
    $server->send_header('Cache-Control', 'public, s-maxage=60, max-age=0, stale-while-revalidate=30');
    $server->send_header('Vary', 'Accept-Encoding');

    return $result;
}, 10, 3);

这使得 WordPress 在为 /wp-json/wp-discourse/v1/discourse-comments?post_id= 这类请求附加 Cache-Control 头。其中 s-maxage=60 是我设置的响应在缓存中保留的时长。

Cloudflare 通常不会缓存此类请求,因此我们需要通过缓存规则温和地引导它这样做:

一些 Cloudflare 缓存规则截图

该规则将包含 /wp-json/wp-discourse/ 的路径标记为可缓存,并指示 Cloudflare 边缘缓存根据源站请求的时长保留它们。在本例中,我们请求保留 60 秒。(我们必须通过插件实现这一点,而不能使用第三张截图中那个漂亮的“输入存活时间”框,因为 Cloudflare 对免费账户允许设置的最小 TTL 是 2 小时,对专业账户是 1 小时,而我需要的时间远小于此。)

我认为这个短时间的缓存会在 WordPress 文章突然爆火、10 万人同时查看时帮上忙——Cloudflare 将分担部分负载,同时我仍能保持评论近乎实时更新的观感。

除了上述几点(这些都不是 wp-discourse 的错!)之外,Cloudflare 与 wp-discourse 的集成非常顺利。我是一名满意的客户。(老实说,我还挺享受这种折腾的乐趣!)

2 个赞