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

Hun*_*ter 18 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 |
+-----------+-------+
Run Code Online (Sandbox Code Playgroud)

表格sales_order包含客户所下的订单,一栏“ aready_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();
   }
}
Run Code Online (Sandbox Code Playgroud)

问题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();
   }
}
Run Code Online (Sandbox Code Playgroud)

但是当我进行strees测试时,问题仍然存在,因为在某些情况下,相同的函数在相同的微时间处理相同的数据,并在相同的确切时间启动mysql事务

Acc*_*t م 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
Run Code Online (Sandbox Code Playgroud)

现在,客户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*/
Run Code Online (Sandbox Code Playgroud)

现在,您很高兴没有客户2!

注意1:

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

MySQL 手册

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

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

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

笔记2

我不了解您的应用程序,但是如果该Cron任务只是取消待处理的订单,请摆脱它,并在用户取消其订单时开始取消过程。

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


MySQL文档的锁定读取示例 向下滚动到“锁定读取示例”


小智 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;
Run Code Online (Sandbox Code Playgroud)

您只需要为模型编写一个方法即可执行上述调用的SQL setCancelRefund(),您可能会得到以下简化的代码:

UPDATE
  sales_order
SET
  status = 'canceled',
  already_refund = %d
where
  order_id = 1
  and status = 'pending'
  and already_refund = 0;
Run Code Online (Sandbox Code Playgroud)


Ric*_*mes 7

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

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

START TRANSACTION;
...
COMMIT;
Run Code Online (Sandbox Code Playgroud)

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

SELECT ... FOR UPDATE;
Run Code Online (Sandbox Code Playgroud)

这会阻止其他连接。

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

删除所有“微时间” LOCK TABLES

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

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


Fra*_*ppé -1

如果我理解,当你说“2个不同的 cron 计划可以同时处理相同的数据”时,你的意思是如果第一个实例需要超过 5 分钟才能完成任务,则脚本的 2 个实例可以同时运行?

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

  1. 选择有限数量的订单status = 'pending'
  2. 立即将所有选定的订单更新为类似的内容status='refunding'
  3. 处理退款并更新相应订单至status='cancelled'

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