PHPのsession_write_closeでセッションロックによる同時リクエストの待ちを防ぐ

PHPのセッションロックとは、デフォルトのファイルハンドラで session_start() した時点から、同じセッションIDのリクエストを1本ずつ直列に処理する仕組みです。重い処理の前に session_write_close() でロックを手放すと、同じユーザーの並行リクエストが待たされずに済みます。

「APIが遅い」と相談されて調べてみたら、APIそのものは速くて、同じブラウザから投げた別のリクエストの終了待ちだった、ということがありました。ネットワークタブを見ると、リクエストが綺麗に階段状に並んでいるやつですね。原因はだいたい、セッションロックです。

セッションロックはなぜ起きるのか

ファイルハンドラは、session_start() でセッションファイルを開いて排他ロックを取り、スクリプトが終わる(または session_write_close() を呼ぶ)まで離しません。同じセッションIDで届いた2本目のリクエストは、session_start() の行で止まって待ちます。

これは「同時に書き込んでデータが壊れる」ことを防ぐための仕様で、バグではありません。ただ、画面を開いた瞬間にAjaxを3本飛ばすような作りだと、3本目は前の2本が終わるまで動きません。1本あたり2秒かかる処理なら、体感は6秒です。

<?php
// 悪い例: セッションを開きっぱなしで重い処理をしている
session_start();

$userId = $_SESSION['user_id'] ?? null;

// 外部APIの呼び出しで3秒かかるとする
$report = fetchSlowReport($userId);

echo json_encode($report);
// スクリプト終了までロックが続くので、同じユーザーの他リクエストは3秒待たされる

session_write_close()はどこで呼べばいいのか

セッションから読みたい値を変数に取り出したら、重い処理に入る前に閉じてしまうのが基本です。ロックを持っている時間が短いほど、並行リクエストの待ちは減ります。

<?php
session_start();

// 必要な値だけ先に取り出す
$userId = $_SESSION['user_id'] ?? null;

// ここでセッションを保存してロックを解放する
session_write_close();

// 時間のかかる処理はロックを手放したあとで行う
$report = fetchSlowReport($userId);

echo json_encode($report);

この形にすると、重い処理の間も同じユーザーの別リクエストが通ります。「セッションは最初に読んで、すぐ閉じる」を癖にしておくと、あとから効いてくる気がしています。

書き込み不要ならread_and_closeで開けるのか

読むだけのエンドポイントなら、session_start() のオプションに read_and_close を渡す手があります。PHP 7.0で入ったオプションで、セッションを開いて読み込んだらすぐ閉じてくれます。$_SESSION の中身はそのまま参照できます。

<?php
session_start(['read_and_close' => true]);

// 読み取りはできる
$userId = $_SESSION['user_id'] ?? null;

// セッションは既に閉じているので、ここから先の重い処理でもロックは持たない
$report = fetchSlowReport($userId);

呼び忘れで詰まる心配がなくなるのが嬉しいところです。ただし、このリクエストの中で $_SESSION を書き換えても保存されません。次の節の落とし穴と同じ話です。

閉じたあとに書き込むとどうなるのか

ここが一番のハマりどころです。session_write_close() のあとでも、$_SESSION は普通の配列として書き換えられます。エラーも出ません。ただ、保存はされません。

<?php
session_start();
$_SESSION['count'] = 1;
session_write_close();

$_SESSION['count'] = 2; // 配列としては書き換わる
var_dump($_SESSION['count']); // int(2)

// ただしこの2はセッションファイルには書かれない。
// 次のリクエストで読むと 1 のまま

「閉じたあとに、うっかりフラッシュメッセージを積んでいた」というバグは、再現が不安定で見つけにくいです。書き込みが必要な処理は、閉じる前にまとめて済ませておくのが安全です。

どうしても閉じたあとに書きたければ、もう一度 session_start() できます。ただしレスポンスを出力し始めたあとだと、「Session cannot be started after headers have already been sent」という警告が出て開き直せません。再オープンに頼る設計は、できれば避けたいところです。

ロックを手放しても安全な処理の見分け方

私の基準は単純で、「そのリクエストがセッションを書き換えるか」です。書き換えないなら、読んだ値を変数に取った時点で閉じて問題ありません。書き換える処理は、書く内容を決めてから最後に一度だけ書いて閉じる形にします。

逆に、ログイン処理のようにセッションIDの再生成や値の更新を伴うものは、無理に早く閉じようとしないほうが安全です。ロック時間を縮める目的で、整合性を崩しては本末転倒ですから。

まとめ

セッションロックは壊れたデータを防ぐ安全装置ですが、長く持ちすぎると同じユーザーの並行リクエストを直列にしてしまいます。値を取り出したら session_write_close()、読むだけなら read_and_close、という二段構えで、だいたいの渋滞は解消するかもしれません。「APIが遅い」と言われたら、一度ネットワークタブの階段を疑ってみるとよさそうです。

よくある質問

Q. session_write_close() を呼ぶと $_SESSION は空になりますか?
A. 空にはならず、その時点の値が配列として残ります。ただし閉じたあとの書き換えは保存されません。

Q. セッションロックはどのセッションハンドラでも起きますか?
A. デフォルトのファイルハンドラでは起きます。データベースやRedisなどのカスタムハンドラは、排他の有無や方法が実装と設定によって異なるので、使っているハンドラのドキュメントで確認してください。

Q. read_and_close はいつから使えますか?
A. PHP 7.0以降で、session_start() のオプション配列に指定できます。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です