php - Manejar la misma función ejecutando y procesando los mismos datos al mismo tiempo




mysql concurrency (8)

Tengo un sistema php que permite a los clientes comprar cosas (hacer un pedido) desde nuestro sistema utilizando la billetera electrónica (crédito de la tienda).

aquí está el ejemplo de la base de datos

**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 tabla sales_order contiene el pedido realizado por el cliente, la columna ya_refund corresponde a un indicador que canceló el pedido ya reembolsado.

Ejecuto un cron cada 5 minutos para verificar si el pedido con estado pendiente puede cancelarse y luego puede reembolsar el dinero al monedero electrónico del cliente

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();
   }
}

El problema de que el cronograma de 2 cron diferentes puede procesar los mismos datos al mismo tiempo usando esta función y hará que el proceso de reembolso se pueda llamar dos veces, por lo que el cliente recibirá un monto de reembolso doble. ¿Cómo puedo manejar este tipo de problema, cuando una misma función se ejecuta al mismo tiempo para procesar los mismos datos? la cláusula if que hice no puede manejar este tipo de problema

actualizar

Intenté usar microtime en la sesión como validación y bloquear la fila de la tabla en MySQL, por lo que al principio configuré la variable para contener el microtime, que cuando almacené en una sesión única generada por order_id , y luego agregué una condición para hacer coincidir el valor de microtiempo con la sesión antes de bloquear la fila de la tabla y actualizar mi tabla de billetera electrónica

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();
   }
}

pero el problema aún persiste cuando estoy haciendo una prueba de strees, porque hay un caso en el que la misma función procesa los mismos datos al mismo tiempo y comienza la transacción mysql al mismo tiempo exacto


@Rick James Answer es genial como siempre, simplemente no te dijo qué datos necesitas bloquear.

Primero déjame comentar lo que dijiste

pero el problema persiste cuando estoy haciendo una prueba de strees,

Las aplicaciones que reconocen la concurrencia no se prueban mediante pruebas de estrés solo porque no está controlando lo que va a suceder y puede que tenga mala suerte y que la prueba dé buenos resultados, mientras que todavía tiene un error furtivo en su aplicación , y confíe en mí concurrencia los errores son los peores :( -

Necesita abrir 2 clientes (sesiones de DB) y simular la condición de carrera con su mano, abrir 2 conexiones en MySQL workbench es suficiente.

Hagámoslo, abra 2 conexiones en su cliente (MySQL Workbench o phpMyAdmin) y ejecute estas declaraciones en este orden, piense en ellas como su script PHP que se ejecuta al mismo tiempo.

**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

Ahora el cliente 2 estará contento por esto, y este caso es lo que hizo la pregunta (imagínese si 5 sesiones pudieran leer el pedido antes de que ya se haya already_refund a 1 por uno de ellos, el cliente 2 estará súper contento ya que él está recibiendo 5 * 2000 )

yo: Ahora tómate tu tiempo y piensa en este escenario, ¿cómo crees que puedes protegerte de esto? ..?

usted: Bloqueando como dijo @Rick

yo: exactamente!

tu: ok, ahora voy a cerrar la mesa del ewallet

yo: Noo, debes bloquear sales_order para que SESSION 2 no pueda leer los datos hasta que SESSION1 termine su trabajo, ahora cambiemos el escenario aplicando el bloqueo.

(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*/

¡Ahora eres feliz, no el cliente 2!

Nota 1:

SELECT * from sales_order where status = 'pending' FOR UPDATE aplicado en este código podría no bloquear solo pending pedidos pending ya que usa una condición de búsqueda en la columna de status y no usa un índice único

El manual MySQL declaró

Para bloquear las lecturas (SELECCIONAR con FOR UPDATE o FOR SHARE), UPDATE y DELETE, los bloqueos que se toman dependen de si la instrucción usa un índice único con una condición de búsqueda única o una condición de búsqueda de tipo rango.
.......

Para otras condiciones de búsqueda y para índices no únicos, InnoDB bloquea el rango de índice escaneado ...

(y esta es una de las cosas que más odio de MySQL. Deseo bloquear solo las filas devueltas por la instrucción select :()

Nota 2

No sé acerca de su aplicación, pero si esta misión cron es solo para cancelar las órdenes pendientes, deshágase de ella y simplemente comience el proceso de cancelación cuando el usuario cancele su orden.

Además, si la columna already_refund siempre se actualiza a 1 junto con la columna de estado se actualiza a canceled , "un pedido cancelado significa que también se reembolsa" , y elimine la columna already_refund , datos adicionales = trabajo adicional y problemas adicionales

La documentación de MySQL ejemplos de lecturas de bloqueo se desplaza hacia abajo hasta "Ejemplos de lectura de bloqueo"


Aparte de la transacción como muestra la respuesta de Rick James .

Puede usar reglas de programación para hacer que un trabajo específico solo pueda ser procesado por un trabajador.

Por ejemplo, el trabajo con ID par programado para trabajar 1 y con ID impar programado para trabajar 2.


Es posible que desee utilizar un Pidfile. Un Pidfile contiene la identificación del proceso de un programa dado. Habrá dos comprobaciones: en primer lugar, si el archivo en sí existe y, en segundo lugar, si la identificación del proceso en el archivo es la de un proceso en ejecución.

<?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);
        }
    }
}

Puedes usarlo de esta manera:

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

Entonces, si hay dos procesos cron simultáneos (¡tiene que ser el mismo archivo!), Si uno tomó el bloqueo, el otro terminará.



La idea de microtiempo agregará complejidad a su código. El $order->getAlreadyRefund() podría estar obteniendo un valor de la memoria, por lo que no es una fuente confiable de verdad.

Sin embargo, puede confiar en una única actualización con las condiciones de que solo se actualice si el estado aún está 'pendiente' y ya está el reintegro 0. También tendrá una instrucción SQL como esta:

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

Solo necesita escribir un método para su modelo que ejecute el SQL anterior llamado setCancelRefund() y podría tener algo más simple como esto:

<?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
     }

   }
}

Para ello, debe usar mysql TRANSACTION y usar SELECT FOR UPDATE.
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html

Si está utilizando PDO, su función setAlredyRefund () puede verse así:

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();
    }
}

Si fuera usted, lo convertiría en un proceso de dos pasos: en lugar de tener una columna "already_refund", tendría una columna "refund_status" y el trabajo cron primero cambiaría esta columna a "to_refund" y luego, en el siguiente trabajo cron del mismo tipo o en un trabajo cron diferente, cuando ocurra el reembolso real, cámbielo nuevamente a "reembolsado".

Sé que tal vez puedas lograr esto al mismo tiempo, pero muchas veces es mejor tener un código / proceso más comprensible, aunque puede llevar un poco más de tiempo. Especialmente cuando se trata de dinero ...


Si las tablas aún no son ENGINE=InnoDB , cambie las tablas a InnoDB. Ver http://mysql.rjweb.org/doc.php/myisam2innodb

Envuelva cualquier secuencia de operaciones que necesite ser 'atómica' en una "transacción":

START TRANSACTION;
...
COMMIT;

Si tiene SELECTs apoyo en la transacción, agregue FOR UPDATE :

SELECT ... FOR UPDATE;

Esto bloquea otras conexiones.

Verifique los errores después de cada declaración SQL. Si obtiene un "punto muerto" de "tiempo de espera", comience de nuevo la transacción.

Extraiga todos los "microtime", LOCK TABLES , etc.

El ejemplo clásico de un "punto muerto" es cuando una conexión toma dos filas y otra conexión toma las mismas filas, pero en el orden opuesto. InnoDB cancelará una de las transacciones y se deshará todo lo que haya hecho (dentro de la transacción).

Otra cosa que puede ocurrir es cuando ambas conexiones toman las mismas filas en el mismo orden. Uno continúa ejecutándose hasta su finalización, mientras que el otro está bloqueado hasta esa finalización. Hay un tiempo de espera predeterminado de 50 segundos generosos antes de que se produzca un error. Normalmente, ambos se completan (uno tras otro) y usted no es más sabio.





race-condition