Quand deux versions d'application partagent une base de données : la transition double écriture et double lecture

Imaginez la situation : votre équipe vient d'ajouter une nouvelle colonne à une table de base de données en production. Le changement de schéma s'est déroulé sans accroc. Vous devez maintenant déployer la nouvelle version de l'application qui écrit dans cette colonne. Mais l'ancienne version de l'application est toujours en cours d'exécution, traite des requêtes, et ne sait rien de cette nouvelle colonne.

Si la nouvelle application commence à écrire uniquement dans la nouvelle colonne, l'ancienne application ne verra pas ces données. Les utilisateurs dont les requêtes atterrissent sur l'ancienne application obtiendront des résultats incomplets ou incohérents. Vous ne pouvez pas arrêter l'ancienne application instantanément. Vous ne pouvez pas basculer tout le système vers la nouvelle version d'un seul coup. Vous avez besoin d'une période de transition où les deux versions coexistent et partagent la même base de données.

C'est là que les patterns de double écriture et double lecture deviennent nécessaires. Ils ne sont pas élégants. Ils ne sont pas simples. Mais ils sont la manière pratique de migrer des structures de données dans un système en production sans interruption de service.

Le problème fondamental : deux versions, une base de données

Lorsque vous étendez un schéma en ajoutant une nouvelle colonne ou une nouvelle table, la base de données peut contenir à la fois les anciennes et les nouvelles structures. Mais les applications qui lisent et écrivent ces données ne sont pas aussi flexibles. L'ancienne version de l'application ne comprend que l'ancienne structure. La nouvelle version de l'application comprend les deux, mais elle doit maintenir le fonctionnement de l'ancienne version jusqu'à ce que toutes les instances aient été mises à niveau.

L'approche naïve consisterait à faire écrire la nouvelle application uniquement dans la nouvelle colonne. Cela casserait immédiatement l'ancienne application. L'ancienne application lit depuis l'ancienne colonne, ne trouve rien, et échoue. La bonne approche est de faire écrire la nouvelle application dans les deux endroits jusqu'à ce que l'ancienne application ait disparu.

Le diagramme de séquence suivant illustre le flux des écritures et des lectures pendant la période de transition :

sequenceDiagram participant OldApp as Ancienne App participant NewApp as Nouvelle App participant DB as Base de données Note over OldApp,DB: Phase de double écriture NewApp->>DB: Écriture dans l'ancienne colonne NewApp->>DB: Écriture dans la nouvelle colonne OldApp->>DB: Lecture depuis l'ancienne colonne DB-->>OldApp: Données de l'ancienne colonne NewApp->>DB: Lecture depuis l'ancienne colonne (préférence ancienne) DB-->>NewApp: Données de l'ancienne colonne Note over OldApp,DB: Point de bascule NewApp->>DB: Lecture depuis la nouvelle colonne (bascule) DB-->>NewApp: Données de la nouvelle colonne Note over OldApp,DB: Après le bascule NewApp->>DB: Écriture uniquement dans la nouvelle colonne NewApp->>DB: Lecture depuis la nouvelle colonne

Double écriture : écrire à deux endroits à la fois

La double écriture signifie que la nouvelle version de l'application écrit des données à la fois dans l'ancienne structure et dans la nouvelle structure à chaque opération d'écriture. Lorsqu'un utilisateur crée un enregistrement, la nouvelle application remplit l'ancienne colonne comme elle le faisait toujours, puis écrit également les mêmes données dans la nouvelle colonne.

Cela semble simple, mais deux détails sont très importants.

Voici un exemple JavaScript qui implémente la double écriture et la double lecture pour une mise à jour de profil utilisateur :

async function updateUserProfile(userId, name, email) {
  // Double écriture : écrire d'abord dans l'ancienne colonne, puis dans la nouvelle
  await db.query(
    'UPDATE users SET name = ?, email = ? WHERE id = ?',
    [name, email, userId]
  );
  await db.query(
    'UPDATE users SET profile_data = ? WHERE id = ?',
    [JSON.stringify({ name, email }), userId]
  );
}

async function getUserProfile(userId) {
  // Double lecture : préférer la nouvelle colonne, avec repli sur l'ancienne
  const row = await db.query(
    'SELECT profile_data, name, email FROM users WHERE id = ?',
    [userId]
  );
  if (row.profile_data) {
    return JSON.parse(row.profile_data);
  }
  return { name: row.name, email: row.email };
}

Premièrement, l'ordre d'écriture doit être cohérent. Écrivez d'abord dans l'ancienne colonne, puis dans la nouvelle. Si le processus échoue après avoir écrit dans l'ancienne colonne mais avant d'écrire dans la nouvelle, l'ancienne application peut toujours lire les données. La nouvelle colonne manquera cet enregistrement, mais vous pourrez le corriger plus tard avec un backfill. Si vous aviez écrit dans la nouvelle colonne en premier et que le processus avait échoué, l'ancienne application verrait immédiatement des données incomplètes. C'est un incident de production en puissance.

Deuxièmement, les valeurs doivent être identiques. Les données écrites dans l'ancienne colonne et la nouvelle colonne doivent représenter la même information. S'il y a une quelconque transformation ou différence de logique entre les deux écritures, vous aurez une incohérence de données qui deviendra très difficile à déboguer plus tard. Gardez la logique d'écriture identique. La nouvelle colonne peut stocker les données dans un format ou une structure différente, mais la signification doit être la même.

Double lecture : lire à partir de deux endroits, en préférant l'ancien

Une fois la double écriture en place, la nouvelle application peut écrire des données que les deux versions peuvent lire. Mais qu'en est-il de la lecture ? La nouvelle application pourrait commencer à lire immédiatement depuis la nouvelle colonne, mais cela crée un problème. L'ancienne application écrit toujours uniquement dans l'ancienne colonne. Si la nouvelle application lit uniquement depuis la nouvelle colonne, elle manquera les données écrites par l'ancienne application.

La solution est la double lecture. La nouvelle application lit à partir des deux endroits mais priorise l'ancienne colonne pendant la transition. Cela garantit que les données écrites par l'ancienne application sont toujours visibles par la nouvelle application. Au fil du temps, à mesure que vous vérifiez que les données circulent correctement vers la nouvelle colonne, vous pouvez progressivement basculer les lectures vers la nouvelle colonne.

Ce basculement progressif est l'endroit où les feature flags deviennent utiles. Vous pouvez configurer la nouvelle application pour lire depuis la nouvelle colonne pour un petit pourcentage de requêtes. Si rien ne casse, augmentez le pourcentage. Si une erreur apparaît, inversez le flag et toutes les lectures reviennent à l'ancienne colonne. Pas besoin de redéploiement.

Qu'en est-il des données écrites par l'ancienne application ?

Pendant cette transition, l'ancienne application est toujours en cours d'exécution et écrit uniquement dans l'ancienne colonne. La nouvelle application doit gérer cela. Lorsque la nouvelle application lit un enregistrement qui a été écrit par l'ancienne application, elle trouve des données uniquement dans l'ancienne colonne. La nouvelle application doit être capable de lire ces données depuis l'ancienne colonne, de les utiliser, et éventuellement de les copier dans la nouvelle colonne dans le cadre d'un processus en arrière-plan.

Ce n'est pas la même chose que la double écriture. C'est un processus de backfill qui s'exécute séparément, remplissant la nouvelle colonne avec des données qui ont été écrites avant que la nouvelle application ne commence à écrire dans les deux endroits. Le backfill est une opération par lots qui s'exécute après que les patterns de double écriture et double lecture sont stables.

La vraie complexité : coordonner la transition

La partie la plus difficile de cette phase n'est pas le code. C'est la coordination. Vous devez savoir quelles instances exécutent quelle version. Vous devez savoir quand la dernière ancienne instance a été décommissionnée. Vous devez surveiller les incohérences de données entre les anciennes et nouvelles colonnes.

Pendant la double écriture, chaque opération d'écriture devient deux écritures. Cela signifie plus de charge sur la base de données, plus de temps de transaction, et plus de points de défaillance potentiels. Surveillez les métriques de votre base de données pendant cette phase. Si la latence d'écriture augmente, vous devrez peut-être regrouper les écritures ou utiliser une réplication asynchrone.

Pendant la double lecture, chaque opération de lecture peut devoir vérifier deux emplacements. Cela ajoute de la complexité à votre logique de requête et peut ralentir les chemins de lecture. Utilisez le cache avec précaution. Ne mettez pas en cache les données de l'ancienne colonne si la nouvelle colonne est censée devenir la source de vérité.

Liste de contrôle pratique pour la transition

  • Confirmez que l'extension du schéma (nouvelle colonne ou table) est déployée et vérifiée.
  • Déployez la nouvelle version de l'application avec la logique de double écriture : écrire d'abord dans l'ancienne colonne, puis dans la nouvelle.
  • Vérifiez que les données écrites par la nouvelle application sont visibles par l'ancienne application.
  • Activez la double lecture dans la nouvelle application : lire depuis l'ancienne colonne par défaut, mais préparez-vous à basculer.
  • Utilisez un feature flag pour basculer progressivement les lectures de l'ancienne colonne vers la nouvelle colonne.
  • Surveillez les incohérences de données entre les anciennes et nouvelles colonnes.
  • Lancez le processus de backfill pour copier les anciennes données dans la nouvelle colonne.
  • Après la fin du backfill et lorsque toutes les lectures utilisent la nouvelle colonne, supprimez la logique de double écriture.
  • Décommissionnez l'ancienne colonne ou table après avoir confirmé qu'aucune application ne lit depuis celle-ci.

Ce qu'il faut retenir

La double écriture et la double lecture ne sont pas des patterns permanents. Ce sont des ponts temporaires qui vous permettent de migrer des structures de données tout en maintenant le système opérationnel pour tous les utilisateurs. L'objectif est d'atteindre un état où seule la nouvelle structure compte, et où l'ancienne structure peut être supprimée. Jusque-là, chaque écriture va à deux endroits, chaque lecture vérifie deux sources, et votre équipe reste vigilante face aux incohérences. Cette phase est inconfortable, mais c'est la seule façon de modifier un schéma de base de données en production sans arrêter le monde.