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

У меня есть система 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 с сеансом перед блокировкой строки таблицы и обновлением моей таблицы электронных кошельков

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 в одно и то же время

Ответы

Ответ 1

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

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

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

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

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

Давайте сделаем это, откройте 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, то клиент 2 будет очень доволен, получив 5 * 2000)

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

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

я: точно!

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

me: Нет, вам нужно заблокировать sales_order, чтобы СЕССИЯ 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!

Примечание1

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

MySQL руководство заявил

Для блокировки чтения (ВЫБРАТЬ с помощью FOR UPDATE или Для операторов SHARE), UPDATE и DELETE - взятые блокировки зависит от того, использует ли оператор уникальный индекс с уникальным условие поиска или условие поиска типа диапазона.
.......

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

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

Примечание2

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

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


В документации по MySQL примеры блокировки чтения прокрутите вниз до "Блокировка чтения примеров"

Ответ 2

Идея 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
     }

   }
}

Ответ 3

Если таблицы еще не ENGINE=InnoDB, переключите таблицы на InnoDB. Смотрите http://mysql.rjweb.org/doc.php/myisam2innodb

Оберните любую последовательность операций, которая должна быть "атомарной", в "транзакцию":

START TRANSACTION;
...
COMMIT;

Если вы поддерживаете SELECTs в транзакции, добавьте FOR UPDATE:

SELECT ... FOR UPDATE;

это блокирует другие соединения.

Проверяйте ошибки после каждого оператора SQL. Если вы получили "тупик" "время ожидания", начните транзакцию заново.

Вычеркните все "микротайм", LOCK TABLES и т.д.

Классический пример "тупика" - это когда одно соединение захватывает два ряда, а другое - те же строки, но в обратном порядке. InnoDB прервет одну из транзакций, и все, что она сделала (внутри транзакции), будет отменено.

Еще одна вещь, которая может возникнуть, - когда оба соединения захватывают одинаковые строки в одном и том же порядке. Один продолжает работать до завершения, а другой блокируется до этого завершения. По умолчанию время ожидания составляет 50 секунд. Обычно оба идут к завершению (один за другим), и вы не мудрее.

Ответ 4

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

Ответ 5

Возможно, вы захотите использовать 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 (это должен быть один и тот же файл!), если один из них взял блокировку, другой завершится.

Ответ 6

Помимо транзакции, как показывает Рик Джеймс ответ.

Вы можете использовать правила расписания, чтобы сделать конкретную работу только для одного работника.

Например, задание с четным идентификатором запланировано на работу 1, а с нечетным идентификатором запланировано на работу2.

Ответ 7

Для этого вы должны использовать 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();
    }
}

Ответ 8

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

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

Ответ 9

Вот простое решение с одним файлом блокировки:

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

?>

Я использую это решение на своих сайтах.

Ответ 10

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

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

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

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

Ответ 11

Это обычное явление в ОС, для этого 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