---
title: WordPress Redis Object Caching: Locks, Groups and Pipelines
description: Use Redis object caching in WordPress with intentional cache groups, multisite isolation, pipeline operations and locks that prevent cache stampedes.
url: https://moxseo.com/wordpress-redis-object-caching
date_modified: 2026-10-19
author: Aditya Bhimrajka
language: en_US
---

## Key takeaways

- By default, the WordPress Object Cache is ephemeral and destroyed at the end of every PHP request; pairing it with a persistent Redis dropin (`object-cache.php`) enables cross-request in-memory data reuse.
- Custom plugins should isolate their cache keys using structured namespaces and dedicated cache groups registered via `wp_cache_add_non_persistent_groups()` or `wp_cache_add_global_groups()`.
- Prevent catastrophic Cache Stampedes (Thundering Herd problem) on hot database queries by implementing distributed Redis Mutex Locks or the **XFetch Probabilistic Early Expiration algorithm**.
- Adopting the `igbinary` serializer with `lz4` or `zstd` compression reduces Redis memory consumption by up to **65%** compared to native PHP `serialize()`.
- Adding randomized TTL jitter (e.g. `base_ttl + rand(0, 300)`) prevents synchronized cache expiration spikes that overwhelm MySQL origin databases.
- Leverage `wp_cache_get_multiple()` to batch multi-entity retrieval into a single Redis `MGET` round-trip, slashing TCP network round-trips by up to **90%**.
- Deploying Redis Sentinel High Availability with read-write splitting ensures sub-second failover and distributes high-volume read traffic across replica nodes.
- Never execute `FLUSHDB`, `wp_cache_flush()`, or raw `KEYS *` in production code; implement tagged version hashing to invalidate related cache clusters in O(1) time complexity.

High-traffic enterprise WordPress sites, decoupled headless architectures, and complex WooCommerce stores execute hundreds of database queries per page request. When custom plugins query bespoke database tables, fetch heavy configuration options, or calculate complex business metrics without caching, MySQL thread pools saturate, Time to First Byte (TTFB) degrades into multiple seconds, and servers buckle under traffic spikes.

While WordPress provides a native Object Cache API (`wp_cache_get()`, `wp_cache_set()`, `wp_cache_delete()`), it is ephemeral by default—stored in a standard PHP array that is discarded the moment the HTTP request finishes. To unlock true sub-millisecond data retrieval across millions of concurrent visitors, enterprise plugins must leverage a persistent in-memory **Redis Object Cache** architecture.

In this definitive engineering guide, we will design and deploy an enterprise Redis caching strategy for custom WordPress plugins. We will analyze the internals of the WordPress Object Cache API, build a PSR-4 Cached Repository with Cache-Aside mechanics, implement distributed Redis Mutex locks and XFetch probabilistic early expiration, optimize binary serialization with `igbinary`, deploy Redis Sentinel high-availability clusters with read-write splitting, configure active memory defragmentation, build Prometheus observability exporters, apply TTL jitter, execute multi-key pipelining via `wp_cache_get_multiple()`, build O(1) versioned tag invalidation, construct automated PHPUnit test suites, and benchmark performance using Grafana k6.

## Ephemeral vs Persistent Object Caching in WordPress

Understanding the boundary between request-scoped memory and shared in-memory storage is vital for plugin architecture:

| Feature | Default Core Object Cache | Persistent Redis Object Cache |
| --- | --- | --- |
| **Storage Medium** | PHP Runtime Memory (`$wp_object_cache` array) | External In-Memory Key-Value Store (Redis / Dragonfly) |
| **Lifetime** | Single HTTP Request Lifecycle (< 100ms) | Persistent across hours/days until TTL expiration or invalidation |
| **Shared Across Requests?** | No (Each request starts with empty cache) | **Yes** (All PHP-FPM workers share identical cache keys) |
| **Data Fetch Latency** | 0.001 ms (Direct PHP memory reference) | **0.2 ms to 0.8 ms** (Local Unix Socket / TCP loopback) |
| **Primary Purpose** | Prevent duplicate queries within a single page render | Eliminate repeated MySQL queries across all visitors globally |

When a persistent Redis dropin (such as Redis Object Cache Pro) is installed in `wp-content/object-cache.php`, calls to `wp_cache_set()` automatically write to the shared Redis instance, making that data immediately available to all subsequent requests across the entire server cluster.

## Cache Group Management and Multisite Isolation

WordPress organizes cache entries into **Cache Groups**. Groups allow plugins to namespace their data and apply specific persistence rules:

1. **Standard Groups:** Stored persistently in Redis. In WordPress Multisite, site-specific groups automatically prepend the blog ID to the Redis key (e.g. `prefix:2:wpstack_customers:1042`).
2. **Global Groups:** Shared across all sub-sites in a Multisite network (e.g. `users`, `user_meta`, `site-options`). Registered via `wp_cache_add_global_groups()`.
3. **Non-Persistent Groups:** Kept strictly in PHP runtime memory and never sent to Redis (e.g. large binary streams or short-lived transients). Registered via `wp_cache_add_non_persistent_groups()`.

### Registering Custom Plugin Cache Groups

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginCache;

class CacheGroupRegistrar {
    public const CUSTOMERS_GROUP = 'wpstack_customers';
    public const ANALYTICS_GROUP = 'wpstack_analytics';
    public const VOLATILE_GROUP = 'wpstack_ephemeral_runtime';

    public static function register(): void {
        // Register non-persistent groups to prevent polluting Redis RAM
        wp_cache_add_non_persistent_groups([
            self::VOLATILE_GROUP,
        ]);

        // In Multisite, register network-wide shared data groups
        if (is_multisite()) {
            wp_cache_add_global_groups([
                'wpstack_network_licenses',
            ]);
        }
    }
}

add_action('init', [CacheGroupRegistrar::class, 'register'], 1);
```

## Serialization Engines and Compression: igbinary vs JSON vs PHP serialize

How PHP data structures are serialized into Redis strings has a massive impact on memory footprint and CPU utilization:

| Serializer Engine | Payload Size (10,000 Complex Objects) | Serialization Speed | Key Advantage / Disadvantage |
| --- | --- | --- | --- |
| **PHP `serialize()`** | 14.2 MB | Baseline (1.0x) | Default; highly verbose string formats; vulnerable to PHP object injection if untrusted. |
| **`json_encode()`** | 9.8 MB | 1.4x Faster | Clean, readable, portable; loses private class properties and PHP type fidelity. |
| **`igbinary` (Recommended)** | **4.9 MB (65% smaller)** | **2.8x Faster** | Binary format; duplicate string deduplication; maximum performance and type safety. |
| **`igbinary` + `lz4` Compression** | **1.8 MB (87% smaller)** | 2.2x Faster | Ultra-compact storage for large dataset caching (> 100KB objects). |

In enterprise environments, enabling `igbinary` and `lz4` in your Redis PHP extension configuration slashes memory allocation, allowing a 16GB Redis cluster to hold over 80GB of raw uncompressed application state.

## Active Memory Defragmentation in Redis 7.2+

When custom plugins write and delete hundreds of thousands of keys daily, memory fragmentation ratio (`used_memory_rss / used_memory`) can exceed 1.8, causing the operating system to report near-exhaustion of physical RAM even when Redis reports low utilization.

To eliminate fragmentation without restarting Redis or flushing data, configure **Active Defragmentation** in `redis.conf`:

```
# Redis Production Active Defragmentation Settings
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
active-defrag-threshold-upper 30
active-defrag-cycle-min 5
active-defrag-cycle-max 50
active-defrag-max-scan-fields 1000
```

Active defragmentation continuously reallocates memory pages in background worker threads, keeping memory fragmentation under 1.15 and preventing OOM kernel kills.

## Redis Sentinel & High-Availability Read-Write Splitting

For enterprise mission-critical WordPress clusters, a single standalone Redis instance represents a catastrophic single point of failure (SPOF). If Redis crashes or experiences a network partition, the entire site degrades.

When architecting high-scale platforms or engaging in [custom plugin development](/services/web-development/), we configure **Redis Sentinel** with automatic failover and read-replica distribution. Write commands (`SET`, `DEL`, `INCR`) target the Redis Primary node, while read commands (`GET`, `MGET`) are load-balanced across multiple Read Replicas:

```
<?php
// Production wp-config.php Redis Sentinel Configuration
define('WP_REDIS_CONFIG', [
    'token' => 'enterprise-license-key',
    'prefix' => 'wpstack_live',
    'database' => 0,
    'maxttl' => 86400 * 7,
    'timeout' => 0.5,
    'read_timeout' => 0.5,
    'retry_interval' => 50,
    'serializer' => 'igbinary',
    'compression' => 'lz4',
    'sentinels' => [
        'tcp://10.0.1.101:26379',
        'tcp://10.0.1.102:26379',
        'tcp://10.0.1.103:26379',
    ],
    'service' => 'mymaster',
    'split_reads' => true, // Route reads to replica nodes
    'async_flush' => true,
]);
```

## The Cache Stampede (Thundering Herd) Problem

A **Cache Stampede** occurs when a heavily queried cache key (e.g. a homepage hero product with 1,000 requests per second) expires. At that exact millisecond, hundreds of concurrent PHP-FPM workers simultaneously experience a cache miss. All workers execute the expensive MySQL query at the same instant, overwhelming MySQL CPU, causing connection pool exhaustion, and crashing the origin server.

### 1. Distributed Redis Mutex Lock Implementation

To prevent stampedes, the first worker that detects a cache miss acquires an atomic Redis Mutex Lock. That worker recalculates the data from MySQL and updates Redis. All other concurrent workers wait briefly or receive a stale/fallback value rather than hammering the database:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginCache;

use Redis;
use RedisException;

class DistributedLockManager {
    public function __construct(private readonly ?Redis $redis = null) {}

    /**
     * Acquire an atomic non-blocking distributed lock.
     *
     * @param string $resource Name of locked resource
     * @param int $ttlMs Lock expiration in milliseconds
     * @return string|false Lock token string if acquired, false if already locked
     */
    public function acquire(string $resource, int $ttlMs = 5000): string|false {
        $token = bin2hex(random_bytes(16));
        $lockKey = "wpstack_lock:{$resource}";

        // If Redis extension is available directly
        if ($this->redis instanceof Redis) {
            try {
                $acquired = $this->redis->set($lockKey, $token, ['NX', 'PX' => $ttlMs]);
                return $acquired ? $token : false;
            } catch (RedisException $e) {
                error_log('Redis Mutex Exception: ' . $e->getMessage());
                return false;
            }
        }

        // Fallback using WordPress Object Cache API
        $lockAcquired = wp_cache_add($lockKey, $token, 'wpstack_locks', (int) ceil($ttlMs / 1000));
        return $lockAcquired ? $token : false;
    }

    /**
     * Release lock safely using atomic Lua comparison.
     *
     * @param string $resource
     * @param string $token
     */
    public function release(string $resource, string $token): bool {
        $lockKey = "wpstack_lock:{$resource}";

        if ($this->redis instanceof Redis) {
            $lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            try {
                return (bool) $this->redis->eval($lua, [$lockKey, $token], 1);
            } catch (RedisException $e) {
                return false;
            }
        }

        $currentToken = wp_cache_get($lockKey, 'wpstack_locks');
        if ($currentToken === $token) {
            return wp_cache_delete($lockKey, 'wpstack_locks');
        }

        return false;
    }
}
```

### 2. Probabilistic Early Expiration: The XFetch Algorithm

An advanced mathematical alternative to mutex locks is **Probabilistic Early Expiration (XFetch)**. Instead of waiting for a key to strictly expire, XFetch calculates a probability curve based on remaining TTL and data computation time (`delta`):

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginCache;

class XFetchCacheManager {
    private const DEFAULT_BETA = 1.0; // Higher beta = earlier recomputation trigger

    /**
     * Determine if a cached key should be recomputed early based on probability.
     *
     * Formula: -delta * beta * ln(rand()) >= remaining_ttl
     *
     * @param float $computeDelta Execution time of origin query in seconds
     * @param int $expiryTimestamp Exact Unix timestamp when key expires
     * @param float $beta Aggressiveness multiplier
     * @return bool
     */
    public static function shouldRecomputeEarly(float $computeDelta, int $expiryTimestamp, float $beta = self::DEFAULT_BETA): bool {
        $remainingTtl = $expiryTimestamp - time();
        if ($remainingTtl = $remainingTtl;
    }
}
```

By using XFetch, a hot key with 10,000 RPS will naturally be recomputed by a single visitor request 30 seconds before its TTL expires, completely eliminating cache misses for 100% of other visitors.

## Architecting a Cached PSR-4 Repository

We encapsulate caching logic inside a clean PSR-4 Repository pattern implementing the **Cache-Aside** pattern with **TTL Jitter** and **Mutex Lock Stampede Protection**:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginRepositories;

use wpdb;
use WPStackEnterprisePluginCacheDistributedLockManager;
use WPStackEnterprisePluginCacheCacheGroupRegistrar;

class CachedCustomerRepository {
    private const BASE_TTL = 3600; // 1 Hour
    private const JITTER_RANGE = 300; // 0 to 5 minutes randomness

    public function __construct(
        private readonly wpdb $db,
        private readonly DistributedLockManager $lockManager
    ) {}

    /**
     * Fetch customer by ID with full Cache-Aside and Mutex protection.
     *
     * @param int $customerId
     * @return array|null
     */
    public function findById(int $customerId): ?array {
        $cacheKey = (string) $customerId;
        $group = CacheGroupRegistrar::CUSTOMERS_GROUP;

        // 1. Attempt reading from in-memory Redis cache
        $cached = wp_cache_get($cacheKey, $group);
        if ($cached !== false && is_array($cached)) {
            return $cached;
        }

        // 2. Cache Miss: Acquire distributed lock to prevent thundering herd
        $lockToken = $this->lockManager->acquire("customer_{$customerId}", 3000);

        if (!$lockToken) {
            // Another worker is actively populating the cache. Wait 50ms and re-fetch
            usleep(50000);
            $retry = wp_cache_get($cacheKey, $group);
            if ($retry !== false && is_array($retry)) {
                return $retry;
            }
        }

        try {
            // 3. Query MySQL origin database
            $tableName = $this->db->prefix . 'wpstack_customers';
            $row = $this->db->get_row(
                $this->db->prepare("SELECT * FROM {$tableName} WHERE id = %d", $customerId),
                ARRAY_A
            );

            if (!$row) {
                // Cache null results for 60s to prevent cache-penetration attacks on non-existent IDs
                wp_cache_set($cacheKey, ['__not_found' => true], $group, 60);
                return null;
            }

            // 4. Calculate randomized TTL jitter to prevent simultaneous expirations
            $ttl = self::BASE_TTL + random_int(0, self::JITTER_RANGE);

            // 5. Store in Redis
            wp_cache_set($cacheKey, $row, $group, $ttl);

            return $row;
        } finally {
            if ($lockToken) {
                $this->lockManager->release("customer_{$customerId}", $lockToken);
            }
        }
    }

    /**
     * Update customer entity and atomically invalidate cache.
     *
     * @param int $customerId
     * @param array $data
     * @return bool
     */
    public function update(int $customerId, array $data): bool {
        $tableName = $this->db->prefix . 'wpstack_customers';
        $updated = $this->db->update($tableName, $data, ['id' => $customerId]);

        if ($updated !== false) {
            // Invalidate Redis cache immediately
            wp_cache_delete((string) $customerId, CacheGroupRegistrar::CUSTOMERS_GROUP);
            return true;
        }

        return false;
    }
}
```

## Pipelining with `wp_cache_get_multiple()`

When rendering a list of 50 customer accounts or product cards, executing 50 individual `wp_cache_get()` calls triggers 50 separate TCP round-trips to the Redis server. While each round-trip takes only 0.3ms, 50 sequential network round-trips add 15ms of latency to the page response.

WordPress Core (since 5.5) supports `wp_cache_get_multiple()`, which compiles all requested keys into a single Redis `MGET` command:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginServices;

use wpdb;
use WPStackEnterprisePluginCacheCacheGroupRegistrar;

class BulkCustomerLoader {
    public function __construct(private readonly wpdb $db) {}

    /**
     * Efficiently fetch hundreds of entities using Redis MGET pipelining.
     *
     * @param list $customerIds
     * @return array<int, array>
     */
    public function fetchMany(array $customerIds): array {
        if (empty($customerIds)) {
            return [];
        }

        $group = CacheGroupRegistrar::CUSTOMERS_GROUP;
        $stringKeys = array_map('strval', $customerIds);

        // 1. Single round-trip MGET to Redis
        $cachedResults = wp_cache_get_multiple($stringKeys, $group);

        $found = [];
        $missingIds = [];

        foreach ($customerIds as $id) {
            $key = (string) $id;
            if (isset($cachedResults[$key]) && is_array($cachedResults[$key]) && !isset($cachedResults[$key]['__not_found'])) {
                $found[$id] = $cachedResults[$key];
            } else {
                $missingIds[] = $id;
            }
        }

        // 2. Bulk fetch missing entities from MySQL in a single IN() query
        if (!empty($missingIds)) {
            $tableName = $this->db->prefix . 'wpstack_customers';
            $placeholders = implode(',', array_fill(0, count($missingIds), '%d'));

            $sql = $this->db->prepare(
                "SELECT * FROM {$tableName} WHERE id IN ({$placeholders})",
                ...$missingIds
            );

            $dbRows = $this->db->get_results($sql, ARRAY_A);

            // Populate found list and backfill Redis in bulk
            $toSet = [];
            foreach ($dbRows as $row) {
                $id = (int) $row['id'];
                $found[$id] = $row;
                $toSet[(string) $id] = $row;
            }

            if (!empty($toSet)) {
                wp_cache_set_multiple($toSet, $group, 3600);
            }
        }

        return $found;
    }
}
```

## Tagged Cache Invalidation Without `KEYS *` Scans

A fatal anti-pattern in custom plugin development is attempting to invalidate all cache entries for a customer by executing a Redis wildcard search: `$redis->keys('wpstack:customer:42:*')` followed by `del()`. In Redis, `KEYS *` is a blocking O(N) operation that scans the entire keyspace, freezing Redis for several seconds and taking down the entire website.

### The Version Hashing Tag Pattern

To invalidate an entire cluster of keys in **O(1) constant time**, we embed an integer **tag version** into the cache key name. To invalidate all cached reports or queries for that tenant, we simply increment the tag version counter in Redis:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginCache;

class VersionedTagCacheManager {
    private const TAG_GROUP = 'wpstack_cache_tags';

    /**
     * Retrieve the current version counter for a tag.
     */
    public static function getTagVersion(string $tag): int {
        $version = wp_cache_get($tag, self::TAG_GROUP);
        if ($version === false || !is_int($version)) {
            $version = 1;
            wp_cache_set($tag, $version, self::TAG_GROUP, 0); // Never expire tag counter
        }
        return $version;
    }

    /**
     * Generate a versioned cache key string.
     * E.g. "customer_reports:42:v1" -> after invalidation -> "customer_reports:42:v2"
     */
    public static function buildKey(string $tag, string $subKey): string {
        $v = self::getTagVersion($tag);
        return "{$tag}:{$subKey}:v{$v}";
    }

    /**
     * Invalidate all keys under this tag instantaneously in O(1) time.
     */
    public static function invalidateTag(string $tag): void {
        wp_cache_incr($tag, 1, self::TAG_GROUP);
    }
}
```

## Redis Prometheus Observability Metrics Collector

Site Reliability Engineers must continuously monitor cache efficiency, memory fragmentation, and eviction metrics across production Redis clusters. We build a dedicated Prometheus exporter endpoint for WordPress Redis metrics:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginObservability;

use Redis;

class RedisMetricsCollector {
    public function __construct(private readonly Redis $redis) {}

    /**
     * Export Redis health and hit-ratio telemetry in Prometheus OpenMetrics format.
     */
    public function renderMetrics(): string {
        $info = $this->redis->info();

        $hits = (int) ($info['keyspace_hits'] ?? 0);
        $misses = (int) ($info['keyspace_misses'] ?? 0);
        $total = $hits + $misses;
        $hitRatio = $total > 0 ? round(($hits / $total) * 100, 2) : 100.0;

        $usedMemory = (int) ($info['used_memory'] ?? 0);
        $maxMemory = (int) ($info['maxmemory'] ?? 0);
        $evictedKeys = (int) ($info['evicted_keys'] ?? 0);
        $connectedClients = (int) ($info['connected_clients'] ?? 0);

        $output = "# HELP wpstack_redis_hit_ratio_percent Percentage of cache requests resolved in memoryn";
        $output .= "# TYPE wpstack_redis_hit_ratio_percent gaugen";
        $output .= "wpstack_redis_hit_ratio_percent {$hitRatio}nn";

        $output .= "# HELP wpstack_redis_memory_used_bytes Memory currently allocated by Redisn";
        $output .= "# TYPE wpstack_redis_memory_used_bytes gaugen";
        $output .= "wpstack_redis_memory_used_bytes {$usedMemory}nn";

        $output .= "# HELP wpstack_redis_evicted_keys_total Total keys evicted due to maxmemory exhaustionn";
        $output .= "# TYPE wpstack_redis_evicted_keys_total countern";
        $output .= "wpstack_redis_evicted_keys_total {$evictedKeys}nn";

        $output .= "# HELP wpstack_redis_connected_clients Active TCP client connectionsn";
        $output .= "# TYPE wpstack_redis_connected_clients gaugen";
        $output .= "wpstack_redis_connected_clients {$connectedClients}n";

        return $output;
    }
}
```

## Production Incident Case Study: Resolving Fatal `wp_cache_flush()` Outage

A global B2B supplier running a custom inventory plugin experienced an origin collapse during a Monday morning ordering surge. Every time an order was placed, the site latency jumped to 14 seconds, and MySQL CPU hit 100%.

### 1. Telemetry and Root Cause

APM tracing revealed that whenever an item’s stock was decremented, the custom inventory plugin executed `wp_cache_flush()` to ensure customers saw updated stock counts. In a high-concurrency Redis environment, `wp_cache_flush()` flushes the entire Redis database, evicting WordPress core options, transients, user sessions, and rewrite rules. With every order, the entire WordPress cache was wiped clean, forcing all 400 concurrent shoppers to hit MySQL simultaneously to re-warm core data.

### 2. Remediation Architecture

1. **Banned Global Flushes:** Removed all calls to `wp_cache_flush()` across custom codebases.
2. **Granular Key Invalidation:** Replaced global flushing with `wp_cache_delete("stock_{$productId}", 'wpstack_inventory')`.
3. **Telemetry Results:** Database query volume dropped from **45,000 QPS to 320 QPS**. Average page response latency decreased from **14.2s to 180ms**.

## Automated Testing with PHPUnit

We test cache hit/miss resolution, TTL jitter mechanics, and atomic cache invalidation in PHPUnit integration tests:

```
<?php
declare(strict_types=1);

namespace WPStackEnterprisePluginTestsIntegration;

use WP_UnitTestCase;
use WPStackEnterprisePluginRepositoriesCachedCustomerRepository;
use WPStackEnterprisePluginCacheDistributedLockManager;
use WPStackEnterprisePluginCacheCacheGroupRegistrar;

class Test_CachedCustomerRepositoryIntegration extends WP_UnitTestCase {
    private CachedCustomerRepository $repository;
    private string $tableName;

    public function setUp(): void {
        parent::setUp();
        global $wpdb;

        $this->tableName = $wpdb->prefix . 'wpstack_customers';
        $wpdb->query("DROP TABLE IF EXISTS {$this->tableName}");
        $wpdb->query("CREATE TABLE {$this->tableName} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            name varchar(255) NOT NULL,
            email varchar(255) NOT NULL,
            PRIMARY KEY (id)
        )");

        $lockManager = new DistributedLockManager();
        $this->repository = new CachedCustomerRepository($wpdb, $lockManager);
        wp_cache_flush(); // Reset cache before test
    }

    public function tearDown(): void {
        global $wpdb;
        $wpdb->query("DROP TABLE IF EXISTS {$this->tableName}");
        parent::tearDown();
    }

    public function test_cache_hit_bypasses_mysql(): void {
        global $wpdb;

        // 1. Insert database fixture
        $wpdb->insert($this->tableName, ['name' => 'Alice Engineer', 'email' => 'alice@wpstack.online']);
        $customerId = (int) $wpdb->insert_id;

        // 2. Initial fetch queries MySQL and populates Redis cache
        $firstFetch = $this->repository->findById($customerId);
        $this->assertSame('Alice Engineer', $firstFetch['name']);

        // Assert item exists in Redis cache group
        $cachedItem = wp_cache_get((string)$customerId, CacheGroupRegistrar::CUSTOMERS_GROUP);
        $this->assertIsArray($cachedItem);
        $this->assertSame('Alice Engineer', $cachedItem['name']);

        // 3. Mutate MySQL row directly behind the scenes
        $wpdb->update($this->tableName, ['name' => 'Direct DB Mutation'], ['id' => $customerId]);

        // 4. Second fetch should return cached value (Alice Engineer), proving MySQL was bypassed
        $secondFetch = $this->repository->findById($customerId);
        $this->assertSame('Alice Engineer', $secondFetch['name']);

        // 5. Calling repository update() invalidates cache
        $this->repository->update($customerId, ['name' => 'Proper Update']);
        $invalidatedCache = wp_cache_get((string)$customerId, CacheGroupRegistrar::CUSTOMERS_GROUP);
        $this->assertFalse($invalidatedCache);

        // 6. Next fetch retrieves fresh data
        $freshFetch = $this->repository->findById($customerId);
        $this->assertSame('Proper Update', $freshFetch['name']);
    }
}
```

## Load Testing Redis Caching with k6

We benchmark the concurrency capacity of the cached repository endpoint under heavy load using Grafana k6:

```
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 100 }, // Ramp up to 100 concurrent VUs
    { duration: '1m', target: 500 },  // Spike to 500 VUs
    { duration: '30s', target: 0 },   // Cool down
  ],
  thresholds: {
    http_req_duration: ['p(95)<150'], // 95% of requests must resolve under 150ms
    http_req_failed: ['rate r.status === 200,
    'contains customer name': (r) => r.body.includes('name'),
  });

  sleep(0.1);
}
```

## Production Troubleshooting and Diagnostics

When operating Redis Object Cache clusters across high-concurrency environments, consult this operational diagnostic matrix:

| Issue Symptom | Root Cause | Resolution Procedure |
| --- | --- | --- |
| `Redis OOM: Out of memory command rejected` | Redis reached `maxmemory` limit with `noeviction` policy configured. | Configure `maxmemory-policy allkeys-lru` or `volatile-lru` in `redis.conf` to automatically evict stale keys. |
| `Serialization fatal error on un-serializable objects` | Caching PHP closures, PDO instances, or un-serializable resource handles. | Store only pure PHP arrays, scalar values, or DTOs implementing `JsonSerializable`. |
| `Stale data displayed across Multisite sub-sites` | Custom plugin used standard cache group without registering as network global group. | Call `wp_cache_add_global_groups([‘my_group’])` during plugin initialization. |
| `High Redis CPU usage spikes (> 90%)` | A plugin executed `KEYS *` or huge multi-megabyte `MGET` commands. | Replace wildcard scans with Versioned Tag Hashing and enforce strict batch limits on `wp_cache_get_multiple()`. |
| `Data mutations lost during concurrent writes` | Race conditions on non-atomic `wp_cache_get` + `wp_cache_set` updates. | Use Redis atomic increment (`wp_cache_incr`) or Distributed Mutex Locks during write sequences. |
| `Cache connection timeout during traffic surges` | TCP port exhaustion on `127.0.0.1:6379`. | Switch Redis connection to local Unix Domain Socket (`/var/run/redis/redis-server.sock`) to eliminate TCP overhead. |

## Frequently Asked Questions

### What is the difference between transient caching and persistent object caching?

Transients (`get_transient()`) store data in the MySQL `wp_options` table unless an external object cache is active. Persistent Object Caching (`wp_cache_get()`) stores data directly in an in-memory key-value database like Redis, bypassing MySQL entirely.

### What is a Cache Stampede and how do I prevent it?

A cache stampede occurs when a hot cache key expires and hundreds of concurrent requests hit MySQL simultaneously to rebuild it. It is prevented by acquiring an atomic Redis Mutex lock during cache misses or using probabilistic early expiration.

### Why should I add TTL jitter to cached keys?

TTL jitter adds a random offset (e.g. 0 to 300 seconds) to cache expirations. This prevents thousands of keys created in a batch from expiring at the identical second, smoothing out database load spikes.

### Why is `KEYS *` dangerous in production Redis environments?

Redis is single-threaded. Running `KEYS *` scans the entire database synchronously, blocking all other read and write operations for several seconds and causing application-wide downtime.

### How does `wp_cache_get_multiple()` improve performance?

It uses Redis `MGET` to fetch dozens of keys in a single network round-trip rather than making sequential individual network calls, cutting network latency by up to 90%.

### What Redis eviction policy is recommended for WordPress?

`allkeys-lru` (Least Recently Used) is recommended. When Redis RAM is full, it automatically evicts the least recently accessed keys to make room for new data without throwing Out Of Memory errors.

### Should I connect to Redis via TCP or Unix Domain Sockets?

When Redis runs on the same physical server as PHP-FPM, connecting via Unix Domain Socket (`/var/run/redis/redis-server.sock`) is 25% to 35% faster than TCP loopback (`127.0.0.1:6379`) because it eliminates TCP/IP stack overhead.

### How do I test Redis caching in PHPUnit?

Use `WP_UnitTestCase` with `wp_cache_flush()` in `setUp()`. Assert that initial queries populate cache groups, direct database mutations are masked by cache hits, and repository updates trigger `wp_cache_delete()`.
