Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:25:33 +08:00
commit ec3986470d
10 changed files with 2945 additions and 0 deletions

View File

@@ -0,0 +1,506 @@
---
name: wp-performance-review
description: Use when reviewing WordPress PHP code for performance issues, auditing themes/plugins for scalability, optimizing WP_Query, analyzing caching strategies, or when user mentions "slow WordPress", "high-traffic", "performance review", "timeout", "500 error", "out of memory", or "site won't load". Detects anti-patterns in database queries, hooks, object caching, AJAX, and template loading that cause failures at scale.
---
# WordPress Performance Review Skill
## Overview
Systematic performance code review for WordPress themes, plugins, and custom code. **Core principle:** Scan critical issues first (OOM, unbounded queries, cache bypass), then warnings, then optimizations. Report with line numbers and severity levels.
## When to Use
**Use when:**
- Reviewing PR/code for WordPress theme or plugin
- User reports slow page loads, timeouts, or 500 errors
- Auditing before high-traffic event (launch, sale, viral moment)
- Optimizing WP_Query or database operations
- Investigating memory exhaustion or DB locks
**Don't use for:**
- Security-only audits (use wp-security-review when available)
- Gutenberg block development patterns (use wp-gutenberg-blocks when available)
- General PHP code review not specific to WordPress
## Code Review Workflow
1. **Identify file type** and apply relevant checks below
2. **Scan for critical patterns first** (OOM, unbounded queries, cache bypass)
3. **Check warnings** (inefficient but not catastrophic)
4. **Note optimizations** (nice-to-have improvements)
5. **Report with line numbers** using output format below
## File-Type Specific Checks
### Plugin/Theme PHP Files (`functions.php`, `plugin.php`, `*.php`)
Scan for:
- `query_posts()` → CRITICAL: Never use - breaks main query
- `posts_per_page.*-1` or `numberposts.*-1` → CRITICAL: Unbounded query
- `session_start()` → CRITICAL: Bypasses page cache
- `add_action.*init.*` or `add_action.*wp_loaded` → Check if expensive code runs every request
- `update_option` or `add_option` in non-admin context → WARNING: DB writes on page load
- `wp_remote_get` or `wp_remote_post` without caching → WARNING: Blocking HTTP
### WP_Query / Database Code
Scan for:
- Missing `posts_per_page` argument → WARNING: Defaults to blog setting
- `'meta_query'` with `'value'` comparisons → WARNING: Unindexed column scan
- `post__not_in` with large arrays → WARNING: Slow exclusion
- `LIKE '%term%'` (leading wildcard) → WARNING: Full table scan
- Missing `no_found_rows => true` when not paginating → INFO: Unnecessary count
### AJAX Handlers (`wp_ajax_*`, REST endpoints)
Scan for:
- `admin-ajax.php` usage → INFO: Consider REST API instead
- POST method for read operations → WARNING: Bypasses cache
- `setInterval` or polling patterns → CRITICAL: Self-DDoS risk
- Missing nonce verification → Security issue (not performance, but flag it)
### Template Files (`*.php` in theme)
Scan for:
- `get_template_part` in loops → WARNING: Consider caching output
- Database queries inside loops (N+1) → CRITICAL: Query multiplication
- `wp_remote_get` in templates → WARNING: Blocks rendering
### JavaScript Files
Scan for:
- `$.post(` for read operations → WARNING: Use GET for cacheability
- `setInterval.*fetch\|ajax` → CRITICAL: Polling pattern
- `import _ from 'lodash'` → WARNING: Full library import bloats bundle
- Inline `<script>` making AJAX calls on load → Check necessity
### Block Editor / Gutenberg Files (`block.json`, `*.js` in blocks/)
Scan for:
- Many `registerBlockStyle()` calls → WARNING: Each creates preview iframe
- `wp_kses_post($content)` in render callbacks → WARNING: Breaks InnerBlocks
- Static blocks without `render_callback` → INFO: Consider dynamic for maintainability
### Asset Registration (`functions.php`, `*.php`)
Scan for:
- `wp_enqueue_script` without version → INFO: Cache busting issues
- `wp_enqueue_script` without `defer`/`async` strategy → INFO: Blocks rendering
- Missing `THEME_VERSION` constant → INFO: Version management
- `wp_enqueue_script` without conditional check → WARNING: Assets load globally when only needed on specific pages
### Transients & Options
Scan for:
- `set_transient` with dynamic keys (e.g., `user_{$id}`) → WARNING: Table bloat without object cache
- `set_transient` for frequently-changing data → WARNING: Defeats caching purpose
- Large data in transients on shared hosting → WARNING: DB bloat without object cache
### WP-Cron
Scan for:
- Missing `DISABLE_WP_CRON` constant → INFO: Cron runs on page requests
- Long-running cron callbacks (loops over all users/posts) → CRITICAL: Blocks cron queue
- `wp_schedule_event` without checking if already scheduled → WARNING: Duplicate schedules
## Search Patterns for Quick Detection
```bash
# Critical issues - scan these first
grep -rn "posts_per_page.*-1\|numberposts.*-1" .
grep -rn "query_posts\s*(" .
grep -rn "session_start\s*(" .
grep -rn "setInterval.*fetch\|setInterval.*ajax\|setInterval.*\\\$\." .
# Database writes on frontend
grep -rn "update_option\|add_option" . | grep -v "admin\|activate\|install"
# Uncached expensive functions
grep -rn "url_to_postid\|attachment_url_to_postid\|count_user_posts" .
# External HTTP without caching
grep -rn "wp_remote_get\|wp_remote_post\|file_get_contents.*http" .
# Cache bypass risks
grep -rn "setcookie\|session_start" .
# PHP code anti-patterns
grep -rn "in_array\s*(" . | grep -v "true\s*)" # Missing strict comparison
grep -rn "<<<" . # Heredoc/nowdoc syntax
grep -rn "cache_results.*false" .
# JavaScript bundle issues
grep -rn "import.*from.*lodash['\"]" . # Full lodash import
grep -rn "registerBlockStyle" . # Many block styles = performance issue
# Asset loading issues
grep -rn "wp_enqueue_script\|wp_enqueue_style" . | grep -v "is_page\|is_singular\|is_admin"
# Transient misuse
grep -rn "set_transient.*\\\$" . # Dynamic transient keys
grep -rn "set_transient" . | grep -v "get_transient" # Set without checking first
# WP-Cron issues
grep -rn "wp_schedule_event" . | grep -v "wp_next_scheduled" # Missing schedule check
```
## Platform Context
Different hosting environments require different approaches:
**Managed WordPress Hosts** (WP Engine, Pantheon, Pressable, WordPress VIP, etc.):
- Often provide object caching out of the box
- May have platform-specific helper functions (e.g., `wpcom_vip_*` on VIP)
- Check host documentation for recommended patterns
**Self-Hosted / Standard Hosting**:
- Implement object caching wrappers manually for expensive functions
- Consider Redis or Memcached plugins for persistent object cache
- More responsibility for caching layer configuration
**Shared Hosting**:
- Be extra cautious about unbounded queries and external HTTP
- Limited resources mean performance issues surface faster
- May lack persistent object cache entirely
## Quick Reference: Critical Anti-Patterns
### Database Queries
```php
// ❌ CRITICAL: Unbounded query.
'posts_per_page' => -1
// ✅ GOOD: Set reasonable limit, paginate if needed.
'posts_per_page' => 100,
'no_found_rows' => true, // Skip count if not paginating.
// ❌ CRITICAL: Never use query_posts().
query_posts( 'cat=1' ); // Breaks pagination, conditionals.
// ✅ GOOD: Use WP_Query or pre_get_posts filter.
$query = new WP_Query( array( 'cat' => 1 ) );
// Or modify main query:
add_action( 'pre_get_posts', function( $query ) {
if ( $query->is_main_query() && ! is_admin() ) {
$query->set( 'cat', 1 );
}
} );
// ❌ CRITICAL: Missing WHERE clause (falsy ID becomes 0).
$query = new WP_Query( array( 'p' => intval( $maybe_false_id ) ) );
// ✅ GOOD: Validate ID before querying.
if ( ! empty( $maybe_false_id ) ) {
$query = new WP_Query( array( 'p' => intval( $maybe_false_id ) ) );
}
// ❌ WARNING: LIKE with leading wildcard (full table scan).
$wpdb->get_results( "SELECT * FROM wp_posts WHERE post_title LIKE '%term%'" );
// ✅ GOOD: Use trailing wildcard only, or use WP_Query 's' parameter.
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM wp_posts WHERE post_title LIKE %s",
$wpdb->esc_like( $term ) . '%'
) );
// ❌ WARNING: NOT IN queries (filter in PHP instead).
'post__not_in' => $excluded_ids
// ✅ GOOD: Fetch all, filter in PHP (faster for large exclusion lists).
$posts = get_posts( array( 'posts_per_page' => 100 ) );
$posts = array_filter( $posts, function( $post ) use ( $excluded_ids ) {
return ! in_array( $post->ID, $excluded_ids, true );
} );
```
### Hooks & Actions
```php
// ❌ WARNING: Code runs on every request via init.
add_action( 'init', 'expensive_function' );
// ✅ GOOD: Check context before running expensive code.
add_action( 'init', function() {
if ( is_admin() || wp_doing_cron() ) {
return;
}
// Frontend-only code here.
} );
// ❌ CRITICAL: Database writes on every page load.
add_action( 'wp_head', 'prefix_bad_tracking' );
function prefix_bad_tracking() {
update_option( 'last_visit', time() );
}
// ✅ GOOD: Use object cache buffer, flush via cron.
add_action( 'shutdown', function() {
wp_cache_incr( 'page_views_buffer', 1, 'counters' );
} );
// ❌ WARNING: Using admin-ajax.php instead of REST API.
// Prefer: register_rest_route() - leaner bootstrap.
```
### PHP Code
```php
// ❌ WARNING: O(n) lookup - use isset() with associative array.
in_array( $value, $array ); // Also missing strict = true.
// ✅ GOOD: O(1) lookup with isset().
$allowed = array( 'foo' => true, 'bar' => true );
if ( isset( $allowed[ $value ] ) ) {
// Process.
}
// ❌ WARNING: Heredoc prevents late escaping.
$html = <<<HTML
<div>$unescaped_content</div>
HTML;
// ✅ GOOD: Escape at output.
printf( '<div>%s</div>', esc_html( $content ) );
```
### Caching Issues
```php
// ❌ WARNING: Uncached expensive function calls.
url_to_postid( $url );
attachment_url_to_postid( $attachment_url );
count_user_posts( $user_id );
wp_oembed_get( $url );
// ✅ GOOD: Wrap with object cache (works on any host).
function prefix_cached_url_to_postid( $url ) {
$cache_key = 'url_to_postid_' . md5( $url );
$post_id = wp_cache_get( $cache_key, 'url_lookups' );
if ( false === $post_id ) {
$post_id = url_to_postid( $url );
wp_cache_set( $cache_key, $post_id, 'url_lookups', HOUR_IN_SECONDS );
}
return $post_id;
}
// ✅ GOOD: On WordPress VIP, use platform helpers instead.
// wpcom_vip_url_to_postid(), wpcom_vip_attachment_url_to_postid(), etc.
// ❌ WARNING: Large autoloaded options.
add_option( 'prefix_large_data', $data ); // Add: , '', 'no' for autoload.
// ❌ INFO: Missing wp_cache_get_multiple for batch lookups.
foreach ( $ids as $id ) {
wp_cache_get( "key_{$id}" );
}
```
### AJAX & External Requests
```javascript
// ❌ WARNING: AJAX POST request (bypasses cache).
$.post( ajaxurl, data ); // Prefer: $.get() for read operations.
// ❌ CRITICAL: Polling pattern (self-DDoS).
setInterval( () => fetch( '/wp-json/...' ), 5000 );
```
```php
// ❌ WARNING: Synchronous external HTTP in page load.
wp_remote_get( $url ); // Cache result or move to cron.
// ✅ GOOD: Set timeout and handle errors.
$response = wp_remote_get( $url, array( 'timeout' => 2 ) );
if ( is_wp_error( $response ) ) {
return get_fallback_data();
}
```
### WP Cron
```php
// ❌ WARNING: WP Cron runs on page requests.
// Add to wp-config.php:
define( 'DISABLE_WP_CRON', true );
// Run via server cron: * * * * * wp cron event run --due-now
// ❌ CRITICAL: Long-running cron blocks entire queue.
add_action( 'my_daily_sync', function() {
foreach ( get_users() as $user ) { // 50k users = hours.
sync_user_data( $user );
}
} );
// ✅ GOOD: Batch processing with rescheduling.
add_action( 'my_batch_sync', function() {
$offset = (int) get_option( 'sync_offset', 0 );
$users = get_users( array( 'number' => 100, 'offset' => $offset ) );
if ( empty( $users ) ) {
delete_option( 'sync_offset' );
return;
}
foreach ( $users as $user ) {
sync_user_data( $user );
}
update_option( 'sync_offset', $offset + 100 );
wp_schedule_single_event( time() + 60, 'my_batch_sync' );
} );
// ❌ WARNING: Scheduling without checking if already scheduled.
wp_schedule_event( time(), 'hourly', 'my_task' ); // Creates duplicates!
// ✅ GOOD: Check before scheduling.
if ( ! wp_next_scheduled( 'my_task' ) ) {
wp_schedule_event( time(), 'hourly', 'my_task' );
}
```
### Cache Bypass Issues
```php
// ❌ CRITICAL: Plugin starts PHP session on frontend (bypasses ALL page cache).
session_start(); // Check plugins for this - entire site becomes uncacheable!
// ❌ WARNING: Unique query params create cache misses.
// https://example.com/?utm_source=fb&utm_campaign=123&fbclid=abc
// Each unique URL = separate cache entry = cache miss.
// Solution: Strip marketing params at CDN/edge level.
// ❌ WARNING: Setting cookies on public pages.
setcookie( 'visitor_id', $id ); // Prevents caching for that user.
```
### Transients Misuse
```php
// ❌ WARNING: Dynamic transient keys create table bloat (without object cache).
set_transient( "user_{$user_id}_cart", $data, HOUR_IN_SECONDS );
// 10,000 users = 10,000 rows in wp_options!
// ✅ GOOD: Use object cache for user-specific data.
wp_cache_set( "cart_{$user_id}", $data, 'user_carts', HOUR_IN_SECONDS );
// ❌ WARNING: Transients for frequently-changing data defeats purpose.
set_transient( 'visitor_count', $count, 60 ); // Changes every minute.
// ✅ GOOD: Use object cache for volatile data.
wp_cache_set( 'visitor_count', $count, 'stats' );
// ❌ WARNING: Large data in transients on shared hosting.
set_transient( 'api_response', $megabytes_of_json, DAY_IN_SECONDS );
// Without object cache = serialized blob in wp_options.
// ✅ GOOD: Check hosting before using transients for large data.
if ( wp_using_ext_object_cache() ) {
set_transient( 'api_response', $data, DAY_IN_SECONDS );
} else {
// Store in files or skip caching on shared hosting.
}
```
### Asset Loading
```php
// ❌ WARNING: Assets load globally when only needed on specific pages.
add_action( 'wp_enqueue_scripts', function() {
wp_enqueue_script( 'contact-form-js', ... );
wp_enqueue_style( 'contact-form-css', ... );
} );
// ✅ GOOD: Conditional enqueue based on page/template.
add_action( 'wp_enqueue_scripts', function() {
if ( is_page( 'contact' ) || is_page_template( 'contact-template.php' ) ) {
wp_enqueue_script( 'contact-form-js', ... );
wp_enqueue_style( 'contact-form-css', ... );
}
} );
// ✅ GOOD: Only load WooCommerce assets on shop pages.
add_action( 'wp_enqueue_scripts', function() {
if ( ! is_woocommerce() && ! is_cart() && ! is_checkout() ) {
wp_dequeue_style( 'woocommerce-general' );
wp_dequeue_script( 'wc-cart-fragments' );
}
} );
```
### External API Requests
```php
// ❌ WARNING: No timeout set (default is 5 seconds).
wp_remote_get( $url ); // Set timeout: array( 'timeout' => 2 ).
// ❌ WARNING: Missing error handling for API failures.
$response = wp_remote_get( $url );
echo $response['body']; // Check is_wp_error() first!
```
### Sitemaps & Redirects
```php
// ❌ WARNING: Generating sitemaps for deep archives (crawlers hammer these).
// Solution: Exclude old post types, cache generated sitemaps.
// ❌ CRITICAL: Redirect loops consuming CPU.
// Debug with: x-redirect-by header, wp_debug_backtrace_summary().
```
### Post Meta Queries
```php
// ❌ WARNING: Searching meta_value without index.
'meta_query' => array(
array(
'key' => 'color',
'value' => 'red',
),
)
// Better: Use taxonomy or encode value in meta_key name.
// ❌ WARNING: Binary meta values requiring value scan.
'meta_key' => 'featured',
'meta_value' => 'true',
// Better: Presence of 'is_featured' key = true, absence = false.
```
**For deeper context on any pattern:** Load `references/anti-patterns.md`
## Severity Definitions
| Severity | Description |
|----------|-------------|
| **Critical** | Will cause failures at scale (OOM, 500 errors, DB locks) |
| **Warning** | Degrades performance under load |
| **Info** | Optimization opportunity |
## Output Format
Structure findings as:
```markdown
## Performance Review: [filename/component]
### Critical Issues
- **Line X**: [Issue] - [Explanation] - [Fix]
### Warnings
- **Line X**: [Issue] - [Explanation] - [Fix]
### Recommendations
- [Optimization opportunities]
### Summary
- Total issues: X Critical, Y Warnings, Z Info
- Estimated impact: [High/Medium/Low]
```
## Common Mistakes
When performing performance reviews, avoid these errors:
| Mistake | Why It's Wrong | Fix |
|---------|----------------|-----|
| Flagging `posts_per_page => -1` in admin-only code | Admin queries don't face public scale | Check context - admin, CLI, cron are lower risk |
| Missing the `session_start()` buried in a plugin | Cache bypass affects entire site | Always grep for `session_start` across all code |
| Ignoring `no_found_rows` for non-paginated queries | Small optimization but adds up | Flag as INFO, not WARNING |
| Recommending object cache on shared hosting | Many shared hosts lack persistent cache | Check hosting environment first |
| Only reviewing PHP, missing JS polling | JS `setInterval` + fetch = self-DDoS | Review `.js` files for polling patterns |
## Deep-Dive References
Load these references based on the task:
| Task | Reference to Load |
|------|-------------------|
| Reviewing PHP code for issues | `references/anti-patterns.md` |
| Optimizing WP_Query calls | `references/wp-query-guide.md` |
| Implementing caching | `references/caching-guide.md` |
| High-traffic event prep | `references/measurement-guide.md` |
**Note**: For standard code reviews, `anti-patterns.md` contains all patterns needed. Other references provide deeper context when specifically optimizing queries, implementing caching strategies, or preparing for traffic events.

File diff suppressed because it is too large Load Diff

View 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.

View File

@@ -0,0 +1,356 @@
# High-Traffic Event Preparation & Performance Measurement
Guide for preparing WordPress sites for traffic surges and measuring performance.
## When to Use This Reference
This guide is for:
- **Pre-launch audits** - Checklist before high-traffic events
- **Performance testing** - How to load test with K6
- **Monitoring setup** - Query Monitor, New Relic integration
- **Alert configuration** - Threshold recommendations
**For code review**, use `anti-patterns.md` instead. This guide covers operational preparation.
## Code-Level Pre-Event Checks
Before a high-traffic event, scan the codebase for:
```bash
# Things that WILL break under load
grep -rn "posts_per_page.*-1" . # Unbounded queries
grep -rn "session_start" . # Cache bypass
grep -rn "setInterval.*fetch\|setInterval.*ajax" . # Polling
# Things that DEGRADE under load
grep -rn "wp_remote_get\|wp_remote_post" . # Uncached HTTP
grep -rn "update_option\|add_option" . # DB writes
```
## Pre-Event Checklist
Before a high-traffic event (product launch, viral content, marketing campaign), verify:
### Cache Hit Rate
- [ ] **Page cache hit rate > 90%** - Check CDN/edge cache analytics
- [ ] **Object cache (memcached) hit rate > 90%** - Check cache stats
- [ ] **InnoDB buffer pool hit rate > 95%** - MySQL performance schema
- [ ] Identify pages consistently missing cache - optimize or exclude from event
### Database Health
- [ ] **No INSERT/UPDATE on uncached page loads** - Use Query Monitor
- Plugins persistently updating options = high DB CPU + binlog growth
- [ ] **Review P95 upstream response times** - Identify slowest endpoints
- High traffic to slow endpoints ties up PHP workers
- [ ] **Check for slow queries** - Queries > 100ms under normal load
- [ ] **Verify indexes** - Run EXPLAIN on common queries
### Error Monitoring
- [ ] **Clean PHP logs** - No fatal errors, minimize warnings
- Easier to debug issues if logs aren't full of noise
- [ ] **Zero 500 errors in 24h** - Check application error rate
- [ ] **Review error tracking** - New Relic, Sentry, etc.
### Load Testing
- [ ] **Run load test simulating expected traffic**
- [ ] **Test specific high-traffic URLs** - Homepage, landing pages
- [ ] **Monitor during test** - CPU, memory, DB connections, response times
## Traffic Pattern Types
### Sustained High Traffic on Multiple URLs
**Impact**: Strain on PHP workers, database connections, memory
**Preparation**:
- Scale horizontally (more app servers)
- Increase PHP worker count
- Optimize database queries
- Implement aggressive caching
### Large Spikes on Few URLs
**Impact**: Bottleneck on specific endpoints, potential cache stampede
**Preparation**:
- Pre-warm caches for target URLs
- Implement cache locking to prevent stampede
- Consider static HTML fallback for extreme cases
### Uncached Content Under Load
**Impact**: Every request hits origin, database overload
**Preparation**:
- Identify why pages bypass cache (cookies, sessions, query params)
- Implement partial caching for personalized pages
- Use ESI (Edge Side Includes) for dynamic fragments
## Load Testing with K6
K6 is an open-source load testing tool for simulating traffic.
### Installation
```bash
# macOS
brew install k6
# Linux
sudo apt-get install k6
# Docker
docker run -i grafana/k6 run - <script.js
```
### Basic Load Test Script
```javascript
// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 100, // 100 virtual users
duration: '5m', // 5 minute test
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // Less than 1% failures
},
};
export default function() {
// Test homepage
let homeResponse = http.get('https://example.com/');
check(homeResponse, {
'homepage status 200': (r) => r.status === 200,
'homepage < 500ms': (r) => r.timings.duration < 500,
});
// Test archive page
let archiveResponse = http.get('https://example.com/category/news/');
check(archiveResponse, {
'archive status 200': (r) => r.status === 200,
});
sleep(1); // Wait 1 second between iterations
}
```
### Running Load Tests
```bash
# Basic run
k6 run load-test.js
# With more virtual users
k6 run --vus 200 --duration 10m load-test.js
# Output to JSON for analysis
k6 run --out json=results.json load-test.js
```
### Key Metrics to Monitor
| Metric | Good | Concerning |
|--------|------|------------|
| `http_req_duration` (p95) | < 500ms | > 1000ms |
| `http_req_failed` | < 1% | > 5% |
| `http_reqs` (throughput) | Stable | Declining under load |
| `vus` vs response time | Linear | Exponential degradation |
## Measuring with Query Monitor
Query Monitor plugin provides detailed performance insights per page.
### Key Panels
**Overview Panel**
- Total page generation time
- Database query time
- HTTP API call time
**Database Queries Panel**
- Individual query times
- Duplicate queries (same query run multiple times)
- Slow queries highlighted
- Query origin (which plugin/theme)
**Object Cache Panel**
- Cache hit/miss ratio
- Total cache operations
- Cache groups usage
### Custom Timing with Query Monitor
```php
// Start timer
do_action('qm/start', 'my_operation');
// Your code here
expensive_operation();
// Stop timer
do_action('qm/stop', 'my_operation');
// For loops, use lap:
do_action('qm/start', 'loop_operation');
foreach ($items as $item) {
process_item($item);
do_action('qm/lap', 'loop_operation');
}
do_action('qm/stop', 'loop_operation');
```
### Target Metrics in Query Monitor
| Metric | Target | Investigate |
|--------|--------|-------------|
| Page generation | < 200ms | > 500ms |
| Database queries | < 50 | > 100 |
| Duplicate queries | 0 | > 5 |
| Slowest query | < 50ms | > 100ms |
| Object cache hits | > 90% | < 80% |
## Measuring with PHP Logging
Manual timing for specific code sections.
### Basic Timing
```php
function my_function() {
$start_time = microtime(true);
// Code to measure
expensive_operation();
$end_time = microtime(true);
$execution_time = $end_time - $start_time;
error_log(sprintf(
'my_function execution time: %.4f seconds',
$execution_time
));
}
```
### Timing Wrapper Function
```php
function measure_execution($callback, $label) {
$start = microtime(true);
$result = $callback();
$duration = microtime(true) - $start;
if ($duration > 0.1) { // Log if > 100ms
error_log(sprintf('[SLOW] %s: %.4fs', $label, $duration));
}
return $result;
}
// Usage
$data = measure_execution(function() {
return expensive_query();
}, 'expensive_query');
```
### Memory Measurement
```php
$mem_start = memory_get_usage();
// Code that might use lots of memory
$large_array = process_data();
$mem_end = memory_get_usage();
$mem_used = ($mem_end - $mem_start) / 1024 / 1024;
error_log(sprintf('Memory used: %.2f MB', $mem_used));
```
## Measuring with New Relic
New Relic provides APM (Application Performance Monitoring) for production.
### Custom Transaction Naming
```php
// Name transactions for better grouping
if (function_exists('newrelic_name_transaction')) {
if (is_single()) {
newrelic_name_transaction('single-post');
} elseif (is_archive()) {
newrelic_name_transaction('archive');
}
}
```
### Custom Instrumentation
```php
// Track custom code segments
if (function_exists('newrelic_start_transaction')) {
newrelic_start_transaction('my_custom_process');
// Your code
process_data();
newrelic_end_transaction();
}
// Add custom attributes for filtering
if (function_exists('newrelic_add_custom_parameter')) {
newrelic_add_custom_parameter('post_type', get_post_type());
newrelic_add_custom_parameter('user_role', $current_user_role);
}
```
### Key New Relic Metrics
- **Apdex score** - User satisfaction (target: > 0.9)
- **Web transaction time** - Average response time
- **Throughput** - Requests per minute
- **Error rate** - Percentage of failed requests
- **Database time** - Time spent in DB queries
## Performance Alerting
Set up alerts for performance degradation.
### Key Alert Thresholds
| Metric | Warning | Critical |
|--------|---------|----------|
| Response time (p95) | > 1s | > 3s |
| Error rate | > 1% | > 5% |
| CPU usage | > 70% | > 90% |
| Memory usage | > 80% | > 95% |
| Database connections | > 80% of max | > 95% of max |
### Alert Channels
- **Immediate**: PagerDuty, Slack, SMS for critical
- **Batched**: Email digest for warnings
- **Dashboard**: Real-time visibility for operations team
## Performance Regression Detection
Catch performance issues before production.
### Baseline Metrics
Establish baseline performance metrics:
```
Homepage: < 200ms, < 30 queries
Archive: < 300ms, < 50 queries
Single: < 250ms, < 40 queries
Search: < 500ms (with ElasticSearch)
```
### CI/CD Integration
```yaml
# Example: Run performance check in CI
- name: Performance Check
run: |
RESPONSE_TIME=$(curl -o /dev/null -s -w '%{time_total}' https://staging.example.com/)
if (( $(echo "$RESPONSE_TIME > 0.5" | bc -l) )); then
echo "Warning: Response time ${RESPONSE_TIME}s exceeds threshold"
exit 1
fi
```
### Query Count Monitoring
```php
// Add to theme's functions.php for staging
add_action('shutdown', function() {
if (!defined('SAVEQUERIES') || !SAVEQUERIES) return;
global $wpdb;
$query_count = count($wpdb->queries);
if ($query_count > 100) {
error_log("[PERFORMANCE] High query count: $query_count");
}
});
```

View File

@@ -0,0 +1,328 @@
# WP_Query Optimization Guide
Best practices for efficient database queries in WordPress applications.
## WP_Query Review Checklist
When reviewing WP_Query code, verify:
- [ ] `posts_per_page` is set (not -1, not missing)
- [ ] `no_found_rows => true` if not paginating
- [ ] `fields => 'ids'` if only IDs needed
- [ ] `update_post_meta_cache => false` if meta not used
- [ ] `update_post_term_cache => false` if terms not used
- [ ] Date limits on archive queries (recent content only)
- [ ] `include_children => false` if child terms not needed
- [ ] No `post__not_in` with large arrays
- [ ] No `meta_query` on `meta_value` (use taxonomy or key presence)
- [ ] Results cached with `wp_cache_set` if repeated
## Core Principles
1. **Always set limits** - Never use `posts_per_page => -1`
2. **Validate inputs** - Check IDs before querying
3. **Limit scope** - Use date ranges, taxonomies to narrow results
4. **Skip unnecessary work** - Use `no_found_rows`, `fields`
5. **Pre-fetch related data** - Avoid N+1 queries
6. **Cache results** - Use object cache for repeated queries
## Essential Query Arguments
### Limiting Results
```php
// REQUIRED: Always limit results
'posts_per_page' => 10,
// Skip counting total posts if not paginating
'no_found_rows' => true, // Skips SQL_CALC_FOUND_ROWS
// Return only IDs when you don't need full post objects
'fields' => 'ids',
// Suppress filters when you need raw results
'suppress_filters' => true,
```
### Date-Based Limiting
For sites with years of content, limit queries to relevant time ranges:
```php
// Limit to recent content
$query = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 10,
'date_query' => [
[
'after' => '3 months ago',
'inclusive' => true
]
]
]);
// Specific date range
'date_query' => [
'after' => '2024-01-01',
'before' => '2024-12-31'
]
// Dynamic date filtering via pre_get_posts
add_action('pre_get_posts', function($query) {
if (!is_admin() && $query->is_main_query() && $query->is_category('news')) {
$query->set('date_query', [
'after' => date('Y-m-d', strtotime('-3 months'))
]);
}
});
```
### Taxonomy Query Optimization
```php
// Exclude child terms to reduce query complexity
'tax_query' => [
[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [6],
'include_children' => false // Important for performance
]
]
// Use term_id instead of slug (avoids extra lookup)
'field' => 'term_id', // Faster than 'slug' or 'name'
```
### Meta Query Optimization
Meta queries are expensive - minimize usage:
```php
// ❌ AVOID: Multiple meta conditions
'meta_query' => [
'relation' => 'AND',
['key' => 'color', 'value' => 'red'],
['key' => 'size', 'value' => 'large'],
['key' => 'price', 'compare' => '>=', 'value' => 100]
]
// ✅ BETTER: Use taxonomies for filterable attributes
// Register 'color' and 'size' as taxonomies instead
// ✅ ALTERNATIVE: Offload to ElasticSearch
// Configure ElasticPress to index post meta
```
## Pre-fetching & Cache Priming
### Update Meta Cache in Bulk
```php
// After running WP_Query, prime the meta cache
$query = new WP_Query($args);
$post_ids = wp_list_pluck($query->posts, 'ID');
// Prime meta cache (single query vs N queries)
update_postmeta_cache($post_ids);
// Prime term cache
update_object_term_cache($post_ids, 'post');
// Prime user cache for authors
$author_ids = wp_list_pluck($query->posts, 'post_author');
cache_users($author_ids);
```
### WP_Query Built-in Cache Updates
```php
// These are on by default but be aware:
'update_post_meta_cache' => true, // Updates meta cache after query
'update_post_term_cache' => true, // Updates term cache after query
// Disable if you don't need meta/terms (saves queries)
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
```
## Caching Query Results
### Object Cache for Repeated Queries
```php
function get_featured_posts() {
$cache_key = 'featured_posts_v1';
$posts = wp_cache_get($cache_key, 'my_plugin');
if (false === $posts) {
$query = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 5,
'meta_key' => 'featured',
'meta_value' => '1'
]);
$posts = $query->posts;
wp_cache_set($cache_key, $posts, 'my_plugin', HOUR_IN_SECONDS);
}
return $posts;
}
// Invalidate when posts are updated
add_action('save_post', function($post_id) {
wp_cache_delete('featured_posts_v1', 'my_plugin');
});
```
### Static Variable Caching (Request-Scoped)
```php
function get_expensive_data($id) {
static $cache = [];
if (!isset($cache[$id])) {
$cache[$id] = expensive_computation($id);
}
return $cache[$id];
}
```
## ElasticSearch Offloading
For complex searches, offload to ElasticSearch via ElasticPress:
### When to Offload
- Full-text search queries
- Complex meta queries
- Faceted search / filtering
- Large result sets with sorting
- Aggregations / analytics
### When NOT to Offload
- Simple primary key lookups
- Transactional data requiring ACID
- Data requiring immediate consistency
- Small data sets
```php
// ElasticPress automatically intercepts WP_Query
// for search queries when configured
// Force MySQL for specific queries
$query = new WP_Query([
'ep_integrate' => false, // Skip ElasticSearch
// ... args
]);
```
## Analyzing Queries with EXPLAIN
Use EXPLAIN to identify slow queries:
```sql
EXPLAIN SELECT * FROM wp_posts
WHERE post_type = 'post'
AND post_status = 'publish'
ORDER BY post_date DESC
LIMIT 10;
```
### Key EXPLAIN Indicators
| Column | Good Value | Bad Value |
|--------|------------|-----------|
| `type` | `const`, `eq_ref`, `ref`, `range` | `ALL` (full table scan) |
| `key` | Named index | `NULL` (no index used) |
| `rows` | Small number | Large number |
| `Extra` | `Using index` | `Using filesort`, `Using temporary` |
### Common Optimization Actions
```sql
-- Add index for frequently queried meta key
ALTER TABLE wp_postmeta ADD INDEX meta_key_value (meta_key, meta_value(50));
-- Add composite index
ALTER TABLE wp_posts ADD INDEX type_status_date (post_type, post_status, post_date);
```
## Query Monitor Integration
Use Query Monitor plugin to identify:
1. **Slow queries** - Queries over threshold
2. **Duplicate queries** - Same query run multiple times
3. **Queries by component** - Which plugin/theme caused each query
4. **Query count** - Total queries per page load
### Target Metrics
| Metric | Target | Concern |
|--------|--------|---------|
| Total queries | < 50 | > 100 |
| Duplicate queries | 0 | > 5 |
| Slowest query | < 50ms | > 100ms |
| Total query time | < 100ms | > 500ms |
## Common Query Patterns
### Get Latest Posts (Optimized)
```php
$query = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 10,
'no_found_rows' => true,
'update_post_term_cache' => false, // If not displaying categories
]);
```
### Get Posts by IDs (Optimized)
```php
$query = new WP_Query([
'post__in' => $post_ids,
'posts_per_page' => count($post_ids),
'orderby' => 'post__in', // Preserve ID order
'no_found_rows' => true,
'ignore_sticky_posts' => true
]);
```
### Check if Posts Exist (Optimized)
```php
// Don't fetch full posts just to check existence
$query = new WP_Query([
'post_type' => 'product',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false
]);
$has_products = $query->have_posts();
```
### Count Posts (Optimized)
```php
// Use wp_count_posts() for simple counts
$counts = wp_count_posts('post');
$published = $counts->publish;
// For filtered counts, use found_posts
$query = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 1, // Minimize actual retrieval
'category_name' => 'news',
// no_found_rows must be FALSE to get found_posts
]);
$total = $query->found_posts;
```