php - Manejar la misma función ejecutando y procesando los mismos datos al mismo tiempo
mysql concurrency (11)
@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"
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
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.
Aquí hay una solución simple con un archivo de bloqueo:
<?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 );
?>
Yo uso esta solución en mis sitios.
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á.
Este es el fenómeno común en el sistema operativo, para esto Mutex ha introducido. Al usar el bloqueo Mutex, puede detener la operación de escritura al mismo tiempo. Use el Mutex junto con su condición if para evitar el reembolso por duplicación.
Para la comprensión detallada siga estos 2 enlaces:
https://www.php.net/manual/en/mutex.lock.php
https://paulcourt.co.uk/article/cross-server-locking-with-mysql-php
Hay una solución simple a este problema.
Utilice una consulta del formulario
UPDATE sales_order SET already_refund = 1 WHERE already_refund = 0 AND id = ?
El resultado de la actualización debe incluir el número de filas afectadas que será cero o uno.
Si es uno, genial hacer el monedero electrónico, de lo contrario fue actualizado por otro proceso.
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 entiendo, cuando dices "2 horarios cron diferentes pueden procesar los mismos datos al mismo tiempo", dices que 2 instancias del script pueden ejecutarse al mismo tiempo si la primera instancia tarda más de 5 minutos en completar la tarea ?
No sé qué parte de su código lleva más tiempo, pero supongo que es el proceso de reembolso en sí. Lo que haría en un caso como este es:
-
Seleccione un número limitado de pedidos con
status = ''pending''
-
Actualice inmediatamente todos los pedidos seleccionados a algo como
status=''refunding''
-
Procese los reembolsos y actualice el pedido correspondiente al
status=''cancelled''
después de cada reembolso.
De esta manera, si se inicia otro trabajo cron, seleccionará un conjunto completamente diferente de órdenes pendientes para procesar.
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.