Évitez les doublons causés par des vérifications et insertions concurrentes grâce à une contrainte d’unicité sur la clé logique de l’article et à INSERT ... ON CONFLICT. Si vous avez également besoin de la ligne existante, utilisez une stratégie de récupération adaptée au niveau d’isolation de votre transaction.
Ce tutoriel s’adresse aux développeurs travaillant dans Terminal sur macOS et ayant besoin d’une fonction Node.js qui crée un article synthétique une seule fois et renvoie sa ligne aux appels concurrents. Les exemples préservent le titre du premier article. Ils s’appuient sur la documentation actuelle de PostgreSQL et la documentation officielle de node-postgres.
Pourquoi une transaction seule n’empêche pas les doublons
Supposons deux appels qui tentent de créer un article avec le slug paper-shapes. Chacun vérifie d’abord si ce slug existe, puis l’insère si la requête ne renvoie rien. Au niveau d’isolation par défaut de PostgreSQL, Read Committed, une requête normale voit les données validées au moment de l’instantané de son instruction. Les deux vérifications peuvent donc indiquer que l’article est absent. PostgreSQL : isolation des transactions
Cette séquence illustrative montre la course critique au niveau de l’application :
| Étape | Appel A | Appel B |
|---|---|---|
| 1 | Vérifie paper-shapes : absent |
|
| 2 | Vérifie paper-shapes : absent |
|
| 3 | Insère l’article | |
| 4 | Tente d’insérer le même slug | |
| 5 | Valide la transaction | Tente de valider la transaction |
Sans contrainte d’unicité sur le slug, ces insertions peuvent créer des lignes distinctes. Une clé primaire reposant sur un identifiant généré automatiquement n’exprime pas la règle selon laquelle les slugs doivent être uniques. PostgreSQL permet de définir des contraintes d’unicité sur une colonne ou sur une combinaison de colonnes. PostgreSQL : contraintes
Encadrer la vérification et l’insertion dans BEGIN et COMMIT ne réserve pas le slug absent. Ajouter FOR UPDATE à une recherche qui ne renvoie aucune ligne ne résout pas non plus le problème : cette clause verrouille les lignes récupérées, et un article absent ne fournit aucune ligne à verrouiller. Cela découle du comportement documenté des verrous de lignes de PostgreSQL. PostgreSQL : verrouillage explicite
Définir précisément ce qui constitue un doublon
Dans cet exemple, un slug exact identifie un seul article. Le titre est un contenu ; une deuxième requête avec le même slug doit donc recevoir l’article enregistré plutôt que remplacer son titre.
Commencez avec une installation locale de PostgreSQL existante, ses outils en ligne de commande et Node.js avec npm. Dans Terminal sur macOS, créez une nouvelle base de données de démonstration et ouvrez son invite SQL :
createdb article_concurrency_demo
psql -d article_concurrency_demo
Exécutez une fois ce schéma dans la nouvelle base de données :
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL,
title text NOT NULL,
CONSTRAINT articles_slug_key UNIQUE (slug)
);
La contrainte distincte UNIQUE (slug) applique la règle concernant les articles. NOT NULL est également important : par défaut, les contraintes d’unicité ordinaires autorisent plusieurs valeurs nulles. PostgreSQL crée automatiquement un index unique pour cette contrainte. PostgreSQL : contraintes
Si vous adaptez une table existante, commencez par rechercher les clés répétées au lieu de supprimer automatiquement des lignes. Cette requête de diagnostic utilise le schéma ci-dessus et ne modifie aucun contenu :
SELECT slug, count(*) AS row_count
FROM articles
GROUP BY slug
HAVING count(*) > 1;
Avant d’ajouter la contrainte à une table existante, décidez quel contenu enregistré conserver. Définissez également la clé voulue : si deux articles peuvent partager un slug dans des collections différentes, la règle nécessitera une clé composite, par exemple collection plus slug. La recherche et la cible de conflit doivent respecter cette même règle.
Choisir le comportement à adopter en cas de conflit
Un conflit est un cas normal dans cette tâche. Définissez son sens avant de choisir le SQL. PostgreSQL permet d’ignorer l’insertion en conflit ou de mettre à jour la ligne existante ; RETURNING renvoie les lignes effectivement insérées ou mises à jour. PostgreSQL : INSERT
| Résultat souhaité | Approche | Résultat renvoyé |
|---|---|---|
| Créer si absent ; sinon ignorer | ON CONFLICT (slug) DO NOTHING |
Avec RETURNING, une insertion ignorée ne renvoie aucune ligne |
| Créer si absent ; sinon remplacer le titre | ON CONFLICT (slug) DO UPDATE |
Avec RETURNING, la ligne insérée ou mise à jour |
| Créer si absent ; sinon préserver et récupérer | DO NOTHING, puis une recherche distincte |
Implémenté dans la section suivante |
Pour une opération qui ne fait qu’insérer, cette instruction SQL complète fonctionne avec le schéma de démonstration :
INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes')
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug, title;
Si le comportement souhaité consiste à remplacer le titre, utilisez cette autre instruction :
INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes — Revised')
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title
RETURNING id, slug, title;
Ici, EXCLUDED.title désigne le titre proposé. Un DO UPDATE inconditionnel fournit le résultat atomique documenté d’insertion ou de mise à jour, sous réserve des erreurs indépendantes et de la gestion des échecs liés au niveau d’isolation. Une mise à jour conditionnelle dont la condition WHERE rejette la mise à jour peut ne rien renvoyer. Choisissez une mise à jour lorsque la modification de l’article enregistré est appropriée. PostgreSQL : INSERT
Implémenter la création ou la récupération de l’article existant avec Read Committed
Il existe un piège lié à la visibilité : DO NOTHING peut ignorer l’insertion à cause d’une ligne concurrente invisible dans l’instantané de l’instruction. Par conséquent, une solution de repli qui effectue une recherche dans la même instruction peut également ne pas la trouver. Une requête Read Committed distincte obtient un nouvel instantané. C’est le principe de l’implémentation ci-dessous. PostgreSQL : isolation des transactions
Quittez l’invite SQL avec \q. Dans un nouveau dossier de travail, installez la dépendance Node.js :
mkdir article-concurrency-demo
cd article-concurrency-demo
npm init -y
npm install pg
Enregistrez le programme complet suivant dans demo.mjs. Il suppose que la base de données et la table créées ci-dessus existent. Son pool utilise le nom de la base de données ainsi que les autres paramètres de connexion standard ; node-postgres prend en charge les variables d’environnement de connexion PostgreSQL si les paramètres locaux diffèrent. node-postgres : connexion
import pg from 'pg';
import assert from 'node:assert/strict';
const { Pool } = pg;
const pool = new Pool({
database: 'article_concurrency_demo',
max: 8
});
async function getOrCreateArticle(slug, title) {
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
for (let attempt = 0; attempt < 5; attempt += 1) {
const inserted = await client.query(`
INSERT INTO articles (slug, title)
VALUES ($1, $2)
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug, title
`, [slug, title]);
if (inserted.rows.length === 1) {
await client.query('COMMIT');
return inserted.rows[0];
}
const existing = await client.query(`
SELECT id, slug, title
FROM articles
WHERE slug = $1
FOR UPDATE
`, [slug]);
if (existing.rows.length === 1) {
await client.query('COMMIT');
return existing.rows[0];
}
}
throw new Error('Article kept disappearing; retry limit reached');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
try {
const results = await Promise.allSettled(
Array.from({ length: 8 }, () =>
getOrCreateArticle('paper-shapes', 'Paper Shapes'))
);
const failures = results.filter(result => result.status === 'rejected');
if (failures.length) {
throw new AggregateError(failures.map(result => result.reason));
}
const rows = results.map(result => result.value);
assert.equal(new Set(rows.map(row => row.id)).size, 1);
const count = await pool.query(
'SELECT count(*)::integer AS n FROM articles WHERE slug = $1',
['paper-shapes']
);
assert.equal(count.rows[0].n, 1);
console.log({ calls: rows.length, matchingRows: count.rows[0].n });
} finally {
await pool.end();
}
Exécutez-le depuis ce dossier :
node demo.mjs
Avec la configuration indiquée et en l’absence d’un autre processus qui modifie les lignes de démonstration, le résultat final attendu est :
{ calls: 8, matchingRows: 1 }
La syntaxe JavaScript du programme a été vérifiée avec Node.js. La démonstration sur la base de données n’a pas été exécutée ici, car les outils PostgreSQL n’étaient pas disponibles ; le résultat ci-dessus est attendu.
Chaque transaction utilise un seul client emprunté pour toutes ses instructions. node-postgres l’exige explicitement : les instructions de transaction exécutées via pool.query peuvent être envoyées à différents clients. La requête de vérification utilise pool.query uniquement après la fin des transactions. node-postgres : transactions
Le texte de la requête et les valeurs sont transmis séparément avec $1 et $2, conformément à l’interface de requêtes paramétrées du pilote. node-postgres : requêtes
La boucle gère un autre entrelacement illustratif : une autre transaction pourrait supprimer l’article en conflit avant que la recherche ne le récupère. Si la recherche ne trouve rien, elle répète la tentative d’insertion, avec une limite explicite de cinq tentatives. En cas d’épuisement, une erreur est renvoyée plutôt qu’une ligne indéfinie.
Une fois récupérée, FOR UPDATE empêche une autre transaction de modifier ou de supprimer la ligne jusqu’à la fin de cette transaction. Cette clause protège un résultat existant ; la contrainte d’unicité gère les insertions concurrentes. Après la validation de la transaction, une autre transaction peut de nouveau modifier la ligne. Si des opérations dépendantes dans la base de données doivent utiliser l’article verrouillé, effectuez-les avant COMMIT. PostgreSQL : verrouillage explicite
Respecter les limites liées au niveau d’isolation
L’exemple sélectionne explicitement Read Committed. Repeatable Read utilise un instantané de transaction établi par sa première instruction substantielle ; une autre requête ne peut donc pas simplement le renouveler. Serializable garantit des résultats validés équivalents à une exécution séquentielle, mais peut annuler des transactions et peut toujours produire des violations d’unicité dans les cas documentés où des opérations se chevauchent. MERGE n’offre pas non plus la même garantie d’insertion ou de mise à jour concurrente. PostgreSQL : isolation des transactions
Pour Repeatable Read ou Serializable, prévoyez de réessayer la transaction entière après le code SQLSTATE 40001, y compris les décisions de l’application qui ont produit ses instructions. Réessayer uniquement la dernière requête ne suffit pas. PostgreSQL indique également que 40P01 est une erreur d’interblocage qui peut justifier une nouvelle tentative. PostgreSQL : gestion des échecs de sérialisation
Ne traitez pas automatiquement chaque violation d’unicité 23505 comme une erreur temporaire. Elle peut représenter un conflit persistant. Une politique de nouvelles tentatives doit reconnaître la condition attendue, limiter le nombre de tentatives et signaler leur épuisement ; PostgreSQL ne garantit pas que les nouvelles tentatives finiront par réussir. La démonstration gère le conflit attendu sur le slug avec ON CONFLICT, tandis que les autres erreurs sont propagées. PostgreSQL : gestion des échecs de sérialisation
Pour cette tâche de création ou de récupération, la répartition des rôles est simple : la contrainte de la base de données définit l’identité de l’article, l’action en cas de conflit détermine comment traiter un article existant, et la transaction encadre la récupération ainsi que les opérations qui en dépendent. Alignez ces choix afin que les appels concurrents puissent partager un article sans remplacer son contenu à leur insu.
Sources
- 5.5. Contraintes — PostgreSQL Global Development Group
- INSERT — PostgreSQL Global Development Group
- 13.2. Isolation des transactions — PostgreSQL Global Development Group
- 13.3. Verrouillage explicite — PostgreSQL Global Development Group
- 13.5. Gestion des échecs de sérialisation — PostgreSQL Global Development Group
- Transactions — node-postgres
- Requêtes — node-postgres
- Connexion — node-postgres