並列プロセステスト

画像



本番環境で時々発生するエラーが発生しましたが、ローカルで再現されていませんか? このようなバグを調査すると、突然スクリプトが並行して実行された場合にのみ表示されることに気付くことがあります。 コードを学習したので、これが二度と起こらないように修正する方法を理解しました。 しかし、そのような修正のためには、テストを書くのがいいでしょう...



この記事では、このような状況をテストするための私のアプローチについて説明します。 また、このアプローチを使用してテストするのに便利なバグのいくつかの例示的な(そしておそらく古典的な)例も示します。 バグのすべての例はライブです-作品で見つかったもの。



今後、記事の最後にgithubへのリンクがあります。githubには、コンソールプロセスのテストを簡単かつ簡単に行える既製のソリューションを投稿します。



例1。 同じものを並行して追加する



チャレンジ。 データベース(PostgreSQL)を使用するアプリケーションがあり、サードパーティシステムからのデータのインポートを手配する必要があります。 account_import (id, external_id)



テーブルに外部システムとのaccount (id, name)



テーブルおよび識別子の関係があるとaccount_import (id, external_id)



ます。 簡単なメッセージ受信メカニズムの概要を見てみましょう。



メッセージを受信すると、まずデータベースにそのようなレコードがあるかどうかを確認します。 その場合、既存のものを更新します。 そうでない場合は、データベースに追加します。



 $data = json_decode($jsonInput, true); // '{"id":1,"name":"account1"}' try { $connection->beginTransaction(); // ,       $stmt = $connection->prepare("SELECT id FROM account_import WHERE external_id = :external_id"); $stmt->execute([ ':external_id' => $data['id'], ]); $row = $stmt->fetch(); usleep(100000); // 0.1 sec //      ,    if ($row) { $stmt = $connection->prepare("UPDATE account SET name = :name WHERE id = ( SELECT id FROM account_import WHERE external_id = :external_id )"); $stmt->execute([ ':name' => $data['name'], ':external_id' => $data['id'], ]); $accountId = $row['id']; } //     else { $stmt = $connection->prepare("INSERT INTO account (name) VALUES (:name)"); $stmt->execute([ ':name' => $data['name'], ]); $accountId = $connection->lastInsertId(); $stmt = $connection->prepare("INSERT INTO account_import (id, external_id) VALUES (:id, :external_id)"); $stmt->execute([ ':id' => $accountId, ':external_id' => $data['id'], ]); } $connection->commit(); } catch (\Throwable $e) { $connection->rollBack(); throw $e; }
      
      





一見、それはよさそうだ。 しかし、システム内のデータを厳密に連続して送信できない場合、問題が発生する可能性があります。 この例では、問題が確実に再現されるように、0.1秒の遅延が必要です。 同じデータを並行してインポートするとどうなりますか? おそらく、データを追加してから更新する代わりに、データを再挿入しようとするため、account_importで主キー違反エラーが発生します。



間違いを修正するには、まずそれを再現することをお勧めします。 そして最良のことは、エラーを再現するテストを書くことです。 このために、bashを使用してコマンドを非同期で実行することを決定し、このためのシンプルなスクリプトを作成しました。このスクリプトは、PHPと併用するだけでなく使用できます。



考え方は単純です。コマンドのいくつかのインスタンスをバックグラウンドで実行し、それらがすべて終了するまで待機して実行コードをチェックします。 実行コードにゼロ以外の値がある場合、バグが見つかりました。 簡略化された形式では、スクリプトは次のようになります。



 # ,    COMMAND=”echo -e '{\"id\":1,\"name\":\"account1\"}' | ./cli app:import” # PID-    pids=() #     results=() #      () expects=() #         stderr for i in $(seq 2) do eval $COMMAND 1>&2 & pids+=($!) ; echo -e '>>>' Process ${pids[i-1]} started 1>&2 done #         $results for pid in "${pids[@]}" do wait $pid results+=($?) expects+=(0) echo -e '<<<' Process $pid finished 1>&2 done #      result=`( IFS=$', '; echo "${results[*]}" )` expect=`( IFS=$', '; echo "${expects[*]}" )` if [ "$result" != "$expect" ] then exit 1 fi
      
      





スクリプトの完全版をgithub投稿しました



このコマンドに基づいて、PHPUnitに新しいアサーションを追加できます。 ここではすべてがすでに簡単になっているため、これについては詳しく説明しません。 前述のプロジェクトで実装されているとしか言えません。 それらを使用するには、AsyncTrait AsyncTrait



をテストに接続するだけです。



このようなテストを作成します。



 use App\Command\Initializer; use Mnvx\PProcess\AsyncTrait; use Mnvx\PProcess\Command\Command; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; class ImportCommandTest extends TestCase { use AsyncTrait; public function testImport() { $cli = Initializer::create(); $command = $cli->find('app:delete'); //   c external_id = 1, //           $commandTester = new CommandTester($command); $commandTester->execute([ 'externalId' => 1, ]); $asnycCommand = new Command( 'echo -e \'{"id":1,"name":"account1"}\' | ./cli app:import', //   dirname(__DIR__), // ,      2 //     ); //   $this->assertAsyncCommand($asnycCommand); } }
      
      





テストを実行した結果、この結論が得られました。



 $ ./vendor/bin/phpunit PHPUnit 6.1.1 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 230 ms, Memory: 6.00MB There was 1 failure: 1) ImportCommandTest::testImport Failed asserting that command echo -e '{"id":1,"name":"account1"}' | ./cli app:import (path: /var/www/pprocess-playground, count: 2) executed in parallel. Output: >>> Process 18143 started >>> Process 18144 started Account 25 imported correctly [Doctrine\DBAL\Exception\UniqueConstraintViolationException] An exception occurred while executing 'INSERT INTO account_import (id, exte rnal_id) VALUES (:id, :external_id)' with params ["26", 1]: SQLSTATE[23505]: Unique violation: 7 :       "account_import_pkey" DETAIL:  "(external_id)=(1)"  . ------- app:import <<< Process 18143 finished <<< Process 18144 finished . /var/www/pprocess-playground/vendor/mnvx/pprocess/src/AsyncTrait.php:19 /var/www/pprocess-playground/tests/ImportCommandTest.php:30 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
      
      





すでに説明した理由。 次に、スクリプトのフラグメントの並列実行の強制ブロックを追加してみましょう(ここではmalkusch / lockを使用します)。



 $mutex = new FlockMutex(fopen(__FILE__, 'r')); $mutex->synchronized(function () use ($connection, $data) { //     try });
      
      





テストに合格しました:



 $ ./vendor/bin/phpunit PHPUnit 6.1.1 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 361 ms, Memory: 6.00MB OK (1 test, 1 assertion)
      
      





誰かがそれを必要とするなら、私これと他の例をgithub投稿しました。



例2。 テーブル内のデータの準備



この例はもう少し興味深いものになります。 users (id, name)



ユーザーテーブルがあり、現在アクティブなユーザーのリストをusers_active (id)



テーブルに保存するとします。



チームを作成し、そのたびにusers_acitve



テーブルからすべてのエントリを削除し、そこにデータを再度追加します。



 try { $connection->beginTransaction(); $connection->prepare("DELETE FROM users_active")->execute(); usleep(100000); // 0.1 sec $connection->prepare("INSERT INTO users_active (id) VALUES (3), (5), (6), (10)")->execute(); $connection->commit(); $output->writeln('<info>users_active refreshed</info>'); } catch (\Throwable $e) { $connection->rollBack(); throw $e; }
      
      





すべては一見しただけで良いです。 実際、並行して実行すると、再びエラーが発生します。



再現するテストを作成します。



 use Mnvx\PProcess\AsyncTrait; use Mnvx\PProcess\Command\Command; use PHPUnit\Framework\TestCase; class DetectActiveUsersCommandTest extends TestCase { use AsyncTrait; public function testImport() { $asnycCommand = new Command( './cli app:detect-active-users', //   dirname(__DIR__), // ,      2 //     ); //   $this->assertAsyncCommand($asnycCommand); } }
      
      





テストを実行して、エラーテキストを確認します。



 $ ./vendor/bin/phpunit tests/DetectActiveUsersCommandTest.php PHPUnit 6.1.1 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 287 ms, Memory: 4.00MB There was 1 failure: 1) DetectActiveUsersCommandTest::testImport Failed asserting that command ./cli app:detect-active-users (path: /var/www/pprocess-playground, count: 2) executed in parallel. Output: >>> Process 24717 started >>> Process 24718 started users_active refreshed <<< Process 24717 finished [Doctrine\DBAL\Exception\UniqueConstraintViolationException] An exception occurred while executing 'INSERT INTO users_active (id) VALUES (3), (5), (6), (10)': SQLSTATE[23505]: Unique violation: 7 :       "users_active_pkey" DETAIL:  "(id)=(3)"  . ------- app:detect-active-users <<< Process 24718 finished . /var/www/pprocess-playground/vendor/mnvx/pprocess/src/AsyncTrait.php:19 /var/www/pprocess-playground/tests/DetectActiveUsersCommandTest.php:19 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
      
      





エラーのテキストから、INSERTが再び並列に実行され、これが望ましくない結果につながることは明らかです。 レコードレベルでロックしてみましょう-トランザクションの開始後に行を追加します。



 $connection->prepare("SELECT id FROM users_active FOR UPDATE")->execute();
      
      





テストを実行します-エラーはなくなりました。 このテストでは、2つのプロセスインスタンスを実行します。 テストのコピー数を3に増やして、何が起こるか見てみましょう。



 $asnycCommand = new Command( './cli app:detect-active-users', //   dirname(__DIR__), // ,      3 //     );
      
      





また、同じエラーがあります。 問題は何ですか、ロックを追加しました?! 少し考えれば、このようなロックはusers_active



テーブルにエントリがある場合にのみ役立つと推測できます。 3つのプロセスが同時に動作する場合、状況は次のとおりです。最初のプロセスがロックを取得します。 2番目と3番目のプロセスは、最初のプロセスのトランザクションが完了するのを待ちます。 トランザクションが完了するとすぐに、2番目と3番目のプロセスが並行して実行され続けるため、望ましくない結果が生じます。



修正するために、ロックをより一般的にします。 例えば



 $connection->prepare("SELECT id FROM users WHERE id IN (3, 5, 6, 10) FOR UPDATE")->execute();
      
      





または、 DELETE



代わりにDELETE



テーブル全体をロックするTRUNCATE



使用することもできます。



例3。 デッドロック



チーム自体が問題を引き起こすことはありませんが、同じリソースを使用して作業する2つの異なるチームの同時呼び出しは問題を引き起こします。 このようなバグの原因を見つけることは簡単ではありません。 しかし、理由が見つかった場合、コードを変更するときに将来戻ってくる問題を回避するために、テストは間違いなく書く価値があります。



そのようなチームをいくつか作成しましょう。 これは、デッドロックが発生する典型的なケースです。



最初のコマンドは、最初にid = 1のレコードを更新し、次にid = 2のレコードを更新します。



 try { $connection->beginTransaction(); $connection->prepare("UPDATE deadlock SET value = value + 1 WHERE id = 1")->execute(); usleep(100000); // 0.1 sec $connection->prepare("UPDATE deadlock SET value = value + 1 WHERE id = 2")->execute(); $connection->commit(); $output->writeln('<info>Completed without deadlocks</info>'); } catch (\Throwable $e) { $connection->rollBack(); throw $e; }
      
      





2番目のコマンドは、最初にID = 2のレコードを更新し、次にID = 1のレコードを更新します。



 try { $connection->beginTransaction(); $connection->prepare("UPDATE deadlock SET value = value + 1 WHERE id = 2")->execute(); usleep(100000); // 0.1 sec $connection->prepare("UPDATE deadlock SET value = value + 1 WHERE id = 1")->execute(); $connection->commit(); $output->writeln('<info>Completed without deadlocks</info>'); } catch (\Throwable $e) { $connection->rollBack(); throw $e; }
      
      





テストは次のようになります。



 use Mnvx\PProcess\AsyncTrait; use Mnvx\PProcess\Command\CommandSet; use PHPUnit\Framework\TestCase; class DeadlockCommandTest extends TestCase { use AsyncTrait; public function testImport() { $asnycCommand = new CommandSet( [ //   './cli app:deadlock-one', './cli app:deadlock-two' ], dirname(__DIR__), // ,      1 //     ); //   $this->assertAsyncCommands($asnycCommand); } }
      
      





テストを実行した結果、エラーの原因がわかります。



 $ ./vendor/bin/phpunit tests/DeadlockCommandTest.php PHPUnit 6.1.1 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 1.19 seconds, Memory: 4.00MB There was 1 failure: 1) DeadlockCommandTest::testImport Failed asserting that commands ./cli app:deadlock-one, ./cli app:deadlock-two (path: /var/www/pprocess-playground, count: 1) executed in parallel. Output: >>> Process 5481 started: ./cli app:deadlock-one >>> Process 5481 started: ./cli app:deadlock-two [Doctrine\DBAL\Exception\DriverException] An exception occurred while executing 'UPDATE deadlock SET value = value + 1 WHERE id = 1': SQLSTATE[40P01]: Deadlock detected: 7 :   DETAIL:  5498    ShareLock  " 294 738";   5499.  5499    ShareLock  " 294737";    5498. HINT:      . CONTEXT:    (0,48)   "deadlock" ------- app:deadlock-two Completed without deadlocks <<< Process 5481 finished <<< Process 5484 finished . /var/www/pprocess-playground/vendor/mnvx/pprocess/src/AsyncTrait.php:39 /var/www/pprocess-playground/tests/DeadlockCommandTest.php:22 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
      
      





この問題は、最初の例と同様にロックを追加することで処理されます。 または、データベースの構造またはデータを処理するアルゴリズムを修正します。



要約する



コードを並行して実行する場合、テストの作成に役立つコードを修正するときに、予期しない状況が発生する可能性があります。 このような状況をいくつか調査し、 pprocessを使用してテストを作成しました



All Articles