Maison >développement back-end >Golang >Faire face aux conditions de course : un exemple pratique
Dans votre carrière, vous rencontrerez des problèmes de chat de Schrödinger, des situations qui fonctionnent parfois et parfois non. Les conditions de course font partie de ces défis (oui, un seul !).
Tout au long de cet article de blog, je présenterai un exemple concret, montrerai comment reproduire le problème et discuterai des stratégies de gestion des conditions de concurrence à l'aide de l'isolation des transactions sérialisables et des verrous consultatifs avec PostgreSQL.
Inspiré de « Conception d'applications à forte intensité de données », chapitre 7 – Transactions « Niveaux d'isolement faibles »
Référentiel Github avec exemple pratique
Cette application gère les gardes des médecins d'un hôpital. Pour nous concentrer sur le problème des conditions de concurrence, simplifions notre scénario. Notre application se résout autour de cette table unique :
CREATE TABLE shifts ( id SERIAL PRIMARY KEY, doctor_name TEXT NOT NULL, shift_id INTEGER NOT NULL, on_call BOOLEAN NOT NULL DEFAULT FALSE );
Nous avons une règle commerciale critique :
Comme vous l'avez peut-être deviné, la mise en œuvre d'une API naïve peut conduire à des scénarios de conditions de concurrence. Considérez cette situation hypothétique :
Jack et John sont tous deux de garde à l'hôpital pendant le même quart de travail. Presque au même moment, ils décident de demander un congé. L’un réussit, mais l’autre s’appuie sur des informations obsolètes sur le nombre de médecins en service. En conséquence, tous deux finissent par quitter leur quart de travail, enfreignant la règle commerciale et laissant un quart de travail spécifique sans médecin de garde :
John --BEGIN------doctors on call: 2-------leave on call-----COMMIT--------> (t) \ \ \ \ \ \ \ \ Database ------------------------------------------------------------------> (t) / / / / / / / / Jack ------BEGIN------doctors on call: 2-----leave on call----COMMIT-------> (t)
L'application est une simple API implémentée dans Golang. Consultez le référentiel GitHub pour obtenir des instructions sur la façon d'exécuter et d'exécuter le script pour reproduire ce scénario de condition de concurrence. En résumé, vous devrez :
Le test tente d'appeler deux médecins simultanément, en atteignant les points finaux avec des approches différentes : shiftId=1 utilise le verrouillage consultatif, shiftId=2 utilise isolation des transactions sérialisables, et shiftId=3 est une implémentation naïve sans contrôle de concurrence.
Les résultats k6 généreront des métriques personnalisées pour indiquer quel shiftId a violé la règle métier :
✓ at least one doctor on call for shiftId=1 ✓ at least one doctor on call for shiftId=2 ✗ at least one doctor on call for shiftId=3 ↳ 36% — ✓ 123 / ✗ 217
Vous aurez besoin d'outils tels que Yarn, Go, K6 et Docker, ou vous pouvez utiliser DevBox pour une configuration plus facile des dépendances du référentiel.
Le problème survient lorsque notre application prend une décision basée sur des données obsolètes. Cela peut se produire si deux transactions se déroulent presque simultanément et que toutes deux tentent de rappeler des médecins pour leur quart de travail. Une transaction réussit comme prévu, mais l'autre, qui repose sur des informations obsolètes, réussit également de manière incorrecte. Comment pouvons-nous prévenir ce comportement indésirable ? Il existe plusieurs façons d'y parvenir, et j'explorerai deux options soutenues par PostgreSQL, bien que des solutions similaires puissent être trouvées dans d'autres systèmes de gestion de bases de données.
L'isolation des instantanés sérialisables détecte et prévient automatiquement les anomalies telles que le biais d'écriture démontré par notre application.
Je n'approfondirai pas la théorie derrière l'isolation des transactions, mais c'est un sujet courant dans de nombreux systèmes de gestion de bases de données populaires. Vous pouvez trouver de bons documents en recherchant l'isolation des instantanés, comme celui-ci dans la documentation officielle de PostgreSQL sur l'isolation des transactions. De plus, voici le document qui a proposé cette solution il y a des années. Parler ne coûte pas cher, alors voyons le code :
Tout d'abord, démarrez la transaction et définissez le niveau d'isolement sur Serialisable :
// Init transaction with serializable isolation level tx, err := db.BeginTxx(c.Request().Context(), &sql.TxOptions{ Isolation: sql.LevelSerializable, })
Ensuite, procédez à l'exécution des opérations. Dans notre cas, il exécute cette fonction :
CREATE OR REPLACE FUNCTION update_on_call_status_with_serializable_isolation(shift_id_to_update INT, doctor_name_to_update TEXT, on_call_to_update BOOLEAN) RETURNS VOID AS $$ DECLARE on_call_count INT; BEGIN -- Check the current number of doctors on call for this shift SELECT COUNT(*) INTO on_call_count FROM shifts s WHERE s.shift_id = shift_id_to_update AND s.on_call = TRUE; IF on_call_to_update = FALSE AND on_call_count = 1 THEN RAISE EXCEPTION '[SerializableIsolation] Cannot set on_call to FALSE. At least one doctor must be on call for this shiftId: %', shift_id_to_update; ELSE UPDATE shifts s SET on_call = on_call_to_update WHERE s.shift_id = shift_id_to_update AND s.doctor_name = doctor_name_to_update; END IF; END; $$ LANGUAGE plpgsql;
Chaque fois que des scénarios incohérents se produisent en raison d'une exécution simultanée, le niveau d'isolation sérialisable permettra à une transaction de réussir et annulera automatiquement les autres avec ce message, afin que vous puissiez réessayer en toute sécurité :
ERROR: could not serialize access due to read/write dependencies among transactions
Une autre façon de garantir l'application de nos règles métier consiste à verrouiller explicitement la ressource pour une équipe spécifique. Nous pouvons y parvenir en utilisant un verrou consultatif au niveau de la transaction. Ce type de verrouillage est entièrement contrôlé par l'application. Vous pouvez trouver plus d'informations à ce sujet ici.
Il est crucial de noter que les verrous peuvent être appliqués à la fois au niveau de la session et de la transaction. Vous pouvez explorer les différentes fonctions disponibles ici. Dans notre cas, nous utiliserons pg_try_advisory_xact_lock(key bigint) → boolean, qui libère automatiquement le verrou après un commit ou un rollback :
BEGIN; -- Attempt to acquire advisory lock and handle failure with EXCEPTION IF NOT pg_try_advisory_xact_lock(shift_id_to_update) THEN RAISE EXCEPTION '[AdvisoryLock] Could not acquire advisory lock for shift_id: %', shift_id_to_update; END IF; -- Perform necessary operations -- Commit will automatically release the lock COMMIT;
Voici la fonction complète utilisée dans notre application :
-- Function to Manage On Call Status with Advisory Locks, automatic release when the trx commits CREATE OR REPLACE FUNCTION update_on_call_status_with_advisory_lock(shift_id_to_update INT, doctor_name_to_update TEXT, on_call_to_update BOOLEAN) RETURNS VOID AS $$ DECLARE on_call_count INT; BEGIN -- Attempt to acquire advisory lock and handle failure with NOTICE IF NOT pg_try_advisory_xact_lock(shift_id_to_update) THEN RAISE EXCEPTION '[AdvisoryLock] Could not acquire advisory lock for shift_id: %', shift_id_to_update; END IF; -- Check the current number of doctors on call for this shift SELECT COUNT(*) INTO on_call_count FROM shifts s WHERE s.shift_id = shift_id_to_update AND s.on_call = TRUE; IF on_call_to_update = FALSE AND on_call_count = 1 THEN RAISE EXCEPTION '[AdvisoryLock] Cannot set on_call to FALSE. At least one doctor must be on call for this shiftId: %', shift_id_to_update; ELSE UPDATE shifts s SET on_call = on_call_to_update WHERE s.shift_id = shift_id_to_update AND s.doctor_name = doctor_name_to_update; END IF; END; $$ LANGUAGE plpgsql;
Dealing with race conditions, like the write skew scenario we talked about, can be pretty tricky. There's a ton of research and different ways to solve these problems, so definitely check out some papers and articles if you're curious.
These issues can pop up in real-life situations, like when multiple people try to book the same seat at an event or buy the same spot in a theater. They tend to appear randomly and can be hard to figure out, especially if it's your first time dealing with them.
When you run into race conditions, it's important to look into what solution works best for your specific situation. I might do a benchmark in the future to compare different approaches and give you more insights.
I hope this post has been helpful. Remember, there are tools out there to help with these problems, and you're not alone in facing them!
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!