Nous faisons tourner tout un système d'authentification sur des stores qui ne connaissent que get et put
Un système d'authentification a tout l'air de réclamer une base de données relationnelle. Utilisateurs, rôles, sessions, grants OAuth, refresh tokens, le tout référencé de manière croisée. Le nôtre n'en utilise pas. Il tourne sur Azure Table Storage, un store qui vous donne une partition key, une row key, et presque rien d'autre. Pas de jointures, pas d'index secondaires significatifs, pas d'opérateur d'incrément, pas de transactions multi-lignes de la forme à laquelle vous êtes habitué. C'était un choix. Voici ce que ce choix rapporte, les patterns qui le font marcher, et le passage où SQL aurait vraiment été plus simple.
La raison de faire ça, c'est le coût et la simplicité opérationnelle. Pas de connection pool à épuiser, pas d'instance de base de données à dimensionner, patcher ou basculer, un scaling par partition offert gratuitement. Une table ne coûte presque rien à conserver et il n'y a rien qui puisse s'épuiser. Le prix à payer, c'est un store sans query planner, ce qui ne devient rentable que lorsque vos access patterns sont aussi prévisibles que le store est bête. Ceux de l'authentification le sont, alors toute la conception s'appuie là-dessus.
La clé fait le travail qu'aurait fait un schéma de table. Sans jointures, vous concevez la clé pour que la requête soit la clé. Vous dénormalisez à dessein, vous choisissez les partitions pour que vos lectures chaudes soient des point-gets, et vous encodez les identités composites directement dans la row key, par exemple "{pk}|{rk}" pour les entrées qui ont besoin d'une identité composée dans un store qui n'offre qu'un seul emplacement de row key. La question « comment est-ce que je retrouve ça » doit trouver sa réponse au moment où vous concevez la clé, pas au moment de la requête. Pour l'authentification, c'est très bien, parce que les access patterns sont connus et ne dérivent pas : récupérer un utilisateur par son id, récupérer les grants d'un utilisateur, consommer un code. Vous n'explorez pas les données. Vous connaissez chaque question à l'avance.
Vous tenez votre propre change log. Chaque ligne porte un Timestamp géré par le serveur, et il est tentant de construire une sauvegarde incrémentale dessus : tout tirer là où Timestamp gt watermark. Ça marche en démo et ça meurt en production, parce que le store indexe la partition key et la row key, et rien d'autre. Timestamp n'est dans aucun index, donc filtrer dessus inspecte chaque ligne de la table. C'est un full scan déguisé en requête, et il devient plus lent chaque jour que la table grossit. Alors le store écrit plutôt son propre change log. Chaque mutation, chaque upsert et chaque suppression, ajoute une ligne à une table de change log indexée par le nom de la table logique, avec le composite "{pk}|{rk}" comme row key et un indicateur précisant si la ligne a été écrite ou supprimée. Désormais « qu'est-ce qui a changé depuis le watermark » est la lecture d'une seule petite partition indexée au lieu d'un scan de toute la table, et la sauvegarde ne fait des point-reads que sur les lignes qui ont réellement bougé. Vous reconstruisez du change-data-capture à partir d'écritures ordinaires, et ça scale avec le churn plutôt qu'avec la taille de la table.
De la concurrence sans transactions. Il n'y a pas de SELECT ... FOR UPDATE ici. Ce que vous obtenez à la place, c'est une seule primitive atomique : l'écriture conditionnelle, livrée sous la forme d'un ETag. Vous n'écrivez une ligne que si la copie que vous avez lue n'a pas changé sous vos pieds. Tout ce qui a besoin de sûreté sous accès concurrent s'exprime en optimistic concurrency plus retry en cas de conflit. L'exemple le plus clair, c'est le compteur de verrouillage de compte. AccessFailedCount doit s'incrémenter de façon atomique, mais le store n'a pas d'incrément. Alors vous lisez la ligne, augmentez le compteur, le réécrivez conditionnellement à l'ETag que vous avez lu, et vous réessayez si quelqu'un vous a devancé. Cette boucle, c'est la façon de rendre un compteur atomique sur un store qui n'a pas de compteur. Balancez cinquante mauvais mots de passe d'un coup et chacun d'eux est pris en compte, parce que le conflit est détecté et réessayé plutôt que perdu.
Une suppression peut faire office de verrou. La consommation à usage unique, un code d'autorisation ou un token à usage unique qui doit être utilisé exactement une fois, est une suppression conditionnelle. C'est une suppression conditionnée par l'ETag : si la suppression réussit, vous avez gagné la course et pouvez continuer ; si elle échoue parce que la ligne avait déjà disparu, quelqu'un d'autre l'a consommée avant vous et vous vous arrêtez. La suppression est le verrou. La même idée fait le leader election, bâti sur un blob lease, un verrou fabriqué à partir d'une écriture atomique plutôt que d'un service de coordination séparé. Vous n'avez presque jamais besoin d'un service de verrous quand l'écriture elle-même est atomique.
Les suppressions doivent être des citoyens de première classe, ce qui explique une bonne moitié de la raison d'être du change log. Un store clé-valeur n'a pas de marqueur de suppression : une ligne supprimée disparaît tout simplement, si bien que tout ce qui scannait les changements ne pourrait même jamais voir qu'elle est partie. Une sauvegarde qui s'appuierait sur les timestamps des lignes traînerait discrètement l'utilisateur supprimé pour toujours. Le change log enregistre la suppression en tant que suppression, de sorte qu'une restauration la rejoue au lieu de la ressusciter. Ce mode de défaillance, une sauvegarde qui ramène fidèlement tous ceux que vous avez retirés, est une histoire à part entière qui mérite son propre article, mais le pattern a sa place dans cette liste : dans un store sans journal de suppressions, vous tenez le journal de suppressions vous-même.
Maintenant l'autre colonne du bilan, ce qu'on abandonne. Vous abandonnez les requêtes ad hoc : un access pattern véritablement nouveau peut signifier une nouvelle conception de clé ou un full scan, parce qu'il n'y a pas de query planner pour vous sauver. Vous abandonnez les jointures, donc vous maintenez à la main des copies dénormalisées et vous êtes responsable de leur cohérence. Vous abandonnez les transactions multi-lignes, donc vous concevez chaque invariant pour qu'il tienne à l'intérieur d'un seul élément, parce que l'atomicité sur un seul élément est tout ce qu'on vous donne. Si vos données sont profondément relationnelles, ou si vos access patterns sont inconnus et encore mouvants, c'est le mauvais outil et ça va faire mal.
Et c'est précisément pour ça que ça convient à l'authentification. Une base de données relationnelle justifie son existence en répondant à des questions auxquelles vous n'avez pas encore pensé. Un système d'authentification n'a pas ces questions. L'ensemble des choses que vous demandez, récupérer cet utilisateur, consommer ce code, élire ce leader, sauvegarder ce qui a changé, est petit, connu et stable. Abandonnez le query planner que vous n'alliez de toute façon jamais utiliser, et en échange vous obtenez une couche de stockage qui ne coûte presque rien à exploiter et qui n'a rien à basculer. Pour la plupart des logiciels, c'est un mauvais marché. Pour celui-ci, c'est le bon.
Ces patterns sont le moteur de stockage sous Authagonal : une conception clé-valeur qui ne coûte presque rien à exploiter, ce qui explique en grande partie comment nous mettons chaque fonctionnalité dans chaque plan.