web-dev-qa-db-ja.com

実行中の同じ関数を処理し、同じデータを同時に処理する

私は、システムから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を実行して、ステータスが保留中の注文をキャンセルできるかどうかを確認します。その後、お客様のeウォレットに返金できます。

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句は、この種の問題を処理できません

更新

セッションでマイクロタイムを検証として使用し、MySQLのテーブル行をロックしようとしたので、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テストを実行しても問題は解決しません

18
Hunter

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

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

けれども、私がストレステストをしているとき、問題はまだ続きます、

同時実行対応アプリケーションは、何が起こるかを制御しておらず、unluckyであり、テスト結果が良い結果ですが、アプリケーションに卑劣なバグがまだあります-そして、同時実行性のバグは最悪です:(-

2つのクライアント(DBセッション)を開き、手動で競合状態をシミュレートする必要があります。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人によって1に更新され、顧客2は5 * 2000

me:では、時間をかけてこのシナリオを考えてみてください。これから自分をどのように保護できると思いますか? ..?

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

me:まさに!

あなた:わかりました、今度は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このコードに適用すると、pending列で検索条件を使用し、一意のインデックスを使用しないため、status注文のみがロックされない場合があります

MySQL マニュアル 記載

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

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

(そしてこれは、MySQLについて私が嫌うほとんどのことの1つです。selectステートメントによって返された行だけをロックしたいのですが:()

注2

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

また、already_refund列は常に1に更新され、ステータス列はcanceledに更新されます 「キャンセルされた注文は、彼も払い戻されることを意味します」、そしてalready_refund列、追加のデータ=追加の作業と追加の問題


MySQLドキュメントの読み取りロックの例スクロールして「読み取りロックの例」

8
Accountant م

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

ただし、ステータスがまだ「保留中」で、すでにalready_refundが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
     }

   }
}
_
7
jasonwubz

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

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

START TRANSACTION;
...
COMMIT;

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

SELECT ... FOR UPDATE;

これは他の接続をブロックします。

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

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

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

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

7
Rick James

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

3
karmakaze

以下は、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 );

?>

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

2
Rufat

私があなただったら、それを2段階のプロセスにします。「already_refund」列を作成する代わりに、「refund_status」列を作成し、cronジョブが最初にこの列を「to_refund」に変更してから、次の手順で同じタイプのcronジョブまたは別のcronジョブで、実際の払い戻しが発生した場合は、再度「refunded」に変更します。

あなたはこれを同時に達成できるかもしれませんが、少し時間がかかるかもしれませんが、多くの場合、より包括的なコード/プロセスを持っている方が良いことを知っています。特にあなたがお金を扱っているとき...

Rick Jamesの回答 のようなトランザクションとは別に表示されます。

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

たとえば、偶数IDが動作するようにスケジュールされたジョブ1と、奇数IDが動作するようにスケジュールされたジョブ2です。

2
Kris Roofe

Pidfileを使用することもできます。 Pidfileには、特定のプログラムのプロセスIDが含まれています。 2つのチェックがあります。1つ目はファイル自体が存在するかどうか、もう1つはファイル内のプロセス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();

したがって、2つの並行cronプロセスがある場合(同じファイルでなければなりません!)、一方がロックを取得すると、もう一方は終了します。

2
jperl

そのためには、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();
    }
}
2
BCM