前回の記事にてタイムアウトとなった場合に2種類の例外がスローされる事を説明しました。クエリ実行中にタイムオーバーとなった場合は、QueryException
。クエリ実行外でタイムオーバーとなり、その後クエリを実行してエラーとなる場合は、自作したTimeoutException
です。今回はこれらのエラーをどうキャッチしてハンドリングするのか解説します。
検証用のページ
説明の為にテスト用のコントローラを用意しました。
<?php namespace App\Http\Controllers; use App\Services\DbQueryTimeout; use Illuminate\Support\Facades\DB; class TimeoutTestController extends Controller { public function index() { $timeout = (int) request('timeout'); try { // 制限時間の指定があればセット if ($timeout) { DbQueryTimeout::set($timeout); } // 重いクエリ1 DB::statement('select sleep(3) union select 1'); // 重いクエリ2 DB::statement('select sleep(5) union select 1'); } catch (\Exception $e) { return 'error'; } return 'success'; } }
ルーティングは以下の通りです。
<?php use Illuminate\Support\Facades\Route; use App\Http\Controllers\TimeoutTestController; Route::get('/timeout_test', [TimeoutTestController::class, 'index']);
index
アクションでは重いクエリが2つ実行されます。それらが正常に処理できた場合はsuccess、何かしらのエラーが発生した場合はerrorを返します。尚、GETパラメタでtimeoutの指定がある場合は、DbQueryTimeout
を使用して制限時間が設定されます。ビルドインサーバーを起動して挙動を確認してみましょう。
php artisan serv
まず、パラメタ無しで /timeout_test
へアクセスしてみてください。約8秒後にsuccessと表示されるはずです。次に、timeoutパラメタを指定して制限時間を設定してみましょう。/timeout_test?timeout=5
として制限時間を5秒にしてアクセスしてみて下さい。すると、約5秒後に処理が中断されerrorと表示されるはずです。
Tips: 重いクエリをシミュレートする方法
重いクエリ = 時間が掛かるクエリ、という事でDB側でsleep()
を実行してシミュレートできます。mysqlではsleep()
は正常にスリープできた場合は0、途中で中断された場合は1が返却されます。しかし、実際の環境ではmax_execution_time
で指定した時間をオーバーして処理が中断された場合は1ではなくエラーが発生します。sleep()
においてもタイムオーバーした場合にエラーを返させるには、union
で追加の処理を実行すればOKです。追加の処理はなんでも良いので今回は適当にselect 1
としました。実際にmysql clientにて以下を実行して試してみると理解しやすいと思います。
# 制限時間を5秒に設定 mysql> set max_execution_time = 5000; Query OK, 0 rows affected (0.01 sec) # 制限時間内に終わるsleepなら0が返却される mysql> select sleep(4); +----------+ | sleep(4) | +----------+ | 0 | +----------+ 1 row in set (4.05 sec) # 制限時間内に終わらずsleepが中断されたら1が返却される mysql> select sleep(5); +----------+ | sleep(5) | +----------+ | 1 | +----------+ 1 row in set (5.06 sec) # unionで追加の処理を含めるとエラーが返却される mysql> select sleep(6) union select 1; ERROR 3024 (HY000): Query execution was interrupted, maximum statement execution time exceeded
エラーのハンドリング
冒頭で述べた通り制限時間をオーバーした場合は2種類のタイムアウトエラーが発生します。しかし、アプリにおいては当然それ以外のエラーも発生し得るので、タイムアウトエラーとそうでないエラーの場合で判別してエラーを出し分ける必要があります。それぞれどの様に判別すれば良いでしょうか。
TimeoutExceptionをキャッチ
先に簡単な方から、TimeoutException
を判別する場合です。こちらはシンプルにスローされたExceptionのインスタンスをチェックすれば判別できます。TimeoutTestController.php のindexアクションを以下の様に書き換えてみましょう。
... public function index() { $timeout = (int) request('timeout'); try { // 制限時間の指定があればセット if ($timeout) { DbQueryTimeout::set($timeout); } // 重いクエリ1 DB::statement('select sleep(3) union select 1'); // クエリ以外の処理で3秒掛かった sleep(3); // 重いクエリ2 DB::statement('select sleep(5) union select 1'); } catch (\Exception $e) { // TimeoutExceptionの場合のエラー文言 if ($e instanceof TimeoutException) { return 'timeout error'; } return 'error'; } return 'success'; }
TimeoutException
は、クエリ実行外でタイムオーバーし、次のクエリが実行されるタイミングでスローされます。そちらを再現する為に、クエリ1とクエリ2の間にPHP側のスリープを3秒挟みました。これにより制限時間が5秒の場合はTimeoutException
がスローされるはずです。再度、ビルドインサーバにて/timeout_test?timeout=5
へアクセスしてみて下さい。今度はtimeout errorと表示されたはずです。
クエリ実行中にタイムアウトした場合のエラー
クエリ実行中にタイムアウトした場合はQueryException
がスローされます。QueryException
はタイムアウト以外でもスローされる例外ですので、前項のTimeoutException
の様にインスタンスだけではタイムアウトエラーを判別できません。判別には一歩踏み込んで、$e->errorInfo
のチェックが必要です。errorInfo
はQueryException
の親クラスであるPDOException
にセットされていた値で、PHPマニュアルによると、データベースハンドラにおける直近の操作に関連する拡張エラー情報が配列で格納されています。タイムアウトエラーとしてQueryException
がスローされた際の$e->errorInfo
は以下の様になります。
[ 0 => "HY000" 1 => 3024 2 => "Query execution was interrupted, maximum statement execution time exceeded" ]
0はSQLSTATEエラーコード、1はドライバ固有のエラーコード、2はそのエラーメッセージです。判別には1のドライバ固有のエラーコードを使用します。こちらの記事によると、max_execution_time
の設定をオーバーした場合のエラーコードは MySQL 8.0.19 以前は3024
か1028
で、以降は3024
に統一されたとの事です。私の環境はまだMySQL5.7なので、1028
のエラーコードも補足する必要があります。これらを考慮して、TimeoutTestController
の例外処理部分を次の様に修正しました。
... } catch (\Exception $e) { $timeoutError = false; switch (get_class($e)) { case TimeoutException::class: $timeoutError = true; break; case QueryException::class: if (in_array($e->errorInfo[1], [3024, 1028])) { $timeoutError = true; } break; } if ($timeoutError) { return 'timeout error'; } return 'error'; } return 'success'; } }
インスタンスのチェックはswitch文でget_class($e)
でクラス名を取得して分岐する様に変更しました。QueryException
の場合はerrorInfoでエラーコードもチェックします。クエリ実行中にタイムオーバーとなるように/timeout_test?timeout=7
へアクセスしてみて下さい。再度、timeout errorが表示されるはずです。
リファクタリング
これまで説明の為にコントローラ側にタイムアウトエラーのエラー処理のコードを記述してきましたが、DbQueryTimeout
と一緒に色々な箇所で使い回すのでまとめておいた方が良さそうです。という事で、DbQueryTimeout::isTimeoutError()
にコードを移しました。
... // タイムアウトエラー発生? public static function isTimeoutError(\Exception $e): bool { $timeoutError = false; switch (get_class($e)) { case TimeoutException::class: $timeoutError = true; break; case \Illuminate\Database\QueryException::class: if (in_array($e->errorInfo[1], [3024, 1028])) { $timeoutError = true; } break; } return $timeoutError; } ...
isTimeoutError()
はシンプルにタイムアウトエラーか否かを判別するメソッドです。このメソッドを使う事でTimeoutTestController
側は次の様にシンプルになりました。
<?php namespace App\Http\Controllers; use App\Services\DbQueryTimeout; use Illuminate\Support\Facades\DB; class TimeoutTestController extends Controller { public function index() { $timeout = (int) request('timeout'); try { // 制限時間の指定があればセット if ($timeout) { DbQueryTimeout::set($timeout); } // 重いクエリ1 DB::statement('select sleep(3) union select 1'); // クエリ以外の処理で3秒掛かった sleep(3); // 重いクエリ2 DB::statement('select sleep(5) union select 1'); } catch (\Exception $e) { if (DbQueryTimeout::isTimeoutError($e)) { return 'timeout error'; } return 'error'; } return 'success'; } }メルマガ購読の申し込みはこちらから。