Introduction
Redis Sentinel is the standard high-availability mechanism for production Redis deployments — it automatically handles master election, failover notification, and client redirection when a master node goes down. But under one specific and deceptively common scenario — a burst of PHP-FPM object-cache reads during a Sentinel-triggered failover — your WordPress or PHP application can enter a catastrophic feedback loop: stale data served, READONLY errors cascading across hundreds of FPM workers, and the dreaded cache stampede that overwhelms your database just when your Redis layer is most needed.
This article dissects the full failure chain from first symptoms to verified resolution, providing real terminal commands, annotated log excerpts, and before/after configuration diffs.
The Failure Anatomy: What Actually Happens
Understanding the sequence of events is critical before touching any configuration.
[T+0] Network blip isolates Redis master (172.16.1.10:6379) from two Sentinels
[T+2s] Sentinels mark master +sdown (subjective down)
[T+5s] Quorum met -> +odown -> elect new master (172.16.1.11:6379)
[T+5s] Old master still accepts writes from PHP-FPM workers (split-brain window)
[T+6s] Sentinel broadcasts switch-master event
[T+6s] PHP-FPM workers using persistent connections to OLD master -> READONLY errors
[T+6s] WordPress Object Cache plugin retries -> falls through to MySQL
[T+6s] 200 simultaneous cache misses -> 200 simultaneous MySQL queries (stampede)
[T+8s] MySQL max_connections exhausted -> WordPress shows "Error establishing a database connection"
[T+10s] Network recovers; old master rejoins as replica -> data written in split-brain window is LOST
This cascading failure is the root cause of most “Redis made things worse during an outage” incidents.
Phase 1: Identifying the Split-Brain Window
Step 1.1 — Check Sentinel Logs for Conflicting Leadership
SSH into each Sentinel node and examine recent activity:
# On sentinel-1 (172.16.1.20)
grep -E "(switch-master|sdown|odown|\+elected)" /var/log/redis/sentinel.log | tail -50
# On sentinel-2 (172.16.1.21)
grep -E "(switch-master|sdown|odown|\+elected)" /var/log/redis/sentinel.log | tail -50
Pathological output indicating split-brain:
1725012005.123 [26310] [SENTINEL] +sdown master mymaster 172.16.1.10 6379
1725012007.441 [26310] [SENTINEL] +odown master mymaster 172.16.1.10 6379 #quorum 2/2
1725012007.442 [26310] [SENTINEL] +elect-leader master mymaster 172.16.1.10 6379
1725012007.900 [26310] [SENTINEL] +switch-master mymaster 172.16.1.10 6379 172.16.1.11 6379
# --- Meanwhile on the isolated old master ---
1725012006.001 [6379] * Connection from 172.16.0.5:48201 accepted <- PHP-FPM still writing!
1725012006.003 [6379] * SET wp_options_alloptions ...
The gap between +sdown at T+2s and switch-master at T+6s is the split-brain window — during this time the old master still accepts writes from any client that has not received the redirect.
Step 1.2 — Confirm Quorum Configuration
redis-cli -p 26379 SENTINEL masters
Look specifically at the quorum field:
1) "name"
2) "mymaster"
3) "ip"
4) "172.16.1.10"
...
27) "quorum"
28) "1" <- DANGER: quorum of 1 on a 2-sentinel setup = no real consensus
A quorum of 1 with only 2 Sentinels means a single Sentinel can trigger a failover unilaterally — a classic misconfiguration that guarantees split-brain during any network partition.
Step 1.3 — Inspect the PHP-FPM Error Landscape
# Grep PHP-FPM slow log and error log for Redis errors
grep -E "(READONLY|Connection refused|Redis server went away|could not connect)" \
/var/log/php-fpm/www-error.log | \
awk '{print $1}' | sort | uniq -c | sort -rn | head -20
Typical output during a failover burst:
847 [11-Sep-2026] # 847 errors in one minute
# Count unique Redis error types
grep "Redis" /var/log/php-fpm/www-error.log | grep -oP "(?<=Redis: ).*?(?=\s)" | sort | uniq -c
391 READONLY
312 Connection refused to 172.16.1.10:6379
144 LOADING Redis is loading the dataset in memory
The LOADING errors reveal a second problem: after failover, the promoted replica is still loading its RDB snapshot — but PHP-FPM is already hammering it.
Phase 2: Diagnosing the PHP-FPM Cache Stampede
Step 2.1 — Correlate FPM Worker Saturation with Redis Errors
# Watch PHP-FPM pool status in real-time
watch -n1 "curl -s http://127.0.0.1/fpm-status?full | grep -E '(active|idle|max active|requests)'"
During a stampede:
pool: www
active processes: 200 <- all workers busy
idle processes: 0
max active processes: 200
max children reached: 14 <- FPM hit the ceiling 14 times
Step 2.2 — Identify Cache Miss Rate via Redis INFO
# Before failover (baseline)
redis-cli -h 172.16.1.11 INFO stats | grep -E "keyspace_(hits|misses)"
# During stampede
redis-cli -h 172.16.1.11 INFO stats | grep -E "keyspace_(hits|misses)"
Before (healthy):
keyspace_hits:98234123
keyspace_misses:412309
# hit rate ~99.58%
During stampede:
keyspace_hits:98234891
keyspace_misses:6109820
# hit rate collapsed to ~94.1% — millions of misses in seconds
Step 2.3 — Confirm Database Connection Exhaustion
mysql -u root -p -e "SHOW GLOBAL STATUS LIKE 'Max_used_connections';"
mysql -u root -p -e "SHOW GLOBAL STATUS LIKE 'Connection_errors_max_connections';"
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| Max_used_connections | 312 | <- far exceeds max_connections (150)
| Connection_errors_max_connections | 187 |
+------------------------+-------+
This confirms the classic stampede kill chain: Redis misses -> MySQL overload -> application errors.
Phase 3: Root Cause Analysis Summary
| Layer | Root Cause | Impact |
|---|---|---|
| Sentinel | Quorum set to 1 on a 2-node setup |
Unilateral failover, no consensus protection |
| Sentinel | down-after-milliseconds too low (500ms) |
False positives on transient network jitter |
| Redis | min-replicas-to-write 0 on master |
Accepts writes with zero replicas acknowledged |
| PHP (phpredis) | Persistent connections with no reconnect-on-failure | Workers cling to dead/demoted master |
| WordPress | Object cache with no stampede protection | All misses hit DB simultaneously |
| MySQL | max_connections 150 |
Saturated under stampede load |
Phase 4: Concrete Fixes — Before/After Configurations
Fix 4.1 — Correct Sentinel Quorum and Timing
File: /etc/redis/sentinel.conf (all Sentinel nodes)
Before:
sentinel monitor mymaster 172.16.1.10 6379 1
sentinel down-after-milliseconds mymaster 500
sentinel failover-timeout mymaster 10000
sentinel parallel-syncs mymaster 1
After:
# Always use a majority quorum: (N_sentinels / 2) + 1
sentinel monitor mymaster 172.16.1.10 6379 2
# 5 seconds — tolerates transient network jitter without false positives
sentinel down-after-milliseconds mymaster 5000
# Allow 60s for a full replica to sync before marking failover complete
sentinel failover-timeout mymaster 60000
# Sync replicas one at a time to avoid overwhelming the new master
sentinel parallel-syncs mymaster 1
# Notify on failover so external systems can react
sentinel notification-script mymaster /opt/redis/failover-notify.sh
# Prevent the old master from re-joining as a replica before it is safe
sentinel deny-scripts-reconfig yes
Apply without restart:
redis-cli -p 26379 SENTINEL SET mymaster down-after-milliseconds 5000
redis-cli -p 26379 SENTINEL SET mymaster failover-timeout 60000
# Repeat on each sentinel node
Fix 4.2 — Enforce Minimum Replica Acknowledgement on Master
File: /etc/redis/redis.conf on the master
Before:
min-replicas-to-write 0
min-replicas-max-lag 10
After:
# Refuse writes if no replica has acknowledged within 10 seconds
# This bounds the split-brain write window
min-replicas-to-write 1
min-replicas-max-lag 10
This causes the old master to stop accepting writes within 10 seconds of losing replica connectivity — drastically reducing the split-brain window from “indefinite” to 10 seconds or less.
# Apply live without restart
redis-cli CONFIG SET min-replicas-to-write 1
redis-cli CONFIG SET min-replicas-max-lag 10
Fix 4.3 — Configure PHP (phpredis) for Sentinel-Aware Connections
If you are using phpredis directly (common in WordPress via the Redis Object Cache plugin), replace the direct IP connection with Sentinel-aware configuration.
File: wp-config.php
Before (hardcoded master IP — catastrophic during failover):
define('WP_REDIS_HOST', '172.16.1.10');
define('WP_REDIS_PORT', 6379);
After (Sentinel-aware, with reconnect protection):
define('WP_REDIS_SENTINEL_MASTER', 'mymaster');
define('WP_REDIS_SENTINELS', [
'tcp://172.16.1.20:26379',
'tcp://172.16.1.21:26379',
'tcp://172.16.1.22:26379',
]);
// Aggressive retry settings to survive the failover window
define('WP_REDIS_TIMEOUT', 1.0); // 1s connect timeout
define('WP_REDIS_READ_TIMEOUT', 2.0); // 2s read timeout
define('WP_REDIS_RETRY_INTERVAL', 100); // 100ms between retries
define('WP_REDIS_MAX_RETRIES', 3);
// Disable persistent connections: they skip Sentinel re-discovery on failover
define('WP_REDIS_PERSISTENT', false);
// Graceful degradation: if Redis is unavailable, serve from DB without fatal error
define('WP_REDIS_GRACEFUL', true);
Fix 4.4 — Implement Distributed Mutex to Prevent Cache Stampede
Even with Sentinel configured correctly, the 2–5 second failover window will cause cache misses. Without a mutex, every PHP-FPM worker that hits a miss will independently query MySQL. The fix is a probabilistic early expiration lock (PER lock) using Redis atomic operations.
Add this to /wp-content/mu-plugins/anti-stampede-cache.php:
<?php
/**
* Anti-stampede wrapper using Redis SETNX mutex + stale-while-revalidate
*/
function get_cache_with_lock( string $key, callable $compute_fn, int $ttl = 3600 ): mixed {
$cached = wp_cache_get( $key, 'stampede_safe' );
$lock_key = 'lock_' . $key;
if ( false !== $cached ) {
// Pre-expiry revalidation: refresh during last 10% of TTL
$expiry = wp_cache_get( $key . '_expiry', 'stampede_safe' );
if ( $expiry && ( $expiry - time() ) < ( $ttl * 0.1 ) ) {
if ( wp_cache_add( $lock_key, 1, 'stampede_safe', 30 ) ) {
$fresh = call_user_func( $compute_fn );
wp_cache_set( $key, $fresh, 'stampede_safe', $ttl );
wp_cache_set( $key . '_expiry', time() + $ttl, 'stampede_safe', $ttl );
wp_cache_delete( $lock_key, 'stampede_safe' );
return $fresh;
}
}
return $cached; // Serve stale while another worker refreshes
}
// Full cache miss: compete for exclusive lock via atomic SETNX
if ( ! wp_cache_add( $lock_key, 1, 'stampede_safe', 30 ) ) {
usleep( 150000 ); // 150ms back-off — wait for winner to populate cache
return wp_cache_get( $key, 'stampede_safe' ) ?: null;
}
$value = call_user_func( $compute_fn );
wp_cache_set( $key, $value, 'stampede_safe', $ttl );
wp_cache_set( $key . '_expiry', time() + $ttl, 'stampede_safe', $ttl );
wp_cache_delete( $lock_key, 'stampede_safe' );
return $value;
}
Verify atomic locking works at the Redis CLI level:
redis-cli SET lock_wp_options_alloptions 1 NX PX 30000
# Returns: OK (lock acquired by first caller)
redis-cli SET lock_wp_options_alloptions 1 NX PX 30000
# Returns: (nil) (second caller correctly blocked)
Fix 4.5 — MySQL Headroom and Connection Pooling
File: /etc/my.cnf or /etc/mysql/mariadb.conf.d/50-server.cnf
Before:
[mysqld]
max_connections = 150
wait_timeout = 28800
After:
[mysqld]
max_connections = 500
wait_timeout = 300
interactive_timeout = 300
thread_cache_size = 32
max_connect_errors = 100000
For cPanel servers, also configure ProxySQL in front of MySQL to queue burst connections rather than hard-refusing them:
# Quick ProxySQL connection-pool bootstrap
mysql -u admin -padmin -h 127.0.0.1 -P 6032 <<'EOF'
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES (0,'127.0.0.1',3306);
UPDATE global_variables SET variable_value='500' WHERE variable_name='mysql-max_connections';
LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;
EOF
Phase 5: Verification Commands — Confirming Resolution
5.1 — Verify All Sentinels Agree on the Master
for ip in 172.16.1.20 172.16.1.21 172.16.1.22; do
echo "=== Sentinel $ip ===";
redis-cli -h $ip -p 26379 SENTINEL get-master-addr-by-name mymaster;
done
Expected output (all three pointing to the same master):
=== Sentinel 172.16.1.20 ===
1) "172.16.1.11"
2) "6379"
=== Sentinel 172.16.1.21 ===
1) "172.16.1.11"
2) "6379"
=== Sentinel 172.16.1.22 ===
1) "172.16.1.11"
2) "6379"
5.2 — Simulate a Controlled Failover and Measure RTO
redis-cli -p 26379 SENTINEL failover mymaster
tail -f /var/log/redis/sentinel.log | grep -E "(switch-master|elected|odown)"
With corrected configuration, total recovery time from +sdown to switch-master should be under 15 seconds.
5.3 — Confirm Split-Brain Write Protection Is Active
# On the old master (now a replica): verify it refuses writes
redis-cli -h 172.16.1.10 -p 6379 SET test_key test_value
# Expected: (error) READONLY You can't write against a read only replica.
redis-cli -h 172.16.1.10 CONFIG GET min-replicas-to-write
# Expected: 1
5.4 — Monitor Redis Hit Rate
redis-cli INFO stats | awk '
/keyspace_hits/ { hits=$2 }
/keyspace_misses/ { misses=$2 }
END { printf "Hit rate: %.2f%%\n", (hits/(hits+misses))*100 }
'
Target: >98% hit rate during steady state with graceful (non-fatal) fallthrough during any future failover.
5.5 — Add Prometheus Alerting for Split-Brain Detection
# prometheus/alerts/redis.yml
groups:
- name: redis_sentinel
rules:
- alert: RedisSentinelMasterDisagreement
expr: count(redis_sentinel_master_address) by (master_name) > 1
for: 30s
labels:
severity: critical
annotations:
summary: "Split-brain detected — Sentinels disagree on master"
- alert: RedisCacheHitRateLow
expr: |
rate(redis_keyspace_hits_total[5m]) /
(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])) < 0.95
for: 2m
labels:
severity: warning
annotations:
summary: "Redis hit rate below 95% — possible stampede in progress"
- alert: RedisSentinelQuorumLost
expr: redis_sentinel_sentinels < 2
for: 10s
labels:
severity: critical
annotations:
summary: "Sentinel quorum lost — failover decisions are blind"
Infrastructure Note
Businesses running high-traffic WordPress or Linux workloads benefit enormously from low-latency Redis connectivity — the physics of TCP round-trip time directly add to your down-after-milliseconds window and widen every split-brain risk. On a NVMe Cloud VPS Pakistan or Dedicated Server Pakistan, Redis master-to-sentinel latency stays under 0.5ms on the same local fabric, shrinking the split-brain window to its theoretical minimum. Pair that with cPanel Web Hosting Pakistan for managed server environments where the Sentinel topology can be pre-configured and monitored at the infrastructure level without manual intervention.
Summary Checklist
| Task | Config / Command | Done |
|---|---|---|
| Set Sentinel quorum >= 2 | sentinel.conf quorum 2 |
✅ |
Raise down-after-milliseconds to 5000ms |
sentinel.conf |
✅ |
Set min-replicas-to-write 1 on master |
redis.conf |
✅ |
| Switch to Sentinel-aware PHP client | wp-config.php |
✅ |
| Disable persistent PHP-FPM Redis connections | WP_REDIS_PERSISTENT false |
✅ |
| Implement SETNX mutex / stale-while-revalidate | mu-plugins/ |
✅ |
| Add TTL jitter to cache keys | Application layer | ✅ |
Raise MySQL max_connections and add ProxySQL |
my.cnf |
✅ |
| Deploy Prometheus alerts for split-brain | redis.yml |
✅ |
| Simulate controlled failover and verify RTO | SENTINEL failover |
✅ |
Properly architected, a Redis Sentinel cluster should recover from a master failure in under 15 seconds with bounded data loss and zero cache stampede — because your PHP-FPM workers wait behind the mutex rather than detonating your database. The key insight is that split-brain and stampede are not Redis bugs — they are emergent properties of under-configured clients interacting with a distributed consensus protocol that requires careful tuning at every layer of the stack.
