Deep EVM #23 : Débogage de performance — Quand les lectures base de données tuent votre latence
Engineering Team
Le symptôme
Votre tableau de bord de monitoring s’allume en rouge. Les temps de réponse passent de 50 ms à 114 secondes. Les erreurs 500 cascadent sur tous les endpoints. Quelque chose étouffe le système, et ce n’est pas un goulot d’étranglement de ressources évident. Cet article trace le problème jusqu’à l’amplification de lectures en base de données.
Amplification de lectures : le problème
L’amplification de lectures se produit quand une opération logique unique déclenche un nombre disproportionné de lectures physiques en base de données.
Exemple concret : un indexeur blockchain qui met à jour les soldes des comptes après chaque bloc. L’approche naïve :
// O(N) — lit TOUS les comptes à chaque bloc
async fn update_balances(db: &Database, block: &Block) {
let all_accounts = db.get_all_accounts().await?; // 500K comptes
for tx in &block.transactions {
// Mettre à jour le solde
let account = all_accounts.get(&tx.from);
// ...
}
}
Avec 500 000 comptes et des blocs toutes les 12 secondes, cela génère des millions de lectures par minute. La base de données ne peut pas suivre.
Diagnostic
1. Métriques de la base de données
-- PostgreSQL : identifier les requêtes lentes
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
2. Traçage distribué
#[instrument(skip(db))]
async fn process_block(db: &Database, block: &Block) -> Result<()> {
let span = tracing::info_span!("process_block", block_number = block.number);
let _guard = span.enter();
// Chaque opération DB est tracée
let accounts = db.get_affected_accounts(block).instrument(span.clone()).await?;
// ...
}
Le traçage révèle que 95 % du temps est passé dans les lectures de base de données.
La solution : O(N) -> O(affecté)
Au lieu de lire tous les comptes, lisez uniquement ceux affectés par les transactions du bloc :
// O(affecté) — lit seulement les comptes modifiés
async fn update_balances(db: &Database, block: &Block) {
// Collecter les adresses uniques du bloc
let affected: HashSet<Address> = block.transactions.iter()
.flat_map(|tx| vec![tx.from, tx.to])
.collect();
// Lire seulement les comptes affectés
let accounts = db.get_accounts_batch(&affected).await?; // ~200 comptes
for tx in &block.transactions {
let account = accounts.get(&tx.from);
// ...
}
}
Passage de 500 000 lectures à environ 200. Facteur d’amélioration : 2500x.
Le pattern CacheDB
Pour les systèmes qui lisent fréquemment les mêmes données, une couche de cache in-memory élimine les lectures répétées :
struct CacheDB {
inner: Arc<dyn Database>,
cache: DashMap<Address, Account>,
}
impl CacheDB {
async fn get_account(&self, addr: &Address) -> Result<Account> {
// Vérifier le cache d'abord
if let Some(account) = self.cache.get(addr) {
return Ok(account.clone());
}
// Miss — lire depuis la base
let account = self.inner.get_account(addr).await?;
self.cache.insert(*addr, account.clone());
Ok(account)
}
fn invalidate_block(&self, block: &Block) {
// Invalider uniquement les comptes modifiés
for tx in &block.transactions {
self.cache.remove(&tx.from);
self.cache.remove(&tx.to);
}
}
}
Résultats
| Métrique | Avant | Après | Amélioration |
|---|---|---|---|
| Lectures/bloc | 500K | ~200 | 2500x |
| Latence P99 | 114s | 45ms | 2533x |
| Erreurs 500 | ~30% | 0% | Éliminées |
| CPU base de données | 95% | 12% | 8x |
Conclusion
L’amplification de lectures est un problème insidieux — le système fonctionne parfaitement avec peu de données et s’effondre à l’échelle. La solution est toujours la même : passez de O(N) à O(affecté), ajoutez une couche de cache avec invalidation ciblée, et instrumentez votre code avec du traçage distribué pour détecter les régressions tôt.