Chez DoorDash, nous utilisons une variété de magasins de données pour alimenter notre activité, mais l'un de nos principaux outils est la base de données relationnelle classique alimentée par Postgres. Au fur et à mesure que notre entreprise se développe et que nos offres de produits s'élargissent, nos modèles de données évoluent, ce qui nécessite des changements de schémas et des remplissages dans les bases de données existantes.
Lorsque DoorDash était plus petit et dans moins de fuseaux horaires, il était raisonnable de prendre quelques minutes de temps d'arrêt la nuit pour effectuer ce type d'opérations sur les données. Mais comme nous nous sommes développés pour inclure des commerçants, des clients et des Dashers dans plus de 4 000 villes sur deux continents, il n'est plus acceptable d'avoir des temps d'arrêt dans notre système. Notre envergure mondiale nous oblige à concevoir des solutions permettant d'effectuer des opérations importantes sur des tables gigantesques sans perturber l'activité de l'entreprise.
Durant notre phase de pré-croissance, la manière la plus évidente de remplir une nouvelle colonne était d'ajouter simplement la colonne en tant que nullable, puis de lancer un processus en arrière-plan pour remplir les lignes par lots. Mais certaines de nos tables sont devenues si grandes et comportent tant d'index que ce processus est beaucoup trop lent pour respecter un calendrier de production raisonnable.
Récemment, notre équipe de stockage a mis au point une technique de remplissage pour nos bases de données Postgres qui nous permet de reconstruire complètement une table - en changeant les types et les contraintes de plusieurs colonnes en une seule fois - sans affecter nos systèmes de production. L'avantage inattendu de cette technique est que nous pouvons échanger transactionnellement les deux tables en avant et en arrière, ce qui nous permet de tester la nouvelle table en toute sécurité et de revenir en arrière si des problèmes surviennent, tout en maintenant l'intégrité des données.
Cette méthode a permis de réduire un projet de trois mois à moins d'une semaine, tout en nous permettant de mettre à jour les tables dans un environnement de production. Non seulement nous avons pu ajouter notre nouveau champ, mais nous avons également eu l'occasion de nettoyer l'ensemble du modèle de données, en corrigeant les incohérences et en ajoutant des contraintes.
La nécessité de remplir les données
Toutes les modifications de schéma ne nécessitent pas forcément un backfill. Souvent, il est plus facile d'ajouter une colonne nullable et de créer un comportement par défaut pour les valeurs NULL dans le code de l'application. Bien que rapide, ce processus présente des inconvénients, tels que l'impossibilité d'ajouter des contraintes au niveau de la base de données. Si le code de l'application oublie par erreur de définir la valeur, il obtiendra un comportement par défaut, qui peut ne pas correspondre à ce qui était prévu.
Cependant, certaines modifications du schéma nécessitent un remplissage. Par exemple, le changement de type de données pour les clés primaires nécessite la mise à jour de toutes les données historiques. De même, la dénormalisation pour des raisons de performance nécessite le remplissage des données historiques si aucun comportement par défaut raisonnable ne peut être mis en œuvre.
Les difficultés des remplissages de données en place pour les grandes tables
Essayer de mettre à jour chaque ligne d'un grand tableau de production pose plusieurs problèmes.
L'un des problèmes est la vitesse. La mise à jour d'une colonne sur un milliard de lignes dans Postgres équivaut à la suppression d'un milliard de lignes et à l'insertion d'un milliard de lignes, grâce à la manière dont le contrôle de concordance multiversion (MVCC) fonctionne sous les couvertures. Les anciennes lignes devront être récupérées par le processus VACUUM. Tout cela exerce une forte pression sur l'infrastructure de données, en utilisant des cycles de calcul et en sollicitant potentiellement les ressources, ce qui entraîne des ralentissements dans les systèmes de production.
Le nombre d'index sur la table amplifie cette pression. Chaque index d'une table nécessite en effet une autre paire insertion/suppression. La base de données devra également aller chercher le tuple dans le tas, ce qui nécessite de lire cette partie de l'index dans le cache. Chez DoorDash, nos accès aux données de production ont tendance à être concentrés à la toute fin de nos données, de sorte que les lectures sérialisées d'un backfill exercent une pression sur les caches de la base de données.
Le second problème est que si les écritures sont trop rapides, nos répliques de lecture peuvent prendre du retard par rapport à l'auteur principal. Ce problème de décalage des répliques se pose chez DoorDash car nous utilisons beaucoup les répliques de lecture AWS Aurora pour gérer le trafic des bases de données en lecture seule pour nos systèmes de production. Dans Postgres standard, les répliques de lecture restent à jour par rapport à la base primaire en lisant le WAL ( write-ahead logging ), qui est un flux de pages mises à jour qui circule de l'auteur primaire vers les répliques de lecture. Aurora Postgres utilise un mécanisme différent pour maintenir les répliques à jour, mais il souffre également d'un problème analogue de décalage de réplication. Les répliques Aurora ont généralement moins de 100 millisecondes de retard, ce qui est suffisant pour notre logique d'application. Mais sans une surveillance attentive, nous avons constaté qu'il est assez facile de pousser le retard de réplication au-delà de 10 secondes, ce qui, sans surprise, entraîne des problèmes de production.
Le troisième problème majeur est que même les modifications de schéma "sûres", telles que l'élargissement d'un INT
colonne à BIGINT
Il est possible de découvrir des bogues inattendus dans le code de production, qu'il n'est pas facile de localiser par simple inspection. Il peut être angoissant de modifier simplement un schéma en cours d'utilisation sans plan de secours.
La solution à tous ces problèmes est d'éviter de modifier la table de production sur place. Au lieu de cela, nous la copions dans une table fantôme légèrement indexée, reconstruisons les index par la suite, puis permutons les tables.
Création d'une table d'ombres
La première étape consiste à créer une table fictive dont le schéma est identique à celui de la table source :
CREATE TABLE shadow_table (LIKE source_table);
La nouvelle table fantôme a le même schéma que la source, mais sans aucun index. Nous avons besoin d'un index sur la clé primaire afin de pouvoir effectuer des recherches rapides pendant le processus de remplissage :
ALTER TABLE shadow_table ADD PRIMARY KEY (id);
La dernière étape consiste à modifier le schéma de la nouvelle table. Comme nous réécrivons l'intégralité de la table, c'est une bonne occasion de se décharger de toute dette technique accumulée au fil du temps. Les colonnes qui ont été précédemment ajoutées à la table comme nullables pour des raisons de commodité peuvent maintenant être remplies avec des données réelles, ce qui permet d'ajouter une colonne NOT NULL
contrainte. Nous pouvons également élargir les types, en prenant par exemple INT
colonnes à BIGINT
colonnes.
ALTER TABLE shadow_table ALTER COLUMN id type BIGINT;
ALTER TABLE shadow_table ALTER COLUMN uuid SET NOT NULL;
ALTER TABLE shadow_table ALTER COLUMN late_added_column SET NOT NULL;
Écriture de la fonction de copie
Ensuite, nous allons créer une fonction Postgres qui copiera et remplira les lignes en même temps. Nous utiliserons cette fonction à la fois dans le déclencheur, qui maintiendra les lignes nouvelles et mises à jour synchronisées avec la table parallèle, et dans le script de remplissage, qui copiera les données historiques.
La fonction est essentiellement une INSERT
associé à un SELECT
à l'aide d'une COALESCE
pour remplir les colonnes nulles. Dans cet exemple, nous n'avons pas ajouté de colonnes, donc nous nous appuyons sur le fait que les deux tables ont des colonnes dans le même ordre, mais si cette opération avait ajouté des colonnes, nous pourrions les traiter ici en listant les colonnes explicitement dans l'instruction INSERT
.
CREATE OR REPLACE FUNCTION copy_from_source_to_shadow(INTEGER, INTEGER)
RETURNS VOID AS $$
INSERT INTO shadow_table
SELECT
id,
COALESCE(uuid, uuid_generate_v4())
created_at,
COALESCE(late_added_column, true),
...
FROM source_table
WHERE id BETWEEN $1 AND $2
ON CONFLICT DO NOTHING
$$ LANGUAGE SQL SECURITY DEFINER;
Ceux-ci COALESCE
sont les parties essentielles - l'effet ici est "regarder pour voir si une valeur est NULL
et, si c'est le cas, le remplacer par cette autre chose". L'utilisation de COALESCE()
nous a permis de réparer des données sur plus d'une douzaine de colonnes en même temps.
Les INT
à BIGINT
La conversion est gratuite avec cette technique. Il suffit de modifier le schéma de la table fantôme avant de lancer la procédure et le fichier INSERT
s'occupe de la promotion du type.
Enfin, nous voulons être sûrs de ne pas faire de mal, c'est pourquoi cette fonction est écrite de manière à minimiser le risque que le script de rechargement écrase accidentellement des données plus récentes avec des données périmées. La principale caractéristique de sécurité ici est la fonction ON CONFLICT DO NOTHING
ce qui signifie qu'il est possible d'exécuter cette fonction plusieurs fois sur la même plage. Nous verrons comment traiter les mises à jour dans le déclencheur ci-dessous.
Réglage du déclencheur
Même les développeurs d'applications versés dans les subtilités du langage SQL n'ont peut-être pas eu l'occasion d'utiliser un déclencheur de base de données, car cette fonctionnalité des bases de données a tendance à ne pas être intégrée dans les cadres d'application. Un déclencheur est une fonctionnalité puissante qui nous permet d'attacher un code SQL arbitraire à diverses actions d'une manière transactionnellement sûre. Dans notre cas, nous attacherons notre fonction de copie à chaque type d'instruction de modification de données (INSERT
, UPDATE
et DELETE
) afin de garantir que toutes les modifications apportées à la base de données de production seront répercutées dans la copie fantôme.
Le déclenchement proprement dit est simple, à l'exception du fait que pour les UPDATE
il effectue une DELETE
et INSERT
à l'intérieur d'une transaction. La suppression et la réinsertion manuelles de cette manière nous permettent de réutiliser la fonction principale de remplissage (qui, autrement, ne ferait rien à cause de la fonction ON CONFLICT DO NOTHING
). Cela garantit également que nous ne commettrons pas d'erreur et que nous n'écraserons pas des données plus récentes parce que la fonction de remplissage ne peut pas effectuer une analyse de type UPDATE
.
CREATE OR REPLACE FUNCTION shadow_trigger()
RETURNS TRIGGER AS
$$
BEGIN
IF ( TG_OP = 'INSERT') THEN
PERFORM copy_from_source_to_shadow(NEW.id, NEW.id);
RETURN NEW;
ELSIF ( TG_OP = 'UPDATE') THEN
DELETE FROM shadow_table WHERE id = OLD.id;
PERFORM copy_from_source_to_shadow(NEW.id, NEW.id);
RETURN NEW;
ELSIF ( TG_OP = 'DELETE') THEN
DELETE FROM shadow_table WHERE id = OLD.id;
RETURN OLD;
END IF;
END;
$$ LANGUAGE PLPGSQL SECURITY DEFINER;
CREATE TRIGGER shadow_trigger
AFTER INSERT OR UPDATE OR DELETE ON source_table
FOR EACH ROW EXECUTE PROCEDURE shadow_trigger();
Effectuer le remblayage
Pour le remplissage proprement dit, nous avons utilisé un script Python personnalisé qui utilise une connexion directe à la base de données dans un shell de production. L'avantage est que le développement est interactif, que nous pouvons tester sur un environnement de mise en scène et que nous pouvons l'arrêter instantanément si quelque chose ne va pas. L'inconvénient est que seul l'ingénieur qui a accès à ce shell de production peut l'arrêter, de sorte qu'il doit être exécuté pendant que quelqu'un est en mesure de le surveiller et de l'arrêter si quelque chose ne va pas.
Lors de notre premier cycle de remplissage, la vitesse était de plusieurs ordres de grandeur plus rapide que lors de nos tentatives précédentes de modification sur place de la table de production d'origine. Nous avons atteint environ 10 000 lignes par seconde.
En fait, le vrai problème est que nous écrivions un peu trop vite pour que nos répliques puissent suivre sous la charge de production. Nos répliques Postgres ont généralement un délai de réplication inférieur à 20 millisecondes, même en cas de forte charge.
Avec une architecture microservices, il est courant qu'un enregistrement soit inséré ou mis à jour, puis immédiatement lu par un autre service. La plupart de notre code résiste à un léger décalage de réplication, mais si le décalage devient trop important, notre système peut commencer à échouer.
C'est exactement ce qui nous est arrivé à la fin du backfill - le délai de réplication a atteint 10 secondes. Nous pensons que, comme Aurora Postgres ne diffuse que les pages mises en cache dans les répliques, nous n'avons eu des problèmes que lorsque nous avons commencé à toucher des données plus récentes résidant dans des pages chaudes.
Quelle qu'en soit la cause, il s'avère qu'Aurora Postgres expose le décalage instantané de la réplication en utilisant la requête suivante :
SELECT max(replica_lag_in_msec) as replica_lag FROM
aurora_replica_status();
Nous utilisons désormais cette vérification dans nos scripts de remblayage entre INSERT
déclarations. Si le décalage devient trop important, nous nous contentons de dormir jusqu'à ce qu'il redescende en dessous des niveaux acceptables. En vérifiant le décalage, il est possible de maintenir le remblayage tout au long de la journée, même en cas de forte charge, et d'avoir la certitude que ce problème ne se posera pas.
Faire l'échange
Postgres peut effectuer des modifications de schéma dans une transaction, y compris renommer des tables et créer et supprimer des déclencheurs. Il s'agit d'un outil extrêmement puissant pour effectuer des modifications dans un système de production en cours d'exécution, car nous pouvons échanger deux tables de manière transactionnelle. Cela signifie qu'aucune transaction entrante ne verra jamais la table dans un état incohérent - les requêtes commencent simplement à circuler de l'ancienne table vers la nouvelle instantanément.
Mieux encore, la fonction de copie et le déclencheur peuvent être réglés pour fonctionner en sens inverse. La fonction de copie et le déclenchement peuvent être réglés pour fonctionner en sens inverse. COALESCE
Les déclarations doivent être supprimées, bien sûr, et s'il y a des différences entre les colonnes, il faut en tenir compte, mais structurellement, le déclenchement inversé est la même idée.
En fait, lorsque nous avons échangé les tables pour la première fois au cours de cette opération particulière, nous avons découvert un bogue dans un code Python hérité qui vérifiait expressément le type d'une colonne. Grâce au déclencheur inversé en place et à la permutation inversée à portée de main, nous avons instantanément rétabli l'ancienne table sans perte de données, ce qui nous a donné le temps de préparer notre code aux changements de schéma. La procédure de double permutation a permis de synchroniser les deux tables dans les deux sens et n'a pas perturbé notre système de production.
Cette capacité à passer d'une table à l'autre tout en les gardant synchronisées est le superpouvoir de cette technique.
Conclusion
Tous les schémas de base de données évoluent avec le temps, mais chez DoorDash, nous avons un ensemble de demandes de produits en constante évolution, et nous devons répondre à ces demandes en étant fluides et dynamiques avec nos bases de données. Les temps d'arrêt ou les fenêtres de maintenance ne sont pas acceptables. Cette technique nous permet non seulement de modifier les schémas en toute sécurité et en toute confiance, mais aussi de le faire beaucoup plus rapidement que les remplissages en place traditionnels.
Bien que cette solution particulière soit adaptée à Postgres et utilise certaines fonctionnalités spécifiques à AWS Aurora, cette technique devrait en général fonctionner avec presque toutes les bases de données relationnelles. Bien que toutes les bases de données ne disposent pas de fonctions DDL transactionnelles, cette technique permet de réduire la période d'interruption au temps nécessaire pour effectuer la permutation.
À l'avenir, nous pourrions envisager d'utiliser cette technique pour d'autres types de modifications de schéma qui n'impliquent même pas un backfill, comme l'abandon d'index peu utilisés. La recréation d'un index pouvant prendre plus d'une heure, l'abandon d'un index comporte un risque considérable. Mais en ayant deux versions de la même table synchronisées en même temps, nous pouvons tester en toute sécurité ce type de modifications avec un risque minimal pour notre système de production.
Remerciements
De nombreuses personnes ont participé à ce projet. Un grand merci à Sean Chittenden, Robert Treat, Payal Singh, Alessandro Salvatori, Kosha Shah et Akshat Nair.