0Pricing
DSA Interview Prep · Lezione

Caching, CDN e bilanciamento del carico

Aggiunga livelli di caching con Redis, sposti gli asset statici su una CDN e distribuisca il traffico tra le repliche con bilanciatori del carico round-robin e consistent hashing.

Caching, CDN e bilanciamento del carico è una lezione DSA Interview Prep gratuita su CoddyKit. Questa è la lezione 3 di 4. Puoi leggere la lezione completa qui gratuitamente — poi esercitati direttamente nel browser con un editor di codice integrato e un tutor IA disponibile 24/7. Fa parte del percorso di apprendimento DSA Interview Prep, e i tuoi progressi si sincronizzano tra il web e l'app CoddyKit. Il corso DSA Interview Prep include 4 lezioni in totale.

Perché il caching è essenziale su larga scala

Il caching memorizza copie dei dati consultati frequentemente in un livello di storage più veloce, così le richieste successive possono essere servite senza accedere al sistema di archiviazione sottostante più lento (database, API esterna). Su larga scala, un numero ridotto di elementi popolari riceve la maggior parte delle richieste — spesso si applica la regola 80/20 (principio di Pareto): il 20% degli elementi rappresenta l’80% del traffico.

Una cache che contiene il 20% degli elementi più richiesti in memoria può assorbire l’80% del carico del database. Per questo l’aggiunta di una cache Redis riduce spesso l’utilizzo della CPU del database del 70–90% e porta la latenza p99 da 10 ms a meno di 1 ms per le richieste soddisfatte dalla cache, senza modificare in modo significativo il database o la logica applicativa.

# Demonstrating the 80/20 caching benefit
import random

# Simulate 1000 requests to 100 items with Zipf-like distribution
def zipf_sample(n_items, n_requests):
    access_counts = {}
    weights = [1.0 / (i + 1) for i in range(n_items)]  # Zipf: item 0 most popular
    total = sum(weights)
    probs = [w / total for w in weights]
    for _ in range(n_requests):
        item = random.choices(range(n_items), weights=probs)[0]
        access_counts[item] = access_counts.get(item, 0) + 1
    return access_counts

random.seed(42)
counts = zipf_sample(100, 10000)
top_20_items = sorted(counts, key=counts.get, reverse=True)[:20]
top_20_requests = sum(counts[i] for i in top_20_items)
print(f'Top 20% of items ({20} of 100) handle {top_20_requests/100:.1f}% of requests')

Pattern cache-aside (caricamento lazy)

Il pattern cache-aside (chiamato anche lazy loading) è la strategia di caching più comune. Il codice applicativo gestisce direttamente la cache: durante una lettura, controlli prima la cache. In caso di cache hit, restituisca immediatamente il risultato. In caso di cache miss, recuperi i dati dal database, li scriva nella cache e poi li restituisca. Durante una scrittura, aggiorni il database e invalidi (elimini) la voce nella cache, così la lettura successiva la aggiornerà.

Questo pattern garantisce che la cache contenga solo dati effettivamente richiesti (senza precaricamento inutile) e rimanga coerente con il database tramite l’invalidazione. Il compromesso è che il primo accesso dopo un cache miss comporta il costo completo del database (avvio a freddo).

# Cache-aside pattern in Python
class CacheAsideService:
    def __init__(self, db, cache):
        self.db = db
        self.cache = cache   # e.g., Redis client

    def get_user(self, user_id):
        cache_key = f'user:{user_id}'
        # 1. Check cache
        cached = self.cache.get(cache_key)
        if cached:
            return cached    # cache hit
        # 2. Cache miss: fetch from DB
        user = self.db.query('SELECT * FROM users WHERE id=%s', user_id)
        # 3. Write to cache with TTL
        self.cache.set(cache_key, user, ttl=3600)  # 1 hour TTL
        return user

    def update_user(self, user_id, data):
        # 1. Write to DB
        self.db.execute('UPDATE users SET ... WHERE id=%s', user_id, data)
        # 2. Invalidate cache (delete, not update)
        self.cache.delete(f'user:{user_id}')
        # Next read will re-populate cache from DB

print('Cache-aside: READ from cache, miss? load from DB + write cache')
print('         WRITE to DB, then DELETE from cache (invalidate)')

Caching write-through e write-behind

Write-through: a ogni scrittura, aggiorni simultaneamente il database e la cache. La cache contiene sempre dati aggiornati. Compromesso: le scritture sono più lente (due operazioni) e la cache si riempie di dati che potrebbero non essere mai più letti.

Write-behind (write-back): durante una scrittura, aggiorni solo la cache e la sincronizzi asincronamente con il database in un secondo momento. In questo modo le scritture sono estremamente rapide, ma si rischia la perdita di dati se la cache si guasta prima della sincronizzazione. Si utilizza nei carichi di lavoro con molte scritture, quando una certa perdita di dati è accettabile (ad esempio, per i contatori delle visualizzazioni e l’analytics).

# Write-through vs Write-behind comparison
strategies = {
    'Cache-aside (Lazy)': {
        'read':  'Check cache; miss => DB + populate cache',
        'write': 'Write DB; delete from cache (invalidate)',
        'consistency': 'Strong (invalidation ensures freshness)',
        'write_latency': 'Fast (one DB write)',
        'risk': 'Cache stampede on popular key expiry',
    },
    'Write-through': {
        'read':  'Always check cache; miss => DB',
        'write': 'Write DB AND cache atomically',
        'consistency': 'Strong (cache always has latest)',
        'write_latency': 'Slower (two writes per operation)',
        'risk': 'Cache polluted with rarely-read data',
    },
    'Write-behind': {
        'read':  'Check cache; miss => DB',
        'write': 'Write cache only; async flush to DB',
        'consistency': 'Eventual (flush may be delayed)',
        'write_latency': 'Very fast (cache write only)',
        'risk': 'Data loss if cache crashes before flush',
    },
}
for name, info in strategies.items():
    print(f'\n{name}:')
    for k, v in info.items(): print(f'  {k}: {v}')

Politiche di espulsione dalla cache

Quando la cache è piena, la politica di espulsione decide quale voce rimuovere. Le politiche più comuni sono:

  • LRU (utilizzata meno di recente): espelle la voce a cui non si accede da più tempo. Offre buone prestazioni nei carichi di lavoro con località temporale. È utilizzata da Redis per impostazione predefinita.
  • LFU (utilizzata meno frequentemente): espelle la voce a cui si è avuto accesso meno volte. È più adatta ai carichi di lavoro in cui alcuni elementi sono sempre popolari, mentre LRU non riuscirebbe a rilevarlo.
  • FIFO: espelle la voce inserita per prima. È semplice, ma offre prestazioni scarse nei tipici carichi di lavoro web.
  • Casuale: espelle una voce scelta casualmente. Nella pratica, con cache molto grandi, è sorprendentemente competitiva con LRU.
# Implementing LRU cache
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = OrderedDict()  # maintains insertion/access order

    def get(self, key):
        if key not in self.cache:
            return -1
        self.cache.move_to_end(key)   # mark as recently used
        return self.cache[key]

    def put(self, key, value):
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            self.cache.popitem(last=False)   # evict LRU (oldest)

cache = LRUCache(3)
for k, v in [('a',1),('b',2),('c',3)]:
    cache.put(k, v)
print('Get a:', cache.get('a'))   # 1 (a now most recently used)
cache.put('d', 4)                  # evicts 'b' (LRU)
print('Get b:', cache.get('b'))   # -1 (evicted)
print('Get c:', cache.get('c'))   # 3

Reti di distribuzione dei contenuti (CDN)

Una CDN è una rete geograficamente distribuita di server edge (Points of Presence, PoP) che memorizzano nella cache contenuti statici e dinamici vicino agli utenti finali. Anziché far viaggiare la richiesta di ogni utente fino a un server di origine in un unico data center, i nodi edge della CDN distribuiscono i contenuti dal PoP più vicino — riducendo la latenza da ~200 ms (tra continenti) a ~5 ms (PoP vicino).

Le CDN sono essenziali per: risorse statiche (immagini, CSS, JS), streaming video (segmenti HLS) e, sempre più spesso, risposte API e HTML renderizzato dal server. La CDN controlla la propria cache edge; in caso di cache miss, recupera il contenuto dall'origine e lo memorizza nella cache per le richieste future.

# CDN architecture flow
cdn_flow = [
    'User requests https://example.com/image.jpg',
    'DNS resolves to the nearest CDN PoP (e.g., Frankfurt for EU users)',
    'CDN edge checks its local cache:',
    '  HIT:  Return cached image directly (5ms latency)',
    '  MISS: Fetch from origin server (e.g., AWS S3 in us-east-1)',
    '        Cache image at edge with Cache-Control: max-age=86400',
    '        Future requests for this image served from edge (HIT)',
    'Cache-Control headers control CDN behaviour:',
    '  max-age=31536000 s-maxage=31536000  -- cache 1 year',
    '  no-cache                             -- always revalidate',
    '  private                              -- CDN must not cache (user-specific)',
]
for step in cdn_flow:
    print(step)

print('\nCDN providers: Cloudflare, AWS CloudFront, Fastly, Akamai')

Bilanciamento del carico: distribuzione del traffico

Un bilanciatore di carico distribuisce le richieste in arrivo tra più server backend, impedendo che un singolo server diventi un collo di bottiglia. Fornisce anche elevata disponibilità: se un server si guasta, il bilanciatore di carico instrada automaticamente il traffico verso i server integri (controlli dello stato ogni 5-30 secondi).

I bilanciatori di carico operano a diversi livelli OSI: Livello 4 (trasporto — instrada in base a IP/porta, molto velocemente) e Livello 7 (applicazione — instrada in base al percorso URL, alle intestazioni e ai cookie, consentendo un instradamento più intelligente). AWS ALB, Nginx e HAProxy sono comuni bilanciatori di carico di Livello 7. AWS NLB è un bilanciatore di carico di Livello 4.

# Load balancing algorithms
algorithms = {
    'Round Robin': {
        'how': 'Rotate through servers in sequence',
        'best_for': 'Stateless servers with similar capacity',
        'weakness': 'Does not account for server load or response time',
    },
    'Weighted Round Robin': {
        'how': 'Round robin but servers with more capacity get more requests',
        'best_for': 'Heterogeneous server fleet',
        'weakness': 'Static weights; does not adapt to runtime load',
    },
    'Least Connections': {
        'how': 'Send to server with fewest active connections',
        'best_for': 'Long-lived connections (WebSocket, streaming)',
        'weakness': 'More complex tracking of connection state',
    },
    'Consistent Hashing': {
        'how': 'Hash request key (user_id, session) to server',
        'best_for': 'Sticky sessions, cache locality per server',
        'weakness': 'Uneven distribution if hash space is not balanced',
    },
    'Random': {
        'how': 'Choose server at random',
        'best_for': 'Simple stateless workloads',
        'weakness': 'No guarantee of load balance in short windows',
    },
}
for alg, info in algorithms.items():
    print(f'{alg}: {info["how"]}')

Hashing consistente: aggiunta e rimozione dei nodi

L'hashing consistente risolve il problema della ridistribuzione delle chiavi della cache quando si aggiungono o rimuovono server. Con l'hashing basato ingenuamente sul modulo (server = hash(key) % n), cambiando n quasi tutte le chiavi vengono rimappate — provocando una cache stampede. L'hashing consistente mappa sia le chiavi sia i server su un anello; ogni chiave viene gestita dal server più vicino in senso orario. L'aggiunta di un server rimappa solo le chiavi comprese tra il nuovo server e il suo predecessore — circa 1/n di tutte le chiavi.

I nodi virtuali (vnodes) migliorano la distribuzione del carico: a ogni server fisico vengono assegnate più posizioni sull'anello, così le chiavi vengono distribuite più uniformemente anche quando il numero di server è ridotto.

import hashlib
import bisect

class ConsistentHashRing:
    def __init__(self, replicas=100):
        self.replicas = replicas      # virtual nodes per server
        self.ring = {}
        self.sorted_keys = []

    def add_server(self, server):
        for i in range(self.replicas):
            key = int(hashlib.md5(f'{server}:{i}'.encode()).hexdigest(), 16)
            self.ring[key] = server
            bisect.insort(self.sorted_keys, key)

    def remove_server(self, server):
        for i in range(self.replicas):
            key = int(hashlib.md5(f'{server}:{i}'.encode()).hexdigest(), 16)
            del self.ring[key]
            self.sorted_keys.remove(key)

    def get_server(self, item):
        key = int(hashlib.md5(item.encode()).hexdigest(), 16)
        idx = bisect.bisect(self.sorted_keys, key) % len(self.sorted_keys)
        return self.ring[self.sorted_keys[idx]]

ring = ConsistentHashRing()
for s in ['server-1', 'server-2', 'server-3']:
    ring.add_server(s)
for item in ['user:1', 'user:2', 'product:abc', 'session:xyz']:
    print(f'{item} => {ring.get_server(item)}')

Cache stampede e soluzioni

Una cache stampede (o thundering herd) si verifica quando una voce di cache molto richiesta scade e molte richieste simultanee non trovano la voce nella cache, inondando il database con la stessa query. Soluzioni:

  • Mutex/lock: una sola richiesta calcola il valore; le altre attendono
  • Scadenza anticipata probabilistica: poco prima del TTL, una richiesta decide casualmente di aggiornare la cache, impedendo la scadenza simultanea
  • Stale-while-revalidate: serve immediatamente contenuti obsoleti mentre aggiorna la cache in modo asincrono
  • Aggiornamento in background: un processo separato aggiorna le chiavi più richieste prima che scadano
import time, threading, random

# Probabilistic early expiry (XFetch algorithm)
class ProbabilisticCache:
    def __init__(self):
        self._cache = {}

    def get(self, key, ttl, recompute_fn, beta=1.0):
        if key in self._cache:
            value, expiry, delta = self._cache[key]
            # XFetch: decide to refresh early with probability proportional to delta/TTL
            remaining = expiry - time.time()
            if remaining > 0:
                early_refresh_score = delta * beta * (-1) * (remaining / ttl)
                if random.random() > (1 - early_refresh_score):  # simplified
                    pass  # could trigger async refresh here
                return value
        # Cache miss or expired
        start = time.time()
        value = recompute_fn()
        delta = time.time() - start          # computation time
        expiry = time.time() + ttl
        self._cache[key] = (value, expiry, delta)
        return value

print('XFetch: refresh probabilistically before expiry based on computation cost')
print('High-cost computations => refresh earlier to avoid stampede')
print('Low-cost computations => refresh closer to TTL')

Invalidazione della cache CDN

L'invalidazione della cache è notoriamente difficile: 'Ci sono solo due problemi difficili nell'informatica: l'invalidazione della cache e dare un nome alle cose.' Quando il contenuto cambia all'origine, i nodi edge della CDN devono distribuire la nuova versione. Strategie:

  • Scadenza basata sul TTL: lasciare che il contenuto scada naturalmente (semplice, ma con una finestra in cui può rimanere obsoleto)
  • Versionamento degli URL: incorporare l'hash del contenuto nell'URL (ad es. main.a3f2b.js); nuovo contenuto = nuovo URL, senza bisogno di invalidazione
  • Svuotamento tramite API CDN: eliminare esplicitamente gli URL tramite una chiamata API dopo il deployment (rapido, ma richiede l'integrazione con l'API della CDN)
# Cache invalidation strategies for CDN/browser
strategies = [
    {
        'name': 'Long TTL + URL versioning (best for static assets)',
        'example': '<script src="/app.a3f2b1c.js"></script>',
        'ttl': 'Cache-Control: max-age=31536000 (1 year)',
        'how': 'Content hash in filename; new deploy = new URL; old URL cached forever (OK)',
    },
    {
        'name': 'Short TTL (for frequently changing content)',
        'example': '/api/v1/config',
        'ttl': 'Cache-Control: max-age=60 (1 minute)',
        'how': 'Simple; content is at most 60s stale; no invalidation needed',
    },
    {
        'name': 'CDN API purge (for news / social media)',
        'example': '/news/breaking-story.html',
        'ttl': 'Cache-Control: s-maxage=3600',
        'how': 'On publish, call CDN.purge(url); edge serves new version immediately',
    },
]
for s in strategies:
    print(f'{s["name"]}:')
    print(f'  Example: {s["example"]}')
    print(f'  TTL: {s["ttl"]}')
    print(f'  Strategy: {s["how"]}\n')

Architettura: mettere insieme tutti gli elementi

Un livello applicativo web completamente scalato utilizza insieme tutte e tre le tecniche: il bilanciamento del carico distribuisce il traffico, la CDN assorbe le richieste per risorse statiche e API memorizzabili nella cache, mentre Redis memorizza nella cache i dati dinamici. Il database riceve solo le richieste che non trovano dati nella cache — in genere il 5-20% delle richieste.

Un tipico flusso di richiesta per un'API con molte letture è: utente → DNS → edge della CDN (cache hit: risposta immediata) → cache miss della CDN → bilanciatore di carico → pool di server applicativi → cache Redis (hit: risposta in 1 ms) → cache Redis (miss) → database (10-50 ms) → risposta memorizzata nella cache Redis + CDN opzionale → utente. Ogni livello riduce significativamente il carico sul database.

# Request flow with cache hit rates
request_flow = [
    ('Browser Cache',       '10%',  '0ms',   'Browser caches GET responses per Cache-Control'),
    ('CDN Edge Cache',      '60%',  '5ms',   'CloudFront/Fastly caches cacheable API responses'),
    ('Load Balancer',        None,  '1ms',   'Routes to healthy app server replica'),
    ('App Server',           None,  '2ms',   'Business logic, auth check'),
    ('Redis Cache',         '25%',  '1ms',   'Caches computed data, hot DB rows'),
    ('Database Read Replica','5%',  '10ms',  'Cache miss: query read replica'),
    ('Database Primary',    '0.1%', '15ms',  'Cache+replica miss: query primary (rare for reads)'),
]
print(f'{'Layer':30s} {'Hit Rate':10s} {'Latency':10s} {'Notes'}')
print('-'*80)
for layer, hit_rate, latency, note in request_flow:
    hr = hit_rate if hit_rate else '-'
    print(f'{layer:30s} {hr:10s} {latency:10s} {note}')
print('\nResult: DB sees ~5% of requests; Redis sees ~25%; CDN absorbs 60%; browser 10%')

Suggerimenti per i colloqui: caching e bilanciamento del carico

Quando discute di caching in un colloquio di system design, affronti sempre questi aspetti: cosa memorizzare nella cache (dati molto richiesti, calcoli costosi), dove memorizzare nella cache (browser, CDN, applicazione, cache delle query del database), quando invalidare (alla scrittura, alla scadenza del TTL o tramite un aggiornamento in background) e quali garanzie di coerenza sono accettabili. Una cache introduce una finestra di incoerenza: sia esplicito al riguardo.

Per il bilanciamento del carico, menzioni la scelta dell'algoritmo, i controlli dello stato, le sessioni persistenti (se necessarie) e la possibilità di scalare orizzontalmente i server applicativi senza stato. Se l'applicazione mantiene uno stato (connessioni WebSocket, sessioni), spieghi come tale stato viene gestito tra le repliche.

# Caching design questions checklist
cache_checklist = [
    'What data to cache? (read-heavy, expensive to compute, rarely updated)',
    'Cache layer: client-side / CDN / app-level / DB query cache?',
    'Cache invalidation strategy: TTL / event-driven / write-through?',
    'Eviction policy: LRU / LFU?',
    'Cache key design: ensure uniqueness, avoid hotspots',
    'Consistency window: acceptable staleness in seconds?',
    'Cache stampede prevention: mutex / stale-while-revalidate?',
    'Cache capacity: how much RAM needed for hot set?',
]
lb_checklist = [
    'Layer 4 vs Layer 7: routing by IP or by URL/headers?',
    'Algorithm: round robin / least-connections / consistent hashing?',
    'Health checks: interval, failure threshold, recovery',
    'Session stickiness: needed? Use cookie-based affinity or external session store',
    'Auto-scaling: scale out when CPU > 70%; scale in when < 30%',
]
print('Cache checklist:')
for item in cache_checklist: print(f'  [ ] {item}')
print('\nLoad balancer checklist:')
for item in lb_checklist: print(f'  [ ] {item}')

Verifica rapida

Metta alla prova la Sua comprensione dei concetti di Data Structures & Algorithms — Coding Interview Prep trattati in questa lezione.

Riepilogo della lezione

In questa lezione ha imparato che: il caching memorizza i dati più richiesti in livelli di memoria veloce (Redis, CDN) per assorbire la maggior parte delle letture e ridurre il carico sul database, cache-aside è il pattern più comune: in caso di miss si caricano i dati dal DB, in caso di hit si restituiscono immediatamente, mentre in scrittura si elimina il dato dalla cache e l'hashing consistente distribuisce le chiavi della cache tra i nodi, così l'aggiunta o la rimozione di nodi rimappa solo ~1/n delle chiavi anziché rimapparle tutte. Nel prossimo argomento progetteremo un rate limiter e un feed di Twitter per applicare tutti i concetti di system design a problemi completi.

Domande Frequenti

La lezione «Caching, CDN e bilanciamento del carico» è gratuita?

Sì — il testo completo di «Caching, CDN e bilanciamento del carico» è gratuito qui sul web. Per esercitarvi in modo interattivo (un editor di codice integrato e un tutor IA 24/7) e sbloccare il resto del corso DSA Interview Prep, passa a CoddyKit PRO. Il corso DSA Interview Prep include 4 lezioni in totale.

Cosa imparerò in «Caching, CDN e bilanciamento del carico»?

Aggiunga livelli di caching con Redis, sposti gli asset statici su una CDN e distribuisca il traffico tra le repliche con bilanciatori del carico round-robin e consistent hashing. Eserciti DSA Interview Prep con codice pratico che esegui direttamente nel browser, e un tutor IA 24/7 risponde alle tue domande mentre lavori sulla lezione.

Ho bisogno di esperienza per iniziare DSA Interview Prep?

Non è richiesta alcuna esperienza precedente. DSA Interview Prep su CoddyKit è strutturato per principianti e studenti avanzati, quindi puoi iniziare da qui o dall'inizio e procedere al tuo ritmo. Questa è la lezione 3 di 4.

Quanto tempo richiede la lezione «Caching, CDN e bilanciamento del carico»?

La maggior parte delle lezioni CoddyKit richiede circa 5–10 minuti. Ogni lezione è breve e interattiva, quindi fai progressi costanti e riprendi esattamente da dove hai lasciato su web e app.

Posso scrivere ed eseguire codice in questa lezione DSA Interview Prep?

Sì. Ogni lezione DSA Interview Prep include un editor di codice integrato, quindi scrivi ed esegui codice reale direttamente nel tuo browser e ricevi feedback istantaneo dall'IA — nessuna configurazione locale necessaria.

Tutte le lezioni di questo corso

  1. Il framework per i colloqui di system design
  2. Archiviazione scalabile dei dati: SQL e NoSQL
  3. Caching, CDN e bilanciamento del carico
  4. Progettare un rate limiter e un feed di Twitter
← Torna a DSA Interview Prep