Le SaaS multi‑tenant commence comme une astuce d’économie élégante : une application, une base de données, plusieurs clients. Puis vous grandissez.
Un gros locataire lance un rapport à midi, la latence p95 double, et votre architecture « tout partagé » se transforme en
souffrance partagée.
Ce texte s’adresse au moment où vous réalisez que « isolation des locataires » n’est pas une case à cocher — c’est une propriété du système qui doit survivre :
la croissance, les migrations, les audits de conformité, un mauvais déploiement, et l’occasionnel client qui considère « Exporter tout » comme un mode de vie.
Ce que l’isolation des locataires signifie réellement en production
« Isolation des locataires » n’est pas seulement « tenant_id est dans chaque table. » C’est du marquage de données. L’isolation est la propriété selon laquelle un
locataire ne peut pas :
- Lire les données d’un autre locataire (évident, mais les bugs amusants se cachent dans les jointures, vues et jobs en arrière‑plan).
- Corrompre ou supprimer les données d’un autre locataire (y compris via des erreurs de migration et des séquences partagées).
- Affamer le système au point que les autres locataires fassent des timeouts (« voisin bruyant » est juste une manière polie de dire « mon incident »).
- Vous forcer dans une seule voie de migration et d’évolutivité (parce que votre plus gros locataire dicte toujours votre architecture).
- Briser les frontières de conformité (résidence des données, rétention, étendue du chiffrement, limites de contrôle d’accès).
L’isolation est aussi opérationnelle. Vous devez être capable de répondre, rapidement et avec confiance :
- Quel locataire a causé le pic de charge ?
- Puis‑je le brider ou le mettre en quarantaine sans couper tout le monde ?
- Puis‑je migrer un locataire en toute sécurité (changement de schéma, déplacement de shard, restauration) pendant que les autres continuent de fonctionner ?
Cela signifie que l’isolation a des couches : isolation des données, isolation des requêtes, isolation des ressources,
isolation des pannes et isolation opérationnelle. Le choix de la base de données influence les cinq, mais il ne vous sauvera pas
d’un modèle de tenancy structurellement condamné.
Modèles de tenancy qui montent en charge (et ceux qui échouent silencieusement)
Modèle A : Tables partagées, schéma partagé (colonne tenant_id)
Un jeu de tables. Chaque ligne inclut tenant_id. C’est le moins cher pour commencer et le plus difficile à maintenir correctement.
Cela peut monter en charge si vous concevez pour cela dès le premier jour : frontières d’accès strictes (RLS ou chemins de requête vérifiés),
partitionnement, et un plan pour déplacer les locataires.
Quand ça marche :
- Beaucoup de petits locataires, charges de travail relativement uniformes.
- Vous avez besoin de migrations simples (un seul schéma).
- Votre produit n’exige pas d’extensions au niveau locataire ou d’indexation sur‑mesure.
Comment ça échoue :
- Quelques « locataires éléphants » dominent l’E/S et la contention de verrous.
- Chaque requête doit être parfaite pour toujours (spoiler : ce ne sera pas le cas).
- Les backfills et analyses se transforment accidentellement en scans globaux de tables.
Modèle B : Schéma par locataire (une base de données, plusieurs schémas)
Chaque locataire a son propre schéma : tenant_123.orders, tenant_456.orders. C’est une forte frontière opérationnelle
pour les noms d’objets, les migrations et les déplacements partiels. PostgreSQL est particulièrement bon ici ; le « schéma » de MySQL est
essentiellement une « base de données », donc la forme diffère.
Quand ça marche :
- Les locataires ont besoin d’index personnalisés, d’extensions ou d’opérations lourdes périodiques.
- Vous voulez un meilleur contrôle du rayon d’impact pour les migrations et restaurations.
- Vous pouvez tolérer un nombre élevé d’objets du catalogue et gérer les migrations à grande échelle.
Comment ça échoue :
- Trop d’objets (tables/index) peuvent ralentir les opérations de métadonnées et les fenêtres de maintenance.
- Le pooling de connexions devient plus délicat si vous comptez sur
SET search_path. - L’analyse inter‑locataires devient plus coûteuse et désordonnée.
Modèle C : Base de données par locataire (ou cluster par locataire)
Le rayon d’impact le plus propre. L’éparpillement le pire. C’est l’isolation par multiplication.
C’est aussi comme finir par exploiter un petit fournisseur cloud en interne.
Quand ça marche :
- Exigences de conformité élevées (frontières nettes, clés de chiffrement par locataire, résidence).
- Les locataires ont des tailles de charge de travail très différentes.
- Vous avez besoin de planification de maintenance et de garanties de restauration par locataire.
Comment ça échoue :
- Surcharge opérationnelle : migrations, monitoring, sauvegardes, basculement, identifiants d’accès.
- Fragmentation de capacité : de nombreuses petites bases gaspillent la mémoire (buffers, caches).
- Les changements à l’échelle du parc deviennent lents et risqués.
Modèle D : Sharding des locataires (mapping locataire→shard)
Les locataires sont assignés à des shards (plusieurs instances DB). Chaque shard peut utiliser des tables partagées ou un schéma par locataire en interne.
Le sharding est ce que vous faites quand vous acceptez la réalité : vous finirez par dépasser une instance, et vous avez besoin d’une distribution contrôlée.
Quand ça marche :
- Vous avez des frontières claires entre locataires et peu de besoin de jointures inter‑locataires.
- Vous pouvez construire et maintenir une couche de routage (au niveau applicatif ou proxy).
- Vous planifiez les déplacements de locataires (rebalancing) comme une opération de routine.
Comment ça échoue :
- L’analyse inter‑locataires devient un problème de requête distribuée.
- Le rebalancing est sous‑estimé ; il devient un « projet spécial » à chaque fois.
- Les locataires chauds existent toujours ; le sharding réduit les probabilités, pas la physique.
MySQL vs PostgreSQL : mécanismes d’isolation qui comptent
Les deux peuvent piloter de grandes flottes SaaS. Les deux peuvent vous nuire. La différence est la façon dont ils vous poussent vers des valeurs par défaut sûres (ou vous permettent
de construire une usine à tirs accidentels avec un excellent uptime).
Isolation des données : primitives d’application
PostgreSQL : Row Level Security (RLS) est un outil d’isolation de première classe
RLS de PostgreSQL permet d’appliquer des filtres par locataire au sein de la base. Bien implémenté, il transforme « on ajoute toujours tenant_id »
en « la base rejettera votre requête si vous oubliez ». Ce n’est pas un luxe ; c’est un trait de survie.
RLS n’est pas magique. Les politiques peuvent être contournées par des rôles avec le privilège BYPASSRLS, et des politiques mal conçues peuvent
nuire aux performances. Mais cela vous donne une garde‑fou déclarative.
MySQL : l’isolation est principalement une question de discipline
MySQL n’a pas de RLS à la manière de PostgreSQL. Vous pouvez l’approcher avec des vues, des routines stockées, des droits de défini‑par, ou une
couche de requêtes très stricte. En pratique, la plupart des équipes s’appuient sur le filtrage côté application et les permissions.
Ça peut suffire — jusqu’à ce que ça ne suffise pas. Dans les postmortems d’incident, « on supposait que tous les chemins de code ajoutent tenant_id »
apparaît de la même façon que « on supposait que les backups fonctionnaient ». C’est une phrase qui vieillit mal.
Concurrence et verrouillage : comment naissent les voisins bruyants
PostgreSQL : MVCC avec réalités de vacuum
Le MVCC de PostgreSQL signifie que les lectures ne bloquent pas les écritures, ce qui est excellent pour les charges mixtes. Mais des tuples morts s’accumulent et
doivent être vacuumés. Les systèmes multi‑tenant créent fréquemment un entropie inégale : un locataire met à jour agressivement, et soudain
autovacuum travaille en horaire prolongé tandis que les autres se demandent pourquoi la latence a grimpé.
MySQL (InnoDB) : MVCC plus d’autres arêtes vives
InnoDB utilise aussi MVCC, mais les comportements de verrouillage et les gap locks sous certains niveaux d’isolation peuvent surprendre les équipes, surtout
avec les requêtes de plage et les index secondaires. Les longues transactions sont l’ennemi universel, mais la « forme » de la douleur diffère.
Isolation opérationnelle : déplacer des locataires, restaurer des locataires, brider des locataires
Avantages PostgreSQL
- Schéma‑par‑locataire est naturel et bien supporté.
- RLS peut rendre les « tables partagées » plus sûres à l’échelle.
- La réplication logique permet des schémas de réplication/migration sélective (avec une conception prudente).
Avantages MySQL
- L’écosystème de réplication mature est excellent ; les outils opérationnels sont largement compris.
- Prédictibilité de performance pour certains patterns OLTP est solide, et de nombreuses organisations ont une grande expérience MySQL.
- Base-de-données‑par‑locataire est simple quand vous traitez déjà « schéma = database ».
Si vous voulez une recommandation franche : si vous êtes engagé dans le multi‑tenancy avec tables partagées et que vous voulez que la base impose des frontières,
PostgreSQL est le partenaire le plus indulgent. Si vous faites base‑par‑locataire avec une forte automatisation opérationnelle et une couche de requêtes stable,
MySQL peut être parfaitement adapté. Le modèle de locataires est la grande décision ; le moteur est le multiplicateur de la douleur des erreurs.
Comment l’isolation échoue à mesure que vous grandissez
Mode d’échec 1 : « On peut toujours ajouter tenant_id plus tard »
Ajouter des frontières de locataires après coup, c’est comme ajouter des ceintures de sécurité une fois la course commencée. Vous pouvez le faire, mais vous
découvrirez combien de choses reposaient sur l’absence de contraintes : hypothèses d’unicité globale, séquences partagées, jobs en arrière‑plan, caches mal clefés,
et des requêtes analytiques qui deviennent soudain coûteuses.
Mode d’échec 2 : Une migration devient N migrations
Avec schéma‑par‑locataire ou base‑par‑locataire, les changements de schéma deviennent une opération sur le parc. L’outil de migration qui marchait
pour une base de données doit maintenant gérer le batching, les retries, l’idempotence, et l’observabilité. Ce n’est pas optionnel ; c’est le prix de l’isolation.
Mode d’échec 3 : La base devient votre ordonnanceur
Les « jobs » batch multi‑tenant (runs de facturation, exports, génération de rapports) finissent souvent par être « juste une requête ». Puis elle s’exécute sur
tous les locataires à la fois, et votre base devient un cluster de calcul partagé sans aucun contrôle d’admission.
Blague #1 : Une base de données est une terrible file d’attente, mais c’est un excellent endroit pour conserver la preuve que vous avez essayé quand même.
Mode d’échec 4 : Les mouvements de locataires sont traités comme rares
Si vous shartez, vous devez pouvoir déplacer les locataires de façon routinière. Traitez les déplacements comme routiniers, et vous construirez des outils,
des sommes de contrôle, des écritures doubles (si nécessaire), des playbooks de cutover, et des chemins de rollback. Traitez les déplacements comme rares,
et chaque mouvement devient un incident sur‑mesure et stressant avec des dirigeants « qui se joignent juste pour écouter ».
Mode d’échec 5 : Votre isolation fonctionne, mais votre observabilité non
Même si les frontières de données sont correctes, vous devez encore attribuer la charge par locataire. Sans métriques et logs tagués par locataire,
chaque problème de performance est un jeu de devinettes. Deviner coûte cher. Et cela a tendance à arriver pendant des pannes, quand tout le monde
est émotionnellement investi à se tromper vite.
Faits intéressants et contexte historique (ceux qui changent les décisions)
- La lignée de PostgreSQL remonte au projet POSTGRES à l’UC Berkeley dans les années 1980 ; il hérite d’une culture de recherche visible dans des fonctionnalités comme MVCC et l’extensibilité.
- MySQL est devenu un standard web à l’ère LAMP principalement parce qu’il était facile à exécuter et rapide pour les patterns courants, pas parce qu’il avait l’ensemble de fonctions relationnelles le plus strict.
- InnoDB n’a pas toujours été le défaut ; MySQL livrait historiquement MyISAM par défaut, qui manquait de transactions—une histoire d’origine qui influence encore le folklore et les déploiements hérités.
- RLS de PostgreSQL est arrivé en v9.5 (mi‑2010s), c’est pourquoi les anciennes piles SaaS construisaient souvent leurs propres couches d’isolation avec des vues ou des conventions d’ORM.
- Autovacuum existe à cause du MVCC ; le modèle de concurrence de PostgreSQL est puissant, mais le « ramassage des déchets » est une taxe opérationnelle à budgéter pour le churn multi‑tenant.
- La réplication de MySQL a évolué par étapes : statement-based vers row-based puis mixed, changeant la manière dont les déplacements de données au niveau locataire sont sûrs et prévisibles selon les charges.
- Le partitionnement a mûri avec le temps dans les deux moteurs ; les implémentations antérieures étaient plus limitées, ce qui explique pourquoi les anciens designs évitaient souvent les partitions et payaient plus tard avec des tailles de table pénibles.
- La gestion des connexions diffère culturellement : PostgreSQL suppose des connexions plus lourdes et encourage le pooling ; MySQL a une longue histoire de nombreuses connexions courtes dans les stacks web, ce qui façonne les défauts et les outils.
Trois mini‑histoires d’entreprise (et ce qu’elles enseignent)
Mini‑histoire n°1 : L’incident causé par une mauvaise hypothèse
Un SaaS B2B de taille moyenne utilisait un modèle de tables partagées dans MySQL. Chaque table avait tenant_id. Leur ORM avait aussi un « default scope »
qui filtrai automatiquement par locataire. Ça paraissait sûr. C’était rapide. Tout le monde a continué.
Puis un job en arrière‑plan a été introduit pour « nettoyer les anciennes sessions ». Il a été écrit dans un service séparé qui n’utilisait pas l’ORM.
L’ingénieur a utilisé une simple requête delete sur la table sessions, en supposant que le filtre locataire était « géré ailleurs ». Il ne l’était pas.
La requête a supprimé des sessions à travers tous les locataires qui correspondaient à la condition d’âge.
L’incident n’était pas une perte de données, mais c’était du chaos visible par les utilisateurs : déconnexions massives, tickets de support, et une journée où la direction a demandé
pourquoi « l’isolation multi‑tenant » n’avait pas empêché cela. La vraie réponse : l’isolation était une convention, pas une frontière appliquée.
La correction n’a pas été que « faire plus attention ». Ils ont implémenté une couche d’accès à la base sûre pour les services en arrière‑plan, ajouté du linting de requêtes pour les filtres de locataire,
et introduit une règle stricte : toute requête inter‑locataires doit être explicitement nommée et revue.
Ils ont aussi planifié d’adopter PostgreSQL RLS pour les tables partagées à plus haut risque — parce que les humains sont créatifs, surtout quand ils sont fatigués.
Mini‑histoire n°2 : L’optimisation qui a retourné la situation
Une autre entreprise utilisait PostgreSQL avec des tables partagées et RLS. Un locataire a rapidement grossi et a commencé à générer d’énormes quantités de
données d’événements. L’ingénierie a décidé « d’optimiser » en ajoutant un index partiel adapté au pattern de requête courant de ce locataire.
Ça semblait malin : des requêtes plus rapides pour le grand client, moins de charge globale.
L’index partiel était défini sur une condition qui incluait tenant_id et une colonne status. Il a bien accéléré les requêtes ciblées.
Mais il a aussi augmenté l’amplification d’écriture et le coût de maintenance pour exactement les tables avec le plus fort churn. Autovacuum a commencé
à prendre du retard, la bloat a augmenté, et d’autres locataires — qui ne bénéficiaient même pas de l’index — ont vu leur latence grimper.
L’équipe a tenté de compenser avec des réglages autovacuum plus agressifs et des instances plus grosses. Cela a stabilisé les symptômes mais pas la cause.
Finalement ils ont supprimé l’index et déplacé le locataire éléphant vers son propre shard, où sa stratégie d’indexation pouvait être aussi étrange qu’elle le souhaitait.
Leçon : les optimisations spécifiques à un locataire dans des tables partagées deviennent souvent des taxes spécifiques au locataire payées par tout le monde. Si le locataire
est assez gros pour mériter un index personnalisé, il est assez gros pour mériter son propre rayon d’impact.
Mini‑histoire n°3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Un SaaS financier utilisait schéma‑par‑locataire dans PostgreSQL. Ce n’était pas glamour, et cela exigeait un runner de migrations capable
d’appliquer les changements locataire par locataire avec un ordre strict. Chaque schéma avait les mêmes tables, les mêmes contraintes, et le même jeu d’extensions. Aucune exception.
Leur stratégie de sauvegarde nocturne était tout aussi ennuyeuse : sauvegardes complètes périodiques, archivage WAL fréquent, et exercices trimestriels de restauration
où ils restauraient un schéma locataire unique dans un cluster de staging et exécutaient une suite de vérification. Ça ressemblait à de la paperasserie,
jusqu’au jour où ce ne l’était pas.
Un déploiement a introduit un bug dans une migration qui a supprimé un index et l’a reconstruit concurrentiellement, mais un timeout et un retry ont causé un état incohérent
dans un sous‑ensemble de schémas locataires. Certains locataires ont vu des requêtes lentes ; d’autres allaient bien. Le système était « up », mais
l’expérience client ne l’était pas.
Parce qu’ils avaient des exercices de restauration par locataire et une checklist de vérification au niveau schéma, ils ont rapidement identifié les locataires affectés,
reconstruit les index de façon contrôlée, et restauré les pires cas depuis le point cohérent le plus récent. Pas de gestes héroïques. Pas de suppositions.
Juste des procédures pratiquées.
Tâches pratiques : commandes, sorties et la décision que vous prenez
Vous n’obtenez pas l’isolation des locataires en argumentant dans une revue de conception. Vous l’obtenez en pouvant prouver des choses à 02:00 avec un shell
et un calme sense de trahison. Ci‑dessous des tâches pratiques à exécuter aujourd’hui.
Tâche 1 : Trouver les requêtes les plus bruyantes dans MySQL
cr0x@server:~$ mysql -e "SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT/1000000000000 AS total_s, AVG_TIMER_WAIT/1000000000000 AS avg_s FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;"
+--------------------------------------------+------------+----------+---------+
| DIGEST_TEXT | COUNT_STAR | total_s | avg_s |
+--------------------------------------------+------------+----------+---------+
| SELECT * FROM orders WHERE tenant_id = ? | 92831 | 1842.11 | 0.01984 |
| SELECT ... JOIN ... WHERE created_at > ? | 8421 | 1201.33 | 0.14266 |
| UPDATE events SET status = ? WHERE id = ? | 2419921 | 992.77 | 0.00041 |
| SELECT ... WHERE tenant_id = ? ORDER BY ? | 182003 | 774.55 | 0.00425 |
| DELETE FROM sessions WHERE expires_at < ? | 12044 | 701.62 | 0.05825 |
+--------------------------------------------+------------+----------+---------+
Ce que cela signifie : temps total passé par digest. Un total_s élevé signifie un fort impact agrégé ; un avg_s élevé signifie lent par appel.
Décision : Si une requête a un total élevé et inclut des filtres locataires, attribuez‑la aux locataires au niveau applicatif (ajoutez des tags locataire) et envisagez un bridage par locataire ou le déplacement du locataire.
Tâche 2 : Identifier les attentes de verrous dans MySQL (qui bloque qui)
cr0x@server:~$ mysql -e "SELECT r.trx_id waiting_trx, r.trx_mysql_thread_id waiting_thread, b.trx_id blocking_trx, b.trx_mysql_thread_id blocking_thread, TIMESTAMPDIFF(SECOND, b.trx_started, NOW()) blocking_s FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx b ON b.trx_id=w.blocking_trx_id JOIN information_schema.innodb_trx r ON r.trx_id=w.requesting_trx_id ORDER BY blocking_s DESC LIMIT 5;"
+-------------+---------------+-------------+----------------+-----------+
| waiting_trx | waiting_thread| blocking_trx| blocking_thread| blocking_s|
+-------------+---------------+-------------+----------------+-----------+
| 4519281 | 3221 | 4519012 | 3189 | 97 |
+-------------+---------------+-------------+----------------+-----------+
Ce que cela signifie : une transaction bloque les autres depuis ~97 secondes. C’est généralement une transaction longue ou un index manquant.
Décision : Si c’est lié au job d’un seul locataire, bridez ou mettez en pause ce job. Si c’est systémique, inspectez la table bloquée et ajoutez l’index nécessaire ou raccourcissez les transactions.
Tâche 3 : Vérifier la longueur de l’historique InnoDB (pression d’undo)
cr0x@server:~$ mysql -e "SHOW ENGINE INNODB STATUS\G" | grep -E "History list length|TRANSACTIONS"
TRANSACTIONS
History list length 138429
Ce que cela signifie : une longueur de liste d’historique élevée suggère que le purge est en retard, souvent à cause de transactions longues.
Décision : Chassez les transactions longues ; corrigez les jobs batch et les requêtes de rapports qui maintiennent des snapshots trop longtemps. C’est un scénario classique multi‑tenant : « un client a lancé un rapport pendant une heure ».
Tâche 4 : Repérer les requêtes qui sautent le locataire dans PostgreSQL via pg_stat_statements
cr0x@server:~$ psql -d appdb -c "SELECT query, calls, total_exec_time::int AS total_ms, mean_exec_time::int AS mean_ms FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;"
query | calls | total_ms | mean_ms
----------------------------------------------------------------------------------------------------------------+-------+----------+---------
SELECT * FROM orders WHERE created_at > $1 ORDER BY created_at DESC LIMIT $2 | 12021 | 8123340 | 675
SELECT * FROM orders WHERE tenant_id = $1 AND created_at > $2 ORDER BY created_at DESC LIMIT $3 | 99311 | 4901221 | 49
UPDATE events SET status = $1 WHERE id = $2 | 89122 | 811220 | 9
SELECT ... JOIN ... WHERE tenant_id = $1 AND state = $2 | 24011 | 644331 | 26
DELETE FROM sessions WHERE tenant_id = $1 AND expires_at < now() | 8012 | 499112 | 62
Ce que cela signifie : la requête en tête n’a pas de filtre locataire. Dans un tenancy à tables partagées, c’est un voyant rouge clignotant.
Décision : Si vous comptez sur l’application pour l’application des filtres, corrigez immédiatement. Si vous utilisez RLS, vérifiez que RLS est activé et réellement appliqué à cette table.
Tâche 5 : Vérifier que RLS est activé et que des politiques existent (PostgreSQL)
cr0x@server:~$ psql -d appdb -c "\dp+ public.orders"
Access privileges
Schema | Name | Type | Access privileges | Column privileges | Policies
--------+--------+-------+-------------------+-------------------+-------------------------------
public | orders | table | app_user=arwdDxt/app_owner | | tenant_isolation (RLS)
Ce que cela signifie : des politiques sont présentes, et RLS est actif pour cette table (indiqué dans la colonne Policies).
Décision : Si la colonne « Policies » est vide pour des tables partagées, vous faites confiance au code applicatif. Décidez si cela est acceptable selon votre modèle de risque.
Tâche 6 : Vérifier si votre rôle peut contourner RLS (PostgreSQL)
cr0x@server:~$ psql -d appdb -c "SELECT rolname, rolbypassrls FROM pg_roles WHERE rolname IN ('app_user','app_owner');"
rolname | rolbypassrls
-----------+-------------
app_user | f
app_owner | t
Ce que cela signifie : app_owner peut contourner RLS. Cela peut être acceptable pour les migrations, désastreux pour l’exécution applicative.
Décision : Assurez‑vous que l’application utilise un rôle avec rolbypassrls = false. Séparez les rôles de migration/admin des rôles d’exécution.
Tâche 7 : Trouver les transactions longues dans PostgreSQL
cr0x@server:~$ psql -d appdb -c "SELECT pid, usename, now()-xact_start AS xact_age, state, left(query,80) AS q FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start ASC LIMIT 5;"
pid | usename | xact_age | state | q
------+----------+--------------+--------+--------------------------------------------------------------------------------
8421 | app_user | 01:22:11 | active | SELECT * FROM orders WHERE created_at > $1 ORDER BY created_at DESC
Ce que cela signifie : une transaction est ouverte depuis 82 minutes. Cela peut bloquer le vacuum et causer du bloat sur les tables.
Décision : Si c’est un rapport/export d’un locataire, exécutez‑le sur une réplique, ajoutez des timeouts, ou repensez‑le (pagination keyset, exports incrémentiels).
Tâche 8 : Vérifier la pression autovacuum et les signaux de bloat (PostgreSQL)
cr0x@server:~$ psql -d appdb -c "SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
relname | n_live_tup | n_dead_tup | last_autovacuum
-----------+------------+------------+-------------------------------
events | 81299311 | 22099122 | 2025-12-31 10:42:11.12345+00
orders | 9921121 | 2200122 | 2025-12-31 10:39:01.01234+00
Ce que cela signifie : beaucoup de tuples morts : churn. Dans les systèmes multi‑tenant, c’est souvent dominé par un petit ensemble de locataires.
Décision : Ajustez autovacuum pour les tables chaudes, mais envisagez aussi d’isoler le locataire qui cause le churn (déplacement shard, schéma/DB séparé).
Tâche 9 : Confirmer qu’une requête utilise l’index locataire (PostgreSQL)
cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE tenant_id = 42 AND created_at > now()-interval '7 days' ORDER BY created_at DESC LIMIT 50;"
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.43..102.55 rows=50 width=128) (actual time=0.214..1.992 rows=50 loops=1)
Buffers: shared hit=391
-> Index Scan Backward using orders_tenant_created_at_idx on orders (cost=0.43..18233.22 rows=8931 width=128) (actual time=0.212..1.978 rows=50 loops=1)
Index Cond: ((tenant_id = 42) AND (created_at > (now() - '7 days'::interval)))
Planning Time: 0.311 ms
Execution Time: 2.041 ms
Ce que cela signifie : scan d’index utilisant un index composite qui inclut tenant_id. Les Buffers sont des hits, pas des lectures : favorable au cache.
Décision : Si vous voyez des scans séquentiels sur des tables partagées pour des requêtes ciblant un locataire, corrigez les index ou le partitionnement avant d’augmenter le nombre de locataires.
Tâche 10 : Confirmer qu’une requête utilise l’index locataire (MySQL)
cr0x@server:~$ mysql -e "EXPLAIN SELECT * FROM orders WHERE tenant_id=42 AND created_at > NOW() - INTERVAL 7 DAY ORDER BY created_at DESC LIMIT 50\G"
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: range
possible_keys: idx_tenant_created_at
key: idx_tenant_created_at
key_len: 12
ref: NULL
rows: 8500
filtered: 100.00
Extra: Using where; Using index
Ce que cela signifie : MySQL a choisi votre index composite. type: range est attendu pour les scans sur fenêtre temporelle. « Using index » suggère un comportement d’index couvrant.
Décision : Si type: ALL apparaît (scan complet), vous n’avez pas d’isolation à l’échelle — vous avez une panne future.
Tâche 11 : Attribuer la charge par utilisateur/hôte (MySQL) comme proxy de locataire
cr0x@server:~$ mysql -e "SELECT user, host, SUM_TIMER_WAIT/1000000000000 AS total_s FROM performance_schema.events_statements_summary_by_user_by_event_name ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;"
+----------+-----------+----------+
| user | host | total_s |
+----------+-----------+----------+
| app_user | 10.0.2.% | 9921.22 |
| app_user | 10.0.9.% | 6211.55 |
+----------+-----------+----------+
Ce que cela signifie : un sous‑ensemble d’hôtes applicatifs est responsable de la majorité du temps DB. Souvent ces hôtes exécutent une charge spécifique (exports, backfills).
Décision : Utilisez cela pour restreindre le service/job responsable, puis corrélez avec les logs applicatifs au niveau locataire.
Tâche 12 : Vérifier les accumulations de connexions (PostgreSQL)
cr0x@server:~$ psql -d appdb -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC;"
state | count
--------+-------
idle | 412
active | 62
Ce que cela signifie : vous avez des centaines de connexions idle. C’est de la mémoire et du surcoût ; cela signale souvent un mauvais pooling ou des patterns de connexion par locataire.
Décision : Placez un pooler en amont (et configurez‑le correctement), ou réduisez le nombre de connexions. Les systèmes multi‑tenant meurent par mille coupures « encore une connexion ».
Tâche 13 : Vérifier les points chauds au niveau table par taille (PostgreSQL)
cr0x@server:~$ psql -d appdb -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total_size FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 5;"
relname | total_size
---------+------------
events | 412 GB
orders | 126 GB
Ce que cela signifie : une table domine le stockage et probablement l’I/O. Dans les systèmes multi‑tenant, la plus grosse table est généralement là où vit le plus gros locataire.
Décision : Envisagez le partitionnement par locataire ou par temps, ou déplacez les locataires générant le plus de lignes vers un shard séparé.
Tâche 14 : Vérifier le lag de réplication (MySQL) pour décider où exécuter les lectures lourdes
cr0x@server:~$ mysql -e "SHOW REPLICA STATUS\G" | egrep "Seconds_Behind_Source|Replica_IO_Running|Replica_SQL_Running"
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 37
Ce que cela signifie : 37 secondes de retard. Exécuter des exports de locataire sur cette réplique peut donner des résultats « derniers » incohérents.
Décision : Si le produit promet des exports quasi‑temps réel, n’utilisez pas une réplique en retard ; bridez ou isolez plutôt la charge du locataire.
Playbook de diagnostic rapide (trouvez le goulot avant qu’il ne vous trouve)
Quand un SaaS multi‑tenant ralentit, vous devez répondre rapidement à deux questions :
(1) la base de données est‑elle réellement le goulot ? (2) si oui, quel locataire ou quelle charge en est la cause ?
Premier point : la base est‑elle saturée ou juste en attente ?
- CPU bloqué (inefficacité des requêtes, index manquants, trop de tâches parallèles).
- I/O bloqué (miss de buffer, scans lourds, checkpoints, bloat).
- Verrouillage (transactions longues, changements de schéma, lignes chaudes).
- Connexions bloquées (exhaustion du pool, contention de threads, trop de connexions idle).
Second point : identifier la classe de coupable principale
- Top requêtes par temps total (digest / pg_stat_statements).
- Top attentes (verrous, I/O, contention de buffers).
- Transactions longues et chaînes de blocage.
Troisième point : attribuer au locataire et décider du confinement
- Si vous avez des tags locataires dans les logs/métriques : isolez le locataire (brider, déplacer le job, déplacer le shard).
- Si vous n’en avez pas : utilisez la corrélation user/host/job, puis corrigez l’observabilité de façon permanente.
Blague #2 : Le moyen le plus rapide de réduire la charge sur la base est d’arrêter d’exécuter la requête qui la cause. Révolutionnaire, je sais.
Une échelle de confinement pratique (du moins au plus invasif)
- Annuler la requête / tuer la session qui provoque un dommage immédiat.
- Brider le locataire à la bordure applicative (limiter les exports, les jobs batch).
- Déplacer les lectures lourdes sur une réplique (si le lag et la consistance le permettent).
- Ajouter l’index manquant ou réécrire la requête (avec des plans EXPLAIN, pas de l’espoir).
- Partitionner ou sharder pour séparer les locataires bruyants.
- Changer le modèle de tenancy (la réponse douloureuse mais parfois correcte).
Erreurs courantes : symptôme → cause racine → correction
1) Symptom : exposition de données inter‑locataires occasionnelle dans les rapports
Cause racine : un chemin de code a oublié le filtrage locataire ; les conventions côté application ne sont pas une enforcement.
Correction : PostgreSQL : implémentez RLS et assurez‑vous que les rôles d’exécution ne peuvent pas le contourner. MySQL : imposez une couche d’accès sécurisée, interdisez le SQL brut pour les tables locataire‑scopées, et ajoutez des tests de linting de requêtes.
2) Symptom : un locataire provoque des pics p95 pour tout le monde pendant un « export »
Cause racine : l’export s’exécute sur le primaire, scannant de larges plages sans indexation ou pagination appropriée.
Correction : exécutez les exports sur une réplique (si acceptable), utilisez la pagination keyset, précomputez les exports, et limitez par locataire. Si le locataire est constamment lourd, déplacez‑le vers un shard séparé.
3) Symptom : PostgreSQL devient plus lent pendant des semaines, puis s’améliore après maintenance
Cause racine : bloat dû aux tables à fort churn ; autovacuum ne suit pas ; transactions longues bloquent le nettoyage.
Correction : trouvez et éliminez les transactions longues, ajustez autovacuum par table chaude, et envisagez le partitionnement ou l’isolation du locataire générant le churn.
4) Symptom : le lag de réplication MySQL augmente pendant les jobs batch des locataires
Cause racine : grosses transactions et rafales d’écriture ; le thread SQL du réplica n’applique pas assez vite.
Correction : fractionnez les jobs en transactions plus petites, ajoutez les index appropriés, et planifiez les jobs lourds. Pour les gros locataires, envisagez une infrastructure dédiée.
5) Symptom : les migrations deviennent risquées et lentes à mesure que le nombre de locataires croît
Cause racine : schéma‑par‑locataire ou base‑par‑locataire sans outils de migration de flotte ; pas de batching, pas d’observabilité, pas de plan de rollback.
Correction : construisez un runner de migrations avec : suivi d’état par locataire, retries, timeouts, contrôle de concurrence, et requêtes de vérification post‑migration. Traitez‑le comme un système de déploiement.
6) Symptom : « On a monté l’instance mais c’est toujours lent »
Cause racine : contention et inefficacité, pas la capacité brute. Des machines plus grosses ne résolvent pas des index manquants ou des attentes de verrous.
Correction : identifiez les top requêtes et attentes ; corrigez les causes racines. Ne scalez verticalement qu’après pouvoir expliquer pourquoi cela aide.
Listes de contrôle / plan étape par étape
Plan étape par étape : choisir un modèle de tenancy qui survit à la croissance
- Classifiez les locataires par charge : petit/moyen/éléphant ; lecture‑lourde vs écriture‑lourde ; batch‑lourd vs interactif.
- Décidez votre cible d’isolation : frontière de données seulement, ou aussi isolation de ressources/pannes. La conformité force souvent la seconde.
- Choisissez le modèle initial :
- Si vous avez besoin d’une application forte au sein de la DB avec tables partagées : PostgreSQL + RLS est le défaut pragmatique.
- Si vous avez besoin de frontières dures par locataire : database‑per‑tenant ou shard‑per‑tenant, indépendamment du moteur.
- Concevez des clés pour la mobilité : évitez les séquences globales qui rendent les déplacements de locataires compliqués ; préférez les UUIDs ou des IDs soigneusement scopiés si le sharding est prévu.
- Rendez les déplacements de locataires routiniers : implémentez des outils pour exporter/importer un locataire, vérifier les comptes/checksums, et couper la bascule.
- Implémentez une observabilité consciente des locataires : chaque chemin de requête DB doit être attribuable à un locataire (au moins aux limites requête/job).
- Définissez des limites par locataire : limites de débit, tailles maximales d’export, timeouts, plafonds de concurrence.
- Établissez un muscle de récupération ennuyeux : tests réguliers de restauration au niveau locataire.
Checklist : multi‑tenancy à tables partagées (PostgreSQL recommandé)
- RLS activé sur chaque table destinée aux locataires.
- Le rôle d’exécution ne peut pas contourner RLS ; rôles admin/migration séparés.
- Chaque requête locataire dispose d’un index composite commençant par
tenant_id(ou d’une stratégie de partitionnement rendant l’accès locataire peu coûteux). - pg_stat_statements activé et surveillé ; top requêtes revues régulièrement.
- Alertes sur transactions longues ; timeouts de statement configurés.
- Autovacuum réglé pour les tables chaudes ; suivi du bloat.
Checklist : schéma‑par‑locataire (point fort de PostgreSQL)
- Le runner de migrations supporte le batching par locataire, l’idempotence et la vérification.
- Conventions de nommage des schémas par locataire appliquées ; pas de snowflakes manuels.
- Stratégie de pooling évite le churn de connexions par requête ; usage prudent de
search_path. - Les sauvegardes et exercices de restauration peuvent restaurer proprement un schéma locataire.
Checklist : database‑per‑tenant (MySQL ou PostgreSQL)
- Provisioning automatisé : création DB, utilisateurs, grants, monitoring, sauvegardes.
- Outils de migration de flotte ; rollouts par étapes ; canaris.
- Service central de routage pour la cartographie (locataire → base de données) avec journalisation d’audit.
- Contrôles de coût : évitez un petit locataire par instance surdimensionnée sauf si la conformité l’impose.
FAQ
1) Dois‑je utiliser RLS PostgreSQL pour un SaaS multi‑tenant ?
Si vous utilisez des tables partagées et que vous êtes sérieux sur la prévention d’un accès inter‑locataires, oui. RLS transforme les filtres locataires manquants
d’un bug latent de sécurité en une erreur de requête. Ce n’est pas gratuit — les politiques doivent être conçues et testées — mais c’est un vrai outil d’application.
2) MySQL peut‑il faire du multi‑tenant à tables partagées en sécurité ?
Oui, mais vous dépendez davantage de la discipline applicative et des processus de revue. Vous pouvez construire des patterns sûrs (vues, procédures stockées,
comptes restreints), mais la base ne va pas naturellement imposer les filtres comme PostgreSQL avec RLS.
3) Le schéma‑par‑locataire crée‑t‑il « trop d’objets » dans PostgreSQL ?
Ça peut. Des milliers de schémas avec de nombreuses tables et index peuvent mettre à mal les catalogues et la maintenance. Si vous choisissez cette voie, investissez dans
des outils de migration, évitez les exceptions par locataire, et évaluez périodiquement si le sharding (moins de locataires par cluster) est plus simple.
4) Quel est le meilleur modèle pour des locataires soumis à une forte conformité ?
Database‑per‑tenant ou shard‑per‑tenant est la réponse courante, car cela donne des frontières claires pour les sauvegardes, restaurations, étendue du chiffrement,
et contrôles d’accès. Les tables partagées peuvent passer des audits, mais cela demande des contrôles rigoureux et des preuves claires.
5) Comment prévenir les incidents de « voisin bruyant » ?
Commencez par l’attribution (métriques conscientes des locataires). Puis implémentez le confinement : limites par locataire, timeouts de requêtes, séparation des charges
(répliques pour les lectures), et un chemin pour isoler les éléphants (les déplacer sur leur propre shard/DB).
6) Le sharding résout‑il l’isolation des locataires ?
Le sharding aide l’isolation des ressources et des pannes en réduisant le rayon d’impact, mais il ne résout pas automatiquement l’isolation des données à l’intérieur d’un shard.
Vous avez toujours besoin de frontières d’accès correctes et de chemins de requêtes sûrs.
7) Quelle stratégie de pooling fonctionne le mieux pour PostgreSQL multi‑tenant ?
Utilisez un pooler et gardez les comptes de connexion raisonnables. Faites attention à l’état de session (comme search_path ou des paramètres par locataire).
Le pooling transactionnel est efficace mais exige de la discipline ; le pooling de session est plus simple mais utilise plus de connexions.
8) tenant_id doit‑il faire partie de chaque clé primaire ?
Souvent oui pour les designs à tables partagées, au moins comme partie de la stratégie de clé, car cela améliore la localité et la sélectivité des index.
Mais ne composez pas aveuglément des clés partout. Choisissez selon les patterns de requêtes, les exigences d’unicité, et les plans futurs de sharding/déplacement.
9) Partitioner par locataire est‑ce une bonne idée ?
Parfois. Cela peut rendre les opérations par locataire plus rapides et améliorer le ciblage de vacuum/maintenance dans PostgreSQL. Mais cela peut faire exploser le nombre de partitions
si vous avez beaucoup de locataires. Le partitionnement temporel est souvent un meilleur défaut pour les tables d’événements, avec tenant_id indexé à l’intérieur des partitions.
10) Quand devrais‑je déplacer un locataire dans sa propre base de données ?
Lorsque la charge spécifique du locataire ou les besoins de conformité forcent régulièrement des réglages spécifiques au locataire qui nuisent aux autres, ou quand vous avez besoin d’un
scaling et de fenêtres de maintenance indépendants. Si vous hésitez chaque semaine, vous connaissez déjà la réponse.
Prochaines étapes que vous pouvez réellement exécuter
Le choix de la base de données est important, mais ce n’est pas l’événement principal. L’événement principal est de savoir si votre stratégie d’isolation est applicable, observable,
et opérationnellement mobile.
- Choisissez un modèle de tenancy que vous pouvez exploiter, pas un modèle qui a l’air élégant sur un tableau blanc.
- Si vous êtes sur PostgreSQL avec tables partagées, implémentez RLS sur les tables à plus haut risque en premier, et séparez les rôles d’exécution des rôles admin.
- Si vous êtes sur MySQL avec tables partagées, formalisez une couche de requêtes sûre pour les locataires, ajoutez du linting/tests pour les prédicats tenant, et construisez des bridages par locataire.
- Instrumentez l’attribution par locataire (logs, métriques, traces). Si vous ne pouvez pas nommer le locataire qui cause la charge, vous ne pouvez pas l’isoler.
- Rendez les déplacements de locataires routiniers. Écrivez le playbook, automatisez‑le, et réalisez des exercices quand personne n’est en feu.
- Planifiez le travail ennuyeux : tests de restauration, alertes sur transactions longues, revues d’index, et canaris de migration. Ce ne sont pas des tâches « plus tard » ; c’est le loyer.
Une citation à garder sur votre mur — paraphrase d’une idée de John Allspaw : la fiabilité vient du système, pas des exploits individuels
.
Le multi‑tenancy en est la preuve ultime.