Обрабатывать запуск одной и той же функции и обрабатывать одни и те же данные одновременно
У меня есть система 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 минут для выполнения задачи.?
Я не знаю, какая часть вашего кода занимает больше всего времени, но я думаю, что это сам процесс возврата. В таком случае я бы сделал следующее:
- Выберите ограниченное количество заказов с помощью
status = 'pending'
- Немедленно обновите все выбранные заказы до чего-то вроде
status='refunding'
- Обработайте возврат и обновляйте соответствующий заказ до
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