以下是我过去几个月在使用 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 通常不会缓存此类请求,因此我们需要通过缓存规则温和地引导它这样做:
该规则将包含 /wp-json/wp-discourse/ 的路径标记为可缓存,并指示 Cloudflare 边缘缓存根据源站请求的时长保留它们。在本例中,我们请求保留 60 秒。(我们必须通过插件实现这一点,而不能使用第三张截图中那个漂亮的“输入存活时间”框,因为 Cloudflare 对免费账户允许设置的最小 TTL 是 2 小时,对专业账户是 1 小时,而我需要的时间远小于此。)
我认为这个短时间的缓存会在 WordPress 文章突然爆火、10 万人同时查看时帮上忙——Cloudflare 将分担部分负载,同时我仍能保持评论近乎实时更新的观感。
除了上述几点(这些都不是 wp-discourse 的错!)之外,Cloudflare 与 wp-discourse 的集成非常顺利。我是一名满意的客户。(老实说,我还挺享受这种折腾的乐趣!)


