没有不值得去解决的问题,也没有不值得去学习的技术!

Fix W3 Total Cache Cache Invalidation in a WordPress Multi-Domain Setup: A Complete Guide for Polylang, Redis, and Admin Subdomains

Recently, I migrated the WordPress English site from its original /en/ path to an independent subdomain:

Plaintext
Chinese site: www.shuijingwanwq.com
English site: en.shuijingwanwq.com
Backend: admin.shuijingwanwq.com

The three domains share the same WordPress codebase, database, and Redis, but each serves a different purpose:

  • www: Chinese frontend, via EdgeOne;
  • en: English frontend, via Cloudflare;
  • admin: WordPress backend, direct connection to the origin server;
  • Multilingual plugin: Polylang;
  • Page cache: W3 Total Cache Disk Enhanced;
  • Object cache: W3 Total Cache Redis Object Cache;
  • Custom code: WPCode.

This architecture achieved the expected results for access, backend upgrades, and CDN traffic control, but it soon exposed a rather subtle issue:

After publishing an article, modifying a WPCode snippet, or clearing the cache from the admin backend, www and en do not necessarily synchronize to receive the latest content.

After multiple rounds of source code analysis, cache comparison, and controlled testing, I finally confirmed that the problem was not simply a failure to clear the page cache. Instead, W3 Total Cache exhibited the following simultaneous issues in the multi-domain environment:

  1. Incomplete Page Cache purge URLs;
  2. Redis Object Cache isolated by Host;
  3. Backend saves occur on the admin Host, while frontend caches are stored on the www and en Hosts;
  4. The Host rules for W3TC’s native cache group versions and individual object cache keys are not entirely consistent.

This article documents the complete troubleshooting process and the final fix.


1. Problem Symptoms

1. After publishing an English article, the English homepage did not show the new article

After publishing an English article via admin.shuijingwanwq.com:

  • The English article detail page was accessible;
  • The article’s database status was normal;
  • The English homepage still displayed the old article list.

W3TC purges the article detail page and also purges a homepage, but when calculating the homepage URL, it is affected by the current backend Host and Polylang language context.

The result is:

Plaintext
Save English article
→ English article detail cache deleted
→ Chinese homepage might be deleted
→ English homepage cache not deleted

2. After regenerating the Chinese homepage, the latest two articles were still missing

Later, I noticed that even after the Chinese homepage Page Cache file was deleted, revisiting the homepage still showed a page missing the latest two articles.

The latest Chinese articles at the time were:

Plaintext
19440
19429

But the Chinese homepage still started displaying from 19419.

This indicated that the problem was no longer just the Page Cache.

3. After modifying a WPCode snippet, the English page still executed old code

I fixed an uninitialized variable in WPCode:

Plaintext
$current_lang         = '';
$current_series_slugs = array();

The backend and the Chinese site had already read the new code, but the English site was still executing the old version.

The WPCode cache comparison results across the three Hosts were:

Plaintext
admin: Contains new initialization code
www:   Contains new initialization code
en:    Does not contain new initialization code

The number of CTAs in the English test article remained:

Plaintext
0

This proved that the Redis Object Cache for the English Host did not synchronize invalidation.


2. First Distinguish Between Page Cache, Object Cache, and CDN

Throughout the troubleshooting process, the first step was to avoid attributing all “page not updating” issues to the CDN.

I fetched three copies of the Chinese homepage separately:

Plaintext
1. Dynamic page directly from the origin server
2. Normal EdgeOne homepage
3. EdgeOne page with random parameters

The results showed that all three pages were missing the same two articles.

Meanwhile, the EdgeOne response status was:

Plaintext
eo-cache-status: MISS

This meant EdgeOne was not returning a stale edge cache at that moment; it was correctly fetching from the origin.

Therefore, the problem lay within the WordPress origin server itself.


3. Compare Normal Queries with Cache-Bypassed Queries

To confirm whether this was a Redis query cache issue, I executed two types of WordPress homepage queries:

Plaintext
cached
uncached

Two types of WordPress homepage queries.

The cached query results under the www Host:

Plaintext
found_posts: 1312

First two posts on the homepage:
19419
19406

The results after forcibly disabling query cache:

Plaintext
found_posts: 1314

First two posts on the homepage:
19440
19429

This step directly proved:

The articles in the database were normal, and the Polylang query was normal. What was actually stale was the WordPress query object cache under the www Host.

More notably, the posts:last_changed across the three Hosts were completely different:

Plaintext
admin:0.97323000 1784020979
www:  0.94302500 1783993649
en:   0.97440600 1784019451

In other words, a single WordPress site had formed three mutually independent article query caches.


4. Why Does W3 Total Cache Generate Three Sets of Redis Caches

Continuing to review the W3 Total Cache source code, I found that the standard object cache key initially consists of:

PHP
return $blog_id . $w3tc_group . $id;

However, when creating the Redis instance, W3TC also passes in:

PHP
$engine_config['blog_id']     = $blog_id;
$engine_config['module']      = 'object';
$engine_config['host']        = Util_Environment::host();
$engine_config['instance_id'] = Util_Environment::instance_id();

The final Redis storage key formula is:

Plaintext
return sprintf(
    'w3tc_%d_%s_%d_%s_%s',
    $this->_instance_id,
    $this->_host,
    $this->_blog_id,
    $this->_module,
    $w3tc_name
);

That is:

Plaintext
w3tc_{instance_id}_{host}_{blog_id}_{module}_{name}

In the current single-site environment, blog_id is actually 0, so the real cause of the cache split is the Host:

Plaintext
w3tc_0_admin.shuijingwanwq.com_0_object_...
w3tc_0_www.shuijingwanwq.com_0_object_...
w3tc_0_en.shuijingwanwq.com_0_object_...

Therefore:

Plaintext
Backend saves article
→ admin Host's object cache changes
→ www Host still retains old objects
→ en Host still retains old objects

This was not actively generated by Polylang, nor was it because my existing MU Plugin set different Salts. It was the Host isolation mechanism of the W3 Total Cache Object Cache.


5. Why Not Simply Let the Three Domains Share the Object Cache

Upon seeing the Host isolation, a natural thought was:

Could we remove the Host and let the three domains share the same Redis Object Cache?

I ultimately did not adopt this approach.

The reason is that www and en are not exact mirror domains with identical content; they are different language sites managed by Polylang:

Plaintext
www: Chinese language context
en:  English language context

The two may have different:

  • lang;
  • canonical;
  • hreflang;
  • menus;
  • categories;
  • homepage article lists;
  • global styles;
  • Polylang configuration objects.

Previously, when the English site displayed Chinese <html lang>, had canonicals pointing to the Chinese site, experienced category redirect errors, and loaded old global styles, these were all related to cross-domain object cache boundaries.

Therefore, a safer direction was not to eliminate Host isolation, but rather:

Preserve the independent cached objects for the three Hosts, and synchronously bump the cache group version when necessary to invalidate old caches globally.


6. The W3TC Cache Group Version Key Happens to Exclude the Host

W3TC’s standard object cache key includes the Host, but the cache group version key format is:

PHP
return sprintf(
    'w3tc_%d_%d_%s_%s_key_version',
    $this->_instance_id,
    $this->_blog_id,
    $this->_module,
    $w3tc_group
);

The format is:

Plaintext
w3tc_{instance_id}_{blog_id}_{module}_{group}_key_version

There is no Host here.

W3TC’s Redis flush() implementation does not iterate and delete all keys; instead, it increments the cache group version:

PHP
public function flush( $w3tc_group = '' ) {
    $this->_get_key_version( $w3tc_group );

    if ( isset( $this->_key_version[ $w3tc_group ] ) ) {
        ++$this->_key_version[ $w3tc_group ];

        $this->_set_key_version(
            $this->_key_version[ $w3tc_group ],
            $w3tc_group
        );
    }

    return true;
}

Therefore, executing:

PHP
wp_cache_flush_group( 'posts' );

can invalidate the old posts objects across all three Hosts at once, without needing to flush the entire Redis database.


7. Controlled Test: Synchronously Invalidate the posts Cache Group

I first recorded the posts:last_changed for the three Hosts, then executed the following from the admin context:

PHP
wp_cache_flush_group( 'posts' );

The execution result was:

Plaintext
Supports flush_group: Yes
Execution result: true

I then re-queried the three Hosts.

The Chinese site result went from:

Plaintext
found_posts:1312

back to:

Plaintext
found_posts:1314

The homepage re-displayed:

Plaintext
19440
19429

The English site’s language context and article list remained normal.

This proved:

Using cache group version invalidation can resolve the issue of frontend query caches not updating after a backend save, while preserving Host isolation.


8. Another Page Cache Gap: Language Homepages Not Added to the Purge Queue

When W3TC saves an article, it calls:

Plaintext
save_post
→ w3tc_flush_post()
→ PgCache_Flush::flush_post()
→ Generate URLs to purge
→ Deferred execution of Page Cache deletion

The article detail page URL can be generated correctly based on the article’s language, but the homepage URL calculation depends on the current request Host.

When the backend request comes from:

Plaintext
admin.shuijingwanwq.com

W3TC does not always add the corresponding language homepage to the purge queue.

Therefore, it is necessary to additionally add the homepage of the current article’s language after W3TC’s native w3tc_flush_post callback:

PHP
$language = pll_get_post_language(
    $post_id,
    'slug'
);

$language_home = pll_home_url(
    $language
);

w3tc_flush_url(
    trailingslashit( $language_home )
);

W3TC’s native processing priority is 1100, and the supplementary callback is placed at 1200, so that URLs can be appended before the deferred purge executes.


9. Why the WPCode Cache Also Expired in the en Host

WPCode stores all loaded active snippets uniformly into:

Plaintext
wpcode_snippets

Its cache class reads data via:

PHP
get_option( 'wpcode_snippets' )

to read data.

But get_option() passes through the WordPress Object Cache’s options cache group.

Therefore, in a multi-Host environment, this actually forms:

Plaintext
admin Host's options cache
www Host's options cache
en Host's options cache

After I modified the WPCode snippet, the database, admin, and www were already up to date, but en still retained the old object.

The serialized cache results for the three Hosts at that time were:

Plaintext
admin:
SHA256: b531ca1288a6cd31179bb61ad6ca48593315e44bfc26048607d8ed7d1a243560
Contains initialization statement: Yes

www:
SHA256: b531ca1288a6cd31179bb61ad6ca48593315e44bfc26048607d8ed7d1a243560
Contains initialization statement: Yes

en:
SHA256: c765320e9ccc3fe0bbf9daafb16554c4c5023b19916d5fd3bcc257ca2277d14c
Contains initialization statement: No

The number of CTAs in the English test article was:

Plaintext
0

10. Do Not Listen for WPCode Saves, Keep the Manual Cache Clearing Habit

I initially considered:

Plaintext
Save WPCode
→ Automatically clear all Page Cache

But this was unnecessary.

When there was only www as a single domain, my habit was:

Plaintext
Save WPCode
→ Manually clear cache

With multiple domains, the ideal solution should maintain the same usage habit:

Plaintext
Save WPCode
→ Performance
→ Purge Modules
→ Object Cache
→ Purge Modules
→ Page Cache

There is no need to automatically clear the entire site every time WPCode is saved, nor is there a need to clear the opcode cache every time.


11. Hook the options Group Synchronization After Object Cache Purge

W3TC’s object cache purge flow is:

Plaintext
do_action( 'w3tc_flush_objectcache' );

$objectcache->flush();

do_action( 'w3tc_flush_after_objectcache' );

Therefore, the most appropriate hook is:

PHP
w3tc_flush_after_objectcache

It covers both:

Plaintext
Performance → Purge Modules → Object Cache
Performance → Purge All Caches

Executing the following within this hook:

PHP
wp_cache_flush_group( 'options' );

can simultaneously invalidate the options cache for all three Hosts.

This approach:

  • Does not listen for WPCode saves;
  • Does not actively clear the Page Cache;
  • Does not flush the entire Redis;
  • Does not affect other object cache groups;
  • Does not clear the opcode cache.

12. Final Complete MU Plugin Code

The final file:

Plaintext
wp-content/mu-plugins/swq-w3tc-polylang-purge.php

The version is 1.3.0:

PHP
<?php
/**
 * Plugin Name: SWQ W3TC Polylang Purge
 * Description: Completes W3 Total Cache Page Cache and Object Cache invalidation for a Polylang multi-domain site.
 * Version: 1.3.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Add the post language homepage to W3TC's current Page Cache purge queue.
 *
 * W3TC registers its native w3tc_flush_post callback at priority 1100.
 * This callback runs afterwards and appends the missing language homepage
 * before W3TC executes its delayed Page Cache cleanup.
 *
 * @param int        $post_id Post ID being flushed.
 * @param bool       $force   Whether W3TC forced the purge.
 * @param mixed|null $extras  Additional W3TC purge information.
 */
function swq_w3tc_polylang_queue_language_home(
    $post_id,
    $force = false,
    $extras = null
) {
    $post_id = (int) $post_id;

    if (
        $post_id < 1
        || ! function_exists( 'w3tc_flush_url' )
        || ! function_exists( 'pll_get_post_language' )
        || ! function_exists( 'pll_home_url' )
    ) {
        return;
    }

    $post = get_post( $post_id );

    if ( ! $post || 'post' !== $post->post_type ) {
        return;
    }

    $language = pll_get_post_language(
        $post_id,
        'slug'
    );

    if ( ! is_string( $language ) || '' === $language ) {
        return;
    }

    $language_home = pll_home_url( $language );

    if (
        ! is_string( $language_home )
        || '' === $language_home
    ) {
        return;
    }

    w3tc_flush_url(
        trailingslashit( $language_home ),
        array(
            'swq_source' => 'polylang_language_home',
            'post_id'    => $post_id,
            'language'   => $language,
        )
    );
}

add_action(
    'w3tc_flush_post',
    'swq_w3tc_polylang_queue_language_home',
    1200,
    3
);

/**
 * Invalidate the shared W3TC Redis version for the WordPress posts group.
 *
 * W3TC places the request Host in individual Redis object keys. As a result,
 * saving a post through admin.shuijingwanwq.com normally invalidates only the
 * admin Host's posts:last_changed value, leaving www and en query caches stale.
 *
 * W3TC's Redis group-version key does not contain the Host. Flushing the posts
 * group therefore invalidates stale post queries across all three Hosts,
 * without flushing the entire Redis database or unrelated cache groups.
 *
 * @param int        $post_id Post ID being flushed.
 * @param bool       $force   Whether W3TC forced the purge.
 * @param mixed|null $extras  Additional W3TC purge information.
 */
function swq_w3tc_sync_posts_object_cache_group(
    $post_id,
    $force = false,
    $extras = null
) {
    static $flushed = false;

    if ( $flushed ) {
        return;
    }

    $post_id = (int) $post_id;

    if (
        $post_id < 1
        || ! function_exists( 'wp_cache_flush_group' )
        || ! function_exists( 'wp_cache_supports' )
        || ! wp_cache_supports( 'flush_group' )
    ) {
        return;
    }

    $post = get_post( $post_id );

    if ( ! $post || 'post' !== $post->post_type ) {
        return;
    }

    /*
     * Prevent repeated group-version increments when W3TC triggers more than
     * one post purge during the same WordPress request.
     */
    $flushed = true;

    wp_cache_flush_group( 'posts' );
}

add_action(
    'w3tc_flush_post',
    'swq_w3tc_sync_posts_object_cache_group',
    1250,
    3
);

/**
 * Invalidate Host-separated options caches after W3TC Object Cache is flushed.
 *
 * W3TC includes the current Host in individual Object Cache keys. Its normal
 * Object Cache purge therefore invalidates only the current Host namespace.
 *
 * The Redis group-version key does not include the Host. Flushing the options
 * group makes admin, www and en reload options such as WPCode's
 * wpcode_snippets data from the shared database.
 *
 * This callback runs after either:
 *
 * - Purge Module: Object Cache
 * - Purge All Caches
 *
 * It does not initiate a Page Cache or opcode-cache purge.
 */
function swq_w3tc_sync_options_group_after_objectcache_flush() {
    if (
        ! function_exists( 'wp_cache_flush_group' )
        || ! function_exists( 'wp_cache_supports' )
        || ! wp_cache_supports( 'flush_group' )
    ) {
        return;
    }

    wp_cache_flush_group( 'options' );
}

add_action(
    'w3tc_flush_after_objectcache',
    'swq_w3tc_sync_options_group_after_objectcache_flush',
    10,
    0
);

13. Final Verification Results

1. Page Cache After Saving an Article

After executing the Chinese article cache purge:

Plaintext
Chinese homepage HTML: Does not exist
Chinese homepage GZIP: Does not exist

Chinese article HTML: Does not exist
Chinese article GZIP: Does not exist

English homepage HTML: Still exists
English homepage GZIP: Still exists

This indicates that when a Chinese article is updated:

  • The Chinese article detail cache is deleted;
  • The Chinese homepage cache is deleted;
  • The English homepage is not mistakenly deleted.

2. posts Query Cache

After purging, the www homepage restored to:

Plaintext
found_posts: 1314

First two posts on the homepage:
19440
19429

3. WPCode Object Cache

Execute:

Plaintext
Performance → Purge Modules → Object Cache

Afterward, the WPCode cache across the three Hosts was completely identical:

Plaintext
Size: 71475

SHA256:
b531ca1288a6cd31179bb61ad6ca48593315e44bfc26048607d8ed7d1a243560

Contains initialization statement: Yes

The number of CTAs in the English test article also went from:

Plaintext
0

back to:

Plaintext
1

4. Page Cache Module Purge

Execute:

Plaintext
Performance → Purge Modules → Page Cache

Check afterwards:

Plaintext
Chinese homepage: Valid HTML/GZIP does not exist
English homepage: Valid HTML/GZIP does not exist
Chinese article: Valid cache does not exist
English article: Valid cache does not exist

Some cache files will become:

Plaintext
_index_slash_ssl.html_old
_index_slash_ssl.html_gzip_old

This is a normal W3TC purge process.

Seeing a few new valid cache files after purging does not indicate purge failure, because the site will still be accessed by users, search engines, and crawlers, and pages will regenerate immediately.


14. Can Page Cache Aliases and Object Cache Global Groups Replace the Plugin

In the W3TC settings, you can also see two features that appear to be related to multiple domains:

Plaintext
Page Cache → Cache Alias Hostnames
Object Cache → Global Groups

But they cannot directly replace the current solution.

Cache Alias Hostnames

This feature is more suited for accessing the same content via multiple mirror domains, for example:

Plaintext
example.com/article/
mirror.example.com/article/

Whereas currently:

Plaintext
www = Chinese
en  = English

They are not a mirror relationship.

The Chinese and English sites have different:

  • Content;
  • URLs;
  • Language contexts;
  • Homepages;
  • canonicals;
  • Categories and menus.

Therefore, www and en should not simply be set as page cache aliases.

Object Cache Global Groups

“Global Groups” primarily resolves cache group sharing among different sites in a WordPress Multisite network.

Currently, this is a standard WordPress single site, and W3TC’s final Redis key still includes the Host.

Even if posts or options is added to the global groups, it cannot automatically remove:

Plaintext
admin.shuijingwanwq.com
www.shuijingwanwq.com
en.shuijingwanwq.com

this layer of Host isolation.

Therefore, an MU Plugin is still needed to synchronously invalidate specific cache groups.


15. Current Daily Operation Methods

Publishing or Updating a Standard Article

Under normal circumstances, manual cache clearing is no longer required:

Plaintext
Backend saves article
→ W3TC purges article detail page
→ MU Plugin supplementary purges corresponding language homepage
→ MU Plugin synchronously invalidates posts object cache group

Modifying a WPCode Snippet

Continue to keep the previous manual operation habit:

Plaintext
Performance
→ Purge Modules
→ Object Cache

Performance
→ Purge Modules
→ Page Cache

No need to purge:

Plaintext
Opcode Cache

And it is not necessarily required to execute every time:

Plaintext
Purge All Caches

16. Boundaries of the Current Solution

This time, I fully verified:

  • WordPress origin server Page Cache;
  • W3TC Redis Object Cache;
  • Polylang Chinese and English language homepages;
  • The three Hosts: admin, www, and en;
  • WPCode active code cache;
  • Page Cache module purge;
  • Object Cache module purge.

When EdgeOne currently returns MISS, the public homepage and the origin server content are already consistent.

However, this article did not independently complete the closed-loop testing for the following CDN scenario:

Plaintext
EdgeOne already HIT
→ Backend updates article
→ Whether to proactively refresh edge HTML

This belongs to the CDN-level cache refresh issue, which is different from the W3TC, Redis, and Polylang origin server cache issues handled in this article.

If it is found again later that:

Plaintext
Origin server has updated
EdgeOne still returns old page

it would be more appropriate to separately handle the EdgeOne API or WordPress automatic refresh mechanism.


17. Summary

The most common point of misjudgment in this issue is:

Just because the Page Cache file has been deleted does not mean the page will definitely generate the latest content.

In the current multi-domain architecture, the complete chain is:

Plaintext
Database
→ WordPress query cache
→ WPCode / options object cache
→ W3TC Page Cache
→ CDN

Clearing only one layer may still result in the page continuing to display old content.

The final solution did not eliminate W3TC’s Host isolation, nor did it crudely flush the entire Redis. Instead, it utilizes W3TC’s own cache group version mechanism:

PHP
wp_cache_flush_group( 'posts' );
wp_cache_flush_group( 'options' );

This preserves the necessary isolation between Polylang’s multiple domains while allowing admin, www, and en to synchronously discard old caches when critical operations occur.

For the current architecture of WordPress, Polylang, W3 Total Cache, Redis, and multiple CDNs, this is safer and easier to maintain than fully sharing the Object Cache or frequently executing a Redis Flush.

系列导航

需要长期技术维护或远程问题排查?

我是拥有 15+ 年经验的 PHP / Go 后端工程师,长期关注已有系统维护、Bug 修复、性能优化、服务器排查、WordPress 网站维护和小功能迭代。

如果你的项目遇到以下情况,可以先从一次小问题排查开始合作:

  • ✅ PHP / Laravel / Yii2 老项目无人维护
  • ✅ Go / Gin 后端接口需要排查或优化
  • ✅ WordPress 网站访问慢、报错或插件冲突
  • ✅ Nginx / MySQL / Redis / Linux 服务器异常
  • ✅ CDN / Cloudflare / DNS / HTTPS 配置问题
  • ✅ 需要长期远程技术支持或兼职维护

更多介绍请查看:关于我 & 合作

微信:13980074657
邮箱:shuijingwanwq@gmail.com
Telegram:@shuijingwan
GitHub:https://github.com/shuijingwan

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理