web-dev-qa-db-fra.com

Gérer la même fonction exécutant et traitant les mêmes données en même temps

J'ai un système php qui permet au client d'acheter des choses (passer une commande) à partir de notre système en utilisant un portefeuille électronique (crédit en magasin).

voici l'exemple de base de données

**sales_order**
+--------+-------+----------+--------+--------------+-----------+
|order_id| price |product_id| status |already_refund|customer_id|
+--------+-------+----------+--------+--------------+-----------+
|   1    | 1000  |    1     |canceled|      1       |     2     |
|   2    | 2000  |    2     |pending |      0       |     2     |
|   3    | 3000  |    3     |complete|      0       |     1     | 
+--------+-------+----------+--------+--------------+-----------+

**ewallet**
+-----------+-------+
|customer_id|balance|
+-----------+-------+
|     1     | 43200 |
|     2     | 22500 |
|     3     | 78400 |
+-----------+-------+

la table sales_order contient la commande que le client a faite, la colonne déjà_refund est pour un indicateur qui a annulé la commande déjà remboursée.

J'exécute un cron toutes les 5 minutes pour vérifier si la commande avec le statut en attente peut être annulée et après cela, il peut rembourser l'argent au portefeuille électronique du client

function checkPendingOrders(){
   $orders = $this->orderCollection->filter(['status'=>'pending']);
   foreach($orders as $order){
     //check if order is ready to be canceled
     $isCanceled = $this->isCanceled($order->getId());
     if($isCanceled === false) continue;
     if($order->getAlreadyRefund() == '0'){ // check if already refund
       $order->setAlredyRefund('1')->save();
       $this->refund($order->getId()); //refund the money to customer ewallet
     }
     $order->setStatus('canceled')->save();
   }
}

Le problème que les 2 programmes cron différents peuvent traiter les mêmes données en même temps en utilisant cette fonction et cela rendra le processus de remboursement peut être appelé deux fois, de sorte que le client recevra un double montant de remboursement. Comment puis-je gérer ce genre de problème lorsqu'une 2 même fonction s'exécute en même temps pour traiter les mêmes données? la clause if que j'ai créée ne peut pas gérer ce genre de problème

mise à jour

j'ai essayé d'utiliser le microtime en session comme validation et de verrouiller la ligne de la table dans MySQL, donc au début j'ai défini la variable pour contenir le microtime, que lorsque j'ai stocké dans une session unique générée par order_id, puis j'ajoute une condition pour faire correspondre la valeur du microtime à la session avant de verrouiller la ligne du tableau et de mettre à jour ma table ewallet

function checkPendingOrders(){
   $orders = $this->orderCollection->filter(['status'=>'pending']);
   foreach($orders as $order){
     //assign unique microtime to session
     $mt = round(microtime(true) * 1000);
     if(!isset($_SESSION['cancel'.$order->getId()])) $_SESSION['cancel'.$order->getId()] = $mt;
     //check if order is ready to be canceled
     $isCanceled = $this->isCanceled($order->getId());
     if($isCanceled === false) continue;
     if($order->getAlreadyRefund() == '0'){ // check if already refund
       $order->setAlreadyRefund('1')->save();
       //check if microtime is the same as the first one that running
       if($_SESSION['cancel'.$order->getId()] == $mt){
        //update using lock row
        $this->_dbConnection->beginTransaction(); 
        $sqlRaws[] =  "SELECT * FROM ewallet WHERE customer_id = ".$order->getCustomerId()." FOR UPDATE;";
        $sqlRaws[] =  "UPDATE ewallet SET balance =(balance+".$order->getPrice().") WHERE customer_id = ".$order->getCustomerId().";";
        foreach ($sqlRaws as $sqlRaw) {
          $this->_dbConnection->query($sqlRaw);
        }
        $this->_dbConnection->commit(); 

       }
     }
     unset($_SESSION['cancel'.$order->getId()]);
     $order->setStatus('canceled')->save();
   }
}

mais le problème persiste quand je fais un test strees, car il y a un cas où la même fonction traite les mêmes données au même microtime et démarre la transaction mysql au même moment exact

18
Hunter

@Rick James Answer est génial comme toujours, il ne vous a tout simplement pas dit quelles données vous devez verrouiller.

Laissez-moi d'abord commenter ce que vous avez dit

mais le problème persiste quand je fais un test strees,

Les applications sensibles à la concurrence ne sont pas testées par des tests de résistance uniquement parce que vous ne contrôlez pas ce qui va se passer et vous risquez d'être malchanceux et les résultats des tests dans le de bons résultats, alors que vous avez toujours un bug sournois dans votre application et croyez-moi, les bugs de concurrence sont les pires :( -

Vous devez ouvrir 2 clients (sessions DB) et simuler la condition de concurrence à la main, l'ouverture de 2 connexions dans MySQL Workbench est suffisante.

Faisons-le, ouvrons 2 connexions dans votre client (MySQL Workbench ou phpMyAdmin) et exécutons ces instructions dans cet ordre, considérez-les comme votre PHP exécuté en même temps).

**sales_order**
+--------+-------+----------+--------+--------------+-----------+
|order_id| price |product_id| status |already_refund|customer_id|
+--------+-------+----------+--------+--------------+-----------+
|   1    | 1000  |    1     |canceled|      1       |     2     |
|   2    | 2000  |    2     |pending |      0       |     2     |
|   3    | 3000  |    3     |complete|      0       |     1     | 
+--------+-------+----------+--------+--------------+-----------+


(SESSION 1) > select * from sales_order where status = 'pending';
-- result 1 row (order_id 2)
(SESSION 2) > select * from sales_order where status = 'pending';
-- result 1 row (order_id 2)
/*
 >> BUG: Both sessions are reading that order 2 is pending and already_refund is 0

 your session 1 script is going to see that this guy needs to cancel
 and his already_refund column is 0 so it will increase his wallet with 2000
*/
(SESSION 1) > update sales_order set  status = 'canceled' , already_refund = 1
              where  order_id = 2
(SESSION 1) > update ewallet set balance = balance + 2000 where customer_id = 2
/*
 same with your session 2 script : it is going to see that this guy needs
 to cancel and his already_refund column is 0 so it will increase his 
 wallet with 2000
*/
(SESSION 2) > update sales_order set  status = 'canceled' , already_refund = 1
              where  order_id = 2
(SESSION 2) > update ewallet set balance = balance + 2000 where customer_id = 2

Maintenant, le client 2 sera heureux à cause de cela, et c'est dans ce cas que vous avez posé la question (imaginez si 5 sessions pourraient lire la commande avant qu'elle ne soit already_refund est mis à jour à 1 par l'un d'eux, le client 2 sera super content car il obtient 5 * 2000)

moi: Maintenant, prenez votre temps et pensez à ce scénario, comment pensez-vous que vous pouvez vous protéger contre cela? ..?

vous: Verrouillage comme l'a dit @Rick

moi: exactement!

vous: ok, maintenant je vais verrouiller la table ewallet

moi: Non, vous devez verrouiller sales_order pour que SESSION 2 ne puisse pas lire les données tant que SESSION1 n'a pas terminé son travail, modifions maintenant le scénario en appliquant le verrou.

(SESSION 1) > START TRANSACTION;
-- MySQL > OK;
(SESSION 2) > START TRANSACTION;
-- MySQL > OK;
(SESSION 1) > select * from sales_order where status = 'pending' FOR UPDATE;
-- MySQL > OK result 1 row (order_id 2)
(SESSION 2) > select * from sales_order where status = 'pending' FOR UPDATE;
-- MySQL > WAAAAAAAAAAAAAAAIT ...... THE DATA IS LOCKED
/*
 now session 2 is waiting for the result of the select query .....

 and session 1 is going to see that this guy needs to cancel and his
 already_refund column is 0 so it will increase his  wallet with 2000
*/
(SESSION 1) > update sales_order set  status = 'canceled' , already_refund = 1
          where  order_id = 2
(SESSION 1) > update ewallet set balance = balance + 2000 where customer_id = 2;
(SESSION 2) >  :/  I am still waiting for the result of the select .....
(SESSION 1) > COMMIT;
-- MySQL > OK , now I will release the lock so any other session can read the data
-- MySQL > I will now execute the select statement of session 2
-- MySQL > the result of the select statement of session 2 is 0 rows
(SESSION 2) >  /* 0 rows ! no pending orders ! 
               Ok just end the transaction, there is nothing to do*/

Maintenant, vous êtes heureux, pas le client 2!

Note 1:

SELECT * from sales_order where status = 'pending' FOR UPDATE appliqué dans ce code peut ne pas verrouiller uniquement les commandes pending car il utilise une condition de recherche sur la colonne status et n'utilise pas d'index unique

Le MySQL manuel déclaré

Pour verrouiller les lectures (SELECT avec FOR UPDATE ou FOR SHARE), UPDATE et DELETE, les verrous qui sont pris dépendent du fait que l'instruction utilise un index unique avec une condition de recherche unique ou une condition de recherche de type plage.
.......

Pour les autres conditions de recherche et pour les index non uniques, InnoDB verrouille la plage d'index scannée ...

(et c'est l'une des choses que je déteste le plus à propos de MySQL. Je souhaite ne verrouiller que les lignes retournées par l'instruction select :()

Note 2

Je ne connais pas votre demande, mais si cette mission cron consiste uniquement à annuler les commandes en attente, alors débarrassez-vous-en et lancez simplement le processus d'annulation lorsque l'utilisateur annule sa commande.

De plus, si le already_refund la colonne est toujours mise à jour à 1 avec la colonne d'état est mise à jour à canceled puis "une commande annulée signifie qu'il est également remboursé" , et se débarrasser des already_refund colonne, données supplémentaires = travail supplémentaire et problèmes supplémentaires


Exemples de documentation de MySQL sur les lectures de verrouillage faites défiler jusqu'à "Exemples de lecture de verrouillage"

8
Accountant م

L'idée de microtime ajoutera de la complexité à votre code. La $order->getAlreadyRefund() pourrait obtenir une valeur de la mémoire, ce n'est donc pas une source fiable de vérité.

Cependant, vous pouvez compter sur une seule mise à jour avec les conditions qu'elle ne se met à jour que si le statut est toujours "en attente" et que déjà_refund est toujours 0. Vous aurez une instruction SQL comme celle-ci:

UPDATE
  sales_order
SET
  status = 'canceled',
  already_refund = %d
where
  order_id = 1
  and status = 'pending'
  and already_refund = 0;

Vous avez juste besoin d'écrire une méthode pour votre modèle qui exécutera le SQL ci-dessus appelé setCancelRefund() et vous pourriez avoir quelque chose de plus simple comme ceci:

<?php

function checkPendingOrders() {
   $orders = $this->orderCollection->filter(['status'=>'pending']);

   foreach($orders as $order) {
     //check if order is ready to be canceled
     $isCanceled = $this->isCanceled($order->getId());
     if ($isCanceled === false) {
        continue;
     }

     if ($order->getAlreadyRefund() == '0') { // check if already refund

        // Your new method should do the following
        // UPDATE sales_order SET status = 'canceled', already_refund = 1 where order_id = %d and status = 'pending' and already_refund = 0; 
        $affected_rows = $order->setCancelRefund();        

        if ($affected_rows == 0) {
            continue;
        }

        $this->refund($order->getId()); //refund the money to customer ewallet
     }

   }
}
7
jasonwubz

Si les tableaux ne sont pas déjà ENGINE=InnoDB, basculez les tables vers InnoDB. Voir http://mysql.rjweb.org/doc.php/myisam2innodb

Enveloppez toute séquence d'opérations qui doit être "atomique" dans une "transaction":

START TRANSACTION;
...
COMMIT;

Si vous avez pris en charge SELECTs dans la transaction, ajoutez FOR UPDATE:

SELECT ... FOR UPDATE;

cela bloque les autres connexions.

Recherchez les erreurs après chaque instruction SQL. Si vous obtenez un "blocage" de "délai d'attente", recommencez la transaction.

Arrachez tout le "microtime", LOCK TABLES, etc.

L'exemple classique d'un "blocage" est lorsqu'une connexion prend deux lignes et une autre connexion prend les mêmes lignes, mais dans l'ordre opposé. L'une des transactions sera annulée par InnoDB, et tout ce qu'elle aura fait (dans la transaction) sera annulé.

Une autre chose qui peut se produire est lorsque les deux connexions récupèrent les mêmes lignes dans le même ordre. L'un continue de fonctionner jusqu'à la fin, tandis que l'autre est bloqué jusqu'à cette fin. Il y a un délai d'attente par défaut de 50 secondes généreuses avant qu'une erreur ne soit donnée. Normalement, les deux se terminent (l'un après l'autre) et vous n'êtes pas plus sage.

7
Rick James

Il existe une solution simple à ce problème. Utilisez une requête du formulaire UPDATE sales_order SET already_refund = 1 WHERE already_refund = 0 AND id = ? Le résultat de la mise à jour doit inclure le nombre de lignes affectées qui sera zéro ou un. Si c'est le cas, faites bien le portefeuille électronique sinon il a été mis à jour par un autre processus.

3
karmakaze

Voici une solution simple avec un fichier de verrouillage:

<?php

// semaphore read lock status
$file_sem = fopen( "sem.txt", "r" );
$str = fgets( $file_sem );
fclose( $file_sem );
$secs_last_mod_file = time() - filemtime( "sem.txt" );

// if ( in file lock value ) and ( difference in time between current time and time of file modifcation less than 600 seconds ),
// then it means the same process running in another thread
if( ( $str == "2" ) && ( $secs_last_mod_file < 600 ) )
{
    die( "\n" . "----die can't put lock in file" . "\n" );
}
// semaphore open lock
$file_sem = fopen( "sem.txt", "w" );
fputs( $file_sem, "2" );
fflush( $file_sem );
fclose( $file_sem );


// Put your code here


// semaphore close lock
$file_sem = fopen( "sem.txt", "w" );
fputs( $file_sem, "1" );
fclose( $file_sem );

?>

J'utilise cette solution dans mes sites.

2
Rufat

Si j'étais vous, j'en ferais un processus en deux étapes: au lieu d'avoir une colonne "déjà_réfinancé", j'aurais une colonne "remboursement_statut" et le travail cron changerait d'abord cette colonne en "to_refund" et ensuite, le suivant tâche cron du même type ou dans une tâche cron différente, lorsque le remboursement réel se produit, remplacez-le par "remboursé".

Je sais que vous pouvez peut-être accomplir cela en même temps, mais il est souvent préférable d'avoir un code/processus plus compréhensible même si cela peut prendre un peu plus de temps. Surtout quand vous avez affaire à de l'argent ...

2

En dehors de transactions comme réponse de Rick James montre.

Vous pouvez utiliser des règles de planification pour qu'un travail spécifique ne puisse être traité que par un seul travailleur.

Par exemple, le travail avec l'id pair prévu pour fonctionner 1, et avec l'id impair prévu pour fonctionner2.

2
Kris Roofe

Vous voudrez peut-être utiliser un Pidfile. Un Pidfile contient l'ID de processus d'un programme donné. Il y aura deux vérifications: premièrement, si le fichier lui-même existe et deuxièmement, si l'ID de processus dans le fichier est celui d'un processus en cours d'exécution.

<?php

class Mutex {

    function lock() {

        /**
         * $_SERVER['PHP_SELF'] returns the current script being executed.
         * Ff your php file is located at http://www.yourserver.com/script.php,
         * PHP_SELF will contain script.php
         *
         * /!\ Do note that depending on the distribution, /tmp/ content might be cleared
         * periodically!
         */
        $pidfile = '/tmp/' . basename($_SERVER['PHP_SELF']) . '.pid';
        if (file_exists($pidfile)) {
            $pid = file_get_contents($pidfile);
            /**
             * Signal 0 is used to check whether a process exists or not
             */
            $running = posix_kill($pid, 0);
            if ($running) {
                /**
                 * Process already running
                 */
                exit("process running"); // terminates script
            } else {
                /**
                 * Pidfile contains a pid of a process that isn't running, remove the file
                 */
                unlink($pidfile);
            }
        }
        $handle = fopen($pidfile, 'x'); // stream
        if (!$handle) {
            exit("File already exists or was not able to create it");
        }
        $pid = getmypid();
        fwrite($handle, $pid); // write process id of current process

        register_shutdown_function(array($this, 'unlock')); // runs on exit or when the script terminates

        return true;
    }

    function unlock() {
        $pidfile = '/tmp/' . basename($_SERVER['PHP_SELF']) . '.pid';
        if (file_exists($pidfile)) {
            unlink($pidfile);
        }
    }
}

Vous pouvez l'utiliser de cette façon:

$mutex = new Mutex();
$mutex->lock();
// do something
$mutex->unlock();

Donc, s'il y a deux processus cron simultanés (il doit s'agir du même fichier!), Si l'un a pris le verrou, l'autre se terminera.

2
jperl

Pour ce faire, vous devez utiliser mysql TRANSACTION et utiliser SELECT FOR UPDATE.
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html

Si vous utilisez PDO, votre fonction setAlredyRefund () peut ressembler à ceci:

function setAlredyRefund($orderID){
    try{
        $pdo->beginTransaction();

        $sql = "SELECT * FROM sales_order WHERE order_id = :order_id AND already_refund = 0 FOR UPDATE";
        $stmt = $pdo->prepare($sql);
        $stmt->bindParam(":orderID", $orderID, PDO::PARAM_INT);
        $stmt->execute();       

        $sql = "UPDATE sales_order SET already_refund = 1";
        $stmt = $pdo->prepare($sql);
        $stmt->execute();       

        $pdo->commit();

    } 

    catch(Exception $e){    
        echo $e->getMessage();    
        $pdo->rollBack();
    }
}
2
BCM