負荷試験ツールで重複禁止のバリデーションを通過できるか試してみる

    負荷試験ツールで重複禁止のバリデーションを通過できるか試してみる

    目次

      はじめに

      普段サーバーサイド側のコードを書くことが多く、SQLを発行するクエリメソッドを調べたりする中で、リクエストが同時に来たときの対策などの記事を見かけることがあります。

      同時に複数のリクエストが来た場合の問題として、読み取りと更新を含むSQLがあると実行される順序によってはDBのデータに予期しない不整合など起きる点が挙げられていました。

      このような現象をローカル環境で再現できるか疑問に思ったので、負荷試験ツールを使って試してみました。

      実行環境

      Dockerで以下のイメージを使いました。

      • php:8.1-fpm-buster
      • nginx:1.20-alpine
      • mysql/mysql-server:8.0

      ※トランザクション分離レベルはデフォルトのREPEATABLE-READです。

      フレームワークはLaravelを使います。

      競合状態が起きる可能性のあるコード

      まずは不整合なデータが作成されそうなコードを考えていきたいと思います。

      DB::beginTransaction();

      try {
      $name = $request->input('name');

      // 重複禁止のバリデーション
      if (Account::where('name', $name)->exists()) {
      throw new Error('アカウント名が重複しています');
      }
      // 新規登録
      Account::create([
      'name' => $name
      ]);

      DB::commit();

      return response()->json([
      'message' => 'アカウント新規登録'
      ]);
      } catch (Error $exception) {

      DB::rollBack();
      throw $exception;
      }

      上記のコードでは重複確認のためレコードを取得しているメソッドと、重複されていないことを確認してレコードを追加するメソッドで2つのSQLを実行しています。

      常にコードに記載してある通りの順序でSQLが実行されれば問題ないのですが、1つ目のリクエストでレコードを取得した直後に2つ目のリクエストでレコード取得が行われると重複確認 → 重複確認 → レコード追加 → レコード追加のような順序で実行され、バリデーションを通過し同一名のアカウントのレコードが登録される可能性があります。

      202404_Try to see if the load testing tool can pass the validation of the duplicate prohibition_01

      多重送信を行いこのようなケースが発生するか検証してみます。

      サーバーが同時に複数のリクエストを処理できるようにする

      続いてリクエストの同時接続数を設定していきます。

      今回はphpを使って通常のプロセス単位でのリクエスト処理を行っていくので、php-fpmの設定で起動するプロセスの数を調整します。

      [www]
      pm = static
      pm.max_children = 2

      pm = staticでプロセスを常に固定数起動するようにしておき、pm.max_children = 2で起動するプロセス数の指定をしています。

      これで二重にリクエストが来ても2つのプロセスによって同時に処理されるようになったはずです。

      リクエストのスクリプトを用意する

      サーバー側の準備が終わったので、負荷試験で実行したいリクエストのスクリプトを用意していきます。

      多重送信を行うために負荷試験ツールを使っていきたいのですが、馴染みのあるjsで書けるk6を選択しました。

      import http from "k6/http";
      import { sleep } from "k6";

      // リクエスト回数
      let requestCount = 1;

      export default function () {
      const postData = {
      name: `ユーザー${requestCount}`,
      };
      http.post("http://host.docker.internal:8080/api/accounts", postData);
      // リクエスト回数をインクリメント
      requestCount++;
      sleep(1);
      }

      最初にリクエスト回数を1として初期化して変数に保存し、リクエストの回数ごとに1つずつインクリメントしています。

      そのため、こちらのスクリプトが実行されるとサーバーに対して { name: 'ユーザー1' }, { name: 'ユーザー2' }, { name: 'ユーザー3' }...というようなリクエストデータを投げ続けていきます。

      多重送信する

      先ほど作ったスクリプトを使って多重送信します。

      k6のドキュメントによるとCLIで実行する際に、--vusで仮想ユーザー数virtual users(VUs)、--durationで実行時間を指定できるみたいでした。

      競合状態が起きる可能性のあるapiに10秒間リクエストを二重に送っていきます。

      docker run --rm -i grafana/k6:latest-with-browser run --vus 2 --duration 10s - < k6/script.js

      結果を見てみる

      k6によって作成されたレコードは以下のようになっていました。

      +---------------+
      | name |
      +---------------+
      | ユーザー1 |
      | ユーザー1 |
      | ユーザー2 |
      | ユーザー3 |
      | ユーザー4 |
      | ユーザー5 |
      +---------------+

      重複禁止のバリデーションを突破し、nameカラムがユーザー1になっているレコードが複数作成されていることがわかります。

      ちなみにですが、実行結果は負荷試験を実行する都度変わったりします。傾向としては序盤の方のリクエストで重複したデータが作成され、以降のリクエストではデータが重複されないケースが多かったです(この辺りの挙動の原因はよく分かっていないです)。

      まとめ

      以上の結果から、DBへのユニーク制約や排他制御を行わず読み取りや更新を含むSQLを実行しているコードでは、複数のリクエストが同時に送られると競合状態が発生する可能性があることを確認できました。

      もちろん、実際の本番環境ではサーバー構成によって起こりやすさなど変わったりするかと思うのですが、このようなエッジケースの対策を意識した方がいいかもしれないです。

      参考