HomeCurriculumSystem DesignCaching Strategies & Content Delivery Networks
Caching Strategies & Content Delivery Networks

Caching Strategies & Content Delivery Networks

Read-through, write-through, write-behind caching, Redis cluster eviction policies, and CDN edge caching.

DT
Authored by DevThesis Systems Editorial Team
Reviewed for Technical Accuracy & Code Determinism

1. The Physics and Economics of Caching#

In distributed software architecture, performance optimization is ultimately dictated by the physical constraints of hardware and network communication. Storage systems exhibit an inescapable trade-off between access speed, capacity, and financial cost: high-speed memory is scarce, volatile, and expensive, whereas low-cost persistent storage is orders of magnitude slower. Caching exploits the principle of locality of reference (both temporal and spatial) to place frequently accessed data into high-speed memory subsystems, drastically reducing access latency and shielding persistent datastores from catastrophic overload.

The Storage Hierarchy and Latency Numbers#

The disparity in access times across modern computing tiers spans over seven orders of magnitude. The following standard reference latencies illustrate why avoiding a disk read or cross-region network hop is the single most impactful optimization in system design:

text
Computing Subsystem                  | Real-World Latency  | Normalized Human Scale (1 ns = 1 sec)
-------------------------------------|---------------------|--------------------------------------
CPU L1 Cache Reference               | 0.5 - 1.0 ns        | 1 second
CPU L2 Cache Reference               | 3.0 - 5.0 ns        | 4 seconds
CPU L3 Cache Reference               | 10 - 20 ns          | 15 seconds
Main Memory (DRAM) Reference         | 50 - 100 ns         | 1.5 minutes
Local NVMe SSD Random Read           | 10 - 50 μs          | 11 hours
Local Spinning HDD Seek              | 5 - 10 ms           | 3.8 months
Same-Datacenter Network RTT          | 0.2 - 0.5 ms        | 4.6 days
Cross-Continental Network RTT (WAN)  | 50 - 100 ms         | 3.1 years

A query hitting a local DRAM cache (such as Redis or an in-memory application heap) resolves in under 100 nanoseconds to 1 millisecond. In contrast, querying an unindexed relational database on disk or crossing a wide-area network takes 10 to 100 milliseconds. DRAM caching provides a 10,000× to 100,000× speed advantage.

The Mathematical Value of Caching: Effective Access Time (EAT)#

The performance of any caching subsystem is governed by the Cache Hit Ratio h (where 0.0 ≤ h ≤ 1.0) and the Cache Miss Ratio (1 - h).

The Effective Access Time (EAT) is the weighted average latency of all data accesses:

EAT = h · T_{cache} + (1 - h) · T_{origin}

Where:

  • T_{cache}: Latency of retrieving data from the cache (e.g., 1 ms).
  • T_{origin}: Latency of fetching data from the primary persistent origin (database or downstream API, e.g., 50 ms).
text
Cache Hit Ratio (h) vs. Effective Access Time (EAT) [T_cache = 1ms, T_origin = 50ms]
Hit Ratio (h) | EAT (ms) | Origin Offload (%) | Speedup vs. No Cache (50ms)
--------------|----------|-------------------|-----------------------------
0.00 (No Hit) | 50.0 ms  | 0.0%               | 1.0×
0.50 (50%)    | 25.5 ms  | 50.0%              | 1.96×
0.80 (80%)    | 10.8 ms  | 80.0%              | 4.63×
0.90 (90%)    | 5.9 ms   | 90.0%              | 8.47×
0.95 (95%)    | 3.45 ms  | 95.0%              | 14.49×
0.99 (99%)    | 1.49 ms  | 99.0%              | 33.56×
0.999 (99.9%) | 1.049 ms | 99.9%              | 47.66×

The Non-Linear Leverage of the 90-to-99 Transition:

Notice the non-linear relationship: moving from an 80% to a 90% hit ratio cuts origin traffic in half (from 20% to 10%). Moving from 90% to 99% cuts origin traffic by another factor of 10 (from 10% down to 1%)! For a database processing 100,000 queries per second, improving cache hit ratio from 90% to 99% reduces database queries from 10,000 QPS down to just 1,000 QPS, frequently saving the database tier from total collapse.

The Multi-Tier Caching Architecture#

Production architectures deploy caching across a multi-tier hierarchy to filter requests progressively before they touch persistent storage:

text
Client Browser (L1: Disk/Memory Cache, Service Worker)
     | (Miss)
     v
Edge CDN Point of Presence (L2: Cloudflare / Fastly Anycast PoP)
     | (Miss)
     v
API Gateway / Reverse Proxy (L3: NGINX / Varnish In-Memory Cache)
     | (Miss)
     v
Application Server Process (L4: In-Memory Heap Cache, Caffeine / Go sync.Map)
     | (Miss)
     v
Distributed Cache Cluster (L5: Redis Cluster / Memcached)
     | (Miss)
     v
Persistent Database (Origin: PostgreSQL / MySQL / Cassandra)

2. Caching Topologies and Write Semantics#

The selection of a caching topology dictates how data is populated, updated, and kept consistent between the volatile cache and the persistent source of truth.

text
Caching Write Semantics Summary
+-------------------------------------------------------------------------+
| Cache-Aside (Lazy Loading)                                              |
| App manages both Cache and DB explicitly. Cache miss triggers read.     |
+-------------------------------------------------------------------------+
| Read-Through / Write-Through                                            |
| App treats Cache as primary store. Cache engine syncs with DB inline.   |
+-------------------------------------------------------------------------+
| Write-Behind (Write-Back)                                               |
| App writes to Cache. Cache asynchronously queues batched DB writes.      |
+-------------------------------------------------------------------------+
| Refresh-Ahead                                                           |
| Cache proactively re-fetches popular keys before TTL expiration.        |
+-------------------------------------------------------------------------+

1. Cache-Aside (Lazy Loading)#

Cache-Aside is the most widely deployed caching pattern in modern microservices. The application code directly orchestrates interactions between the cache and the database:

Read Flow:

  1. Application queries the cache for key K.
  2. Cache Hit: Cache returns data. Application returns immediately to client.
  3. Cache Miss: Cache returns null. Application queries primary database for K.
  4. Application stores retrieved data in cache with a configured Time-To-Live (TTL), and returns data to client.

Write Flow (Invalidation vs. Update):

When the application mutates an entity, it faces two design options:

  • Option A: Update Cache (`SET key value`): Write new value to DB, then update cache.

- Severe Defect (Race Condition Overwrite): If Thread 1 writes value A to DB, and Thread 2 writes value B to DB concurrently, network delays can cause Thread 1's cache update to arrive after Thread 2's cache update. The cache now holds stale value A, while the DB holds B.

  • Option B: Invalidate Cache (`DEL key`): Write new value to DB, then delete the key from the cache.

- Best Practice: Subsequent reads will experience a clean cache miss and fetch the fresh database value. Always prefer cache invalidation over cache mutation in Cache-Aside architectures.

python
# Production Cache-Aside Pattern with Safe Invalidation
import redis
import json
import psycopg2

class CacheAsideRepository:
    def __init__(self, redis_client: redis.Redis, db_conn):
        self.cache = redis_client
        self.db = db_conn

    def get_user_profile(self, user_id: int) -> dict:
        cache_key = f"user:profile:{user_id}"
        
        # Step 1: Check Cache
        cached_data = self.cache.get(cache_key)
        if cached_data:
            return json.loads(cached_data)

        # Step 2: Cache Miss - Query Database
        with self.db.cursor() as cur:
            cur.execute("SELECT id, username, email FROM users WHERE id = %s;", (user_id,))
            row = cur.fetchone()
            if not row:
                # Cache negative result for 30s to prevent Cache Penetration
                self.cache.setex(cache_key, 30, json.dumps({}))
                return {}
            
            profile = {"id": row[0], "username": row[1], "email": row[2]}

        # Step 3: Populate Cache with TTL (e.g., 3600 seconds)
        self.cache.setex(cache_key, 3600, json.dumps(profile))
        return profile

    def update_user_email(self, user_id: int, new_email: str):
        cache_key = f"user:profile:{user_id}"
        
        # Step 1: Mutate Database First
        with self.db.cursor() as cur:
            cur.execute("UPDATE users SET email = %s WHERE id = %s;", (new_email, user_id))
            self.db.commit()

        # Step 2: Evict/Invalidate Cache Key (Never mutate in place!)
        self.cache.delete(cache_key)

2. Read-Through & Write-Through Caching#

In Read-Through and Write-Through architectures, the application treats the caching subsystem as the unified, primary datastore. The application does not communicate directly with the database; instead, the caching proxy or datastore plugin encapsulates all database interactions.

  • Read-Through: On a cache miss, the cache infrastructure itself executes the query to the underlying persistent store, populates the cache slot, and returns the result to the caller.
  • Write-Through: When the application writes a record, it writes directly to the cache. The cache synchronously writes the update to the persistent database within the same transaction boundary before acknowledging success to the caller.

- Advantage: Guarantees that the cache is never stale. Eliminates cache-aside boilerplate in application code.

- Disadvantage: High write latency. Every write incurs the synchronous latency penalty of both memory allocation and persistent disk fsync.

3. Write-Behind (Write-Back) Caching#

In Write-Behind caching, the application writes data exclusively to the in-memory cache, which acknowledges the operation with sub-millisecond latency. The cache then places the mutation into an asynchronous queue or dirty log buffer. A background worker process periodically drains this queue, coalesces updates, and executes bulk writes to the database.

text
Write-Behind (Write-Back) Pipeline:
Application ----(1. Fast In-Memory Write: 0.5ms)----> [ Cache Engine ]
     ^                                                      |
     +---(2. Immediate 200 OK Response)--------------------+
                                                            |
                                             (3. Asynchronous Batch Queue)
                                                            v
                                                  [ Worker Background Flush ]
                                                            |
                                             (4. Bulk SQL INSERT/UPDATE)
                                                            v
                                                  [ Persistent Database ]
  • Write Coalescing: If an entity's state changes 500 times in 10 seconds (e.g., a viral video view counter or live gaming leaderboard), the cache absorbs all 500 increments in memory and writes a single aggregated update (UPDATE views SET count = count + 500) to the database, slashing database write load by 99.8%.
  • Catastrophic Vulnerability (Data Loss): If the cache server loses power, suffers an unhandled kernel panic, or runs out of memory before the dirty buffer is flushed, all unpersisted writes are permanently lost. Write-Behind is suitable for non-critical high-velocity telemetry, rate-limiting counters, and analytics, but is strictly prohibited for financial balances or transactional order state.

3. Cache Eviction Policies & In-Memory Storage Mechanics#

When a cache's memory utilization reaches its provisioned physical threshold (maxmemory), the engine must evict existing keys to make room for new insertions. The eviction policy determines which items are pruned.

Classical Eviction Algorithms#

  1. Least Recently Used (LRU):

- Tracks the timestamp of last access for every item. When eviction triggers, the item that has not been accessed for the longest duration is discarded.

- Data Structure: A combination of a Hash Map (for O(1) key lookup) and a Doubly-Linked List (for O(1) node removal and head insertion).

text
LRU Cache Data Structure (Hash Map + Doubly-Linked List)

Hash Map:
[ "keyA" ] -> Node A
[ "keyB" ] -> Node B
[ "keyC" ] -> Node C

Doubly-Linked List (Ordered by Recency):
[ Head / Most Recent ] <-> [ Node B ] <-> [ Node A ] <-> [ Node C ] <-> [ Tail / Least Recent ]
                                                                             |
                                                                   (Evicted first on overflow)
python
# Production O(1) LRU Cache with Doubly Linked List & Hash Map
class LRUNode:
    def __init__(self, key: str, value: any):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}  # key -> LRUNode
        # Sentinel dummy head and tail nodes
        self.head = LRUNode("", None)
        self.tail = LRUNode("", None)
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node: LRUNode):
        prev_node = node.prev
        next_node = node.next
        prev_node.next = next_node
        next_node.prev = prev_node

    def _add_to_front(self, node: LRUNode):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def get(self, key: str) -> any:
        if key not in self.cache:
            return None
        node = self.cache[key]
        # Move accessed node to front (most recently used)
        self._remove(node)
        self._add_to_front(node)
        return node.value

    def put(self, key: str, value: any):
        if key in self.cache:
            node = self.cache[key]
            node.value = value
            self._remove(node)
            self._add_to_front(node)
        else:
            if len(self.cache) >= self.capacity:
                # Evict tail node (least recently used)
                lru_node = self.tail.prev
                self._remove(lru_node)
                del self.cache[lru_node.key]
            new_node = LRUNode(key, value)
            self.cache[key] = new_node
            self._add_to_front(new_node)
  1. Least Frequently Used (LFU):

- Tracks the total cumulative access count for each key. Evicts the key with the smallest frequency counter.

- Defect (Historical Frequency Accumulation): A key that was accessed 1,000,000 times during a marketing campaign six months ago will linger in memory indefinitely, even if it is never accessed again, blocking newly popular items from entering the cache.

- Mitigation: Counter decay over time (Morris counter logarithmic decay, as implemented in Redis LFU).

  1. Adaptive Replacement Cache (ARC):

- Formulated by Nimrod Megiddo and Dharmendra Modha at IBM, ARC dynamically tunes between recency (LRU) and frequency (LFU) using two separate doubly-linked lists. It self-adjusts based on whether workload patterns exhibit scan-heavy or repetition-heavy traits, vastly outperforming pure LRU.

Redis Eviction Policies (`maxmemory-policy`)#

Redis provides eight distinct eviction configurations:

Policy NameScopeEviction MetricOperational Use Case
noevictionAll keysNone (Returns OOM error on writes)Critical datastores where dropping any key causes fatal business errors
allkeys-lruAll keysApproximated LRUStandard web caching tier; ideal when access follows power-law distribution
volatile-lruExpiring keysApproximated LRUWhen only temporary keys should be pruned, preserving permanent keys
allkeys-lfuAll keysApproximated LFU with decayWorkloads where historical frequency reflects long-term utility
volatile-lfuExpiring keysApproximated LFU with decayPreserving permanent metadata while evicting rarely read cached sessions
allkeys-randomAll keysUniform random selectionBenchmarks or when access distribution is entirely uniform
volatile-randomExpiring keysUniform random selectionRandom eviction restricted strictly to keys with configured TTLs
volatile-ttlExpiring keysShortest remaining TTLPrioritizing keys that are already closest to natural expiration

The Engineering Elegance of Redis Approximated LRU:

A true LRU implementation requires maintaining a doubly-linked list. In a Redis datastore holding 100,000,000 keys, storing two 64-bit pointers (16 bytes) plus node metadata per key would consume gigabytes of memory purely in tracking pointers!

Instead, Redis uses an Approximated LRU Algorithm:

  1. Every Redis object header stores a 24-bit timestamp field representing its last access time.
  2. When eviction triggers, Redis does not sort the entire database. Instead, it samples N random keys (configured by maxmemory-samples, default 5 to 10).
  3. It evaluates the oldest key within this small sample and evicts it.
  4. With maxmemory-samples = 10, the approximated LRU curve aligns almost identically with theoretical true LRU, saving vast amounts of memory overhead.

4. Cache Pathologies and Systemic Failure Modes#

Caching failure modes are notorious for causing catastrophic, cascading cluster outages. Understanding these pathologies and their mathematical remedies is essential for production resilience.

text
Cache Failure Modes at a Glance
+--------------------------------------------------------------------------+
| 1. Cache Penetration: Non-existent keys bypass cache, hitting DB.        |
|    Mitigation: Bloom Filters, caching empty null-objects.                |
+--------------------------------------------------------------------------+
| 2. Cache Stampede (Thundering Herd): Hot key expires; 10,000 threads     |
|    query DB concurrently. Mitigation: Mutex Locks, XFetch algorithm.     |
+--------------------------------------------------------------------------+
| 3. Cache Avalanche: Massive block of keys expire simultaneously.         |
|    Mitigation: Randomized TTL jitter (TTL +/- delta).                    |
+--------------------------------------------------------------------------+
| 4. Cache Hot-Spotting: Viral key overwhelms single Redis node NIC.       |
|    Mitigation: Client L1 in-process caching, key salting.                |
+--------------------------------------------------------------------------+

1. Cache Penetration#

The Failure: Requests query keys that exist neither in the cache nor in the primary database (e.g., malicious SQL scanning for user_id = -1009384). Because the key is never in the database, the cache is never populated. 100% of these requests penetrate straight through to the database, causing high CPU utilization.

Mitigation Architectures:

  1. Negative Caching (Null Object Pattern): When the database returns an empty result, store an explicit empty token ({}) in the cache with a short TTL (e.g., 30 to 60 seconds). Subsequent queries hit the cache and return empty immediately.
  2. Bloom Filters: Place an in-memory Bloom Filter at the API Gateway or application entry. A Bloom Filter is a space-efficient probabilistic data structure that guarantees:

- If the Bloom filter returns False: The key definitely does not exist. Reject the request immediately without touching the cache or database!

- If the Bloom filter returns True: The key probably exists (with a tiny, tunable false positive probability p). Proceed to cache/DB.

2. Cache Stampede (Thundering Herd / Dogpiling)#

The Failure: A heavily read "celebrity key" (e.g., the front page product listing accessed 20,000 times per second) expires. In the millisecond it takes for the first request to query the database and re-populate the cache, thousands of concurrent worker threads detect a cache miss simultaneously. All 20,000 threads hammer the primary database with identical heavy queries, crashing the database within seconds.

Mitigation Architectures:

  1. Distributed Mutex Locking: Only the first thread that observes a miss is granted a distributed lock (SET lock_key uuid NX EX 5). It queries the database and repopulates the cache. All other threads sleep for 50 milliseconds and re-read the cache.
  2. The XFetch Probabilistic Early Expiration Algorithm (Vattani et al., VLDB 2015):

Instead of waiting for the key to expire at TTL, reading processes evaluate a probabilistic formula on every read:

Trigger Early Refresh if: -β · δ · ln(rand()) > Remaining TTL

Where:

  • δ (Delta): The time taken to compute and populate the value from the database.
  • β (Beta): Aggressiveness tuning constant (β > 0, typically set to 1.0).
  • rand(): Uniform random float in the open interval (0, 1).

As the key approaches expiration, the probability of a read triggering an asynchronous background database refresh increases exponentially. Exactly one background thread refreshes the cache before it actually expires, achieving a 0% stampede rate with zero distributed lock contention!

python
# Production Implementation of XFetch Early Expiration
import math
import random
import time

def should_refresh_xfetch(cached_object: dict, beta: float = 1.0) -> bool:
    # Evaluates whether caller should execute early background refresh.
    # cached_object schema: {'value': ..., 'ttl_timestamp': 1714005000, 'compute_time_sec': 0.15}
    now = time.time()
    remaining_ttl = cached_object['ttl_timestamp'] - now
    compute_time = cached_object['compute_time_sec']

    if remaining_ttl <= 0:
        return True # Hard expired

    # XFetch Probabilistic condition
    # -beta * delta * ln(random()) > remaining_ttl
    random_val = random.random()
    if random_val == 0:
        return True
        
    early_trigger_threshold = -beta * compute_time * math.log(random_val)
    return early_trigger_threshold > remaining_ttl

3. Cache Avalanche#

The Failure: A batch job imports 1,000,000 products at 02:00 AM, configuring every single key with an identical TTL of exactly 24 hours (86,400 seconds). At 02:00 AM the following morning, all 1,000,000 keys expire at the exact same second. The cache hit ratio instantly plummets from 99% to 0%, causing an avalanche of queries to crush the database tier.

Mitigation: TTL Jitter:

Always add a uniform random perturbation to the base TTL:

Actual TTL = Base TTL + random(-Δ_{jitter}, +Δ_{jitter})

For example, for a base TTL of 24 hours, add a random jitter of ± 10% (± 2.4 hours). Key expirations are now spread uniformly across a 4.8-hour window, eliminating the avalanche spike.


5. Content Delivery Networks (CDN) & Edge Caching Architecture#

While distributed caches (Redis) reside inside the private cloud datacenter, Content Delivery Networks (CDNs) push static and dynamic assets to hundreds of Points of Presence (PoPs) at the physical internet edge, mere miles away from end-users.

text
Global Anycast BGP Routing to Edge PoPs
[ User in Tokyo ]       ---> Anycast IP: 198.51.100.1 ---> [ Tokyo Edge PoP ] (Sub-5ms RTT)
[ User in London ]      ---> Anycast IP: 198.51.100.1 ---> [ London Edge PoP ] (Sub-5ms RTT)
[ User in São Paulo ]   ---> Anycast IP: 198.51.100.1 ---> [ São Paulo Edge PoP ] (Sub-5ms RTT)
                                                                 |
                                                          (Cache Miss WAN)
                                                                 v
                                                    [ Origin Data Center US-East ]

Border Gateway Protocol (BGP) Anycast#

Traditional DNS routing maps different IP addresses to different regions. In contrast, Anycast BGP announces the exact same public IP address simultaneously from hundreds of edge datacenters across the global internet backbone. Internet Service Provider (ISP) routers forward client TCP packets along the shortest autonomous system (AS) hop path, automatically terminating client connections at the topologically closest edge server without DNS latency or geo-pinning failures.

HTTP Caching Standards & Control Headers#

Edge proxies make caching decisions strictly based on standard HTTP/1.1 and HTTP/2 headers:

http
HTTP/1.1 200 OK
Content-Type: application/json
Date: Thu, 30 Oct 2026 12:00:00 GMT
ETag: W/"33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 29 Oct 2026 18:30:00 GMT
Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=600
Surrogate-Key: product-page store-inventory catalog-2026

Detailed Header Directives:

  1. Cache-Control: public: Indicates the response may be cached by any intermediate proxy (CDN, corporate proxy, ISP cache) and the end-user's browser.
  2. Cache-Control: private: Prohibits intermediate shared caches (CDNs) from storing the response. Only the user's local browser may store it. Mandatory for responses containing user-specific PII or personalized dashboards.
  3. max-age=3600: Browser cache lifespan in seconds (1 hour).
  4. s-maxage=86400: Shared cache (CDN) lifespan in seconds (24 hours). Overrides max-age for intermediate CDN proxies.
  5. stale-while-revalidate=600: If a request arrives within 600 seconds after s-maxage expires, the CDN immediately serves the stale cached asset to the user (0 ms latency!), and asynchronously dispatches a background request to the origin to fetch the fresh asset.
  6. no-cache: The asset may be cached, but must be revalidated with the origin via ETag (If-None-Match) before serving. If unchanged, origin returns 304 Not Modified.
  7. no-store: Strictly prohibits writing the response to any persistent storage or memory cache under any circumstance. Used for sensitive banking transactions.

Edge Invalidation: Surrogate Keys (Cache Tags)#

Traditional URL-based cache purging (PURGE /products/104) requires sending explicit invalidation API calls for every URL where that product appears (category pages, search results, recommendations).

Modern CDNs (Fastly, Cloudflare) support Surrogate Keys (Cache Tags):

  1. Origin emits a header: Surrogate-Key: product-104 category-footwear brand-nike.
  2. The CDN indexes the cached asset under all three tags.
  3. When product 104's price changes, the backend issues an API call: PURGE KEY product-104.
  4. The CDN instantly purges every cached page, JSON API snippet, and image associated with product-104 globally within 150 milliseconds!

6. Real-World Production Architecture: Multi-Tiered Redis & CDN Blueprint#

The following blueprint represents a resilient, high-throughput caching topology for an enterprise commerce engine:

text
Multi-Tier Caching Blueprint

[ Client Browser ]
        |
        v
[ Anycast CDN Edge (Cloudflare / Fastly) ]
  - Edge Rules: s-maxage=86400, stale-while-revalidate=600
  - Purge API via Surrogate Keys
        | (Cache Miss / Origin Request)
        v
[ Regional Ingress L7 Load Balancer (NGINX) ]
  - Fastcgi / Proxy Cache: In-memory micro-cache (1-2 seconds)
        |
        v
[ Application Compute Pods (Go / Node.js) ]
  - L1 In-Process Cache: Caffeine / Go Bigcache (1,000 hot keys, 10s TTL)
        |
        +----(XFetch Early Expiration Check)
        |
        v
[ Distributed L2 Cache: Redis Cluster (6 Nodes) ]
  - 3 Primaries, 3 Replicas
  - 16,384 Hash Slots distributed across shards
  - maxmemory-policy: allkeys-lfu
        | (Cache Miss)
        v
[ Primary Database Cluster (PostgreSQL) ]

Production Redis Configuration (`redis.conf`) Snippet#

conf
# /etc/redis/redis.conf - Production High-Throughput Specification
port 6379
bind 0.0.0.0
protected-mode yes
tcp-backlog 2048
timeout 0
tcp-keepalive 300

# Memory Management Architecture
maxmemory 16gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10

# LFU Tuning Parameters
# lfu-log-factor: higher value means counter requires more hits to increase
# lfu-decay-time: minutes without access to decrement counter by 1
lfu-log-factor 10
lfu-decay-time 1

# Durability & Persistence: Hybrid RDB + AOF
# RDB Snapshots for fast disaster recovery
save 900 1
save 300 10
save 60 10000

# Append-Only File (AOF) for minimal RPO loss
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
no-appendfsync-on-rewrite yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# Replication & Cluster Topology
cluster-enabled yes
cluster-config-file nodes-6379.conf
cluster-node-timeout 5000
cluster-migration-barrier 1

Critical Telemetry Metrics and Observability#

To maintain a healthy distributed caching fleet, track the following metrics:

  1. keyspace_hits and keyspace_misses: Calculate global hit ratio:

Hit Ratio = (keyspace_hits) / (keyspace_hits + keyspace_misses)

Alert if global hit ratio falls below 95%.

  1. mem_fragmentation_ratio: Calculated as used_memory_rss / used_memory.

- ratio < 1.0: System is paging memory to swap disk (severe performance emergency).

- ratio > 1.5: Excessive memory fragmentation; memory allocator (jemalloc) is wasting RAM. Trigger activedefrag yes.

  1. evicted_keys: Rate of keys evicted per second. Sudden spikes indicate that maxmemory is undersized for current working set requirements.
  2. connected_clients and blocked_clients: Tracking blocked clients waiting on list pops or transactional locks.

7. Common Pitfalls, Anti-Patterns & Failure Modes#

1. Caching Un-indexed Complex SQL Result Sets#

A common anti-pattern involves caching the output of a query like SELECT * FROM orders WHERE status = 'pending' AND created_at > NOW() - INTERVAL '1 day'.

Because this query is dynamic, invalidating it when an order status changes requires complex query analysis.

Rule: Cache entities by primary key (order:1040), not arbitrary relational predicate result sets. Use Redis sets or sorted sets (ZSET) to store ID index lists (order_ids_by_status:pending), and fetch the underlying entities via MGET.

2. The PII / Auth Token Edge Caching Catastrophe#

If a developer configures an edge CDN rule to cache /api/v1/user/me with Cache-Control: public, max-age=300, the CDN will store the profile response of the first user who accesses the endpoint. For the next 300 seconds, every other user globally who queries their own profile will receive the first user's private personal data, causing a catastrophic data breach.

Rule: Any response generated based on an Authorization header, session cookie, or user identifier must strictly emit Cache-Control: private, no-cache, no-store.

3. Out-of-Order Cache Invalidation Races#

In high-throughput systems, asynchronous threads can race:

  • Thread 1 writes to DB.
  • Thread 2 writes newer value to DB.
  • Thread 2 issues cache invalidate.
  • Thread 1's delayed cache invalidate executes or Thread 1 populates the cache with its older snapshot.

Mitigation: Use transactional Redis Lua scripts or compare-and-swap version tokens (e.g., entity version increment) before writing to cache.


8. Checkpoint Practice Problems & Step-by-Step Solutions#

Problem 1: Effective Access Time (EAT) & Hit Ratio Optimization Math#

Scenario:

An e-commerce API gateway routes product queries through a distributed Redis caching tier before falling back to a PostgreSQL database.

Performance profiling reveals:

  • Cache retrieval latency: T_{cache} = 1.2 ms.
  • Database query latency: T_{db} = 45.0 ms.
  • Current peak traffic volume: λ = 20,000 requests/second.
  • Current baseline cache hit ratio: h_1 = 0.88 (88%).

The engineering team plans to implement Bloom Filters and optimize TTL policies to boost the cache hit ratio to h_2 = 0.98 (98%).

Questions:

  1. Calculate the current Effective Access Time EAT_1 and the current query throughput hitting the PostgreSQL database.
  2. Calculate the new Effective Access Time EAT_2 and the new database query throughput under h_2 = 0.98.
  3. Compute the percentage reduction in database load and the overall application latency speedup factor.

Step-by-Step Solution:

Step 1: Calculate Baseline Metrics (h_1 = 0.88)

Current EAT formula:

EAT_1 = h_1 · T_{cache} + (1 - h_1) · T_{db}

EAT_1 = (0.88 × 1.2 ms) + (0.12 × 45.0 ms)

EAT_1 = 1.056 ms + 5.400 ms = 6.456 ms

Current database query load:

QPS_{db, 1} = λ · (1 - h_1) = 20,000 × 0.12 = 2,400 queries/second

Step 2: Calculate Optimized Metrics (h_2 = 0.98)

New EAT formula:

EAT_2 = h_2 · T_{cache} + (1 - h_2) · T_{db}

EAT_2 = (0.98 × 1.2 ms) + (0.02 × 45.0 ms)

EAT_2 = 1.176 ms + 0.900 ms = 2.076 ms

New database query load:

QPS_{db, 2} = λ · (1 - h_2) = 20,000 × 0.02 = 400 queries/second

Step 3: Calculate Load Reduction and Speedup Factor

  1. Database Load Reduction Percentage:

Load Reduction = (QPS_{db, 1} - QPS_{db, 2}) / QPS_{db, 1} = (2,400 - 400) / 2,400 = 2,000 / 2,400 ≈ 0.8333 (83.33%)

The database write and query pressure is reduced by 83.33%!

  1. Overall Application Latency Speedup:

Speedup = EAT_1 / EAT_2 = 6.456 ms / 2.076 ms ≈ 3.1098 (3.11×)

Result: Increasing the cache hit ratio by just 10 percentage points (from 88% to 98%) speeds up average user response times by 3.11× and eliminates over 83% of backend database queries.


Problem 2: Probabilistic Early Expiration (XFetch Algorithm) Parameter Sizing#

Scenario:

A high-traffic financial market data ticker key is stored in Redis.

  • Key computation time from upstream Bloomberg API: δ = 0.25 seconds (250 ms).
  • Hard TTL: 60 seconds.
  • Constant read throughput on this single key: λ = 4,000 reads/second.
  • XFetch tuning aggressiveness constant: β = 1.5.

The XFetch early refresh trigger condition is:

-β · δ · ln(rand()) > Remaining TTL

Questions:

  1. Calculate the earliest possible time before hard expiration (i.e., when Remaining TTL is at its maximum) at which an early refresh could theoretically trigger if rand() = 0.00001 (10^{-5}).
  2. Calculate the exact probability P(Refresh) that a single read operation will trigger an early refresh when there are exactly 0.8 seconds of remaining TTL.
  3. Over the span of that one second (from Remaining TTL = 1.0s to 0.0s), during which 4,000 read operations arrive, calculate the probability that at least one incoming read operation successfully triggers an early refresh, guaranteeing zero cache stampede.

Step-by-Step Solution:

Step 1: Calculate Theoretical Earliest Trigger Window

Given:

β = 1.5, δ = 0.25 s

β · δ = 1.5 × 0.25 = 0.375 seconds

For rand() = 10^{-5}:

ln(10^{-5}) = -5 · ln(10) ≈ -5 × 2.302585 = -11.5129

Threshold = -0.375 × (-11.5129) = 4.317 seconds

Result: Under rare random draws, early refreshes can begin triggering up to 4.32 seconds before the hard TTL expires.

Step 2: Probability of Refresh at Remaining TTL = 0.8s

Condition for refresh:

-β · δ · ln(rand()) > Remaining TTL

-0.375 · ln(rand()) > 0.8

ln(rand()) < -0.8 / 0.375 = -800 / 375 ≈ -2.1333

Exponentiate both sides (since e^x is strictly monotonically increasing):

rand() < e^{-2.1333}

e^{-2.1333} ≈ 0.11844

Since rand() is uniformly distributed on (0, 1), the probability is:

P(Single Read Triggers Refresh) = 0.11844 (11.84%)

Result: At 0.8 seconds prior to expiration, any individual read has an 11.84% probability of triggering a background refresh.

Step 3: Cumulative Probability across 4,000 Requests

At an arrival rate of 4,000 reads/second, even if we conservatively evaluate the probability at Remaining TTL = 1.0 s:

ln(rand()) < -1.0 / 0.375 ≈ -2.6667

P(Single Read Triggers at 1.0s) = e^{-2.6667} ≈ 0.06948 (6.95%)

The probability that a single read does NOT trigger a refresh is:

P(No Refresh) = 1 - 0.06948 = 0.93052

The probability that none of the n = 4,000 requests trigger a refresh:

P(None of 4000 Trigger) = (0.93052)^{4000}

ln(P) = 4000 · ln(0.93052) ≈ 4000 · (-0.07201) = -288.04

P(None of 4000 Trigger) = e^{-288.04} ≈ 6.4 × 10^{-126} ≈ 0.000000000000...

Probability that at least one request triggers:

P(At least one triggers) = 1 - e^{-288.04} = 1.000000000000... (100.0%)

Result: The mathematical guarantee of an early refresh is virtually absolute. The key will be proactively updated in the background, completely shielding the upstream API from a thundering herd.


Problem 3: Bloom Filter Optimal Bit-Array Sizing and Hash Count for Cache Penetration Prevention#

Scenario:

A cybersecurity infrastructure protects an e-commerce platform with n = 50,000,000 registered product items.

To completely prevent Cache Penetration attacks, an edge Bloom Filter must inspect all incoming product lookup requests before querying the Redis cluster.

The platform architect mandates that the false positive probability must not exceed p = 0.001 (0.1% or 1 in 1,000 requests).

Questions:

  1. Calculate the theoretical optimal bit-array size m (in bits and in Megabytes) required to support n = 50,000,000 elements with false positive rate p = 0.001.
  2. Calculate the optimal number of independent hash functions k required to achieve this false positive rate.
  3. If an attacker floods the platform with 10,000,000 fake, non-existent product ID queries, calculate the expected number of queries that will be successfully blocked at the edge Bloom filter, and the number of queries that will penetrate to Redis.

Step-by-Step Solution:

Step 1: Calculate Optimal Bit Array Size m

The theoretical formula for optimal Bloom filter bit capacity is:

m = -(n · ln(p)) / (ln 2)^2

Given:

  • n = 50,000,000
  • p = 0.001
  • ln(0.001) ≈ -6.907755
  • ln 2 ≈ 0.693147
  • (ln 2)^2 ≈ (0.693147)^2 ≈ 0.480453

Substitute into formula:

m = - (50,000,000 · (-6.907755)) / 0.480453 = 345,387,750 / 0.480453 ≈ 718,879,368 bits

Convert bits to Megabytes (1 Byte = 8 bits, 1 MB = 1024 × 1024 bytes = 1,048,576 bytes):

Total Bytes = 718,879,368 / 8 = 89,859,921 bytes

Total Megabytes (MB) = 89,859,921 / 1,048,576 ≈ 85.696 MB

Result: An 85.7 MB bit array in RAM can guard 50 million keys with a 99.9% accuracy rate.

Step 2: Calculate Optimal Hash Functions k

The formula for the optimal number of hash functions k is:

k = (m / n) · ln 2

Using m / n = 718,879,368 / 50,000,000 ≈ 14.3776:

k = 14.3776 × 0.693147 ≈ 9.965

Rounding to the nearest integer:

k = 10 hash functions

(Engineering Note: In production, instead of executing 10 expensive cryptographic hash calculations, systems use the Kirsch-Mitzenmacher optimization: `g_i(x) = h_1(x) + i · h_2(x) ±od m` using just two base hashes like Murmur3).

Step 3: Analyze Attack Filtering Efficiency

The attacker sends N_{attack} = 10,000,000 non-existent key requests.

Since none of these keys exist in the database, a true negative query is correctly identified whenever the Bloom filter returns False.

Accuracy rate:

P(Blocked) = 1 - p = 1 - 0.001 = 0.999 (99.9%)

Queries blocked at edge:

Blocked Queries = 10,000,000 × 0.999 = 9,990,000 queries

Queries penetrating through (false positives):

Penetrating Queries = 10,000,000 × 0.001 = 10,000 queries

Result: 9.99 million out of 10 million malicious queries are rejected in memory at the edge proxy, completely neutralizing the cache penetration vector.


Problem 4: Redis Cluster Hash Slot Allocation & Memory Sizing with Jittered Expirations#

Scenario:

A social network plans a Redis Cluster to cache user feed timelines.

  • Total active users: U = 18,000,000.
  • Average timeline cache payload per user: S_{payload} = 4.5 KB.
  • Redis internal memory overhead per key (dict entry + SDS string + redisObject): S_{overhead} = 250 bytes.
  • Memory fragmentation factor for jemalloc: F_{frag} = 1.25 (25% memory fragmentation).
  • Desired max cluster memory headroom buffer: 30% (system must operate at <= 70% RAM capacity).
  • Physical server specification: Standard cloud instances with 64 GB RAM each.

Questions:

  1. Calculate the total uncompressed raw data memory and total cluster RAM required to support all 18,000,000 user feeds with overhead and fragmentation.
  2. Calculate the minimum number of Redis primary master nodes required to host this workload without exceeding the 70% RAM utilization threshold.
  3. If each primary master has one dedicated replica for High Availability (1:1 replication), calculate the total primary and replica node count.
  4. Calculate the average number of Redis Cluster hash slots assigned to each primary master node across the 16,384 total cluster slots.

Step-by-Step Solution:

Step 1: Calculate Total Memory Footprint

Per-key total size:

S_{key} = S_{payload} + S_{overhead} = 4,500 bytes + 250 bytes = 4,750 bytes

Total raw memory for 18,000,000 users:

M_{raw} = 18,000,000 × 4,750 bytes = 85,500,000,000 bytes

Convert to Gigabytes (1 GB = 1024^3 = 1,073,741,824 bytes):

M_{raw} = 85,500,000,000 / 1,073,741,824 ≈ 79.628 GB

Apply memory fragmentation factor (F_{frag} = 1.25):

M_{actual} = M_{raw} × 1.25 = 79.628 × 1.25 ≈ 99.535 GB

Apply the 30% headroom buffer (operating at 70% max utilization):

M_{cluster, total} = M_actual / 0.70 = 99.535 / 0.70 ≈ 142.193 GB

Result: The cluster requires approximately 142.2 GB of usable primary RAM.

Step 2: Sizing Primary Master Instances

Each cloud instance provides 64 GB RAM.

At 70% safe operating limit, usable RAM per node is:

Usable RAM per node = 64 GB × 0.70 = 44.8 GB

Number of primary master nodes:

N_{masters} = ⌈M_actual / Usable_RAM_per_node⌉ = ⌈99.535 / 44.8⌉ = ⌈2.221⌉ = 3 master nodes

To ensure resilience and clean hash slot distribution across failure domains, a minimum of 3 primary masters is mandatory for any Redis Cluster deployment:

N_{masters} = 3 primary instances

Each of the 3 instances will hold:

Memory per master = 99.535 GB / 3 ≈ 33.18 GB

Utilization per node = 33.18 GB / 64 GB ≈ 51.84% (Safely below 70%)

Step 3: Total Node Count with Replicas

With 1:1 primary-to-replica pairing:

N_{replicas} = 3 instances

N_{total} = N_{masters} + N_{replicas} = 3 + 3 = 6 instances

Result: The production deployment requires 6 servers (3 primaries and 3 replicas).

Step 4: Hash Slot Allocation

Redis Cluster defines exactly 16,384 hash slots (0 to 16,383):

Slots per Master = 16,384 / 3 ≈ 5,461.33

Standard allocation:

  • Master 1: Slots 0 to 5460 (5,461 slots).
  • Master 2: Slots 5461 to 10922 (5,462 slots).
  • Master 3: Slots 10923 to 16383 (5,461 slots).

Key Architectural Takeaways

  • Mastering Caching Strategies & Content Delivery Networks is critical for System Design production systems.
  • Strict invariant verification guarantees runtime stability and predictability.
  • Always follow industry-standard telemetry, logging, and security practices.

Hands-on Code Playground Available

Execute, edit, and experiment with code for this topic in the live sandbox.

Practice Check • Quick Quiz

Test Your Understanding

In System Design, what is the main purpose of Caching Strategies & Content Delivery Networks?

A
To guarantee deterministic performance and correctness for caching strategies & content delivery networks.
B
To bypass all system security checks during execution.
C
To increase memory allocation overhead unnecessarily.
D
To force non-blocking single-threaded execution globally.