J'utilise Postgres 9.3 et je dois empêcher les insertions dans une table sur la base d'un nombre de lignes spécifiques déjà dans la table. Voici le tableau:
Table "public.team_joins"
Column | Type | Modifiers
-----------------+--------------------------+---------------------------------------------------------
id | integer | not null default nextval('team_joins_id_seq'::regclass)
team_id | integer | not null
Indexes:
"team_joins_pkey" PRIMARY KEY, btree (id)
"team_joins_team_id" btree (team_id)
Foreign-key constraints:
"team_id_refs_teams_id" FOREIGN KEY (team_id) REFERENCES teams(id) DEFERRABLE INITIALLY DEFERRED
Ainsi, par exemple, si une équipe avec l'ID 3 n'autorise que 20 joueurs et que SELECT COUNT(*) FROM team_joins WHERE team_id = 3
est égal à 20, aucun joueur ne devrait pouvoir rejoindre l'équipe 3. Quelle est la meilleure façon de gérer cela et d'éviter les problèmes de concurrence ? Dois-je utiliser une transaction SERIALIZABLE
pour insérer, ou puis-je simplement utiliser une clause WHERE
comme celle-ci dans l'instruction d'insertion?
INSERT INTO team_joins (team_id)
VALUES (3)
WHERE (
SELECT COUNT(*) FROM team_joins WHERE team_id = 3
) < 20;
Ou y a-t-il une meilleure option que je n'envisage pas?
En règle générale, vous disposez d'une table team
(ou similaire) avec une colonne team_id
Unique.
Votre contrainte FK indique autant: ... REFERENCES teams(id)
- je vais donc travailler avec teams(id)
.
Ensuite, pour éviter les complications (conditions de concurrence critique ou blocages) lors d'une charge d'écriture simultanée, il est généralement plus simple et moins coûteux de prendre un verrou d'écriture sur la ligne parent dans team
puis, dans la même transaction, écrivez la ou les lignes enfants dans team_joins
(INSERT
/UPDATE
/DELETE
).
BEGIN;
SELECT * FROM teams WHERE id = 3 FOR UPDATE; -- write lock
INSERT INTO team_joins (team_id)
SELECT 3 -- inserting single row
FROM team_joins
WHERE team_id = 3
HAVING count(*) < 20;
COMMIT;
Exemple pour single ligne INSERT
. Pour traiter un ensemble entier à la fois, vous devez en faire plus; voir ci-dessous.
On pourrait suspecter un problème de cas d'angle dans le SELECT
. Et s'il y a encore non ligne avec team_id = 3
? La clause WHERE
n'annulerait-elle pas le INSERT
?
Ce ne serait pas le cas, car la clause HAVING
en fait une agrégation sur l'ensemble entier qui toujours renvoie exactement une ligne (qui est éliminée s'il y en a 20 ou plus pour le team_id
déjà donné) - exactement le comportement que vous voulez. Le manuel:
Si une requête contient des appels de fonction agrégés, mais pas de clause
GROUP BY
, Le regroupement se produit toujours: le résultat est une ligne de groupe unique (ou peut-être aucune ligne du tout, si la ligne unique est ensuite éliminé parHAVING
) . La même chose est vraie si elle contient une clauseHAVING
, même sans appel de fonction d'agrégation ou clause GROUP BY .
Accentuation sur moi.
Le cas où aucune ligne parent n'est trouvée n'est pas un problème non plus. Votre contrainte FK applique de toute façon l'intégrité référentielle. Si team_id
N'est pas dans la table parent, la transaction se termine avec une violation de clé étrangère dans les deux cas.
Tous les opérations d'écriture éventuellement en concurrence sur team_joins
Doivent suivre le même protocole.
Dans le cas UPDATE
, si vous modifiez le team_id
, Vous verrouillez la source et l'équipe cible.
Les verrous sont libérés à la fin de la transaction. Explication détaillée dans cette réponse étroitement liée:
Dans Postgres 9.4 ou version ultérieure, le nouveau, plus faible FOR NO KEY UPDATE
peut être préférable. Fait aussi le travail, moins de blocage, potentiellement moins cher. Le manuel:
Se comporte de la même manière que
FOR UPDATE
, Sauf que le verrou acquis est plus faible: ce verrou ne bloquera pas les commandesSELECT FOR KEY SHARE
Qui tentent d'acquérir un verrou sur les mêmes lignes. Ce mode de verrouillage est également acquis par toutUPDATE
qui n'acquiert pas de verrouFOR UPDATE
.
Une autre incitation à envisager la mise à niveau ...
En supposant utilement que vous avez une colonne player_id integer NOT NULL
. Même verrouillage que ci-dessus, plus ...
Syntaxe courte:
INSERT INTO team_joins (team_id, player_id)
SELECT 3, unnest('{5,7,66}'::int[])
FROM team_joins
WHERE team_id = 3
HAVING count(*) < (21 - 3); -- 3 being the number of rows to insert
La fonction set-return dans la liste SELECT
n'est pas conforme au SQL standard, mais elle est parfaitement valide dans Postgres.
Ne combinez simplement pas plusieurs fonctions de retour d'ensemble dans la liste SELECT
avant Postgres 10, ce qui a finalement corrigé un comportement inattendu.
SQL standard plus propre et plus détaillé faisant de même:
INSERT INTO team_joins (team_id, player_id)
SELECT team_id, player_id
FROM (
SELECT 3 AS team_id
FROM team_joins
WHERE team_id = 3
HAVING count(*) < (21 - 3)
) t
CROSS JOIN (
VALUES (5), (7), (66)
) p(player_id);
C'est tout ou rien. Comme dans un jeu de Blackjack: un de trop et l'ensemble INSERT
est sorti.
Pour terminer, tout cela pourrait être commodément encapsulé dans une fonction VARIADIC
PL/pgSQL:
CREATE OR REPLACE FUNCTION f_add_players(team_id int, VARIADIC player_ids int[])
RETURNS bool AS
$func$
BEGIN
SELECT * FROM teams WHERE id = 3 FOR UPDATE; -- lock team
-- SELECT * FROM teams WHERE id = 3 FOR NO KEY UPDATE; -- in pg 9.4+
INSERT INTO team_joins (team_id, player_id)
SELECT $1, unnest($2) -- use $1, not team_id
FROM team_joins t
WHERE t.team_id = $1 -- table-qualify to disambiguate
HAVING count(*) < 21 - array_length($2, 1);
-- HAVING count(*) < 21 - cardinality($2); -- in pg 9.4+
RETURN FOUND; -- true if INSERT
END
$func$ LANGUAGE plpgsql;
Appel (notez la syntaxe simple avec une liste de valeurs):
SELECT f_add_players(3, 5, 7, 66);
Ou, pour passer un réel tableau - notez à nouveau la clé VARIADIC
:
SELECT f_add_players(3, VARIADIC '{5,7,66}');
En relation:
Je réponds à votre commentaire
Pour l'instant, j'insère toujours des lignes simples, mais à l'avenir, il sera probablement nécessaire/souhaitable d'insérer un ensemble entier à la fois
Je ne sais pas comment vous stockez un joueur a rejoint une équipe ou non. Je vais donc les appeler "newplayer" Si vous avez beaucoup de "newplayers" en attente de rejoindre une équipe, je suggère ce genre de requête pour savoir combien d’équipes vous devez créer:
SELECT DISTINCT ((ROW_NUMBER() OVER () -1)/20) +1)
FROM newplayer
Il retourne une liste de nombres de 1 au maximum nécessaire. Si vous avez 55 joueurs sans équipe, il retournera "1", "2", "3". Ensuite, vous pouvez vous joindre à cette demande pour insérer vos 3 équipes à la fois.
Pour vous team_joins, quelque chose comme ceci:
WITH match AS (SELECT ((ROW_NUMBER() OVER () -1)/20) +1) AS teamId, newplayers.id as playerId)
INSERT INTO team_joins (team_id, player_id)
match.teamId, match.playerId
FROM match
À vous de changer "20" en "team.limit" et de soustraire le nombre de jointures déjà insérées pour chaque équipe.
Vous pouvez facilement accomplir cela atomiquement ..
INSERT INTO team_joins (team_id)
SELECT team_id
FROM team_joins
GROUP BY team_id
HAVING count(*) < 20;
Cependant, je ne suis pas sûr que la meilleure façon en raison de problèmes de concurrence. Sans SERIALIAZABLE
, il est toujours possible mais extrêmement peu probable sous des charges de travail normales, que la sélection se termine avant le début de INSERT
. Ma façon préférée de résoudre le problème de concurrence n'est pas de INSERT
ou DELETE
simultanément, sauf si vous devez le faire. Au lieu de cela, pour pré-insérer lorsque vous ajoutez l'équipe et allouez les membres de l'équipe. Sans SERIALIZABLE
, il existe de nombreux cas Edge. Avec SERIALIZABLE
, vous devez rejouer les transactions - ces deux solutions sont plus concrètes et mais beaucoup plus complexes.
En règle générale, il est facile et naturel de verrouiller/protéger des lignes et de garantir contre toute modification lors d'une transaction. Il est complexe de protéger les tables contre les INSERT.
Vous pouvez trouver mon autre réponse intéressante Stratégie pour les réservations de groupe simultanées?