Files
gh-elvismdev-claude-wordpre…/skills/wp-performance-review/references/caching-guide.md
2025-11-29 18:25:33 +08:00

12 KiB

WordPress Caching Strategy Guide

Comprehensive guide to caching strategies for high-performance WordPress applications.

Caching Review Checklist

When reviewing caching implementation:

Page Cache Compatibility

  • No session_start() on frontend
  • No setcookie() on public pages (unless necessary)
  • No $_SESSION usage on cacheable pages
  • POST used only for writes, GET for reads

Object Cache Usage

  • Expensive queries wrapped with wp_cache_get/wp_cache_set
  • Cache keys include relevant variables (locale, user role if needed)
  • TTL set appropriately (not too long, not too short)
  • wp_cache_get_multiple for batch lookups
  • Cache invalidation on relevant save_post hooks

Race Condition Prevention

  • wp_cache_add used for locking when needed
  • Stale-while-revalidate for high-traffic cached items
  • Cache pre-warming via cron for critical data

Caching Layers Overview

User Request
    ↓
┌─────────────────┐
│   CDN / Edge    │ ← Full page cache (HTML)
│   Cache         │   Static assets (JS, CSS, images)
└────────┬────────┘
         ↓
┌─────────────────┐
│  Page Cache     │ ← Full page cache (server-level)
│  (Varnish, etc) │   Bypassed by: cookies, POST, query vars
└────────┬────────┘
         ↓
┌─────────────────┐
│  Object Cache   │ ← Database query results
│  (Redis/Memcached)│   Transients, options, computed data
└────────┬────────┘
         ↓
┌─────────────────┐
│   Database      │ ← MySQL query cache (if enabled)
│   (MySQL)       │   InnoDB buffer pool
└─────────────────┘

Page Cache

Cache Headers

// Set cache-friendly headers
function set_cache_headers() {
    if (!is_user_logged_in() && !is_admin()) {
        header('Cache-Control: public, max-age=300, s-maxage=3600');
        header('Vary: Accept-Encoding');
    }
}
add_action('send_headers', 'set_cache_headers');

// Prevent caching for dynamic pages
function prevent_page_cache() {
    if (is_user_specific_page()) {
        header('Cache-Control: private, no-cache, no-store');
        nocache_headers();
    }
}

Cache Bypass Triggers (Avoid These)

// ❌ Starting PHP sessions bypasses cache
session_start();  // Avoid on frontend

// ❌ Unique query parameters create cache misses
// https://example.com/?utm_source=twitter&utm_campaign=123
// Solution: Strip marketing params at CDN level

// ❌ POST requests always bypass cache
// Use GET for read operations

// ❌ Setting cookies prevents caching
setcookie('my_cookie', 'value');  // Use sparingly

TTL (Time To Live) Strategy

Content Type Recommended TTL
Homepage 5-15 minutes
Archive pages 15-60 minutes
Single posts 1-24 hours
Static pages 24+ hours
Media files 1 year (versioned)

Object Cache

Basic Usage

// Store data
wp_cache_set('my_key', $data, 'my_group', 3600);  // 1 hour expiry

// Retrieve data
$data = wp_cache_get('my_key', 'my_group');
if (false === $data) {
    $data = expensive_computation();
    wp_cache_set('my_key', $data, 'my_group', 3600);
}

// Delete data
wp_cache_delete('my_key', 'my_group');

// Add only if doesn't exist (atomic)
$added = wp_cache_add('my_key', $data, 'my_group', 3600);

Batch Operations (Efficient)

// ❌ BAD: Multiple round-trips
foreach ($ids as $id) {
    $data[$id] = wp_cache_get("item_$id", 'items');
}

// ✅ GOOD: Single round-trip
$keys = array_map(fn($id) => "item_$id", $ids);
$data = wp_cache_get_multiple($keys, 'items');

Cache Groups

// Use groups to organize and bulk-delete
wp_cache_set('post_123', $data, 'my_plugin_posts');
wp_cache_set('post_456', $data, 'my_plugin_posts');

// Clear entire group (if supported by backend)
wp_cache_flush_group('my_plugin_posts');  // Redis supports this

Cache Key Versioning

// Version cache keys for easy invalidation
function get_cache_key($base) {
    $version = wp_cache_get('cache_version', 'my_plugin') ?: 1;
    return "{$base}_v{$version}";
}

// Invalidate all by incrementing version
function invalidate_all_cache() {
    wp_cache_incr('cache_version', 1, 'my_plugin');
}

Race Conditions

Problem: Concurrent Cache Regeneration

When cache expires, multiple requests may simultaneously regenerate it.

Request A ─┬─ Cache miss ─→ Start regeneration ───────→ Set cache
           │
Request B ─┴─ Cache miss ─→ Start regeneration ───────→ Set cache (duplicate!)
           │
Request C ─┴─ Cache miss ─→ Start regeneration ───────→ Set cache (duplicate!)

Solution: Locking Pattern

function get_expensive_data() {
    $cache_key = 'expensive_data';
    $lock_key = 'expensive_data_lock';
    
    // Try to get cached data
    $data = wp_cache_get($cache_key);
    if (false !== $data) {
        return $data;
    }
    
    // Try to acquire lock (atomic operation)
    $lock_acquired = wp_cache_add($lock_key, true, '', 30);  // 30 second lock
    
    if ($lock_acquired) {
        // We got the lock - regenerate cache
        $data = expensive_computation();
        wp_cache_set($cache_key, $data, '', 3600);
        wp_cache_delete($lock_key);  // Release lock
        return $data;
    }
    
    // Another process is regenerating - wait and retry
    usleep(100000);  // 100ms
    return get_expensive_data();  // Retry (add max retries in production)
}

Solution: Stale-While-Revalidate

function get_data_with_stale() {
    $cache_key = 'my_data';
    $stale_key = 'my_data_stale';
    
    $data = wp_cache_get($cache_key);
    if (false !== $data) {
        return $data;
    }
    
    // Try to get stale data while regenerating
    $stale_data = wp_cache_get($stale_key);
    
    // Regenerate in background (non-blocking)
    if (false === $stale_data) {
        // No stale data - must wait
        $data = regenerate_data();
        wp_cache_set($cache_key, $data, '', 300);
        wp_cache_set($stale_key, $data, '', 3600);  // Keep stale longer
        return $data;
    }
    
    // Schedule background regeneration
    wp_schedule_single_event(time(), 'regenerate_my_data');
    
    // Return stale data immediately
    return $stale_data;
}

Cache Stampede Prevention

Problem

Cache expires → Thousands of requests hit database simultaneously.

Solution 1: Jitter (Randomized Expiry)

function set_cache_with_jitter($key, $data, $base_ttl) {
    // Add ±10% randomization to TTL
    $jitter = rand(-($base_ttl * 0.1), $base_ttl * 0.1);
    $ttl = $base_ttl + $jitter;
    wp_cache_set($key, $data, '', $ttl);
}

Solution 2: Pre-warming via Cron

// Regenerate cache before it expires
add_action('pre_warm_popular_caches', function() {
    $popular_queries = ['homepage_posts', 'featured_products', 'menu_items'];
    
    foreach ($popular_queries as $query) {
        // Force regeneration
        wp_cache_delete($query);
        get_cached_query($query);  // Regenerates and caches
    }
});

// Schedule to run before typical expiry
if (!wp_next_scheduled('pre_warm_popular_caches')) {
    wp_schedule_event(time(), 'hourly', 'pre_warm_popular_caches');
}

Solution 3: Early Expiry Check

function get_with_early_expiry($key, $ttl, $regenerate_callback) {
    $data = wp_cache_get($key);
    
    if (false !== $data) {
        // Check if we should pre-regenerate (last 10% of TTL)
        $meta = wp_cache_get("{$key}_meta");
        if ($meta && (time() - $meta['created']) > ($ttl * 0.9)) {
            // Trigger background regeneration
            wp_schedule_single_event(time(), 'regenerate_cache', [$key]);
        }
        return $data;
    }
    
    // Cache miss - regenerate
    $data = call_user_func($regenerate_callback);
    wp_cache_set($key, $data, '', $ttl);
    wp_cache_set("{$key}_meta", ['created' => time()], '', $ttl);
    
    return $data;
}

Transients

When to Use Transients

  • Data with known expiration
  • External API responses
  • Computed data that's expensive to regenerate

Transient Best Practices

// ✅ GOOD: Named clearly, reasonable TTL
set_transient('weather_data_seattle', $data, HOUR_IN_SECONDS);

// ❌ BAD: Dynamic keys create transient bloat
set_transient("user_{$user_id}_preferences", $data, DAY_IN_SECONDS);
// Better: Use user meta or object cache

Transient Storage Warning

Without persistent object cache, transients are stored in wp_options table:

// Check if object cache is available
if (wp_using_ext_object_cache()) {
    // Transients use object cache (good)
    set_transient('my_data', $data, HOUR_IN_SECONDS);
} else {
    // Transients go to database (potentially bad)
    // Consider: file cache, skip caching, or warn admin
}

Partial Output Caching

Cache rendered HTML fragments for repeated use:

function get_cached_sidebar() {
    $cache_key = 'sidebar_html_' . get_locale();
    $html = wp_cache_get($cache_key, 'partials');
    
    if (false === $html) {
        ob_start();
        get_sidebar();
        $html = ob_get_clean();
        wp_cache_set($cache_key, $html, 'partials', HOUR_IN_SECONDS);
    }
    
    return $html;
}

In-Memory Caching (Request-Scoped)

Static Variables

function get_current_user_data() {
    static $user_data = null;
    
    if (null === $user_data) {
        $user_data = expensive_user_query();
    }
    
    return $user_data;
}

Global Variable Pattern

// Store request-scoped data
global $my_plugin_cache;
$my_plugin_cache = [];

function get_item($id) {
    global $my_plugin_cache;
    
    if (!isset($my_plugin_cache[$id])) {
        $my_plugin_cache[$id] = fetch_item($id);
    }
    
    return $my_plugin_cache[$id];
}

Cache Invalidation Strategy

Event-Based Invalidation

// Clear post-related caches when post is updated
add_action('save_post', function($post_id, $post) {
    // Clear specific post cache
    wp_cache_delete("post_{$post_id}", 'posts');
    
    // Clear related caches
    wp_cache_delete('recent_posts', 'listings');
    wp_cache_delete('homepage_posts', 'listings');
    
    // Clear category archive caches
    $categories = wp_get_post_categories($post_id);
    foreach ($categories as $cat_id) {
        wp_cache_delete("category_{$cat_id}_posts", 'archives');
    }
}, 10, 2);

Tag-Based Invalidation (Advanced)

// Store cache tags
function set_tagged_cache($key, $data, $tags, $ttl) {
    wp_cache_set($key, $data, '', $ttl);
    
    foreach ($tags as $tag) {
        $tag_keys = wp_cache_get("tag_{$tag}", 'cache_tags') ?: [];
        $tag_keys[] = $key;
        wp_cache_set("tag_{$tag}", array_unique($tag_keys), 'cache_tags');
    }
}

// Invalidate by tag
function invalidate_tag($tag) {
    $keys = wp_cache_get("tag_{$tag}", 'cache_tags') ?: [];
    foreach ($keys as $key) {
        wp_cache_delete($key);
    }
    wp_cache_delete("tag_{$tag}", 'cache_tags');
}

Memcached vs Redis

Feature Memcached Redis
Speed Slightly faster Fast
Data types String only Strings, lists, sets, hashes
Persistence No Optional
Memory efficiency Higher Lower
Cache groups Limited Full support
Complexity Simple More features

Recommendation: Use what your host provides. Memcached for simple caching, Redis if you need advanced features.