Mysql
 sql >> Base de données >  >> RDS >> Mysql

Duplication de transaction PHP PDO

Faisant écho au commentaire de @GarryWelding :la mise à jour de la base de données n'est pas un endroit approprié dans le code pour gérer le cas d'utilisation décrit. Verrouiller une ligne dans la table des utilisateurs n'est pas la bonne solution.

Reculez d'un pas. Il semble que nous voulions un contrôle précis sur les achats des utilisateurs. Il semble que nous ayons besoin d'un endroit pour stocker un enregistrement des achats des utilisateurs, et ensuite nous pourrons vérifier cela.

Sans plonger dans la conception d'une base de données, je vais lancer quelques idées ici...

En plus de l'entité "utilisateur"

user
   username
   account_balance

Il semble que nous soyons intéressés par certaines informations sur les achats effectués par un utilisateur. Je lance quelques idées sur les informations/attributs qui pourraient nous intéresser, sans prétendre qu'ils sont tous nécessaires pour votre cas d'utilisation :

user_purchase
   username that made the purchase
   items/services purchased
   datetime the purchase was originated
   money_amount of the purchase
   computer/session the purchase was made from
   status (completed, rejected, ...)
   reason (e.g. purchase is rejected, "insufficient funds", "duplicate item"

Nous ne voulons pas essayer de suivre toutes ces informations dans le "solde du compte" d'un utilisateur, d'autant plus qu'il peut y avoir plusieurs achats d'un utilisateur.

Si notre cas d'utilisation est beaucoup plus simple que cela et que nous ne voulons garder une trace que de l'achat le plus récent d'un utilisateur, nous pourrions alors l'enregistrer dans l'entité utilisateur.

user
  username 
  account_balance ("money")
  most_recent_purchase
     _datetime
     _item_service
     _amount ("money")
     _from_computer/session

Et ensuite, à chaque achat, nous pourrions enregistrer le nouveau account_balance et écraser les informations précédentes sur "l'achat le plus récent"

Si tout ce qui nous intéresse, c'est d'empêcher plusieurs achats "en même temps", nous devons définir cela... cela signifie-t-il dans la même microseconde exacte ? en moins de 10 millisecondes ?

Voulons-nous seulement empêcher les achats « dupliqués » à partir de différents ordinateurs/sessions ? Qu'en est-il de deux demandes en double sur la même session ?

Ce n'est pas comment je résoudrais le problème. Mais pour répondre à la question que vous avez posée, si nous partons d'un cas d'utilisation simple - "empêcher deux achats à moins d'une milliseconde l'un de l'autre", et nous voulons le faire dans une UPDATE de user tableau

Étant donné une définition de table comme celle-ci :

user
  username                 datatype    NOT NULL PRIMARY KEY 
  account_balance          datatype    NOT NULL
  most_recent_purchase_dt  DATETIME(6) NOT NULL COMMENT 'most recent purchase dt)

avec la date et l'heure (à la microseconde près) du dernier achat enregistré dans la table des utilisateurs (en utilisant l'heure renvoyée par la base de données)

UPDATE user u
   SET u.most_recent_purchase_dt = NOW(6) 
     , u.account_balance  = u.account_balance - :money1
 WHERE u.username         = :user
   AND u.account_balance >= :money2
   AND NOT ( u.most_recent_purchase_dt >= NOW(6) + INTERVAL -1000 MICROSECOND
         AND u.most_recent_purchase_dt <  NOW(6) + INTERVAL +1001 MICROSECOND 
           )

On peut alors détecter le nombre de lignes concernées par l'instruction.

Si aucune ligne n'est affectée, alors soit :user n'a pas été trouvé, ou :money2 était supérieur au solde du compte, ou most_recent_purchase_dt était dans une plage de +/- 1 milliseconde de maintenant. Nous ne pouvons pas dire lequel.

Si plus de zéro ligne est affectée, nous savons qu'une mise à jour a eu lieu.

MODIFIER

Pour souligner quelques points clés qui auraient pu être négligés...

L'exemple SQL attend la prise en charge des fractions de seconde, ce qui nécessite MySQL 5.7 ou version ultérieure. Dans 5.6 et versions antérieures, la résolution DATETIME n'était qu'à la seconde près. (Notez la définition de la colonne dans l'exemple de table et SQL spécifie la résolution jusqu'à la microseconde... DATETIME(6) et NOW(6) .

L'exemple d'instruction SQL attend username être la CLÉ PRIMAIRE ou une clé UNIQUE dans le user table. Ceci est noté (mais pas mis en évidence) dans l'exemple de définition de table.

L'exemple d'instruction SQL remplace la mise à jour de user pour deux instructions exécutées en une milliseconde les uns des autres. Pour les tests, remplacez cette résolution en millisecondes par un intervalle plus long. par exemple, changez-le en une minute.

C'est-à-dire, changez les deux occurrences de 1000 MICROSECOND à 60 SECOND .

Quelques autres remarques :utilisez bindValue à la place de bindParam (puisque nous fournissons des valeurs à l'instruction, nous ne renvoyons pas les valeurs de l'instruction.

Assurez-vous également que PDO est configuré pour lancer une exception lorsqu'une erreur se produit (si nous n'allons pas vérifier le retour des fonctions PDO dans le code) afin que le code ne mette pas son petit doigt (figuratif) au coin de notre bouche Dr.Evil style "Je suppose juste que tout se passera comme prévu. Quoi?")

# enable PDO exceptions
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$sql = "
UPDATE user u
   SET u.most_recent_purchase_dt = NOW(6) 
     , u.account_balance  = u.account_balance - :money1
 WHERE u.username         = :user
   AND u.account_balance >= :money2
   AND NOT ( u.most_recent_purchase_dt >= NOW(6) + INTERVAL -60 SECOND
         AND u.most_recent_purchase_dt <  NOW(6) + INTERVAL +60 SECOND
           )";

$sth = $dbh->prepare($sql)
$sth->bindValue(':money1', $amount, PDO::PARAM_STR);
$sth->bindValue(':money2', $amount, PDO::PARAM_STR);
$sth->bindValue(':user', $user, PDO::PARAM_STR);
$sth->execute(); 

# check if row was updated, and take appropriate action
$nrows = $sth->rowCount();
if( $nrows > 0 ) {
   // row was updated, purchase successful
} else {
   // row was not updated, purchase unsuccessful
}

Et pour souligner un point que j'ai fait plus tôt, "verrouiller la ligne" n'est pas la bonne approche pour résoudre le problème. Et faire la vérification comme je l'ai démontré dans l'exemple, ne nous dit pas la raison pour laquelle l'achat a échoué (fonds insuffisants ou dans le délai spécifié de l'achat précédent.)