Initial commit
This commit is contained in:
447
skills/wp-performance-review/references/caching-guide.md
Normal file
447
skills/wp-performance-review/references/caching-guide.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# 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
|
||||
|
||||
```php
|
||||
// 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)
|
||||
|
||||
```php
|
||||
// ❌ 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
|
||||
|
||||
```php
|
||||
// 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)
|
||||
|
||||
```php
|
||||
// ❌ 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
|
||||
|
||||
```php
|
||||
// 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
|
||||
|
||||
```php
|
||||
// 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
|
||||
|
||||
```php
|
||||
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
|
||||
|
||||
```php
|
||||
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)
|
||||
|
||||
```php
|
||||
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
|
||||
|
||||
```php
|
||||
// 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
|
||||
|
||||
```php
|
||||
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
|
||||
|
||||
```php
|
||||
// ✅ 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:
|
||||
|
||||
```php
|
||||
// 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:
|
||||
|
||||
```php
|
||||
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
|
||||
|
||||
```php
|
||||
function get_current_user_data() {
|
||||
static $user_data = null;
|
||||
|
||||
if (null === $user_data) {
|
||||
$user_data = expensive_user_query();
|
||||
}
|
||||
|
||||
return $user_data;
|
||||
}
|
||||
```
|
||||
|
||||
### Global Variable Pattern
|
||||
|
||||
```php
|
||||
// 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
|
||||
|
||||
```php
|
||||
// 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)
|
||||
|
||||
```php
|
||||
// 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.
|
||||
Reference in New Issue
Block a user