Be Software Architecture Wise
Be Wise Apps

Read in English · Leer en español · Auf Deutsch lesen

Section 7.4 sur 7.8 · Avancé · 30 min de lecture

Réplication, partitionnement et sharding

Leçon vidéo

5 min · narré en anglais, avec sous-titres
Écouter seulement (audio)
Lire la transcription

Tôt ou tard, un serveur de base de données unique manque de quelque chose : de disponibilité, de capacité ou de stockage. Dans cette leçon, vous allez découvrir les deux façons de répartir les données sur plusieurs machines, et ce que chacune fait à l’exactitude.

Imaginez une bibliothèque très fréquentée. Pour continuer à servir ses lecteurs si l’un de ses bâtiments est inondé, elle conserve des copies identiques de son catalogue dans plusieurs annexes. C’est la réplication. Les copies sont mises à jour par coursier, si bien qu’une annexe peut avoir un peu de retard. Quand la collection est trop grande pour un seul bâtiment, la bibliothèque la divise, avec une moitié de l’alphabet ici et l’autre là-bas. C’est le partitionnement, souvent appelé sharding. Bien diviser signifie qu’aucune annexe n’est débordée, et que les lecteurs ont rarement besoin de visiter toutes les annexes pour obtenir une réponse. Les systèmes réels font généralement les deux : chaque partition est répliquée.

Le schéma le plus courant est la réplication leader-suiveur. Toutes les écritures vont à un seul nœud, le leader, qui transmet en continu son journal de modifications aux suiveurs. Le choix clé est le moment où le leader annonce un commit. Avec un suiveur synchrone, le commit attend que le suiveur ait reçu la modification. Une écriture validée survit à la perte du leader, mais chaque commit paie un aller-retour réseau. Avec un suiveur asynchrone, le commit n’attend pas. C’est rapide, mais si le leader meurt, les dernières modifications peuvent être perdues, alors même que le client a été informé de leur validation.

Les suiveurs asynchrones n’ont généralement que quelques millisecondes de retard, mais sous charge, ce retard peut atteindre des secondes, voire des minutes. Les utilisateurs le remarquent. Une cliente enregistre une nouvelle adresse de livraison, et le leader répond OK. Elle recharge la page. La lecture est servie par un suiveur, et elle voit son ancienne adresse. On dirait que l’enregistrement a échoué. Sa modification n’atteint le suiveur qu’après le retard. Cette garantie non respectée s’appelle la lecture de ses propres écritures. Le remède consiste à lire sur le leader les modifications récentes de l’utilisateur lui-même, ou à attendre que le suiveur rattrape son retard. Décidez, requête par requête, quelles lectures peuvent être périmées.

Quand un leader tombe en panne, le failover le remplace par un suiveur : détecter la défaillance, choisir le suiveur le plus à jour, et rediriger les écritures. Chaque étape peut mal tourner. La défaillance la plus dangereuse est le split brain : deux nœuds se croient tous deux leader, acceptent tous deux des écritures, et les données divergent. La protection exige un quorum, pour qu’un nœud ne puisse être leader que tant qu’une majorité est d’accord, et le fencing, pour que l’ancien leader ne puisse pas continuer à écrire. L’incident de GitHub d’octobre 2018 montre à quel point c’est difficile. Une panne réseau de quarante-trois secondes a déclenché un failover vers l’autre côte. Les deux côtes détenaient alors des écritures qui manquaient à l’autre, et le service a été dégradé pendant plus d’une journée.

Passons au partitionnement. Chaque enregistrement va dans une partition selon sa clé de partition, et l’objectif est de répartir la charge de manière égale. Le partitionnement par plage donne à chaque partition une plage de clés, par exemple des mois. Le parcours d’un mois ne touche qu’une partition. Mais si la clé croît avec le temps, chaque nouvelle commande tombe sur la partition la plus récente : un point chaud. Le partitionnement par hachage répartit uniformément les nouvelles commandes entre les partitions. Le prix à payer est que le parcours d’un mois doit interroger toutes les partitions. De nombreux stockages combinent les deux. Hachez l’identifiant du client pour choisir la partition, et triez par date à l’intérieur, si bien que l’historique d’un client se lit en un seul parcours.

Choisissons une clé de sharding. La plateforme de livraison de repas reçoit dix millions de commandes par jour, soit environ trois mille cinq cents écritures par seconde au pic. Un seul primaire bien réglé peut absorber cela, donc le débit à lui seul n’impose pas le sharding. Le stockage, si. À environ quarante gigaoctets par jour, deux ans en ligne représentent vingt-neuf téraoctets, et reconstruire une réplique sur un seul nœud prendrait trop de temps. Environ deux téraoctets par shard donnent seize shards, chacun avec deux suiveurs, et mille vingt-quatre partitions logiques par-dessus, si bien que la croissance déplace des partitions entières au lieu de rehacher les clés. Viennent ensuite les clés candidates. L’identifiant de commande disperserait l’historique de chaque client. La date de commande créerait un point chaud. Le restaurant a des clés chaudes, et certaines villes sont bien plus grandes que d’autres. La décision est donc de hacher l’identifiant du client et de trier par date de commande. Les autres requêtes reçoivent des chemins distincts, comme un modèle de lecture pour la file de chaque restaurant. Remarquez : avec seulement quatre-vingt-dix jours en ligne, aucun sharding n’aurait été nécessaire.

Quatre erreurs courantes. Premièrement, traiter les répliques comme des sauvegardes. Une réplique copie fidèlement une suppression erronée en quelques millisecondes. Deuxièmement, se fier à un failover automatique qui n’a jamais été répété. Troisièmement, partitionner trop tôt, ce qui complique les jointures, les transactions et les changements de schéma. Essayez d’abord l’indexation, la mise en cache, les répliques en lecture et l’archivage. Et quatrièmement, partitionner selon une clé qui augmente toujours, ce qui envoie chaque nouvelle écriture sur une seule partition. Une clé de sharding est très difficile à changer après coup : consignez donc les volumes qu’elle suppose.

La section complète traite aussi de la réplication sans leader, du hachage cohérent et des index secondaires. Ensuite, dans la section sept point cinq, nous verrons CQRS et l’event sourcing. À très vite.

Ce qu’il faut savoir

Deux raisons différentes de répartir les données

Un serveur de base de données unique finit toujours par manquer de quelque chose : de disponibilité (c’est une seule machine, qui peut tomber en panne), de capacité de lecture, de capacité d’écriture, de stockage, ou de proximité avec des utilisateurs situés sur d’autres continents. Il existe deux façons complémentaires de répartir les données sur plusieurs machines, et il est important de ne pas les confondre. La réplication conserve une copie des mêmes données sur plusieurs nœuds, pour la disponibilité, le passage à l’échelle des lectures et une latence plus faible près des utilisateurs. Le partitionnement, appelé sharding dans de nombreux produits, découpe les données pour que chaque nœud en détienne un sous-ensemble, pour un débit d’écriture et un stockage qui dépassent les capacités d’une seule machine. Les systèmes réels font généralement les deux : chaque partition est répliquée.

Les deux ont de lourdes conséquences sur l’exactitude. La réplication pose la question de ce que voit un lecteur pendant que les copies divergent, ce qui est le sujet des modèles de cohérence, de CAP et de PACELC. Le partitionnement pose la question de ce qui arrive aux requêtes, aux index et aux transactions qui s’étendent sur plusieurs partitions. Cette section explique les mécanismes de façon assez approfondie pour juger les promesses d’une base de données et concevoir une clé de sharding.

Réplication leader-suiveur

Le schéma le plus courant est la réplication leader-suiveur (aussi appelée primaire-réplique ; la documentation plus ancienne dit maître-esclave). Un nœud, le leader, accepte toutes les écritures. Il enregistre chaque modification dans son journal (le journal d’écriture anticipée de PostgreSQL, le journal binaire de MySQL) et transmet ce journal en continu aux suiveurs, qui appliquent les modifications dans le même ordre. Les lectures peuvent aller au leader ou aux suiveurs. La réplication en continu (streaming replication) de PostgreSQL, la réplication MySQL, les groupes de disponibilité de SQL Server, les replica sets de MongoDB et la plupart des bases de données cloud managées fonctionnent ainsi.

Le choix de conception clé est le moment où le leader annonce qu’un commit a réussi. Avec la réplication synchrone, il attend qu’un suiveur confirme avoir reçu la modification, si bien qu’une écriture validée survit à la perte du leader ; le prix est que chaque commit inclut un aller-retour réseau, et que si le suiveur synchrone est hors service, les écritures se bloquent. Avec la réplication asynchrone, le leader confirme immédiatement et transmet la modification ensuite ; les commits sont rapides et les suiveurs peuvent être très éloignés, mais si le leader meurt, les dernières modifications qui n’avaient atteint aucun suiveur sont perdues, alors même que le client a été informé de leur validation. Un compromis courant, appelé semi-synchrone dans MySQL, attend un suiveur et laisse les autres être asynchrones. À la banque, perdre un paiement confirmé est inacceptable, si bien qu’au moins une copie synchrone (ou une base de données fondée sur le consensus) est la norme ; pour la base de données des cartes de la plateforme de livraison de repas, quelques secondes de perte potentielle peuvent être un prix acceptable pour la vitesse. C’est, sous une forme concrète, le compromis sur le RPO vu dans disponibilité et fiabilité.

Un leader avec un suiveur synchrone et un suiveur asynchrone

Les clients écrivent sur le leader, qui diffuse son journal à un suiveur synchrone et à un suiveur asynchrone ; les lectures peuvent aller aux suiveursServeurs appécritureLeaderreçoit les écrituressync : commit attendasync : commitimmédiatSuiveur 1synchronetoujours à jourSuiveur 2asynchroneen retardlecturelecture (parfois périmée)diffuse son journal(WAL ou binlog)
Toutes les écritures vont au leader ; son journal de modifications est transmis aux suiveurs, et le commit n’attend que le suiveur synchrone.

Le retard de réplication et les anomalies qu’il provoque

Les suiveurs asynchrones n’ont généralement que quelques millisecondes de retard, mais sous charge, pendant une grosse mise à jour par lots ou sur un réseau lent, le retard de réplication peut atteindre des secondes, voire des minutes. Lire sur les suiveurs produit alors des anomalies que les utilisateurs remarquent. Douglas Terry et ses collègues ont défini en 1994 les deux premières garanties ci-dessous, la lecture de ses propres écritures et les lectures monotones, comme des garanties de session ; la troisième compte surtout lorsque les données sont aussi partitionnées.

Lecture de ses propres écritures non respectée

Une cliente met à jour son adresse de livraison sur le leader, puis lit aussitôt sur un suiveur en retard et voit l’ancienne adresseClientLeaderSuiveur1 enregistre adresse2 OK3 actualise4 ancienne adr.5 changement reçuaprès le retardCorrectif : lire ses changements récents sur le leader, ou attendre que le suiveur soit à jour
La modification de la cliente est confirmée par le leader, mais pas encore appliquée sur le suiveur qui sert sa lecture suivante.

Les anomalies dues au retard et leurs remèdes

GarantieCe qui tourne mal sans elleRemèdes courants
Lecture de ses propres écritures (read-your-writes, read-after-write)Une cliente modifie son adresse de livraison, recharge la page et voit l’ancienne : on dirait que l’enregistrement a échoué, alors elle recommence.Lire sur le leader les données que l’utilisateur vient peut-être de modifier (par exemple pendant une minute après sa dernière écriture) ; ou noter la position du journal du leader au moment de l’écriture et ne lire sur un suiveur qu’une fois qu’il a appliqué cette position.
Lectures monotonesL’app d’un livreur s’actualise deux fois ; la première lecture tombe sur un suiveur à jour et montre une commande comme affectée, la seconde tombe sur un suiveur en retard et la commande semble de nouveau non affectée. Le temps paraît reculer.Acheminer les lectures de chaque utilisateur vers le même suiveur (par exemple en hachant l’id de l’utilisateur), en ne basculant que lorsqu’il est indisponible.
Lectures à préfixe cohérentLes lecteurs voient une réponse avant la question, ou l’événement livrée d’une commande avant récupérée, parce que les deux ont été écrits sur des partitions différentes qui n’ont pas le même retard.Écrire les données liées par causalité sur la même partition, ou suivre explicitement les dépendances causales.

Aucune de ces anomalies n’existe si toutes les lectures vont au leader ; le remède le plus simple consiste donc souvent à envoyer au leader les lectures qui doivent être fraîches, et à n’utiliser les suiveurs que pour les lectures qui tolèrent des données périmées : listes de produits, rapports, flux de recherche. Concevez cela délibérément, requête par requête, au lieu d’envoyer par défaut toutes les lectures vers les répliques.

Failover et split brain

Le failover (basculement) remplace un leader défaillant par un suiveur. Il comporte trois étapes : détecter que le leader est tombé en panne (généralement par un délai d’expiration), choisir le nouveau leader (idéalement le suiveur le plus à jour, souvent par un protocole de consensus comme Raft entre les nœuds ou par un coordinateur externe comme etcd ou ZooKeeper), et rediriger les écritures vers lui. Chaque étape peut mal tourner. Un délai d’expiration trop court déclenche un failover lors d’une brève coupure réseau ou d’un pic de charge, ce qui aggrave la situation ; un délai trop long prolonge la panne. Avec la réplication asynchrone, les écritures que l’ancien leader n’avait pas encore transmises sont perdues, et si l’ancien leader revient en les détenant toujours, elles entrent en conflit avec les nouvelles écritures.

La défaillance la plus dangereuse est le split brain (cerveau divisé) : deux nœuds se croient tous deux leader et acceptent tous deux des écritures, si bien que les données divergent d’une manière qu’il peut être impossible de fusionner automatiquement. Il survient généralement après une partition réseau dans laquelle l’ancien leader est isolé mais toujours en vie. La protection exige un quorum (un nœud ne peut être leader que tant qu’une majorité est d’accord), le fencing (l’autorité du nouveau leader est imposée par un jeton toujours croissant que le stockage vérifie, ou l’ancien leader est mis hors tension de force, une technique surnommée STONITH : shoot the other node in the head, « abattre l’autre nœud »), et des clients qui suivent de façon fiable le leader actuel.

L’incident de GitHub du 21 octobre 2018 montre à quel point c’est difficile en pratique. Une panne réseau a coupé pendant 43 secondes la liaison entre son point d’interconnexion de la côte Est des États-Unis et son principal centre de données de la côte Est. Son outillage de failover, fonctionnant comme prévu, a promu des bases de données primaires de la côte Ouest. Quand la connectivité est revenue, chaque côte détenait des écritures qui manquaient à l’autre, si bien que GitHub ne pouvait pas simplement rebasculer vers la côte Est ; pour protéger l’intégrité des données, l’entreprise a choisi de restaurer puis de réconcilier, et le service a été dégradé pendant 24 heures et 11 minutes. Le failover automatique entre régions avait transformé une coupure de 43 secondes en une journée entière de rétablissement.

Réplication multi-leader et sans leader

Dans la réplication multi-leader, plusieurs nœuds acceptent des écritures et se répliquent entre eux. On l’utilise pour plusieurs centres de données (chaque région écrit localement, ce qui évite aux utilisateurs une latence transocéanique), pour les clients qui travaillent hors ligne (une app d’agenda ou de notes est en fait un leader sur chaque appareil) et pour l’édition collaborative. Le problème inévitable, ce sont les conflits d’écriture : deux leaders modifient le même enregistrement en même temps. Les résolutions possibles sont last write wins (la dernière écriture l’emporte ; simple, mais qui écarte des données sans avertissement et dépend des horloges), la fusion des valeurs, la conservation des deux en demandant à une personne de trancher, ou l’utilisation de types de données conçus pour fusionner automatiquement (CRDT, conflict-free replicated data types). L’approche la plus fiable est d’éviter les conflits : donner à chaque enregistrement une région d’origine qui est seule à accepter ses écritures, comme le pays de résidence d’un client.

La réplication sans leader, popularisée par l’article Dynamo d’Amazon (DeCandia et ses collègues, 2007) et utilisée dans Apache Cassandra, ScyllaDB et Riak, n’a aucun leader. Le client, ou un nœud coordinateur agissant pour lui, envoie chaque écriture aux N répliques d’une clé et la considère comme réussie quand W d’entre elles l’ont confirmée ; il envoie chaque lecture aux répliques et attend R réponses, en retenant la version la plus récente. Si R + W > N, chaque ensemble de lecture recoupe chaque ensemble d’écriture réussie sur au moins une réplique, si bien qu’une lecture voit normalement la dernière écriture confirmée. Les répliques qui ont manqué des écritures sont réparées par la réparation à la lecture (read repair : le coordinateur réécrit la version la plus récente quand il en voit une périmée) et par une anti-entropie en arrière-plan qui compare les répliques. Cassandra expose ces choix requête par requête sous forme de niveaux de cohérence comme ONE, QUORUM et ALL.

Une écriture et une lecture en quorum avec N = 3

Dans un stockage sans leader à trois répliques, une écriture va aux trois et réussit quand deux confirment ; une lecture interroge les trois, en attend deux et retient la plus récente : lectures et écritures se recoupentÉcriture N = 3, W = 2Lecture R = 2ClientRéplique Av2Réplique Bv2Réplique Cv1 (était HS)2 acks : écriture réussieClientRéplique Av2Réplique BlenteRéplique Cv1réponses v2 et v1 :v2 (récente) gagneC réparée en v2R + W = 4 > N = 3 : toute lecturerecoupe la dernière écriture
Avec W = 2 et R = 2, au moins une réplique de chaque lecture a vu la dernière écriture confirmée.

On accorde souvent trop de confiance aux quorums. R + W > N ne rend pas à lui seul le système linéarisable : les écritures concurrentes, les écritures qui ont échoué sur certaines répliques mais réussi sur d’autres, les sloppy quorums (quorums approximatifs, qui écrivent sur des nœuds de remplacement pendant les pannes) et last write wins avec des horloges décalées permettent tous des données périmées ou perdues. Le chapitre 6 détaille l’arithmétique des quorums et ces cas limites dans CAP et PACELC. Les stockages sans leader sont excellents pour une haute disponibilité en écriture malgré les pannes et entre centres de données ; ils conviennent mal aux invariants comme ne jamais vendre deux fois la même place, sauf s’ils proposent une fonction de transaction légère ou de consensus pour ces opérations.

Partitionnement : par plage de clés ou par hachage

Le partitionnement affecte chaque enregistrement à une partition selon sa clé de partition (ou clé de sharding), et l’objectif est de répartir les données et la charge de manière égale. Une partition qui reçoit une charge disproportionnée est un point chaud, et une répartition inégale s’appelle un déséquilibre (skew). Il existe deux schémas de base.

Le partitionnement par plage de clés donne à chaque partition une plage contiguë de clés, comme les volumes d’une encyclopédie. Les parcours de plages sont efficaces parce que les clés adjacentes sont stockées ensemble (toutes les commandes d’octobre dans une ou deux partitions), et les partitions peuvent être scindées quand elles grossissent, comme dans Bigtable, HBase, Spanner et le sharding par plage de MongoDB. Le danger est une clé qui croît avec le temps : si la clé est un horodatage ou un numéro de commande séquentiel, chaque nouvelle écriture tombe sur la dernière partition, qui devient un point chaud pendant que les autres restent inactives. Le partitionnement par hachage applique une fonction de hachage à la clé et affecte des plages de valeurs de hachage aux partitions, ce qui répartit uniformément même les clés séquentielles. Le prix à payer est que les requêtes par plage sur la clé doivent désormais interroger toutes les partitions. De nombreux stockages combinent les deux avec une clé composée : Cassandra hache la clé de partition pour choisir le nœud et trie les lignes au sein de la partition selon des colonnes de clustering, si bien que (customer_id, placed_at) regroupe les commandes d’un client, dans l’ordre chronologique, sur un seul nœud.

Partitionnement par plage ou par hachage pour les commandes

Le partitionnement par plage regroupe les clés adjacentes : les clés chronologiques tombent toutes sur la dernière partition ; le hachage répartit les clés mais disperse les plagesPar plagePar hachageP1janv. à avr.P2mai à aoûtP3depuis sept.parcours d’un mois :une partition (bien)nouv. commandes en P3 :hotspot (mal)P1hash 0 à 33P2hash 34 à 66P3hash 67 à 99nouv. commandes répartiessur P1, P2, P3 (bien)parcours d’un mois :toutes les partitions (mal)Clé composée : hash(customer_id) choisit la partition,placed_at y trie les lignes : l’historique d’un client est un parcours
Le partitionnement par plage garde les plages ensemble mais concentre les écritures chronologiques ; le hachage répartit les écritures mais disperse les plages.

Hachage cohérent et rééquilibrage

La façon évidente d’associer les valeurs de hachage aux nœuds, hash(key) mod N, a un défaut rédhibitoire : quand N change, presque toutes les clés se déplacent. Passer de 4 à 5 nœuds déplace environ 80 % des clés (toute clé dont le reste diffère pour 4 et pour 5), et passer de 10 à 11 environ 91 %, ce qui revient à copier presque tout le jeu de données simplement pour ajouter une machine. Le hachage cohérent (consistent hashing), introduit par David Karger et ses collègues en 1997 pour les caches web, place à la fois les nœuds et les clés sur un espace de hachage circulaire ; chaque clé appartient au nœud suivant dans le sens des aiguilles d’une montre. Un nœud ajouté ne reprend que les clés situées entre lui et son prédécesseur, environ 1/N du total, et un nœud retiré cède ses clés à son voisin. Avec peu de nœuds, les arcs sont inégaux ; on attribue donc à chaque nœud physique de nombreux nœuds virtuels (tokens) en différents points de l’anneau, ce qui équilibre la charge et permet à un nouveau nœud de prendre un peu à chaque nœud existant. Dynamo et Cassandra utilisent ce schéma.

Le hachage cohérent sur un anneau

Nœuds et clés sont hachés sur un anneau ; chaque clé appartient au nœud suivant (sens horaire), donc l’ajout du nœud D ne reprend que les clés entre le prédécesseur de C et DNœud ANœud BNœud CNouv. nœud Danneauclés : sens horaireAjout du nœud Dhash mod N, de 4 à 5 nœuds :≈ 80 % des clés bougenthachage cohérent :seules les clés entre B et Dbougent (de C à D),soit environ 1/N du totalnœuds virtuels : un serveura de nombreux points sur l’anneaupour lisser la charge
Les clés appartiennent au nœud suivant dans le sens horaire : un nouveau nœud reprend un seul arc au lieu de tout redistribuer.

Deux autres stratégies de rééquilibrage sont courantes. Avec un nombre fixe de partitions, vous créez au départ beaucoup plus de partitions que de nœuds (par exemple 1024 pour 10 nœuds) et vous affectez des partitions entières aux nœuds ; ajouter un nœud déplace quelques partitions entières et ne rehache jamais les clés. Les shards d’Elasticsearch, les vBuckets de Couchbase et de nombreux schémas de sharding faits maison procèdent ainsi, et la décision principale est de choisir un nombre de partitions assez grand pour la croissance future. Avec le partitionnement dynamique, comme dans HBase et MongoDB, une partition se scinde quand elle dépasse un seuil de taille, et les deux moitiés peuvent aller sur des nœuds différents. Quel que soit le schéma, le rééquilibrage déplace de grandes quantités de données sur le réseau pendant que le système sert du trafic ; de nombreuses équipes préfèrent donc qu’une personne approuve chaque déplacement, et elles le brident pour protéger la latence.

Les requêtes doivent aussi être acheminées vers la bonne partition : soit n’importe quel nœud accepte une requête et la transmet (les nœuds Cassandra apprennent l’anneau par gossip), soit une couche de routage connaît la carte des partitions (mongos de MongoDB), soit la bibliothèque cliente détient la carte. La carte elle-même est généralement conservée dans un service de coordination comme etcd ou ZooKeeper, ou dans le groupe de consensus propre à la base de données.

Clés chaudes et déséquilibre

Le hachage répartit uniformément les clés, pas la charge. Si une clé est bien plus active que les autres, sa partition est chaude quel que soit le schéma : le compte d’une célébrité sur un réseau social, le restaurant phare d’une chaîne nationale pendant une promotion, ou le compte de règlement propre à une banque, qui intervient dans chaque paiement. Les stockages managés explicitent les limites ; Amazon DynamoDB, par exemple, documente un plafond par partition de 3000 unités de lecture et 1000 unités d’écriture par seconde. Les parades consistent notamment à mettre en cache les lectures chaudes, à scinder une clé chaude en lui ajoutant un petit suffixe aléatoire (écritures de restaurant:88#0 à restaurant:88#9, au prix de la lecture des dix clés et de leur combinaison), à déplacer un très gros locataire vers des partitions dédiées et à agréger les compteurs en mémoire avant de les écrire. Toutes ajoutent de la complexité : mesurez donc d’abord quelles clés sont réellement chaudes.

Index secondaires entre partitions

Le partitionnement fonctionne sans heurt pour les recherches par clé de partition. Les requêtes portant sur autre chose ont besoin d’un index secondaire, et il existe deux façons de le partitionner. Un index local (partitionné par document) vit avec chaque partition et ne couvre que les lignes de celle-ci. Les écritures ne mettent à jour qu’une partition, mais une requête sur l’attribut indexé doit être envoyée à toutes les partitions et les résultats fusionnés (scatter-gather, dispersion puis regroupement), si bien que sa latence est fixée par la partition la plus lente et que son coût augmente avec le nombre de partitions. Un index global (partitionné par terme) est lui-même partitionné selon la valeur indexée, si bien qu’une requête lit une seule partition d’index ; mais une seule écriture peut alors devoir mettre à jour des partitions d’index situées sur d’autres nœuds, ce qui se fait généralement de façon asynchrone, de sorte que l’index peut brièvement être en retard sur les données. DynamoDB illustre les deux : un index secondaire local partage la clé de partition de la table, tandis qu’un index secondaire global a la sienne et offre une cohérence à terme.

Index secondaires locaux et globaux

Un index local vit avec chaque partition : écritures locales, mais une requête par restaurant interroge toutes les partitions ; un index global est partitionné par valeur indexée : la requête lit une partition d’index, mais chaque écriture peut mettre à jour un index sur un autre nœudIndex local (par partition)Index global (partition par terme)Partition 1commandes par clientindex : restaurant(lignes locales)Partition 2commandes par clientindex : restaurant(lignes locales)Requêterestaurant = 88disperser vers tous, regrouperPartition 1commandes par clientPartition 2commandes par clientIndex partie arestaurants 1-50Index partie brestaurants 51-99asyncRequêterestaurant = 88une partition d’index ;parfois un peu périmée
Un index local rend les écritures peu coûteuses et disperse les requêtes ; un index global rend les requêtes ciblées et les mises à jour d’index distantes et généralement asynchrones.

Exemple commenté

Exemple commenté : dimensionner et choisir une clé de sharding pour les commandes

La plateforme de livraison de repas s’est étendue à 30 pays et traite 10 millions de commandes par jour, avec un pic en soirée à trois fois le rythme moyen. Chaque commande génère environ 10 écritures au cours de sa vie (création, lignes, changements de statut, références de paiement) et occupe environ 4 Ko, index compris. Les clients peuvent consulter tout leur historique de commandes ; la réglementation exige que les commandes puissent être retrouvées pendant six ans, mais le support n’a besoin d’un accès en ligne qu’aux deux dernières années.

Débit d’écriture10 000 000 / 86 400 s = environ 116 commandes par seconde en moyenne ; pic d’environ 350 par seconde ; à 10 écritures chacune, environ 3500 écritures par seconde au pic. Un seul primaire bien réglé peut absorber cela ; le débit à lui seul n’impose pas le sharding.
Croissance du stockage10 000 000 x 4 Ko = 40 Go par jour, environ 14,6 To par an, environ 29 To pour deux ans en ligne.
Faut-il partitionner ?Si seuls 90 jours devaient rester en ligne (3,6 To), un primaire plus des répliques, avec un partitionnement des tables par date et un archivage vers l’entrepôt de données, serait la réponse ennuyeuse et correcte. Avec deux ans en ligne, soit 29 To, les temps de restauration et de reconstruction des répliques sur un seul nœud deviennent inacceptables : l’équipe passe donc au sharding.
Dimensionner les shardsViser environ 2 To par shard pour qu’une réplique puisse être reconstruite en quelques heures : 29 To / 2 To = environ 15, donc 16 shards, chacun composé d’un leader et de deux suiveurs. Créer 1024 partitions logiques réparties sur les 16 shards, pour que la croissance se traduise par des déplacements de partitions entières, sans rehachage. Écritures au pic par shard : 3500 / 16 = environ 220 par seconde.
Candidate : order_id (hachage)Des écritures parfaitement réparties, mais l’historique d’un client comme la file en direct d’un restaurant deviennent du scatter-gather sur 16 shards. Rejetée comme clé principale de placement.
Candidate : placed_at (plage)Idéale pour les rapports par période, mais chaque nouvelle commande s’écrit dans la partition la plus récente : un point chaud garanti. Rejetée.
Candidate : restaurant_idLa file d’un restaurant est locale, mais les grandes chaînes et les promotions créent des clés chaudes, et l’historique des clients se disperse. Rejetée.
Candidate : pays ou villeFavorise la résidence des données et les requêtes locales, mais une grande ville peut représenter 15 % de toutes les commandes : un déséquilibre. Conservée uniquement comme premier niveau grossier pour la résidence (clusters UE et hors UE), pas comme clé de sharding.
DécisionPartitionner par hash(customer_id), avec des lignes triées par placed_at dans chaque partition. L’id de commande intègre le numéro de partition du client, si bien qu’une recherche par id de commande va directement vers un seul shard.
Couvrir les autres requêtesLa file en direct du restaurant est servie par un modèle de lecture distinct, indexé par restaurant et construit à partir des événements de commande (un index global délibéré, à cohérence à terme). L’analytique s’exécute dans l’entrepôt de données. Les transactions entre clients ne sont pas nécessaires : chaque commande ne concerne qu’un client.

Les chiffres ont changé la question. Le débit n’a jamais exigé le sharding ; ce sont les données conservées en ligne qui l’ont imposé. Réduire la fenêtre en ligne aurait entièrement supprimé ce besoin, ce qui mérite d’être discuté avec le métier avant de construire un système partitionné.

La clé de sharding a été choisie à partir des requêtes les plus importantes et de leurs besoins de cohérence, pas à partir du seul modèle de données. Chaque autre requête a reçu un chemin explicite, conçu séparément.

Le choix d’une clé de sharding est l’une des décisions sur lesquelles il est le plus difficile de revenir : la changer plus tard signifie déplacer chaque ligne. Consignez-la dans un ADR avec les volumes et les requêtes qu’elle supposait.

Erreurs courantes

Traiter les répliques comme des sauvegardes. Une réplique copie fidèlement un DELETE erroné en quelques millisecondes ; les sauvegardes et la restauration à un instant donné restent nécessaires (chapitre 0). Lire sur les répliques par défaut, sans décider quelles requêtes tolèrent le retard. Se fier à un failover automatique jamais répété, ou capable de promouvoir un nœud alors que l’ancien leader accepte encore des écritures. Partitionner trop tôt : une base de données partitionnée complique les jointures, les transactions, les changements de schéma et l’exploitation, et les nœuds uniques modernes sont très puissants ; épuisez d’abord l’indexation, la mise en cache, les répliques en lecture, l’archivage et le passage à l’échelle vertical. Partitionner selon une clé qui croît de façon monotone, ce qui concentre chaque insertion sur une seule partition. Oublier les chemins d’accès secondaires, et découvrir après le découpage qu’une requête importante doit désormais interroger chaque shard. Comme le montre la suite de ce chapitre, les bonnes alternatives passent souvent par des données dérivées : des modèles de lecture, des caches et des stockages analytiques, chacun construit pour un type de requête.

Vérifiez votre compréhension

Pourquoi la réplication asynchrone risque-t-elle de perdre des écritures dont les clients ont été informés qu’elles étaient validées ?

Le leader confirme avant qu’un suiveur ait la modification ; s’il tombe en panne avant de la transmettre, le suiveur promu n’a jamais reçu ces écritures.

Un utilisateur enregistre une modification et voit aussitôt l’ancienne valeur. Quelle garantie n’est pas respectée, et quel est un remède possible ?

La lecture de ses propres écritures (read-your-writes). Servez depuis le leader les lectures de cet utilisateur portant sur des données récemment modifiées, ou attendez que le suiveur ait appliqué la position de l’écriture dans le journal.

Pourquoi hash mod N est-il un mauvais moyen d’affecter les clés aux nœuds ?

Changer N déplace la plupart des clés (environ 80 % en passant de 4 à 5 nœuds), alors que le hachage cohérent ou des partitions logiques fixes n’en déplacent qu’environ 1/N.

Quel est le compromis entre index secondaires locaux et globaux ?

Les index locaux gardent les écritures sur une seule partition mais obligent les requêtes à interroger toutes les partitions ; les index globaux permettent aux requêtes de cibler une seule partition d’index mais exigent des mises à jour d’index entre partitions, généralement asynchrones.

Suivant · 7.5CQRS et event sourcingSéparer le modèle qui modifie les données des modèles qui les lisent, stocker l’état comme une séquence d’événements en ajout seul, et les coûts réels : cohérence à terme, versionnage des événements et données à caractère personnel.Continuer →

Sources et lectures complémentaires

Une question sur cette section ? ArchitectBot, l’assistant d’étude IA du cours, répond d’abord à partir du cours.

Demander : « Notre table des commandes approche les 5 To sur un seul serveur PostgreSQL. Devrions-nous la partitionner (sharding), et si oui, comment choisir la clé de sharding ? »