处理同时运行并处理相同数据的相同功能

时间:2019-08-21 07:02:21

标签: php mysql concurrency race-condition

我有一个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包含客户所下的订单,“ reales_refund”列用于标记已取消订单的退款。

我每5分钟运行一次cron,以检查是否可以取消状态为待处理的订单,然后可以将其退还给客户电子钱包

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

使用此功能,两个不同的cron计划可以同时处理相同数据的问题,这将导致退款过程可以被调用两次,因此客户将获得双倍的退款金额。当两个相同的函数同时运行以处理相同的数据时,如何处理此类问题?我制定的if子句无法处理此类问题

更新

我曾尝试在会话中使用microtime作为验证并锁定MySQL中的表行,所以与存储在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事务

11 个答案:

答案 0 :(得分:8)

@Rick James Answer像往常一样很棒,他只是没有告诉您您需要锁定哪些数据。

首先,请允许我对您所说的话发表评论

  

但是当我进行strees测试时,问题仍然存在,

并发压力感知应用程序不会仅通过压力测试进行测试,这是因为您无法控制将要发生的情况,并且可能不幸,并且测试结果良好,而您仍然可以您的应用程序-中的偷偷摸摸的错误,请相信我,并发错误是最糟糕的:(--

您需要手动打开2个客户端(数据库会话)并模拟竞争状况,在MySQL工作台中打开2个连接就足够了。

让我们这样做,在客户端(MySQL Workbench或phpMyAdmin)中打开2个连接,并按此顺序执行这些语句,将它们视为同时运行的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时会非常高兴) < / p>

我:现在,花点时间思考一下这种情况,您认为如何保护自己免受这种情况的侵害? ..?

:按照@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!

注意1:

在此代码中应用的

SELECT * from sales_order where status = 'pending' FOR UPDATE可能不会仅锁定pending个订单,因为它使用status列上的搜索条件而不使用唯一索引

MySQL manual表示

  

用于锁定读取(使用FOR UPDATE进行选择或   FOR SHARE),UPDATE和DELETE语句,所采取的锁   取决于语句是否使用具有唯一性的唯一索引   搜索条件或范围类型的搜索条件。
  .......

     

对于其他搜索条件和非唯一索引,InnoDB锁定索引   扫描范围...

(这是我最不喜欢MySQL的事情之一。我希望只锁定select语句返回的行:()

Note2

我不了解您的应用程序,但是如果该cron任务只是取消待处理的订单,则将其删除,并在用户取消其订单时开始取消过程。

如果already_refund列始终被更新为1,而状态列也被更新为canceled,则“已取消的订单意味着他也将被退款“ ,并且摆脱了already_refund列,多余的数据=额外的工作和额外的问题


MySQL documentation examples of locking reads 向下滚动至“锁定阅读示例”

答案 1 :(得分:7)

微时间思想将增加代码的复杂性。 $order->getAlreadyRefund()可能正在从内存中获取值,因此它不是可靠的事实来源。

但是,您可以依赖单个更新,条件是仅在状态仍为“ pending”且has_refund仍为0时才更新。您将具有如下SQL语句:

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

您只需要为模型编写一个方法即可执行上述称为setCancelRefund()的SQL,您可能会遇到以下这样的简单情况:

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

   }
}

答案 2 :(得分:7)

如果表尚未ENGINE=InnoDB,请将表切换到InnoDB。参见http://mysql.rjweb.org/doc.php/myisam2innodb

在“交易”中包装需要“原子化”的任何操作序列:

START TRANSACTION;
...
COMMIT;

如果您在交易中支持SELECTs,请添加FOR UPDATE

SELECT ... FOR UPDATE;

这会阻止其他连接。

在每个SQL语句之后检查错误。如果遇到“等待超时”的“僵局”,请重新开始事务。

删除所有“微时间”,LOCK TABLES等。

一个典型的死锁示例是,一个连接抓住两行,而另一个连接抓住相同的行,但顺序相反。一笔交易将被InnoDB中止,并且它所做的任何事情(在交易内部)都将被撤消。

可能发生的另一件事是,两个连接都以相同顺序抓住相同的行。一个继续运行直到完成,而另一个被阻止直到完成。在给出错误之前,默认的超时时间是50秒。通常情况下,两者都会完成(一个接一个),而您都不是一个明智的选择。

答案 3 :(得分:3)

有一个解决此问题的简单方法。使用UPDATE sales_order SET already_refund = 1 WHERE already_refund = 0 AND id = ?格式的查询。更新的结果应包括受影响的行数,该数将为零或一。如果是ewallet,最好这样做,否则它将由另一个进程更新。

答案 4 :(得分:2)

您可能要使用Pidfile。 Pidfile包含给定程序的进程ID。将进行两项检查:首先,文件本身是否存在,其次,文件中的进程ID是否为正在运行的进程的ID。

<?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进程(必须是同一个文件!),则如果一个获得了锁,则另一个将终止。

答案 5 :(得分:2)

除了显示Rick James's answer之类的交易外。

您可以使用计划规则来使特定工作只能由一名工人处理。

例如,将偶数id安排为工作1且将奇数id安排为工作2的作业。

答案 6 :(得分:2)

为此,必须使用mysql TRANSACTION并使用SELECT FOR UPDATE。
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html

如果您使用的是PDO,则您的Function 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();
    }
}

答案 7 :(得分:2)

如果我是您,我将使其分为两个步骤:而不是使用“ already_refund”列,而使用“ refund_status”列,cron作业将首先将该列更改为“ to_refund”,然后,在下一个相同类型或不同cron作业中,当实际退款发生时,将其再次更改为“ refunded”。

我知道也许您可以同时完成此操作,但是很多时候最好具有更易理解的代码/过程,尽管这可能需要更多时间。尤其是在处理金钱时...

答案 8 :(得分:2)

这是带有一个锁定文件的简单解决方案:

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

?>

我在自己的网站上使用此解决方案。

答案 9 :(得分:-1)

如果我理解,当您说“ 2个不同的cron时间表可以同时处理相同的数据”时,您的说法是:如果第一个实例花费5分钟以上的时间,则脚本的2个实例可以同时运行完成任务?

我不知道您代码的哪一部分花费最多的时间,但是我想这本身就是退款过程。在这种情况下,我要做的是:

  1. 通过status = 'pending'选择数量有限的订单
  2. 立即将所有选定订单更新为status='refunding'
  3. 处理退款并在每次退款后将相应的订单更新为status='cancelled'

这样,如果启动另一个cron作业,它将选择一组完全不同的待处理订单进行处理。

答案 10 :(得分:-1)

这是OS中的常见现象,为此Mutex引入了。通过使用互斥锁,您可以同时停止写操作。将Mutex与if条件一起使用,以避免重复退款。

要了解详细信息,请点击以下两个链接:

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

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