1. トップページ
  2. PHPの処理中にユーザーがブラウザを閉じた時の挙動

PHPの処理中にユーザーがブラウザを閉じた時の挙動

PHP

こんにちは。気がつくと実務でのPHP使用歴が10年を超えていました。(もちろん他のこともいろいろやっています。)

さて、Web経由でPHPが実行されている間に、ユーザーがブラウザの「読み込み停止」ボタンをクリックしたりブラウザを閉じたりすると、スクリプトの実行は途中であっても終了されます。

この振る舞いは ignore_user_abort という実行時設定により有効/無効を切り替えることができますが、デフォルトでは無効、つまり接続が切られるとスクリプトを中断する挙動となっています。

この記事では、この中断のイメージと、最も何か問題になりそうなDBトランザクション内で中断されたときの挙動についてメモします。

 

スクリプトが中断されるイメージ

まず、単純にスクリプトが中断される様子を見ていきます。
適当に、以下の内容を含む index.php と、中身のない test.log を作成してください。

<?php
$logfile = 'test.log';

echo '<html><head><title>test</title></head><body>';
for ($i = 0; $i < 100; $i++) {
    echo $i . "<br />";
    flush(); // クライアントへ送信することを保証するために必要
    file_put_contents($logfile, $i . "\n", FILE_APPEND);
    sleep(1);
}
echo '</body></html>';

続いて以下のDockerを使用したコマンドによりApacheを起動してください。

docker run -d --rm --name deleteme-20240317 -v "$PWD":/var/www/html -p 80:80 php:8.3-apache

http://localhost にアクセスすると、 0 1 2 3 ... と1秒ごとに数字が出力されると思います。
このとき、 test.log にも同様に数字が1秒ごとに出力されています。

数字の出力を画面上で確認できたら、適当なタイミングでブラウザのタブを閉じることで、数字の出力がログファイルも含めて途中で停止します。
これが、スクリプトの中断のイメージです。

 

DBトランザクション実行中に中断されるとどうなるか

次に、スクリプトの中断がDBトランザクション実行中に起こった場合の挙動を見ていきます。
上記で作成したDockerコンテナは停止しておいてください。

docker stop deleteme-20240317

index.php を以下の内容に置き換えてください。

<?php
$logfile = 'test.log';

register_shutdown_function(function() use ($logfile) {
    $status = connection_aborted() ? 'ABORTED' : 'NORMAL';
    file_put_contents($logfile, "END({$status})\n", FILE_APPEND); // ---- 【1】
});

echo '<html><head><title>test</title></head><body>';
$dbh = new PDO('mysql:dbname=testdb;host=deleteme-20240317-db', 'root', 'password');
$dbh->exec('CREATE TABLE `testtable` (`id` SERIAL)');
try {
    $dbh->beginTransaction();

    for ($i = 0; $i < 100; $i++) {
        echo $i . "<br />";
        flush(); // クライアントへ送信することを保証するために必要
        $dbh->exec('INSERT INTO `testtable` () VALUES ()');
        sleep(1);
    }

    file_put_contents($logfile, "COMMITED\n", FILE_APPEND); // ---- 【2】
    $dbh->commit();
} catch (PDOException $e) {
    file_put_contents($logfile, "ROLLBACKED\n", FILE_APPEND); // ---- 【3】
    $dbh->rollBack();
}
echo '</body></html>';

続いて以下のDockerを使用したコマンドによりApacheとMySQLを起動してください。
コマンドの途中で、PDOのMySQL拡張をインストールし、Apacheを再起動しています。

docker network create deleteme-20240317-network

docker run -d --rm --name deleteme-20240317-app --network deleteme-20240317-network \
    -v "$PWD":/var/www/html -p 80:80 php:8.3-apache

docker exec -it deleteme-20240317-app docker-php-ext-install pdo_mysql
docker exec -it deleteme-20240317-app apachectl graceful

docker run -d --rm --name deleteme-20240317-db --network deleteme-20240317-network \
    -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=testdb mysql:8.3

http://localhost にアクセスすると、 0 1 2 3 ... と1秒ごとに数字が出力されるのは同じです。
一方で今度は、ログファイルへの数字の出力ではなく、トランザクションの中でDBテーブル testdb.testtable に1秒ごとにレコードが挿入されています。

数字の出力を画面上で確認できたら、適当なタイミングでブラウザのタブを閉じます。
上のスクリプトには【1】【2】【3】という3つのログを仕込んでいますが、スクリプトを中断したときに test.log に出力されているのは【1】の END(ABORTED) のみになります。
そのままDBテーブルの中身も見てみると、空になっています。

docker exec -it deleteme-20240317-db mysql -u root -p
mysql> SELECT * FROM testdb.testtable;

このとき、PDOを使用したトランザクションはPHPによって自動でロールバックされていました。
こちらのページにこの挙動に関する説明があります。

スクリプトが終了したり接続が閉じられようとした際に、もし処理が 完了していないトランザクションがあれば PDO が自動的に ロールバックします。これは、スクリプトが予期せぬ状態で終了した場合に データの不整合が発生するのを避けるための安全装置です。もし 明示的にコミットしていなければ、おそらく何かおかしなことが 起こったのだろうと推測されます。そのため、データを守るために ロールバックが行われるのです。

実際に上記のスクリプトをもう一度、MySQLのGeneral Query Logを有効化した状態で実行してみると、 ROLLBACK が実行されていることがわかります。
これが、DBトランザクション実行中にスクリプトの中断が起こった際のイメージです。

 

今回紹介したPHPでの接続処理に関する説明はこちらのページに詳細が記載されています。
上記マニュアルにも記載のある通り、 ignore_user_abortignore_user_abort() 関数によっても局所的な有効化が可能です。

一般的なWebサイトで問題になる頻度は高くなさそうですが、例えば計測ツールの構築やE2Eテスト実行時といった特定の状況によっては発生しやすく、また処理によって途中で中断してしまうとまずいケースは存在します。