C'est une histoire sur la raison pour laquelle vous ne devez jamais ignorer les erreurs lorsque vous êtes dans une transaction dans une base de données. Découvrir comment utiliser correctement les transactions et quoi faire lors de leur utilisation n'est pas une option. Spoiler: il s'agira de verrous consultatifs dans PostgreSQL!
J'ai travaillé sur un projet dans lequel les utilisateurs peuvent importer un grand nombre d'entités lourdes (appelons-les produits) d'un service externe dans notre application. Pour chaque produit, des données encore plus diverses qui lui sont associées sont chargées à partir d'API externes. Il n'est pas rare qu'un utilisateur charge des centaines de produits avec toutes les dépendances, par conséquent, l'importation d'un produit prend un temps tangible (30 à 60 secondes), et l'ensemble du processus peut prendre beaucoup de temps. L'utilisateur peut être fatigué d'attendre le résultat et il a le droit de cliquer sur le bouton "Annuler" à tout moment et l'application devrait être utile avec le nombre de produits qui ont pu être téléchargés à ce moment.
L '«importation interrompue» est implémentée comme suit: au début, pour chaque produit, un enregistrement de tâche temporaire est créé dans la plaque signalétique de la base de données. Pour chaque produit, une tâche d'importation en arrière-plan est lancée, qui télécharge le produit, l'enregistre dans la base de données avec toutes les dépendances (fait tout en général) et à la toute fin supprime son enregistrement de tâche. Si au moment où la tâche d'arrière-plan démarre, il n'y aura aucun enregistrement dans la base de données - la tâche se termine simplement en silence. Ainsi, pour annuler l'importation, il suffit de supprimer simplement toutes les tâches et c'est tout.
Peu importe si l'importation a été annulée par l'utilisateur ou complètement terminée par lui-même - en tout cas, l'absence de tâches signifie que tout est terminé et que l'utilisateur peut commencer à utiliser l'application.
Le design est simple et fiable, mais il y avait un petit bug. Un rapport de bogue typique à son sujet était: «Une fois l'importation annulée, l'utilisateur affiche une liste de ses produits. Cependant, si vous actualisez la page, la liste des produits est complétée par plusieurs entrées. " La raison de ce comportement est simple: lorsque l'utilisateur clique sur le bouton «Annuler», il est immédiatement transféré dans la liste de tous les produits. Mais à l'heure actuelle, les importations déjà commencées de certains biens «continuent» de fonctionner.
Bien sûr, c'est une bagatelle, mais les utilisateurs ont été intrigués par la commande, donc ce serait bien de le réparer. J'avais deux façons: identifier et «tuer» les tâches en cours d'exécution ou, lorsque j'appuie sur le bouton Annuler, attendre qu'elles soient terminées et «mourir de leur propre chef» avant de transférer l'utilisateur. J'ai choisi la deuxième façon - d'attendre.
Les verrous transactionnels se précipitent à la rescousse
Pour tous ceux qui travaillent avec des bases de données (relationnelles), la réponse est évidente: utilisez les transactions !
Il est important de se rappeler que dans la plupart des SGBDR, les enregistrements mis à jour dans une transaction seront bloqués et inaccessibles aux modifications par d'autres processus jusqu'à ce que cette transaction soit terminée. Les enregistrements sélectionnés à l'aide de SELECT FOR UPDATE
seront également verrouillés.
Exactement notre cas! J'ai bouclé la tâche d'importer des marchandises individuelles dans une transaction et j'ai bloqué l'enregistrement de tâche au tout début:
ActiveRecord::Base.transaction do task = Import::Task.lock.find_by(id: id)
Désormais, lorsque l'utilisateur souhaite annuler l'importation, l'opération d'arrêt d'importation supprimera les tâches pour les importations qui n'ont pas encore commencé et sera obligé d'attendre la fin de celles déjà existantes:
user.import_tasks.delete_all
Simple et élégant! J'ai exécuté les tests, vérifié l'importation localement et au niveau de la mise en scène et déployé "au combat".
Pas si vite ...
Satisfait de mon travail, j'ai été très surpris de trouver bientôt des rapports de bugs et des tonnes d'erreurs dans les logs. De nombreux produits n'étaient pas du tout importés. Dans certains cas, un seul produit pourrait rester après l'achèvement de toutes les importations.
Les erreurs dans les journaux n'étaient pas encourageantes non plus: PG::InFailedSqlTransaction
avec une backtrack menant au code qui exécutait les SELECT
innocents. Que se passe-t-il du tout?
Après une journée de débogage épuisante, j'ai identifié trois causes principales des problèmes:
- Insertion compétitive d'enregistrements conflictuels dans la base de données.
- Annulation automatique des transactions dans PostgreSQL après des erreurs.
- Silence des problèmes (exceptions Ruby) dans le code d'application.
Problème 1: insertion compétitive d'entrées conflictuelles
Étant donné que chaque opération d'importation prend jusqu'à une minute et qu'il existe un grand nombre de ces tâches, nous les effectuons en parallèle pour gagner du temps. Les enregistrements de marchandises dépendants peuvent se recouper, dans la mesure où tous les produits de l'utilisateur peuvent se référer à un seul enregistrement, créé une fois puis réutilisé.
Il existe des vérifications pour trouver et réutiliser les mêmes dépendances dans le code d'application, mais maintenant, lorsque nous utilisons des transactions, ces vérifications sont devenues inutiles : si la transaction A a créé un enregistrement dépendant mais n'est pas encore terminée, la transaction B ne pourra pas découvrir son existence et essaiera de créer un doublon record.
Problème 2: Annulation automatique des transactions PostgreSQL après des erreurs
Bien sûr, nous avons empêché la création de tâches en double au niveau de la base de données à l'aide du DDL suivant:
ALTER TABLE product_deps ADD UNIQUE (user_id, characteristics);
Si une transaction en cours A a inséré un nouvel enregistrement et que la transaction B essaie d'insérer un enregistrement avec les mêmes valeurs des champs user_id
et characteristics
, la transaction B recevra une erreur:
BEGIN; INSERT INTO product_deps (user_id, characteristics) VALUES (1, '{"same": "value"}');
Mais il y a une caractéristique qui ne doit pas être oubliée - la transaction B, après avoir détecté une erreur, sera automatiquement annulée et tout le travail qui y sera effectué ira à l'égout. Cependant, cette transaction est toujours ouverte dans un état «erroné», mais avec toute tentative d'exécution d'une demande, même la plus inoffensive, seules les erreurs seront renvoyées en réponse:
SELECT * FROM products; ERROR: current transaction is aborted, commands ignored until end of transaction block
Eh bien, il est complètement inutile de dire que tout ce qui a été entré dans la base de données dans cette transaction ne sera pas enregistré:
COMMIT;
Troisième problème: le silence
À ce stade, il était déjà clair que le simple fait d'ajouter des transactions à l'application l'a brisée. Il n'y avait pas d'autre choix: je devais plonger dans le code d'importation. Dans le code, les motifs suivants ont souvent attiré mon attention:
def process_stuff(data)
L'auteur du code ici nous dit: "Nous avons essayé, nous n'avons pas réussi, mais ça va, nous continuons sans." Et bien que les raisons de ce choix puissent être tout à fait explicables (tout ne peut pas être traité au niveau de l'application), c'est ce qui rend impossible toute logique basée sur des transactions: une exécution rejetée ne pourra pas flotter jusqu'au bloc de transaction
, et ne provoquera pas une restauration correcte transactions (ActiveRecord intercepte toutes les erreurs dans ce bloc, annule la transaction et les relance).
Tempête parfaite
Et voici comment ces trois facteurs se sont réunis pour créer le parfait la tempête bug:
- Une application dans une transaction essaie d'insérer un enregistrement en conflit dans la base de données et provoque une erreur de "clé en double" de PostgreSQL. Toutefois, cette erreur n'entraîne pas l'annulation de la transaction dans l'application, car elle est "étouffée" dans l'une des parties de l'application.
- La transaction devient invalide, mais l'application n'en a pas connaissance et continue de fonctionner. Dans toute tentative d'accès à la base de données, l'application reçoit à nouveau une erreur, cette fois "la transaction en cours est abandonnée", mais cette erreur peut également être supprimée ...
- Vous avez probablement déjà compris que quelque chose dans l'application continue de se casser, mais personne ne le saura jusqu'à ce que l'exécution atteigne le premier endroit, où il n'y a pas de
rescue
trop gourmand et où l'erreur peut finalement apparaître, être enregistrée, enregistré dans le tracker d'erreur - quoi que ce soit. Mais cet endroit sera déjà très loin de l'endroit qui est devenu la cause première de l'erreur, et cela seul transformera le débogage en cauchemar.
Alternative aux verrous transactionnels dans PostgreSQL
La recherche de rescue
dans le code d'application et la réécriture de toute la logique d'importation ne sont pas une option. Un long moment. J'avais besoin d'une solution rapide et je l'ai trouvée chez les postgres! Il a une solution intégrée pour les verrous, une alternative au verrouillage des enregistrements dans les transactions, des verrous de conseil de session. Je les ai utilisés comme suit:
Tout d'abord, j'ai d'abord supprimé la transaction d'emballage. Dans tous les cas, interagir avec des API externes (ou tout autre «effet secondaire») du code d'application avec une transaction ouverte est une mauvaise idée, car même si vous annulez la transaction avec toutes les modifications de notre base de données, les modifications des systèmes externes resteront et l'application dans son ensemble peut se trouver dans un état étrange et indésirable. Le joyau de l'isolateur peut vous aider à vous assurer que les effets secondaires sont correctement isolés des transactions.
Ensuite, dans chaque opération d'importation, je prends un verrou partagé sur une clé unique pour l'importation entière (par exemple, créée à partir de l'ID utilisateur et du hachage à partir du nom de la classe d'opération):
SELECT pg_advisory_lock_shared(42, user.id);
Des verrous partagés sur la même clé peuvent être pris simultanément par n'importe quel nombre de sessions.
L'annulation de l'opération d'importation supprime simultanément toutes les entrées de tâche de la base de données et essaie de prendre un verrou exclusif sur la même clé. Dans ce cas, elle devra attendre que tous les verrous partagés soient libérés:
SELECT pg_advisory_lock(42, user.id)
Et c'est tout! Maintenant, l '«annulation» attendra que toutes les importations «en cours» de biens individuels soient terminées.
De plus, maintenant que nous ne sommes pas connectés par une transaction, nous pouvons utiliser un petit hack pour limiter le temps d'attente pour l'annulation de l'importation (dans le cas où certains «sticks» d'importation), car il n'est pas bon de bloquer le flux du serveur Web pendant une longue période (et forcer attendre l'utilisateur):
transaction do execute("SET LOCAL lock_timeout = '30s'") execute("SELECT pg_advisory_lock(42, user.id)") rescue ActiveRecord::LockWaitTimeout nil
Il est sûr d'attraper une erreur en dehors du bloc de transaction
, car ActiveRecord annulera déjà la transaction .
Mais que faire de l'insertion compétitive d'enregistrements identiques?
Malheureusement, je ne connais pas de solution qui fonctionnerait bien avec des inserts compétitifs . Il existe les approches suivantes, mais elles bloqueront toutes les insertions simultanées jusqu'à la fin de la première des transactions:
INSERT … ON CONFLICT UPDATE
(disponible depuis PostgreSQL 9.5) dans la deuxième transaction sera verrouillé jusqu'à ce que la première transaction soit terminée, puis il renverra l'enregistrement qui a été inséré par la première transaction.- Verrouillez un enregistrement général dans une transaction avant d'exécuter des validations pour insérer un nouvel enregistrement. Ici, nous attendrons que l'enregistrement inséré dans une autre transaction soit visible et que les validations ne puissent pas fonctionner correctement.
- Prenez une sorte de verrouillage de recommandation générale - l'effet est le même que pour le blocage d'un enregistrement général.
Eh bien, si vous n'avez pas peur de travailler avec des erreurs de niveau de base, vous pouvez simplement attraper l'erreur d'unicité:
def import_all_the_things
Assurez-vous simplement que ce code n'est plus inclus dans une transaction.
Pourquoi sont-ils bloqués?
Les contraintes UNIQUE et EXCLUDE bloquent les conflits potentiels en empêchant leur enregistrement en même temps. Par exemple, si vous avez une contrainte unique sur une colonne entière et qu'une transaction insère une ligne avec une valeur de 5, les autres transactions qui tentent également d'insérer 5 seront bloquées, mais les transactions qui tentent d'insérer 6 ou 4 réussiront immédiatement, sans blocage. Étant donné que le niveau minimum d'isolation des transactions de PostgreSQL est READ COMMITED
, une transaction n'est pas autorisée à voir les modifications non validées des autres transactions. Par conséquent, un INSERT
avec une valeur en conflit ne peut pas être accepté ou rejeté jusqu'à ce que la première transaction valide ses modifications (puis le second reçoit une erreur d'unicité) ou annule (puis l'insertion dans la deuxième transaction réussira). En savoir plus à ce sujet dans un article de l'auteur des restrictions EXCLUDE .
Prévenir une catastrophe future
Vous savez maintenant que tout le code ne peut pas être encapsulé dans une transaction. Ce serait bien de s'assurer que personne d'autre n'emballe un tel code dans une transaction à l'avenir, répétant mon erreur.
Pour ce faire, vous pouvez encapsuler toutes vos opérations dans un petit module auxiliaire qui vérifiera si la transaction est ouverte avant d'exécuter le code d'opération encapsulé (ici, il est supposé que toutes vos opérations ont la même interface - la méthode d' call
).
Maintenant, si quelqu'un essaie de boucler un service dangereux dans une transaction, il recevra immédiatement une erreur (à moins, bien sûr, de le garder silencieux).
Résumé
La principale leçon à tirer: faites attention aux exceptions. Ne gérez pas tout de suite, ne récupérez que les exceptions que vous savez comment gérer et laissez le reste accéder aux journaux. N'ignorez jamais les exceptions (uniquement si vous n'êtes pas sûr à 100% pourquoi vous faites cela). Plus tôt une erreur est constatée, plus il sera facile de déboguer.
Et n'en faites pas trop avec les transactions dans la base de données. Ce n'est pas une panacée. Utilisez notre isolateur de gemmes et after_commit_everywhere - ils aideront vos transactions à devenir complètement infaillibles.
Que lire
Ruby exceptionnel par Avdi Grimm . Ce petit livre vous apprendra comment gérer les exceptions existantes dans Ruby et comment concevoir correctement un système d'exceptions pour votre application.
Utilisation de transactions atomiques pour alimenter une API idempotente par @Brandur. Son blog contient de nombreux articles utiles sur la fiabilité des applications, Ruby et PostgreSQL.