Tout produit SaaS sert plusieurs clients à partir d’un même code. Une question étroite décide d’une grande partie de votre architecture : lorsque le client A et le client B stockent tous deux des données dans votre système, comment empêchez-vous A de voir un jour les lignes de B ?
Il existe deux réponses courantes. Une base de données partagée avec une colonne qui indique à qui appartient chaque ligne, ou une base de données distincte pour chaque client. La plupart des comparaisons s’arrêtent à « ça dépend ». Cet article s’intéresse à ce dont cela dépend réellement : les coûts d’ingénierie précis que chaque approche vous impose, et la façon de savoir quel coût votre produit peut se permettre.
J’utilise Laravel pour les exemples concrets parce que c’est ce avec quoi je travaille, mais le compromis lui-même ne dépend ni du framework ni du langage. La même décision se retrouve dans Rails, Django, Spring et en SQL pur.
Un spectre, pas un choix binaire
Avant d’aborder les deux options, il faut voir que l’« isolation » n’est pas un simple interrupteur. C’est un curseur, généralement décrit de pool (tout est partagé) à silo (tout est séparé) :
POOL ──────────────────────────────────────────────► SILO moins cher, plus dense plus isolé, plus coûteux
BD partagée ──► BD partagée ──► Base de données ──► Infra séparée schéma partagé un schéma par par tenant par tenant (colonne tenant tenant_id)Plus on va vers la droite, plus l’isolation est forte et plus le coût par tenant est élevé. Les deux options comparées ici, le schéma partagé et la base de données par tenant, sont les deux points de ce curseur entre lesquels la plupart des équipes choisissent réellement. Les extrémités (un schéma par tenant, ou une infrastructure entièrement séparée) sont des variantes du même compromis, pas des compromis différents.
L’idée clé est ce que le curseur mesure vraiment : si l’isolation des tenants est assurée par votre code applicatif ou par votre infrastructure. Tout ce qui suit découle de cette seule distinction.
Approche 1 : base de données partagée, isolation dans le code
Les lignes de tous les tenants vivent dans les mêmes tables. Chaque ligne porte une colonne (tenant_id, organization_id, parfois simplement user_id) qui désigne son propriétaire. L’isolation signifie : chaque requête est filtrée par cette colonne, sans exception.
-- Une seule table, les données de tous les tenants mélangéesSELECT * FROM invoices WHERE tenant_id = ? AND status = 'overdue';Le danger, ce sont les mots « sans exception ». Oubliez le filtre une seule fois, dans une requête brute, un rapport ou un job en arrière-plan, et vous avez montré à un client les données d’un autre. Les bases de code matures ne comptent donc pas sur la mémoire des développeurs. Elles rendent le filtre automatique et difficile à contourner.
Dans Laravel, c’est un global scope : une règle au niveau du modèle qui ajoute discrètement la condition tenant_id à chaque requête, de sorte qu’un simple Invoice::all() signifie déjà « les factures de ce tenant ». Un trait renseigne la colonne à l’insertion pour qu’on ne puisse pas non plus l’oublier à l’écriture :
trait BelongsToTenant{ protected static function booted(): void { static::creating(fn ($model) => $model->tenant_id ??= currentTenant()->id); static::addGlobalScope('tenant', fn ($query) => $query->where('tenant_id', currentTenant()->id) ); }}Ce schéma (une colonne, un filtre automatique à la lecture, un renseignement automatique à l’écriture) est toute l’idée, dans n’importe quel framework. Une seconde couche, les policies d’autorisation, mérite d’être ajoutée pour qu’une requête qui devine l’identifiant d’un enregistrement d’un autre tenant reçoive un 403 explicite plutôt que de disparaître silencieusement d’une liste filtrée.
Et la sécurité au niveau des lignes (RLS) ?
C’est l’objection naturelle à tout ce qui précède : si le risque est un WHERE oublié, pourquoi ne pas faire appliquer le filtre de tenant par la base de données afin qu’une clause oubliée ne puisse rien divulguer ? C’est exactement ce que fait la sécurité au niveau des lignes (RLS, row-level security), et c’est l’option la plus solide que la plupart des équipes n’envisagent jamais.
Dans PostgreSQL, on l’active sur la table et on lui associe une policy qui lit le tenant courant dans une variable de session :
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.tenant_id')::int);Ensuite, l’application renseigne cette variable une fois par requête, et à partir de là chaque requête sur invoices est filtrée par la base de données, que l’application ait pensé ou non à ajouter la condition :
SET app.tenant_id = '42'; -- à définir au début de la requêteSELECT * FROM invoices; -- la base ne renvoie que les lignes du tenant 42Un filtre oublié, une requête brute, un job de reporting qui a ignoré le scope : aucun ne peut plus fuiter d’un tenant à l’autre, car l’application de la règle ne vit plus dans le code applicatif. Cela fait réellement passer l’isolation en schéma partagé d’une application « par le code » vers une application « par l’infrastructure », et réduit l’écart de rayon d’impact avec la base de données par tenant plus que tout autre élément de cette liste.
Ce n’est cependant pas gratuit, et les réserves expliquent pourquoi elle reste sous-utilisée :
- La variable de session doit être définie correctement sur chaque connexion. C’est le nouveau piège. Avec un pool de connexions qui redistribue des connexions réutilisées, une variable laissée par une requête précédente est elle-même une fuite entre tenants : il faut donc la limiter à la transaction (
SET LOCAL) ou la réinitialiser à chaque fois. - Les propriétaires de table et les superutilisateurs contournent la RLS par défaut. Le rôle avec lequel votre application se connecte doit être un rôle non propriétaire, ou il faut utiliser
FORCE ROW LEVEL SECURITY, faute de quoi la policy ne fait discrètement rien pour ce rôle. - Chaque requête porte le prédicat de la policy, ce qui a de réelles implications sur le planificateur et les performances (généralement sans souci avec un index sur
tenant_id, mais ce n’est pas nul). - C’est une fonctionnalité de Postgres / SQL Server, pas une fonctionnalité universelle. MySQL n’a pas de RLS native, et comme la pile par défaut de Laravel utilise MySQL, c’est une option que l’on choisit avec une autre base de données, que le framework ne fournit pas. Laravel ne branchera pas non plus la variable de session pour vous ; vous la définiriez dans un listener de connexion ou un middleware de requête.
Là où elle s’inscrit : la RLS permet de conserver la densité et la simplicité d’exploitation d’une seule base partagée tout en obtenant une isolation que la base de données impose, au lieu de faire confiance à chaque requête pour bien se comporter. Si vous êtes sur Postgres et que l’isolation compte, c’est souvent une meilleure réponse que de passer directement à une base de données par tenant.
Approche 2 : une base de données par tenant, isolation dans l’infrastructure
Chaque tenant obtient sa propre base de données. Le cycle de vie d’une requête gagne une étape au début :
- Une requête arrive, par exemple pour
acme.app.com. - L’application détermine de quel tenant il s’agit, généralement à partir du sous-domaine, d’un domaine personnalisé ou d’un jeton.
- Elle pointe la connexion à la base de données vers celle de ce tenant (
acme_db). - Tout ce qui suit est une application ordinaire à tenant unique : des requêtes normales, aucun filtrage.
Il n’y a nulle part de colonne tenant_id, car il n’y a rien à filtrer : la connexion est la frontière.
Une clause WHERE manquante ne peut plus fuiter d’un tenant à l’autre, car les données de l’autre tenant ne sont tout simplement pas accessibles sur cette connexion. C’est tout l’attrait en une phrase : l’isolation tient même quand votre code contient un bug.
Dans Laravel, l’outil courant est stancl/tenancy, qui gère la résolution du tenant, la création et la migration de la base de chaque tenant à l’inscription et, point essentiel, rend aussi le cache, les files d’attente et le stockage de fichiers conscients du tenant, pas seulement la connexion à la base de données. (Un paquet plus léger, spatie/laravel-multitenancy, se contente d’identifier le tenant courant et vous laisse la logique de séparation des données.) Mais là encore, le mécanisme est universel : résoudre le tenant, changer de connexion, exécuter des requêtes ordinaires.
Les coûts qui décident vraiment
C’est ici que la plupart des comparaisons restent vagues. Le choix est un arbitrage entre les problèmes que vous préférez avoir. Présenté sans détour :
| Dimension | BD partagée | BD par tenant |
|---|---|---|
| Rayon d’impact d’un bug | Fuite sur tous les tenants | Limité à un seul |
| Isolation assurée par | Le code applicatif | L’infrastructure |
| Migrations | Exécutées une fois | Exécutées × N bases |
| Coût par tenant | Proche de zéro | Surcoût fixe |
| Voisins bruyants | Contention partagée | Naturellement isolés |
| Requêtes inter-tenants | Une seule requête | Éclatement sur N bases |
| Suppression d’un tenant | Suppressions filtrées | Suppression de la base |
| Connexions | Un seul pool | Limites de pool par base |
Quatre de ces points valent plus qu’une cellule de tableau.
Le rayon d’impact est le vrai titre
C’est la dimension que l’on sous-estime. Dans une base partagée dont l’isolation n’est assurée que dans le code, le pire scénario est catastrophique et total : un seul filtre oublié dans un point d’accès de reporting peut exposer d’un coup les données de tous les clients. (C’est exactement l’écart que comble la sécurité au niveau des lignes, ce qui justifie sa mise en place sur Postgres.) Avec une base de données par tenant, le même type de bug est contenu : les données ne sont physiquement pas sur la connexion, donc rien ne peut fuiter. Vous n’achetez pas ici du confort, vous achetez un pire scénario plus petit. Pour certains produits (dossiers de santé, données financières, tout ce qui est assorti d’une loi de notification des violations de données), ce pire scénario plus petit est toute la décision.
Le coût d’exploitation augmente avec le nombre de tenants
Une base partagée n’a qu’un schéma à migrer, une chose à sauvegarder, un jeu de métriques à surveiller. La base de données par tenant multiplie tout cela par votre nombre de tenants. Une migration n’est plus une seule instruction. C’est la même instruction exécutée à tour de rôle sur chaque base de tenant, et une migration défectueuse peut réussir à moitié sur des centaines de bases avant que vous le remarquiez. Les sauvegardes, la supervision et la question « chaque base est-elle bien sur le dernier schéma ? » deviennent des problèmes de flotte plutôt que des problèmes d’objet unique.
La densité des ressources est un coût, pas une note de bas de page
Chaque base de données a un surcoût fixe (mémoire, empreinte de connexion, stockage des sauvegardes, taille minimale), qu’elle contienne une ligne ou un million. Une base partagée amortit ce coût sur tous les tenants. Les bases par tenant le paient par tenant. Si vos tenants sont des milliers de petits utilisateurs peu coûteux à servir, ce surcoût peut dominer votre facture d’infrastructure pour un bénéfice d’isolation dont ces utilisateurs n’ont pas besoin. C’est pourquoi le modèle partagé domine les produits B2C à gros volume et faible prix par tenant : c’est souvent la seule approche dont l’économie unitaire tienne la route.
La gestion des connexions est le point sensible. Un pool de connexions vers une seule base est une routine. Vous ne pouvez pas garder ouvert un pool vers dix mille bases de tenants à la fois ; la résolution doit se connecter, travailler puis libérer, et mal le faire est une façon classique d’épuiser la limite de connexions d’un serveur de base de données sous charge.
Voisins bruyants et croissance inégale
Dans une base partagée, les tenants se disputent le même CPU, les mêmes E/S, le même cache du planificateur de requêtes. Un client qui lance un rapport énorme, ou qui croît simplement dix fois plus vite que les autres, dégrade l’expérience de tous. On lutte contre cela avec des index, des réplicas en lecture et des limites de requêtes, mais on ne peut pas l’éliminer complètement ; la contention est structurelle.
La base de données par tenant transforme cette même croissance inégale en option plutôt qu’en problème : comme chaque tenant est déjà une base distincte, un tenant qui dépasse les autres peut être déplacé sur du matériel dédié sans toucher à l’installation de qui que ce soit d’autre. Mais cette option n’est pas gratuite ; elle se paie dans une autre monnaie. Dès qu’un tenant vit ailleurs, votre infrastructure devient hétérogène : migrations, déploiements, sauvegardes et supervision doivent tenir compte de tenants situés à différents endroits, et « où vit réellement ce tenant ? » devient une question à laquelle votre système doit répondre correctement à chaque requête. Vous avez échangé un problème de contention de performance contre un problème de gestion de flotte. Savoir lequel de ces deux problèmes votre équipe est le mieux équipée pour porter fait partie de la décision.
Un cadre pour choisir
En laissant de côté les détails, tout se ramène à quatre questions :
- La séparation physique est-elle une exigence, et non une préférence ? Si un contrat client, un régulateur ou une loi sur la résidence des données impose que les données d’un tenant soient physiquement séparées de celles d’un autre, cela tranche : optez pour la base de données par tenant (ou plus à droite sur le spectre), quoi qu’il en soit du reste. C’est la raison la plus courante pour laquelle des systèmes réels prennent cette voie, et c’est notablement une exigence métier, pas technique.
- Quelle est la forme de votre base de tenants ? Une poignée de gros clients à forte valeur rend le surcoût par tenant facile à justifier. Des milliers de petits tenants à bas prix le rendent ruineux ; l’argument de la densité l’emporte nettement.
- Quelle place les données inter-tenants tiennent-elles dans le produit ? Analytique à l’échelle de la plateforme, facturation à l’usage, tableaux de bord d’administration couvrant tous les clients : tout cela est trivial dans une base partagée, et un vrai problème d’ingénierie sur des bases séparées. Si c’est au cœur de ce que vous construisez, pesez-le lourdement.
- Quelle défaillance pouvez-vous le moins vous permettre ? C’est celle sur laquelle il faut s’attarder. Le pire jour d’une base partagée est une fuite de données entre tenants. Le pire jour d’une installation par tenant est un jour d’exploitation : une migration qui a corrompu un sous-ensemble de bases, un tenant bloqué sur un ancien schéma. Choisissez l’architecture dont votre produit et votre équipe peuvent réellement survivre au pire scénario.
Le choix par défaut, et quand le quitter
Les conseils accumulés dans la communauté backend aboutissent au même endroit, et le détail des coûts explique pourquoi : commencez par une base de données partagée et un cloisonnement par colonne, et ne passez à la base de données par tenant que lorsque quelque chose de concret vous y oblige.
« Quelque chose de concret » n’est presque jamais la simple échelle ; une base partagée bien indexée va très loin. C’est une exigence : un contrat signé qui impose l’isolation, une réglementation sur l’emplacement physique des données, un tenant si gros qu’il lui faut son propre matériel. En l’absence de l’un de ces cas, le modèle partagé offre une surface d’exploitation plus réduite, une meilleure économie unitaire et des requêtes inter-tenants triviales, et vous gardez la possibilité d’extraire plus tard un tenant exigeant, ce qui est nettement plus facile que de rapatrier toute une flotte de silos dans une seule base.
L’erreur n’est pas de choisir l’une ou l’autre. C’est de choisir par défaut l’architecture la plus isolée et la plus coûteuse parce qu’elle paraît plus rigoureuse, puis de porter son poids d’exploitation pour une garantie d’isolation que pas un seul de vos clients n’a jamais demandée.