php - Обрабатывать запуск одной и той же функции и обрабатывать одни и те же данные одновременно




mysql concurrency (8)

У меня есть система php, которая позволяет покупателям покупать вещи (делать заказы) в нашей системе с помощью электронного кошелька (кредит магазина).

вот пример базы данных

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

Таблица sales_order содержит заказ, сделанный клиентом, столбецready_refund предназначен для флага, который отменил заказ, уже возвращенный.

Я запускаю cron каждые 5 минут, чтобы проверить, можно ли отменить заказ с ожидающим статусом, и после этого он может вернуть деньги на электронный кошелек клиента.

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

Проблема в том, что 2 разных расписания cron могут обрабатывать одни и те же данные в одно и то же время, используя эту функцию, и это приведет к тому, что процесс возврата может быть вызван дважды, поэтому клиент получит двойную сумму возврата. Как я могу справиться с такой проблемой, когда одновременно работают две одинаковые функции для обработки одних и тех же данных? пункт if , который я сделал, не может справиться с этим видом проблемы

Обновить

я попытался использовать microtime в сеансе в качестве проверки и заблокировать строку таблицы в MySQL, поэтому вначале я установил переменную для хранения microtime, чем когда я сохранял в уникальном сеансе, созданном order_id , а затем я добавил условие сопоставить значение microtime с сеансом перед блокировкой строки таблицы и обновить мою таблицу 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();
   }
}

но проблема все еще сохраняется, когда я делаю тест strees, потому что есть случай, когда одна и та же функция обрабатывает одни и те же данные в одно и то же время и запускает транзакцию mysql в одно и то же время


@ Рик Джеймс: Ответ великолепен, как всегда, он просто не сказал вам, какие данные вам нужно заблокировать.

Сначала позвольте мне прокомментировать то, что вы сказали

но проблема все еще сохраняется, когда я делаю тест на стри,

Приложения с поддержкой параллелизма не тестируются стресс-тестами только потому, что вы не контролируете то, что должно произойти, и вам может быть не повезло, и результаты теста дают хорошие результаты, в то время как у вас все еще есть скрытая ошибка в вашем приложении - и поверьте мне, параллелизм ошибки самые худшие :( -

Вам нужно открыть 2 клиента (сеансы БД) и вручную смоделировать состояние гонки, достаточно открыть 2 соединения в MySQL Workbench.

Давайте сделаем это, откроем 2 соединения в вашем клиенте (MySQL Workbench или phpMyAdmin) и выполним эти операторы в таком порядке, представьте их как выполняемый одновременно PHP-скрипт.

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

Теперь клиент 2 будет счастлив из-за этого, и в этом случае вы задали вопрос (представьте, что если 5 сессий могли прочитать заказ до того, как он уже already_refund обновлен до 1, то клиент 2 будет очень счастлив, так как он становится 5 * 2000 )

Я: Теперь не торопитесь и подумайте об этом сценарии, как вы думаете, вы можете защитить себя от этого? ..?

Вы: Блокировка, как сказал @Rick

я: точно!

Вы: хорошо, теперь я пойду и заблокирую таблицу электронных ewallet

Я: Нет, вам нужно заблокировать sales_order чтобы SESSION 2 не могла прочитать данные, пока SESSION1 не закончит свою работу, теперь давайте изменим сценарий, применив блокировку.

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

Теперь вы счастливы, а не клиент 2!

Note1:

SELECT * from sales_order where status = 'pending' FOR UPDATE примененный в этом коде, может не блокировать только pending ордера, поскольку он использует условие поиска в столбце status и не использует уникальный индекс

В manual MySQL указано

Для блокировок операций чтения (SELECT с операторами FOR UPDATE или FOR SHARE), UPDATE и DELETE устанавливаемые блокировки зависят от того, использует ли инструкция уникальный индекс с уникальным условием поиска, или условие поиска типа диапазона.
.......

Для других условий поиска и неуникальных индексов InnoDB блокирует сканированный диапазон индексов ...

(и это одна из тех вещей, которые я ненавижу в MySQL. Я бы хотел заблокировать только те строки, которые были возвращены оператором select :()

Заметка 2

Я не знаю о вашем приложении, но если эта миссия cron состоит только в отмене отложенных ордеров, то избавьтесь от него и просто начните процесс отмены, когда пользователь отменяет свой ордер.

Кроме того, если already_refund всегда обновляется до 1, а столбец состояния обновляется до canceled то «отмененный заказ означает, что он также был возвращен» , и избавьтесь от already_refund , дополнительные данные = дополнительная работа и дополнительные проблемы

В документации по MySQL примеры блокировки чтения прокручиваются вниз до «Примеры блокировки чтения»


Возможно, вы захотите использовать Pidfile. Pidfile содержит идентификатор процесса данной программы. Будет две проверки: во-первых, существует ли сам файл, и, во-вторых, если идентификатор процесса в файле совпадает с идентификатором запущенного процесса.

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

Вы можете использовать это так:

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

Итак, если есть два одновременных процесса cron (это должен быть один и тот же файл!), Если один из них взял блокировку, другой завершится.


Для этого вы должны использовать mysql TRANSACTION и использовать SELECT FOR UPDATE.
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html

Если вы используете PDO, ваша функция setAlredyRefund () может выглядеть примерно так:

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

Если бы я был вами, я бы сделал это в два этапа: вместо столбца "ready_refund "у меня был бы столбец" refund_status ", и задание cron сначала изменило бы этот столбец на" to_refund ", а затем на следующем Задание cron того же типа или в другом задании cron, когда происходит фактическое возмещение, измените его снова на «возмещение».

Я знаю, что, возможно, вы можете сделать это одновременно, но во многих случаях лучше иметь более понятный код / ​​процесс, даже если это может занять немного больше времени. Особенно, когда вы имеете дело с деньгами ...


Если я понимаю, когда вы говорите: «2 разных расписания cron могут обрабатывать одни и те же данные одновременно», вы говорите, что 2 экземпляра скрипта могут работать одновременно, если первый экземпляр занимает более 5 минут для выполнения задачи. ?

Я не знаю, какая часть вашего кода занимает больше всего времени, но я думаю, что это сам процесс возврата. Что бы я сделал в таком случае:

  1. Выберите ограниченное количество заказов со status = 'pending'
  2. Немедленно обновите все выбранные заказы до status='refunding'
  3. Обработайте возвраты и обновляйте соответствующий заказ до status='cancelled' после каждого возврата.

Таким образом, если будет запущено другое задание cron, он выберет для обработки совершенно другой набор отложенных ордеров.


Идея microtime добавит сложности вашему коду. $order->getAlreadyRefund() может получать значение из памяти, поэтому он не является надежным источником правды.

Однако вы можете положиться на одно обновление с условиями, которые оно обновляет, только если статус все еще «ожидает», и уже по-прежнему равен 0. У вас будет оператор SQL, подобный этому:

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

Вам просто нужно написать метод для вашей модели, который будет выполнять вышеупомянутый SQL с именем setCancelRefund() и у вас может быть что-то более простое:

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

   }
}

Существует простое решение этой проблемы. Используйте запрос в форме UPDATE sales_order SET already_refund = 1 WHERE already_refund = 0 AND id = ? Результат обновления должен включать количество затронутых строк, которое будет равно нулю или единице. Если он один, отлично работает ewallet, иначе он был обновлен другим процессом.


Это обычное явление в ОС, для этого Mutex ввел. Используя блокировку Mutex, вы можете одновременно остановить операцию записи. Используйте Mutex вместе с вашим условием if, чтобы избежать возврата дубликатов.

Для детального понимания следуйте этим 2 ссылкам:

https://www.php.net/manual/en/mutex.lock.php

https://paulcourt.co.uk/article/cross-server-locking-with-mysql-php







race-condition