同時アクセス数制限を銀行の窓口になぞらえて実装する

2016/09/01

ある処理の同時実行数を制限する方法は色々ある。
DBにデータをため、別プロセスがポーリングして制限数を超えるまでINSERT順にデータを取り出して処理するという方式が一般的かつ簡潔かもしれないが、DBとポーリング手法を使わずにJavaプログラムだけで制御してみた。
synchronizedを多用することになったため、synchronizedを使ったプログラムの排他制御、同期処理の良い練習になった。

要件

  • WebでクライアントからあるURLにアクセスされると重い処理が走る。
  • この重い処理を同時に実行できるのは1サーバにつき、20アクセスまでとする。

実装方針

Webからリクエストが来るということを考えると、タイムアウトを考慮しなくてはならず、長時間待たせるわけにはいかない。また、別プロセスでポーリングして処理を実行というのは、各スレッドがリアルタイムに処理をするWebアクセスでの実装に合わない。

そこで銀行の窓口になぞらえて実装するのがいいのではないかと考えた。
銀行では次のように窓口での受付を待つことになる。

  1. 整理券を受け取る。
  2. 待合席に座り、整理券番号が呼ばれるのを待つ。

また、次のような特性も併せ持つ。

  • もし番号が呼ばれるのが遅ければ、一旦席を立ち、外に出て小用を済ませてもいい。
  • 席を立って戻るのが遅ければ、番号がすでに呼ばれてしまっていることもあり、再度整理券を取る必要がある。

Webでアクセスされて動く各スレッドが、アプリケーションスコープで動くアクセス制限用クラスを通してメインの処理を行い、アクセス制限用クラスは銀行の窓口を模倣するという構成で実装していく。もし時間がかかりすぎるようであれば一旦レスポンスに整理券を入れて返してしまい、再度アクセスしてもらうようにする。
尚、今回の重いメイン処理は2秒間スリープするだけとする。

クラス構成

ソース 説明
AccessLimitUtil.java 銀行の窓口を模倣するアクセス制限用クラス
AccessLimitedException.java アクセス制限にかかった時に投げられる例外
TestRunner.java 重いメイン処理を走らせるテストクラス

AccessLimitUtil

気を付けた点

アプリケーションスコープで動くSingletonクラスなので、各スレッドから同時アクセスされる。現在の処理数や新しく発行する整理券番号を取得する際にスレッド間の競合が起きないようにするため、synchronizedを使い、同期するようにする必要がある。
synchronizedを複数のオブジェクトに対して使っているが、気を付けたことは、予期せぬデッドロックを避けるため、処理の一連の流れの中でロックするオブジェクトが入れ子にならないようにすることである。次に書くソースのような、methodAでobjectAをsynchronizedブロックでロックしたまま、そのブロック内でobjectBをロックするmethodBを呼び出さないようにしている。

ソース

では、本題のAccessLimitUtilを掲載する。

ソース解説

Singleton

はじめのSingleton startからSingleton endまでで、Singletonクラスを実現している。普通のSingletonの作り方より複雑なのは、このクラスを継承して、アクセス制御の仕方やアクセス時間等をカスタマイズ可能にするため。普通のSingletonはコンストラクタをprivateで宣言するため、継承ができない。継承可能なSingletonの作り方は結城浩 氏のSingletonのサブクラス化を参考にさせていただいた。

ロジック

ロジックは今まで書いた通り、銀行の窓口の仕組みを実装している。

accessメソッドが公開しているクラスである。引数に、処理を実行する関数とその関数への引数を受け取っている。処理実行部分を外部から受け取ることで、アクセス制御の仕組みをどんな処理にでも適用できるようにしている。
最後の引数nTicket(numbered ticket, 整理券)は通常nullを設定する。銀行の窓口待ちでいうところの、「外から戻ってきて整理券がまだ有効かチェックする」ときだけ、値を設定する。

ロジックの流れは次のようになる。

  1. 整理券を持っているか確認(nTicket == null ?)。
    • 持っていない -> 整理券を発行。整理券は現在時刻(long)を採用する。
    • 持っている -> 整理券を再登録。古ければ新規発行。
  2. 整理券をもって順番待ち。一定時間sleepしながら制限時間いっぱいまで順番がきたか確認する。
    • 制限時間を超えた場合、例外を投げる。例外には整理券番号をセットする。
  3. 受付された場合、現在の処理スレッド数をカウントアップする。
  4. メインの処理を実行する。
  5. 現在の処理スレッド数をカウントダウンする。
  6. 管理している整理券のうち、古い整理券を無効とする。

AccessLimitedException

ソース

ソース解説

エラーメッセージ等を格納する通常のExceptionを拡張して、整理券を格納できるようにしている。

TestRunner

ソース

ソース解説

mainメソッドから並列で1000スレッドが処理を同時に実行しようとしている。AccessLimitUtilを噛まして実行することで、並列で20スレッド分しか実行されず、残りのスレッドは処理可能になるまで順番を待つ。タイムアウトしてしまえばAccessLimitedExceptionが飛んでくるので、例外から整理券を取得し、再度処理実行を試みる。

これを実行し、AccessLimitUtilにログ取得のため忍ばせておいた現在のスレッド数を確認する式System.out.println("current threads: " + proceeding.get());の出力結果をみると、必ず20スレッドまでしかカウントが伸びないことが分かる。

タイムアウトした時は再実行してくれる。Webで実際に実装するとしたら、レスポンスに整理券番号をセットしてJSONで返すなどして、Ajaxで再帰処理をするのがいいだろう。

-Java