Performance — sobriété d'abord
Kohinos n'est pas Twitter. Une instance type vise quelques centaines à quelques milliers
d'adhérents — charge faible mais financière. La règle locale :
préférer la simplicité robuste à la performance bricolée. Toute optimisation
commence par une mesure, jamais par une intuition. La performance vient
après l'intégrité financière, la souveraineté, la sobriété et la
maintenabilité (cf. .claude/rules/global.md).
1. Intégrité financière · 2. Souveraineté (self-host) · 3. Sobriété (Raspberry Pi) · 4. Maintenabilité · 5. Performance. Un patch qui améliore la perf mais complexifie la logique monétaire n'est pas un bon patch pour Kohinos.
Cibles matérielles
Le projet est conçu pour tourner sur du matériel modeste. Ces cibles couvrent 99 % des MLCC françaises.
| Cible | RAM / vCPU | Adhérents max conseillés | Notes |
|---|---|---|---|
| Raspberry Pi 4 | 4 Go / 4 cœurs ARMv8 | < 1 000 | SD card en lecture seule + SSD USB3 pour var/ et MariaDB. |
| Raspberry Pi 5 | 8 Go / 4 cœurs Cortex-A76 | < 5 000 | NVMe via HAT recommandé. Confortable jusqu'à ~3 fédérations colocataires. |
| VPS minimal | 2 vCPU / 4 Go | ~10 000 | Cible production type Fédération Kohinos. Au-delà : passer à 4 vCPU / 8 Go avant Redis. |
| Serveur dédié | 4+ vCPU / 8+ Go | > 10 000 | Probablement sur-dimensionné : questionner la vraie charge avant d'investir. |
Une MLCC active = quelques dizaines de transactions par jour, un pic mensuel sur les prélèvements SEPA et SSA (cron de nuit). Le besoin perf est dominé par les batch jobs, pas par le trafic HTTP.
Mesurer avant d'optimiser
Aucune optimisation sans une mesure préalable. Trois outils légaux dans ce projet :
- Symfony Web Profiler (dev / staging) : l'onglet Doctrine liste toutes les requêtes, leur durée et leur trace. Onglet Performance donne le timeline du request.
- Tideways ou SPX (self-host, à privilégier).
SPXest une extension PHP sous licence libre, parfaite sur Pi. Blackfire.io = cloud propriétaire : à éviter par la règle de souveraineté. - MariaDB
EXPLAINsur toute requête remontée comme lente par le profiler.slow_query_log = 1en prod aveclong_query_time = 1seconde.
# Activer le slow query log MariaDB
sudo mysql -e "SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';"
# Inspecter une requête repérée
EXPLAIN SELECT * FROM transaction WHERE solidoume_parameter_id = 4
AND reference LIKE 'Solidoume%' AND DATE(created_at) >= '2026-01-01';
Patterns Doctrine performants
-
N+1 queries : une boucle qui accède à une relation
lazy = autant de requêtes que d'éléments. Utiliser l'agent
n-plus-one-detectorsur les services suspects (ex. listings SSA, recap mensuel SEPA). -
JOIN FETCH quand on sait que la relation sera utilisée :
SELECT t, m FROM Transaction t JOIN FETCH t.mandate m WHERE .... -
Pagination obligatoire dès qu'une liste peut dépasser
50 éléments — utiliser
KnpPaginatorouDoctrine\ORM\Tools\Pagination\Paginator. -
$em->clear()ouiterateBatched()dans les batchs longs (cron SEPA / SSA) tous les 100 objets, sinon l'UnitOfWork explose la RAM du Pi. -
Ne pas faire de QueryBuilder dans un contrôleur ou un service —
toujours dans le repository, méthode
find*()bien nommée (cf. règles projet).
// Exemple batch SEPA — vider l'UnitOfWork tous les 100 mandats
$batchSize = 100;
$i = 0;
foreach ($mandateRepo->iterateActiveDue($month) as $mandate) {
$this->processOne($mandate);
if ((++$i % $batchSize) === 0) {
$this->em->flush();
$this->em->clear(); // libère la RAM, indispensable sur Pi
}
}
$this->em->flush();
$this->em->clear();
Cache
Symfony et Doctrine fournissent plusieurs couches de cache. Sur Kohinos elles sont activées progressivement : filesystem suffit jusqu'à 1 000 adhérents, Redis local au-delà.
| Couche | Adapter recommandé | Note |
|---|---|---|
| Symfony cache app | cache.adapter.filesystem (dev/petit prod) ·
cache.adapter.redis (Redis local) |
APCu déconseillé sur Kohinos (workload random-write, fragmentation mémoire). |
| Doctrine metadata cache | Pool cache.system (PHP array en prod via cache:warmup) |
Voir config/packages/doctrine.yaml — déjà branché. |
| Doctrine query cache | Même pool — généré au warmup | Met en cache le SQL des DQL, pas les résultats. |
| Doctrine result cache | À activer ponctuellement (ex. GlobalParameter) |
Penser invalidation manuelle quand on modifie le paramètre. |
| HTTP cache | Symfony HttpCache (reverse-proxy intégré) ou Varnish | Pages publiques CMS uniquement — jamais les vues comptes. |
# Bascule vers Redis (prod > 1000 adhérents) — config/packages/cache.yaml
framework:
cache:
app: cache.adapter.redis
default_redis_provider: redis://127.0.0.1:6379
OPcache & JIT
OPcache (extension opcache déjà listée dans
installation.html) est obligatoire en
prod : on parle d'un facteur ×3 à ×8 sans aucune autre modification.
; /etc/php/8.4/fpm/conf.d/10-opcache.ini (prod)
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256 ; Mo (128 pour un Pi 4)
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000 ; 6000 pour un Pi 4
opcache.validate_timestamps=0 ; PROD seulement : rebuild au déploiement
opcache.save_comments=1 ; nécessaire pour Doctrine attributes
; JIT (PHP 8.4) — gain marginal sur Symfony, à activer après mesure
opcache.jit=tracing
opcache.jit_buffer_size=128M ; 64M sur un Pi
opcache.validate_timestamps=0 en prod
Cette option fige le bytecode en mémoire. Un git pull seul ne suffit
pas à appliquer le nouveau code : il faut recharger php-fpm
(systemctl reload php8.4-fpm) au déploiement. Voir
déploiement.html.
JIT : PHP 8.4 propose tracing et function.
Le gain pour une appli Symfony classique (I/O DB dominant) est de l'ordre de 5–15 %
seulement. À activer après avoir mesuré l'impact sur votre charge réelle.
Frontend
Vanilla JS + Bootstrap 5.2 = empreinte modeste. Mais Webpack Encore et le navigateur laissent beaucoup à gagner :
- Tree-shaking activé par défaut en
encore.production(). Vérifier que les imports sont nommés (import {x} from 'lib') et non full bundle. splitEntryChunks()danswebpack.config.jspour mutualiservendors~*.js.- Lazy loading avec
import()dynamique sur les modules gros et utilisés rarement (CKEditor, charts SSA). - Images compressées : WebP / AVIF avec fallback PNG, gérés via
Sonata Media +
imageminou un croncwebp. - HTTP/2 ou HTTP/3 côté Nginx / Apache — déjà actif dans le vhost fourni (cf. installation).
- Cache-Control long sur
build/(Encore ajoute un hash au nom de fichier, donc immutable safe).
Indexation BDD
Sur DBAL 3, Doctrine ne crée pas automatiquement d'index sur les clés étrangères :
c'est au développeur d'ajouter #[ORM\Index]. Cibles à vérifier en priorité :
| Table / colonne | Raison |
|---|---|
transaction.solidoume_parameter_id | Filtré dans tous les recap SSA. |
transaction.reference | LIKE 'Solidoume%', 'Cotisation MLC%'… (préfixe → index utilisable). |
sepa_mandate.mandate_reference | Lookup UMR pour notifications + webhooks. |
sepa_mandate.last_processed_month | Évite le double prélèvement. |
sepa_recurring_request.last_month_payed | Idem. |
payment.helloassoid | Recherche webhook HelloAsso. |
| Toute FK | JOIN sans index = scan complet. |
// Exemple : index sur une référence souvent filtrée
#[ORM\Entity]
#[ORM\Index(name: 'idx_tx_solidoume_param', columns: ['solidoume_parameter_id'])]
#[ORM\Index(name: 'idx_tx_reference', columns: ['reference'])]
class Transaction
{
// ...
}
Un index sur VARCHAR(255) coûte beaucoup plus cher qu'un index sur
VARCHAR(64). Pour les références techniques (UMR, IDs externes), réduire
à la longueur réelle utile. Pour les recherches sur reference en LIKE
préfixé, un index simple suffit ; pour du LIKE médian, envisager un index FULLTEXT
(ou de re-modéliser).
Toujours EXPLAIN une nouvelle requête métier avant la mise
en prod, particulièrement les batchs SEPA/SSA qui scannent l'historique.
Anti-patterns à refuser
-
Cacher un solde de compte. Le solde temps-réel se calcule
depuis les opérations (cf.
AccountableObject). Un cache de solde = risque d'écart financier = refus immédiat. -
Pré-fetcher des données financières. Une transaction lue
doit l'être au moment où on en a besoin. Pas de batch nocturne qui
pré-agrège un solde dans un champ
balance_cached. - Microservices pour 100 adhérents. Le monolithe Symfony tient sur un Pi 4. Découper en services réseau ajoute des modes de panne sans bénéfice mesurable.
-
Queue lourde (RabbitMQ, Kafka) pour des cron mensuels.
symfony/messenger+ transportdoctrine://suffit amplement pour les volumes Kohinos. - CDN propriétaire (Cloudflare, AWS CloudFront) pour des assets publics. HTTP/2 + cache-control immutable = même résultat sans intermédiaire qui voit le trafic des adhérents.
- Cacher pour masquer un bug. Si une requête est lente, corriger la requête, pas l'envelopper dans un cache à TTL court.
Sobriété — performance ≠ scale
Le Raspberry Pi est une contrainte de design : il force à se demander si une feature mérite vraiment sa complexité. Une instance Kohinos qui tient sur 5 W sans Redis, sans queue externe et sans CDN est plus simple à maintenir, plus simple à auto-héberger, et plus difficile à censurer. C'est cohérent avec la roadmap Reticulum / LoRa : moins on dépend de l'infrastructure, plus on est libre.
Simpler than faster. Avant d'ajouter une couche, demandez-vous : cette optimisation économise combien de Wh par mois ? Combien de minutes de RAM libérée ? Et combien de minutes de dette technique je signe pour la maintenir ?
- Ai-je mesuré (profiler + EXPLAIN) ?
- Le gain est-il > 100 ms / requête utilisateur ?
- Le service à ajouter est-il self-hostable ?
- Ce cache touche-t-il une donnée financière (interdit) ?
- Le code reste-t-il lisible à 6 mois pour un futur contributeur solo ?
Si la réponse à une seule question est non : refuser le patch.
Voir aussi : installation.html · monitoring.html.