Il problema che non si vede finché è troppo tardi
C’è un pattern ricorrente nelle architetture a microservizi: tutto funziona in modo impeccabile per mesi, i test automatici sono verdi, il monitoraggio non mostra allarmi rilevanti, e il team è soddisfatto della solidità del sistema. Poi, spesso durante un picco di traffico ordinario o un evento previsto, qualcosa si inceppa. La latenza schizza verso l’alto, le code si allungano, i timeout si moltiplicano. Il problema non è stato causato dall’evento in sé — era già lì, nascosto sotto la superficie.
Questo articolo analizza i colli di bottiglia più insidiosi che affliggono i microservizi in produzione: quelli che non emergono nei test di carico standard e che vengono spesso diagnosticati troppo tardi.
1. Esaurimento del connection pool al database
Il problema del connection pool è forse il più comune e il meno visibile fino al momento critico. In un’architettura a microservizi, ogni istanza di ogni servizio apre e gestisce il proprio pool di connessioni al database. Il calcolo è semplice: se hai 10 microservizi con 5 istanze ciascuno e ogni istanza mantiene un pool di 10 connessioni, stai occupando 500 connessioni sul database. Scala orizzontalmente per gestire un picco di traffico, e quelle connessioni raddoppiano in pochi minuti.
La maggior parte dei database relazionali ha un limite fisico di connessioni concorrenti. PostgreSQL, per esempio, ha un default di 100 connessioni per il parametro max_connections. Superato il limite, nuove richieste di connessione falliscono con errori che spesso vengono nascosti da retry automatici, mascherando il problema effettivo fino a quando il sistema non collassa.
Come diagnosticarlo
-- PostgreSQL: connessioni attive per applicazione
SELECT application_name, count(*), state
FROM pg_stat_activity
GROUP BY application_name, state
ORDER BY count DESC;
-- Verifica il limite configurato
SHOW max_connections;
Come risolverlo
La soluzione più efficace per scenari ad alta concorrenza è l’uso di un connection pooler esterno come PgBouncer. PgBouncer opera in modalità transaction pooling: riusa le connessioni al database tra più client, riducendo drasticamente il numero di connessioni fisiche necessarie indipendentemente dal numero di istanze dei servizi.
# pgbouncer.ini — configurazione essenziale
[databases]
mydb = host=pg-primary port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
reserve_pool_size = 5
In .NET, assicurati che il pool di connessioni Npgsql sia configurato in modo coerente con i limiti imposti da PgBouncer:
// Connection string con pool sizing esplicito
"Host=pgbouncer-host;Database=mydb;Username=app;Password=...;
Maximum Pool Size=10;Minimum Pool Size=2;Connection Idle Lifetime=300"
2. Thread pool starvation nelle chiamate sincrone
Il thread pool starvation è un problema particolarmente subdolo nelle applicazioni ASP.NET Core o nelle applicazioni Spring Boot che mischiano codice asincrono e sincrono. Quando un thread del pool viene bloccato in attesa di un’operazione I/O (una query al database, una chiamata HTTP a un servizio dipendente, una lettura da file), quel thread non è disponibile per elaborare nuove richieste. Se questo accade su scala, il thread pool si esaurisce e le nuove richieste rimangono in coda indefinitamente.
Il sintomo tipico è una degradazione progressiva delle performance durante i picchi: il throughput crolla, i tempi di risposta salgono in modo apparentemente inspiegabile, ma il database e la CPU risultano ancora sotto carico normale.
Pattern da evitare in .NET
// ❌ SBAGLIATO: blocca un thread del pool
public IActionResult GetData()
{
var result = _service.GetDataAsync().Result; // .Result blocca il thread
return Ok(result);
}
// ❌ Altrettanto problematico
public IActionResult GetData()
{
var result = _service.GetDataAsync().GetAwaiter().GetResult();
return Ok(result);
}
// ✅ CORRETTO: il thread viene restituito al pool durante l'attesa
public async Task<IActionResult> GetData()
{
var result = await _service.GetDataAsync();
return Ok(result);
}
Come diagnosticare il thread pool starvation
In .NET, puoi monitorare lo stato del thread pool a runtime:
ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads);
ThreadPool.GetMaxThreads(out int maxWorker, out int maxCompletionPort);
// Se workerThreads è vicino a 0 con carico moderato, sei in starvation
Console.WriteLine($"Available: {workerThreads}/{maxWorker}");
Strumenti come dotnet-counters offrono un monitoraggio continuo in produzione senza impatto significativo:
dotnet-counters monitor --counters System.Runtime[threadpool-thread-count,threadpool-queue-length] -p <PID>
3. Cascading failures per dipendenze lente o non responsive
In un’architettura a microservizi, ogni servizio dipende da altri servizi. Questa catena di dipendenze è la fonte di uno dei problemi più difficili da gestire in produzione: il cascading failure. Se il Servizio B risponde lentamente, il Servizio A che lo chiama accumula richieste in attesa. I thread del pool di A vengono occupati in attesa di B, la latenza di A aumenta, il Servizio C che dipende da A inizia a ricevere timeout, e nel giro di pochi minuti l’intera catena collassa.
La soluzione consolidata è il pattern Circuit Breaker, che interrompe temporaneamente le chiamate verso un servizio degradato e restituisce immediatamente un errore (o una risposta di fallback), permettendo al sistema di degradare in modo controllato invece di collassare a cascata.
Implementazione con Polly in .NET
// Configurazione circuit breaker con Polly v8
var circuitBreakerPolicy = new ResiliencePipelineBuilder()
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5, // Apre dopo il 50% di fallimenti
SamplingDuration = TimeSpan.FromSeconds(10),
MinimumThroughput = 10, // Minimo 10 richieste nel campione
BreakDuration = TimeSpan.FromSeconds(30), // Attende 30s prima di riprovare
OnOpened = args =>
{
_logger.LogWarning("Circuit breaker aperto per {Service}", serviceName);
return default;
}
})
.AddTimeout(TimeSpan.FromSeconds(3)) // Timeout esplicito su ogni chiamata
.Build();
// Uso
var result = await circuitBreakerPolicy.ExecuteAsync(async token =>
await _httpClient.GetFromJsonAsync<OrderDto>("/api/orders/123", token),
cancellationToken);
4. Mancanza di backpressure nei flussi di messaggi
I sistemi basati su message broker (Kafka, RabbitMQ, Azure Service Bus) introducono un’ulteriore classe di colli di bottiglia: quando il ritmo di produzione dei messaggi supera la capacità di elaborazione dei consumer, le code crescono indefinitamente. Questo può portare a latenza crescente nell’elaborazione, out-of-memory degli broker, e scenari in cui messaggi vengono elaborati con ore o giorni di ritardo rispetto alla loro produzione.
La soluzione è implementare la backpressure: i consumer controllano attivamente il ritmo di ricezione in base alla propria capacità di elaborazione. In Kafka, questo si gestisce configurando correttamente il numero di partizioni, il numero di consumer nel consumer group, e i parametri max.poll.records e fetch.max.bytes.
// Esempio: consumer con controllo esplicito del concurrency via MassTransit
services.AddMassTransit(x =>
{
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.ReceiveEndpoint("orders-queue", e =>
{
e.PrefetchCount = 10; // Non prelevare più di 10 messaggi alla volta
e.ConcurrentMessageLimit = 5; // Elabora al massimo 5 messaggi in parallelo
e.ConfigureConsumer<OrderConsumer>(ctx);
});
});
});
5. N+1 queries non rilevate in produzione
Il problema delle N+1 queries è ben noto in teoria ma sorprendentemente comune in produzione, soprattutto dopo refactoring o l’aggiunta di nuove funzionalità. L’ORM carica una lista di entità (1 query), poi per ogni entità carica lazy una relazione (N query). In sviluppo, con 10 elementi nel database, il problema è invisibile. In produzione, con 10.000 elementi, si traducono in 10.001 query per ogni richiesta.
// ❌ N+1: per ogni ordine viene eseguita una query separata per i prodotti
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// Lazy load: ogni accesso a order.Products genera una nuova query
var productNames = order.Products.Select(p => p.Name);
}
// ✅ Eager loading: una sola query con JOIN
var orders = await _context.Orders
.Include(o => o.Products)
.ToListAsync();
Per rilevare le N+1 in produzione, abilita il logging delle query EF Core in ambiente di staging con soglia di warning per query lente:
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging(false) // Mai in produzione
.ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning)));
Monitoraggio proattivo: gli indicatori chiave
Rilevare questi problemi prima che diventino incidenti richiede un set di metriche mirate. Oltre alle metriche standard (CPU, RAM, latenza), monitora attivamente:
- Connessioni attive al database per istanza e per servizio
- Thread pool queue length: una coda crescente anche sotto carico moderato è un segnale di allarme
- Circuit breaker state changes: ogni apertura di un circuit breaker è un evento da tracciare e analizzare
- Message lag sulle code Kafka/RabbitMQ: il ritardo tra produzione e consumo
- Slow queries: percentuale di query che superano una soglia prefissata (es. 500ms)
Strumenti come Prometheus + Grafana con i rispettivi exporter per PostgreSQL, RabbitMQ e .NET (via prometheus-net) permettono di costruire dashboard specifiche per questi pattern senza dipendere esclusivamente dai log.
Conclusione
I colli di bottiglia nascosti nei microservizi hanno una caratteristica comune: emergono sotto carico, in condizioni che i test standard non replicano fedelmente. Connection pool esauriti, thread in starvation, cascading failures, code in crescita incontrollata e N+1 queries sono problemi architetturali che richiedono attenzione già in fase di design, non solo quando il sistema è già in produzione e sotto stress.
Identificare preventivamente questi pattern, configurare correttamente i pool e i circuit breaker, e costruire un layer di osservabilità specifico per questi indicatori è l’investimento tecnico con il miglior ritorno in termini di stabilità per qualsiasi sistema distribuito di dimensioni non banali.
Fonte: The Hidden Bottlenecks That Break Microservices in Production – DZone