Quelle est la manière la plus courante de gérer les mises à jour simultanées dans une base de données SQL?
Considérons un schéma SQL simple (contraintes et valeurs par défaut non affichées ..) comme
create table credits (
int id,
int creds,
int user_id
);
L'intention est de stocker une sorte de crédits pour un utilisateur, par exemple quelque chose comme la réputation de stackoverflow.
Comment gérer les mises à jour simultanées de cette table? Quelques options:
update credits set creds= 150 where userid = 1;
Dans ce cas, l'application a récupéré la valeur actuelle, calculé la nouvelle valeur (150) et effectué une mise à jour. Ce qui est catastrophique si quelqu'un d'autre fait de même en même temps. Je suppose qu'envelopper la récupération de la valeur actuelle et la mise à jour dans une transaction résoudrait cela, par exemple Begin; select creds from credits where userid=1; do application logic to calculate new value, update credits set credits = 160 where userid = 1; end;
Dans ce cas, vous pouvez vérifier si le nouveau crédit serait <0 et le tronquer à 0 si les crédits négatifs n'ont aucun sens.
update credits set creds = creds - 150 where userid=1;
Ce cas n'aurait pas besoin de s'inquiéter des mises à jour simultanées car la base de données s'occupe du problème de cohérence, mais a le défaut que les crédits deviendraient heureusement négatifs, ce qui pourrait ne pas avoir de sens pour certaines applications.
Donc, simplement, quelle est la méthode acceptée pour traiter le problème (assez simple) décrit ci-dessus, et si la base de données génère une erreur?
Utiliser des transactions:
BEGIN WORK;
SELECT creds FROM credits WHERE userid = 1;
-- do your work
UPDATE credits SET creds = 150 WHERE userid = 1;
COMMIT;
Quelques notes importantes:
La combinaison de transactions avec des procédures stockées SQL peut faciliter la gestion de cette dernière partie; l'application ne ferait qu'appeler une seule procédure stockée dans une transaction et la rappeler si la transaction échoue.
Pour les tables MySQL InnoDB, cela dépend vraiment du niveau d'isolement que vous définissez.
Si vous utilisez le niveau par défaut 3 (REPEATABLE READ), vous devrez verrouiller toute ligne qui affecte les écritures suivantes, même si vous êtes dans une transaction. Dans votre exemple, vous devrez:
SELECT FOR UPDATE creds FROM credits WHERE userid = 1;
-- calculate --
UPDATE credits SET creds = 150 WHERE userid = 1;
Si vous utilisez le niveau 4 (SERIALISABLE), un simple SELECT suivi d'une mise à jour est suffisant. Le niveau 4 dans InnoDB est implémenté en verrouillant en lecture chaque ligne que vous lisez.
SELECT creds FROM credits WHERE userid = 1;
-- calculate --
UPDATE credits SET creds = 150 WHERE userid = 1;
Cependant dans cet exemple spécifique, puisque le calcul (ajout de crédits) est assez simple pour être fait en SQL, un simple:
UPDATE credits set creds = creds - 150 where userid=1;
sera équivalent à un SELECT FOR UPDATE suivi de UPDATE.
Envelopper le code dans une transaction n'est pas suffisant dans certains cas, quel que soit le niveau d'isolement que vous définissez (par exemple, imaginez que vous avez déployé votre code sur 2 serveurs différents en production).
Disons que vous avez ces étapes et 2 threads de concurrence:
1) open a transaction
2) fetch the data (SELECT creds FROM credits WHERE userid = 1;)
3) do your work (credits + amount)
4) update the data (UPDATE credits SET creds = ? WHERE userid = 1;)
5) commit
Et cette chronologie:
Time = 0; creds = 100
Time = 1; ThreadA executes (1) and creates Txn1
Time = 2; ThreadB executes (1) and creates Txn2
Time = 3; ThreadA executes (2) and fetches 100
Time = 4; ThreadB executes (2) and fetches 100
Time = 5; ThreadA executes (3) and adds 100 + 50
Time = 6; ThreadB executes (3) and adds 100 + 50
Time = 7; ThreadA executes (4) and updates creds to 150
Time = 8; ThreadB tries to executes (4) but in the best scenario the transaction
(depending of isolation level) won't allow it and you get an error
La transaction vous empêche de remplacer la valeur creds par une valeur incorrecte, mais ce n'est pas suffisant car je ne veux échouer aucune erreur.
Je préfère plutôt un processus plus lent qui n'échoue jamais et j'ai résolu le problème avec un "verrouillage de ligne de base de données" au moment où je récupère les données (étape 2) qui empêche les autres threads de lire la même ligne jusqu'à ce que j'en ai fini.
Il y a peu de façons de faire dans SQL Server et c'est l'une d'entre elles:
SELECT creds FROM credits WITH (UPDLOCK) WHERE userid = 1;
Si je recrée la chronologie précédente avec cette amélioration, vous obtenez quelque chose comme ceci:
Time = 0; creds = 100
Time = 1; ThreadA executes (1) and creates Txn1
Time = 2; ThreadB executes (1) and creates Txn2
Time = 3; ThreadA executes (2) with lock and fetches 100
Time = 4; ThreadB tries executes (2) but the row is locked and
it's has to wait...
Time = 5; ThreadA executes (3) and adds 100 + 50
Time = 6; ThreadA executes (4) and updates creds to 150
Time = 7; ThreadA executes (5) and commits the Txn1
Time = 8; ThreadB was waiting up to this point and now is able to execute (2)
with lock and fetches 150
Time = 9; ThreadB executes (3) and adds 150 + 50
Time = 10; ThreadB executes (4) and updates creds to 200
Time = 11; ThreadB executes (5) and commits the Txn2
Un verrouillage optimiste à l'aide d'une nouvelle colonne timestamp
peut résoudre ce problème de concurrence.
UPDATE credits SET creds = 150 WHERE userid = 1 and modified_data = old_modified_date
Pour le premier scénario, vous pouvez ajouter une autre condition dans la clause where pour vous assurer de ne pas écraser les modifications apportées par un utilisateur simultané. Par exemple.
update credits set creds= 150 where userid = 1 AND creds = 0;
Vous pouvez configurer un mécanisme de mise en file d'attente où les ajouts ou les soustractions d'une valeur de type de rang seraient mis en file d'attente pour un traitement périodique LIFO par une tâche. Si les informations en temps réel sur le "solde" d'un rang sont requis, cela ne conviendrait pas car le solde ne serait pas calculé tant que les entrées de file d'attente en attente ne seraient pas rapprochées, mais si c'est quelque chose qui ne nécessite pas de rapprochement immédiat, il pourrait servir.
Cela semble refléter, du moins à l'extérieur, comment des jeux comme l'ancienne série Panzer General gèrent les mouvements individuels. Le tour d'un joueur arrive, et ils déclarent leurs mouvements. Chaque mouvement à son tour est traité dans l'ordre, et il n'y a pas de conflits car chaque mouvement a sa place dans la file d'attente.
Le tableau peut être modifié comme ci-dessous, introduire une nouvelle version de champ pour gérer le verrouillage optimiste. C'est un moyen plus rentable et efficace d'obtenir de meilleures performances plutôt que d'utiliser des verrous au niveau de la base de données pour créer des crédits de table (int id, int creds, int user_id, int version);
sélectionnez creds, user_id, version parmi les crédits où user_id = 1;
supposons que cela renvoie creds = 100 et version = 1
mettre à jour les crédits définir creds = creds * 10, version = version + 1 où user_id = 1 et version = 1;
Assurez-vous toujours que quiconque possède le dernier numéro de version ne peut mettre à jour que cet enregistrement et que les écritures sales ne seront pas autorisées.
Il y a un point critique dans votre cas lorsque vous diminuez le champ de crédit actuel de l'utilisateur d'un montant demandé et s'il a diminué avec succès, vous effectuez d'autres opérations et le problème est en théorie, il peut y avoir de nombreuses demandes parallèles pour une opération de diminution lorsque, par exemple, l'utilisateur a 1 crédits en solde et avec 5 demandes parallèles de 1 crédit, il peut acheter 5 choses si la demande sera envoyée exactement en même temps et que vous vous retrouvez avec -4 crédits sur équilibre de l'utilisateur.
Pour éviter cela vous devez diminuer la valeur actuelle des crédits avec le montant demandé (dans notre exemple 1 crédit) et vérifiez également si la valeur actuelle moins le montant demandé est supérieure ou égale à zéro :
MISE À JOUR des crédits SET creds = creds-1 OERE creds-1> = et userid = 1
Cela garantira que l'utilisateur n'achètera jamais beaucoup de choses sous peu de crédits s'il dosera votre système.
Après cette requête, vous devez exécuter ROW_COUNT () qui indique si le crédit utilisateur actuel répond aux critères et la ligne a été mise à jour:
UPDATE credits SET creds = creds-1 WHERE creds-1>=0 and userid = 1
IF (ROW_COUNT()>0) THEN
--IF WE ARE HERE MEANS USER HAD SURELY ENOUGH CREDITS TO PURCHASE THINGS
END IF;
Une chose similaire dans un PHP peut être fait comme:
mysqli_query ("UPDATE credits SET creds = creds-$amount WHERE creds-$amount>=0 and userid = $user");
if (mysqli_affected_rows())
{
\\do good things here
}
Ici, nous n'avons utilisé ni SELECT ... FOR UPDATE ni TRANSACTION mais si vous mettez ce code dans transaction, assurez-vous que le niveau de transaction fournit toujours les données les plus récentes de la ligne (y compris celles d'autres transactions déjà validées). Vous pouvez également utiliser ROLLBACK si ROW_COUNT () = 0
Inconvénient du crédit WHERE - montant $> = 0 sans verrouillage de ligne:
Après la mise à jour, vous savez sûrement une chose que l'utilisateur avait un montant suffisant sur le solde créditeur même s'il essaie de pirater des crédits avec de nombreuses demandes, mais vous ne savez pas ce qui était crédit avant charge (mise à jour) et ce qui était crédit après charge (mise à jour).
Mise en garde:
N'utilisez pas cette stratégie à l'intérieur du niveau de transaction qui ne fournit pas les données de ligne les plus récentes.
N'utilisez pas cette stratégie si vous voulez savoir quelle était la valeur avant et après la mise à jour.
Essayez simplement de vous fier au fait que le crédit a été chargé avec succès sans descendre en dessous de zéro.
Si vous stockez un horodatage de dernière mise à jour avec l'enregistrement, lorsque vous lisez la valeur, lisez également l'horodatage. Lorsque vous allez mettre à jour l'enregistrement, vérifiez que l'horodatage correspond. Si quelqu'un venait derrière vous et se mettait à jour avant vous, les horodatages ne correspondraient pas.