php - 同じ機能を実行して同じデータを同時に処理する




mysql concurrency (8)

1つのロックファイルを使用した簡単なソリューションを次に示します。

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

?>

私は自分のサイトでこのソリューションを使用しています。

e-ウォレット(ストアクレジット)を使用して、システムから顧客が商品を購入(注文)できるようにする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には顧客が行った注文が含まれ、列already_refundは注文をキャンセルしたフラグがすでに返金されているためのものです。

5分ごとにcronを実行して、ステータスが保留中の注文をキャンセルできるかどうかを確認します。その後、顧客のewalletに返金できます

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スケジュールが同じデータを同時に処理でき、返金プロセスが2回呼び出される可能性があるため、顧客は2倍の返金金額を受け取ります。 同じデータを処理するために2つの同じ機能が同時に実行されている場合、この種の問題をどのように処理できますか? 私が作った if 句はこの種の問題を処理できません

更新

セッションで検証としてmicrotimeを使用し、MySQLでテーブル行をロックしようとしたため、最初に変数をmicrotimeを含むように設定し、order_idによって生成された一意のセッションに保存してから条件を追加しますテーブル行をロックして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();
   }
}

しかし、同じ関数が同じマイクロタイムで同じデータを処理し、同じ正確な時間にmysqlトランザクションを開始する場合があるため、streesテストを行っているときに問題が解決しません


@Rick James Answerはいつものように素晴らしいです。彼は、どのデータをロックする必要があるかを教えてくれませんでした。

まず、あなたの言ったことについてコメントさせてください

しかし、私はstreesテストをしているとき、問題はまだ持続します、

同時実行対応アプリケーションは、発生することを制御しておらず、アプリケーションに不正なバグがあるにもかかわらず、 不幸 でテスト結果が良好な結果になる可能性があるため、ストレステストによるテストは行われ ません。バグは最悪です:(-

2つのクライアント(DBセッション)を開き、手で競合状態をシミュレートする必要があります。MySQLワークベンチで2つの接続を開くだけで十分です。

クライアントで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が満足するようになり、このケースがあなたが質問したことです already_refund 5回のセッションで注文を読み取ることができた場合、 already_refund 1人に already_refund が更新されると、顧客2は非常に満足します) 5 * 2000 取得しています)

私: 今、あなたの時間をかけてこのシナリオを考えてください。これから自分を守ることができると思いますか? ..?

あなた: @Rickが言ったようにロック

私: まさに!

あなた: ewallet 、今私は行って ewallet テーブルをロックします

me: いいえ、 sales_order をロックして、SESSION1が作業を完了するまでSESSION 2がデータを読み取れないようにする必要があります。次に、ロックを適用してシナリオを変更しましょう。

(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 、このコード SELECT * from sales_order where status = 'pending' FOR UPDATE 適用すると、一意のインデックスを使用せずに status 列で検索条件を使用するため、 pending 注文のみがロックされない場合があります

MySQL manual は、

ロック読み取り(SELECT with FOR UPDATEまたはFOR SHARE)、UPDATE、およびDELETEステートメントの場合、取得されるロックは、ステートメントが一意の検索条件を持つ一意のインデックスを使用するか、範囲タイプの検索条件を使用するかによって異なります。
.......

他の検索条件、および一意でないインデックスの場合、InnoDBはスキャンされたインデックス範囲をロックします...

(そして、これはMySQLで嫌いなものの1つです。selectステートメントで返された行のみをロックしたいです:()

注2

アプリケーションについては知りませんが、このcronミッションが保留中の注文をキャンセルすることだけである場合、それを取り除き、ユーザーが注文をキャンセルしたときにキャンセルプロセスを開始するだけです。

また、ステータス列が canceled 更新されると共に、 already_refund 列が1に更新さ canceled 場合、 「キャンセルされた注文は彼も返金されます」 、および already_refund 列を already_refund ます。余分なデータ=余分な作業と余分な問題

ロック読み取りのMySQLドキュメント例は 、「ロック読み取り例」までスクロールダウンし ます


この問題には簡単な解決策があります。 UPDATE sales_order SET already_refund = 1 WHERE already_refund = 0 AND id = ? という形式のクエリを使用します 更新の結果には、影響を受ける行の数が含まれている必要があります。これは0または1です。 それが1つである場合は、ewalletを実行してください。



テーブルがまだ ENGINE=InnoDB でない場合、テーブルを ENGINE=InnoDB に切り替えます。 http://mysql.rjweb.org/doc.php/myisam2innodb 参照して http://mysql.rjweb.org/doc.php/myisam2innodb

「トランザクション」で「アトミック」である必要がある一連の操作をラップします。

START TRANSACTION;
...
COMMIT;

トランザクションで SELECTs をサポートしている場合は、 FOR UPDATE 追加します。

SELECT ... FOR UPDATE;

これにより、他の接続がブロックされます。

SQLステートメントごとにエラーを確認します。 「待機タイムアウト」の「デッドロック」が発生した場合、トランザクションを最初からやり直してください。

すべての「マイクロタイム」、 LOCK TABLES などを削除します。

「デッドロック」の古典的な例は、1つの接続が2つの行を取得し、別の接続が同じ行を取得しますが、順序は逆です。 トランザクションの1つはInnoDBによって中止され、(トランザクション内で)行った処理はすべて取り消されます。

発生する可能性がある別のことは、両方の接続が同じ順序で同じ行を取得する場合です。 一方は完了まで実行を続け、もう一方はその完了までブロックされます。 エラーが発生する前に、50秒のデフォルトのタイムアウトがあります。 通常、両方とも次々に完了し、あなたは賢明な人ではありません。


マイクロタイムのアイデアは、コードに複雑さを追加します。 $order->getAlreadyRefund() はメモリから値を取得している可能性があるため、信頼できる真実のソースではありません。

ただし、ステータスが「pending」で、already_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を実行するモデルのメソッドを作成するだけで、次のような 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
     }

   }
}

私が理解している場合、「2つの異なるcronスケジュールは同じデータを同時に処理できます」と言うとき、最初のインスタンスがタスクを完了するのに5分以上かかる場合、スクリプトの2つのインスタンスを同時に実行できると言います?

あなたのコードのどの部分が最も時間がかかるかわかりませんが、それは返金プロセスそのものだと思います。 このような場合に私がすることは:

  1. status = 'pending' 限られた数の注文を選択し status = 'pending'
  2. 選択したすべての注文をすぐに status='refunding' などのように更新します
  3. 払い戻しを処理し、払い戻しごとに対応する注文を status='cancelled' に更新し status='cancelled'

これにより、別のcronジョブが開始された場合、まったく異なる保留中の注文のセットが選択されて処理されます。


Rick Jamesの答えが 示すようなトランザクションは別として。

スケジュールルールを使用して、特定のジョブのみを1人のワーカーで処理できるようにします。

たとえば、偶数IDがwork 1にスケジュールされ、奇数idがwork2にスケジュールされたジョブ。





race-condition