Le SaaS a 400 locataires sur un Postgres partagé sur RDS. Le writer est à 70% CPU pendant les heures de bureau, principalement sur des requêtes de lecture contre la vue tableau de bord. La proposition est d’ajouter deux read replicas, de router la requête tableau de bord vers eux, et de libérer le writer pour les vraies écritures. Six semaines plus tard, un client pagent le support : il a facturé un client, rafraîchi la liste de factures, et la nouvelle facture est manquante. Il rafraîchit encore dix secondes plus tard et elle apparaît. L’application lit depuis un replica qui retardait de 8 secondes à ce moment-là, et le client suppose qu’il a perdu ses données. La stratégie de replica est correcte, l’implémentation a besoin de trois patterns pour ne pas générer de tickets de support.
Ce qui se passe
Un read replica est une copie asynchrone des données du writer. Le mécanisme de réplication sur RDS Postgres est la streaming replication, sur Aurora c’est le partage du volume cluster avec un retard milliseconde. Le replica est assez frais pour la plupart des requêtes, et assez obsolète pour être faux pour une classe spécifique de requête : la lecture qui suit l’écriture propre de l’utilisateur.
Les patterns qui tournent mal :
- Rafraîchissement de tableau de bord après une action. L’utilisateur clique sur « créer une facture », l’application écrit dans le primaire, puis l’utilisateur navigue vers la liste de factures qui lit depuis le replica. La liste est servie depuis une connexion qui n’a pas encore vu l’écriture. La facture est manquante.
- Décalage liste/détail. La requête liste lit depuis le replica (montrant l’état mis en cache) ; la requête détail pour un élément que l’utilisateur vient d’éditer lit depuis le primaire (montrant le nouvel état). La liste montre un nombre, le détail un autre, l’utilisateur ouvre un ticket.
- Requête d’agrégation avec une écriture. Un rapport mensuel tourne contre le replica ; l’utilisateur clique sur « inclure les données du jour », ce qui écrit un flag et re-requête l’agrégation. L’agrégation n’inclut pas encore le flag parce que la réplication n’a pas rattrapé.
Le retard de réplication est aussi non uniforme entre replicas. Une charge qui exécute un gros scan séquentiel sur un replica peut retarder ce replica significativement pendant qu’un autre replica reste frais. Les décisions de routage qui supposent que « n’importe quel replica fait l’affaire » ignorent cela.
Le bon modèle mental : un read replica est un cache. Il est approximativement cohérent. L’application doit savoir quelles lectures tolèrent une cohérence approximative et lesquelles non. Le routage par locataire empire le problème, ne l’arrange pas — si toutes les lectures du locataire A vont vers replica-1 et que replica-1 retarde, le locataire A voit des données obsolètes sur chaque page.
Quand les read replicas sont la bonne réponse pour un SaaS multilocataire :
- Le ratio lecture/écriture est élevé (la plupart des charges — 80/20 ou plus).
- L’application peut identifier quelles lectures peuvent tolérer une donnée obsolète (la plupart des requêtes tableau de bord, rapports, vues liste passées la page 1).
- L’équipe accepte de marquer certains endpoints comme « primaire-seul ».
Quand les read replicas ne suffisent pas et que promouvoir une base par locataire fait sens :
- Un locataire unique génère plus de 20% de la charge d’écriture de la base.
- Un locataire a des exigences de résidence des données que la base partagée ne satisfait pas.
- Un locataire fait tourner des requêtes analytiques lourdes qui affectent le buffer cache du writer.
- Le rayon de blast d’un locataire bruyant unique est inacceptable.
Comment vérifier
Mesurez le retard en continu. La métrique à surveiller sur RDS Postgres est ReplicaLag (secondes), sur Aurora c’est AuroraReplicaLag (millisecondes). Le retard est publié par replica :
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name ReplicaLag \
--dimensions Name=DBInstanceIdentifier,Value=prod-db-1-replica-1 \
--start-time $(date -u -v-7d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 60 \
--statistics Maximum \
--query 'Datapoints[].Maximum' \
--output text | sort -n | tail -5
Le maximum sur une semaine est le nombre à planifier. S’il est de 30+ secondes, l’application doit traiter le replica comme approximatif seulement. S’il est régulièrement sous une seconde sur Aurora, les cas d’usage qui tolèrent « quasi temps-réel mais pas cohérence-écriture » sont la plupart d’entre eux.
À l’intérieur de Postgres, vérifiez le retard du point de vue du writer :
SELECT
application_name,
client_addr,
state,
sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,
EXTRACT(EPOCH FROM (now() - reply_time)) AS reply_lag_seconds
FROM pg_stat_replication;
replay_lag_bytes est plus honnête que reply_lag_seconds — un replica qui a accusé réception d’une position mais ne l’a pas encore rejouée montre un reply lag nul et un replay lag significatif.
Dans l’application, instrumentez la fenêtre read-after-write. Un pattern simple : chaque appel d’écriture incrémente un compteur de version par locataire dans le cache applicatif (Redis, en mémoire). Les lectures subséquentes pour ce locataire vérifient le cache, et si une écriture récente est en cours, routent vers le primaire. Le seuil (par exemple « pendant 10 secondes après une écriture, lire depuis le primaire ») dépend de la charge. Mesurez le retard de réplication réel, doublez-le pour la sécurité, c’est votre fenêtre lecture-depuis-primaire.
Confirmez quel locataire génère le plus de charge d’écriture :
SELECT
current_setting('app.tenant_id') AS tenant,
COUNT(*) AS query_count,
SUM(CASE WHEN query LIKE 'INSERT%' OR query LIKE 'UPDATE%' OR query LIKE 'DELETE%' THEN 1 ELSE 0 END) AS writes
FROM pg_stat_statements
JOIN pg_database d ON d.oid = pg_stat_statements.dbid
WHERE d.datname = 'production'
GROUP BY 1
ORDER BY writes DESC
LIMIT 10;
Cela suppose que l’application définit app.tenant_id via SET LOCAL par requête, ce qu’elle devrait faire pour l’attribution des requêtes. Si elle ne le fait pas, instrumentez-le maintenant.
La correction
Les trois patterns qui font fonctionner les read replicas en SaaS multilocataire :
Routage read-your-own-writes. Pistez les écritures récentes par locataire ou par utilisateur. Après une écriture, routez les lectures de ce locataire vers le primaire pour une fenêtre configurable (typiquement 5–30 secondes, selon le pire retard replica). L’implémentation la plus simple : une clé Redis tenant:{id}:last_write avec un TTL égal à la fenêtre de routage. Chaque écriture la pose ; chaque lecture la vérifie avant de choisir une connexion.
Dans la couche d’accès aux données de l’application :
def get_db_connection(tenant_id, write=False):
if write:
redis.setex(f"tenant:{tenant_id}:last_write", 30, "1")
return primary_connection()
if redis.exists(f"tenant:{tenant_id}:last_write"):
return primary_connection()
return replica_connection()
Cela échange l’utilisation du replica contre la correction. Une charge où chaque requête inclut une écriture va router tout vers le primaire, défaisant le replica. C’est le mauvais design — le chemin de lecture ne devrait pas exiger une écriture pour chaque requête.
Sélection explicite de endpoint par requête. Étiquetez chaque site d’appel à la base avec son exigence de cohérence. Trois étiquettes :
strict(lire depuis le primaire, jamais le replica) — utilisé pour les chemins read-after-write que la couche de routage ne peut pas détecter, comme les handlers de webhooks.recent(lire depuis le primaire si dans la fenêtre read-your-own-writes, sinon depuis le replica) — la plupart des lectures initiées par l’utilisateur.eventual(toujours lire depuis le replica) — requêtes analytiques, rapports, vues liste au-delà de la page 1, journaux d’audit.
Les étiquettes vivent dans le code, pas dans la configuration runtime. Un développeur qui ajoute une nouvelle requête doit penser à quelle étiquette s’applique. Défaut à recent est sensé ; l’étiquette explicite empêche la dérive silencieuse.
Conscience santé-et-retard par replica dans la couche de routage. Ne tournez pas en round-robin sur tous les replicas. Utilisez le reader endpoint d’Aurora (qui équilibre la charge en interne et tient compte du retard), ou sur RDS Postgres maintenez un health check côté application qui exclut les replicas dont le retard dépasse un seuil. Un pattern simple est une couche de routage qui interroge chaque pg_last_wal_replay_lsn du replica versus pg_current_wal_lsn du primaire toutes les 5 secondes et exclut tout ce qui retarde de plus de 2 secondes.
Quand promouvoir un replica vers une base dédiée par locataire :
- Les requêtes du locataire dépassent 10x celles du locataire médian, sur une mesure soutenue (lignes scannées, I/O, temps CPU).
- Le locataire a une exigence réglementaire que la base partagée ne peut satisfaire (résidence des données, isolation des journaux d’audit).
- Les écritures du locataire sont le goulot d’étranglement pour l’instance writer.
- Le coût d’une base isolée (instance + stockage + sauvegarde) est inférieur au coût d’agrandir le writer partagé pour gérer le locataire bruyant.
La mécanique de la promotion :
# Créer un read replica du writer partagé, scopé à ce seul locataire.
# En pratique, le pattern schéma-par-locataire permet de découper proprement.
# Des données locataire dans des tables partagées exigent une réécriture
# côté application pour déplacer les lignes.
aws rds promote-read-replica \
--db-instance-identifier prod-db-1-replica-tenant-XYZ \
--backup-retention-period 7
Après promotion, le replica devient un writer indépendant. Le routage de connexion de l’application doit diriger ce locataire vers le nouveau endpoint. Le schéma résiduel du replica (lignes pour d’autres locataires qui étaient aussi sur le replica) est éliminé via un strip de schéma hors-ligne.
Le pattern qui ne marche pas : essayer de « sharder le SaaS multilocataire un locataire à la fois » en promouvant des replicas. La promotion est mécanique ; la couche de routage par locataire de l’application est la partie difficile. Construisez d’abord la couche de routage par locataire, puis les promotions deviennent une opération tactique.
Trois règles que nous tenons sur les opérations base multilocataire :
Chaque écriture est aussi un événement de routage. La lecture après l’écriture doit venir du bon endroit. Bâtissez cela dans la couche d’accès aux données ou ce ne sera pas bâti du tout.
Le retard de replica est une métrique, pas une supposition. Un replica sans retard hier peut retarder pendant 30 minutes aujourd’hui pendant un vacuum. Alertez sur ReplicaLag au-dessus de la fenêtre read-your-own-writes. Paginez sur ReplicaLag au-dessus de 60 secondes.
Promouvez des locataires quand les données vous le disent, pas quand un client se plaint. Le bruit d’un seul client peut être tuné sans promotion. Un pattern à travers plusieurs clients bruyants est un signal que le modèle partagé atteint ses limites.
Le modèle replica partagé fait grossir un SaaS d’environ un ordre de grandeur au-delà de là où un writer unique se serait effondré, mais seulement si le pattern read-after-write est traité comme une préoccupation applicative de première classe, pas comme un détail après coup.
Stack Harbor conçoit et opère les topologies RDS / Aurora en production dans le cadre de Gestion AWS et de la couche Environnements infogérés. Si votre posture base de données se résume à « case Multi-AZ cochée + espoir », commencez par une consultation d’infrastructure.