Performance et sauvegardes
Cette page décrit les réglages de PostgreSQL et du pool de connexions, les outils pour diagnostiquer une requête lente, et le plan de sauvegarde et de restauration avec ses objectifs de perte de données (RPO) et de durée de reprise (RTO). Les procédures d'exploitation complètes sont sur les pages Sauvegardes et Runbooks.
Pool de connexions
Le client Prisma utilise l'adaptateur @prisma/adapter-pg, qui s'appuie sur le pool du pilote pg. Il est configuré dans backend/src/config/prisma.config.ts :
| Paramètre | Variable | Défaut | Rôle |
|---|---|---|---|
max |
DB_POOL_MAX |
10 | Connexions simultanées au plus, par process |
connectionTimeoutMillis |
DB_CONNECTION_TIMEOUT_MS |
5 000 ms | Attente maximale d'une connexion libre du pool ou de l'ouverture d'une connexion |
idleTimeoutMillis |
DB_IDLE_TIMEOUT_MS |
30 000 ms | Fermeture d'une connexion inutilisée |
application_name |
blocklucky-backend |
Nom visible dans pg_stat_activity |
Le client est un singleton par process. Hors production, il est mémorisé dans global pour que le rechargement à chaud de tsx watch ne multiplie pas les pools.
Délais par connexion
Chaque connexion ouverte par l'application reçoit trois délais côté serveur :
| Paramètre PostgreSQL | Valeur | Effet |
|---|---|---|
statement_timeout |
DB_STATEMENT_TIMEOUT_MS, 15 000 ms |
Une requête plus longue est annulée par le serveur |
lock_timeout |
5 000 ms, fixe | Une requête qui attend un verrou plus de 5 s échoue au lieu de bloquer |
idle_in_transaction_session_timeout |
60 000 ms, fixe | Une session restée en transaction ouverte sans activité est fermée, ce qui libère ses verrous |
Une requête annulée par statement_timeout remonte avec le code PostgreSQL 57014. Le gestionnaire d'erreurs backend/src/middlewares/error.middleware.ts la traduit en HTTP 503 « La requête a dépassé le délai autorisé ». Les codes 40001 (sérialisation) et 40P01 (interblocage) donnent une 409 « Conflit de transaction, veuillez réessayer ».
Le délai de 15 s reste sous la limite HTTP_REQUEST_TIMEOUT_MS de 30 s du serveur HTTP : une requête SQL bloquée échoue proprement avant que le serveur ne coupe la connexion du client.
Dimensionnement
| Élément | Valeur |
|---|---|
max_connections du serveur |
100 (service postgres de docker-compose.yml) |
| Connexions par process | DB_POOL_MAX, 10 |
| Process par déploiement Compose | 2 (backend et worker) plus le job migrate ponctuel |
Au-delà de 8 process de 10 connexions, il reste trop peu de marge sous 100 connexions pour les connexions d'administration et de migration. Il faudra alors placer un PgBouncer en mode transaction devant PostgreSQL, ou réduire DB_POOL_MAX.
Le worker consomme peu de connexions : la file chain-actions et la file ledger ont une concurrence de 1, l'indexeur traite les événements un par un.
Index et requêtes
Les index existants et la requête que chacun sert sont décrits sur la page Index et contraintes. Les requêtes les plus fréquentes sont couvertes :
| Requête | Index |
|---|---|
Tour courant (status IN ('OPEN', 'LOCKED')) |
LotteryRound_status_idx |
| Tour par numéro du contrat | LotteryRound_contractRoundId_key |
| Compte par adresse (indexeur, file d'actions) | User_walletAddress_key |
Tickets d'un compte par date (my-tickets) |
Ticket_userId_purchasedAt_idx |
| Tickets d'un tour (comptage, payeurs) | Ticket_lotteryId_idx |
| Idempotence de l'indexeur | Ticket_lotteryId_ticketIndex_key |
| Historique des points | PointTransaction_userId_createdAt_idx |
Deux requêtes ne sont pas servies par un index dédié :
- le classement (
getLeaderboardData) trie les comptes actifs parpointsBalance, sans index sur cette colonne : PostgreSQL parcourt la tableUser; - le comptage des participants distincts d'un tour (
countDistinctParticipants) agrège les tickets du tour paruserIdà partir deTicket_lotteryId_idx.
Aux volumes visés (un objectif de 10 000 participants par tour), ces parcours restent rapides. Ils sont les premiers candidats à surveiller dans pg_stat_statements.
Diagnostic : pg_stat_statements
Le service postgres de Docker Compose démarre avec :
| Option | Valeur | Effet |
|---|---|---|
shared_preload_libraries |
pg_stat_statements |
Charge l'extension de statistiques des requêtes |
pg_stat_statements.track |
all |
Mesure aussi les requêtes imbriquées dans des fonctions |
log_min_duration_statement |
500 | Journalise toute requête de plus de 500 ms |
log_lock_waits |
on |
Journalise les attentes de verrou plus longues que deadlock_timeout |
idle_in_transaction_session_timeout |
60 000 | Même délai que celui de l'application, appliqué à tous les clients |
max_connections |
100 |
L'extension est créée à l'initialisation du volume par docker/postgres/init/01-extensions.sql (CREATE EXTENSION IF NOT EXISTS pg_stat_statements;). Ce script ne s'exécute qu'à la création du volume pgdata : sur un volume plus ancien, il faut lancer la commande à la main.
Les requêtes les plus coûteuses en temps cumulé :
SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS moyenne_ms, rows, left(query, 120) AS requete
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;Les connexions de l'application et leur état :
SELECT state, count(*) FROM pg_stat_activity WHERE application_name = 'blocklucky-backend' GROUP BY state;Le plan d'une requête suspecte, par exemple l'historique d'un citoyen :
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM "Ticket" WHERE "userId" = '…' ORDER BY "purchasedAt" DESC LIMIT 20;Pour voir les requêtes SQL générées par Prisma, démarrer le backend avec DB_LOG_QUERIES=true : le client journalise alors chaque requête (niveau query), en plus des erreurs et avertissements.
La sonde GET /health/ready exécute SELECT 1 avec un délai de 2 s et répond 503 si la base ne répond pas ; GET /health/deep donne aussi la latence mesurée. Le détail est sur la page Observabilité.
Sauvegardes
Ce qu'il faut sauvegarder
| Donnée | Source de vérité | Mécanisme |
|---|---|---|
| Comptes, empreintes des mots de passe, clés chiffrées des portefeuilles, pièces d'identité chiffrées en attente, EtherPoints, partenaires, récompenses, analytique | PostgreSQL | pg_dump manuel |
| Tickets, tours, cagnottes | Smart contract BlockLucky |
Aucun : reconstruction par l'indexeur (voir Reconstruire depuis la chaîne) |
| Files de jobs, préférences, quêtes, lots de codes promo | Redis | Persistance AOF appendfsync everysec sur le volume redisdata |
Clé ENCRYPTION_KEY |
Secret d'environnement | Hors de la base, à conserver séparément |
Danger
Une sauvegarde de la base sans la clé ENCRYPTION_KEY est inutilisable pour les portefeuilles : les clés privées chiffrées ne peuvent pas être déchiffrées. À l'inverse, la sauvegarde contient les clés chiffrées et les photos des pièces d'identité encore en attente, et doit être protégée comme un secret : quiconque obtient le fichier et la clé contrôle les portefeuilles et lit ces pièces. Les images effacées après une décision restent présentes dans les sauvegardes antérieures, jusqu'à leur destruction.
Sauvegarde logique
Depuis la racine du dépôt, avec la pile Docker Compose démarrée :
docker compose exec -T postgres pg_dump -U etherbay_admin -d blocklucky_db -Fc > backup-$(date +%Y%m%d-%H%M).dumpLe format -Fc (custom) est compressé et permet de restaurer table par table avec pg_restore. pg_dump lit un instantané cohérent sans bloquer l'application.
Aucune sauvegarde n'est planifiée dans le dépôt : il n'existe pas de tâche cron, ni d'archivage continu des journaux WAL. La sauvegarde est manuelle.
Tester une restauration
Restaurer dans une base temporaire, sans toucher à blocklucky_db :
docker compose exec -T postgres createdb -U etherbay_admin blocklucky_restore_test
docker compose exec -T postgres pg_restore -U etherbay_admin -d blocklucky_restore_test --no-owner < backup-AAAAMMJJ-HHMM.dump
docker compose exec -T postgres psql -U etherbay_admin -d blocklucky_restore_test -c 'SELECT count(*) FROM "User";'
docker compose exec -T postgres dropdb -U etherbay_admin blocklucky_restore_testRestauration réelle
Destructive : arrêter d'abord l'API et le worker.
docker compose stop backend worker
docker compose exec -T postgres pg_restore -U etherbay_admin -d blocklucky_db --clean --if-exists --no-owner < backup-AAAAMMJJ-HHMM.dump
docker compose start backend workerAprès la restauration, le curseur ChainCursor est revenu à la date du dump. Au redémarrage, l'indexeur relit les blocs postérieurs et recrée les tickets achetés depuis : les données de la loterie se recalent seules sur la chaîne. Les points de ces tickets sont crédités à ce moment, puisque les tickets sont absents de la base restaurée. En revanche, les comptes créés et les échanges de récompenses postérieurs au dump sont perdus.
RPO et RTO
| Donnée | RPO cible | RTO cible | Comment l'objectif est tenu |
|---|---|---|---|
| Comptes, EtherPoints, récompenses (PostgreSQL) | 24 h | 1 h | Un pg_dump par jour ; restauration en moins d'une heure aux volumes du projet |
| Files, préférences, quêtes (Redis) | 1 s | 15 min | AOF avec fsync chaque seconde ; Redis recharge l'AOF au redémarrage |
| Tickets, tirages, cagnottes (chaîne) | 0 | Durée de la réindexation | Rien n'est perdu sur la chaîne ; la base se recale par l'indexeur |
Le RPO (Recovery Point Objective) est la quantité maximale de données qu'on accepte de perdre, exprimée en durée. Le RTO (Recovery Time Objective) est la durée maximale acceptée pour revenir en service.
Attention
Le RPO de 24 h de PostgreSQL suppose un pg_dump quotidien, qui n'est pas automatisé. Sans discipline d'exploitation, le RPO réel est la date du dernier dump manuel. Pour un hébergement réel, il faudrait planifier les dumps, les copier hors de la machine, ou activer l'archivage WAL pour une restauration à un instant donné.
Le RPO de 1 s de Redis ne vaut que contre un arrêt du process : l'AOF est sur le même volume que les données. Une perte du volume redisdata efface les files et les données utilisateur stockées dans Redis, sans copie ailleurs (voir Données Redis).
Le RTO de la chaîne dépend du nombre de blocs à relire. L'indexeur avance d'au plus 2 000 blocs par passe de 3 s ; sur Sepolia, à un bloc toutes les 12 s environ, une semaine représente environ 50 000 blocs, soit environ 25 passes et un peu plus d'une minute de rattrapage.