[PHP]session_write_close()完全解説|セッションロック解放と並列リクエスト最適化の実践

PHP

はじめに

PHPのセッションはファイルベースのハンドラを使う場合、session_start() から session_write_close()(または スクリプト終了)までファイルロックを保持し続けます。このロックのせいで、同一セッションIDを持つ複数のリクエスト(AJAXの並列呼び出しなど)が直列に処理されてしまい、深刻なパフォーマンス問題を引き起こします。

session_write_close() はセッションデータをストレージに書き込み、セッションロックをその場で解放する関数です。書き込みが終わった時点で即座に呼び出すことで、並列リクエストのブロッキングを排除し、レスポンス時間を大幅に改善できます。パフォーマンスとセキュリティの両面で押さえておきたい関数です。


関数の概要

項目内容
関数名session_write_close()
別名session_commit()(完全な同義語)
所属PHP セッション関数
導入バージョンPHP 4.0.4以降
PHP 8.x対応済み
戻り値true(成功) / false(失敗)

構文

session_write_close(): bool

パラメータはありません。呼び出すだけでセッションデータが書き込まれ、ロックが解放されます。


session_write_close() が行うこと

① $_SESSION の内容をシリアライズする
      ↓
② セッションハンドラの write() を呼び出してストレージに保存する
      ↓
③ セッションハンドラの close() を呼び出す
      ↓
④ セッションファイルロックを解放する
      ↓
⑤ セッション状態が PHP_SESSION_NONE になる

⚠️ session_write_close() 後に $_SESSION へ書き込んでも、次のリクエストには反映されません。再び書き込みたい場合は session_start() で再オープンする必要があります。


session_write_close() vs session_abort() の違い

関数データ書き込みロック解放ユースケース
session_write_close()✅ 書き込む✅ 解放する変更を保存してロックを解放したい
session_abort()❌ 書き込まない✅ 解放する読み取り専用アクセス後にロックだけ解放したい

セッションロック問題のイメージ

【session_write_close() なしの場合】

ブラウザ → リクエストA(session_start → 処理3秒 → スクリプト終了)
ブラウザ → リクエストB(session_start → ⏳ ロック待ち → やっと開始...)
ブラウザ → リクエストC(session_start → ⏳ ロック待ち → さらに待つ...)

【session_write_close() を早めに呼ぶ場合】

ブラウザ → リクエストA(session_start → 書き込み → write_close → 処理続行)
ブラウザ → リクエストB(        ↑ ロック解放済み → session_start → 即開始)
ブラウザ → リクエストC(                           session_start → 即開始)

基本的な使い方

<?php
session_start();

// セッションへの書き込みが完了したらすぐ解放する
$_SESSION['user_id']    = 42;
$_SESSION['last_login'] = time();

session_write_close(); // ← ここでロック解放

// 以降の重い処理はロックなしで実行できる
// sleep(5); や 外部API呼び出しなど
echo "処理完了\n";

実践的なクラスベースの使用例

例1:早期ロック解放による並列リクエスト最適化クラス

<?php
/**
 * セッションロックを早期に解放してAJAX並列リクエストのブロッキングを防ぐクラス
 * セッションへの読み書きが完了した時点で即座に write_close を呼ぶ
 */
class SessionEarlyRelease
{
    private bool  $isClosed     = false;
    private float $startTime;
    private array $writeLog     = [];

    public function __construct()
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start([
                'name'            => 'AppSession',
                'cookie_httponly' => true,
                'cookie_samesite' => 'Lax',
                'lazy_write'      => true,
            ]);
        }
        $this->startTime = microtime(true);
    }

    /**
     * セッションに値をセットする
     */
    public function set(string $key, mixed $value): void
    {
        $this->assertOpen();
        $_SESSION[$key]    = $value;
        $this->writeLog[]  = "SET: {$key}";
    }

    /**
     * セッションから値を取得する
     */
    public function get(string $key, mixed $default = null): mixed
    {
        return $_SESSION[$key] ?? $default;
    }

    /**
     * セッションへの書き込みが完了したらロックを解放する
     * この後 set() は呼べなくなる
     */
    public function release(): void
    {
        if ($this->isClosed) {
            return;
        }

        $result = session_write_close();
        $this->isClosed = true;

        $elapsed = round((microtime(true) - $this->startTime) * 1000, 2);
        echo "セッションロック解放: {$elapsed}ms 経過時点\n";
        echo "書き込み操作: " . implode(', ', $this->writeLog) . "\n";
        echo "結果: " . ($result ? '成功' : '失敗') . "\n";
    }

    /**
     * セッションを再オープンして書き込み可能にする
     */
    public function reopen(): void
    {
        if (!$this->isClosed) {
            return;
        }
        session_start();
        $this->isClosed  = false;
        $this->writeLog  = [];
        $this->startTime = microtime(true);
        echo "セッション再オープン: " . substr(session_id(), 0, 8) . "...\n";
    }

    public function isClosed(): bool
    {
        return $this->isClosed;
    }

    private function assertOpen(): void
    {
        if ($this->isClosed) {
            throw new \RuntimeException(
                'session_write_close() 後にセッションへの書き込みはできません。reopen() を呼んでください。'
            );
        }
    }
}

$session = new SessionEarlyRelease();
$session->set('user_id', 42);
$session->set('last_action', 'checkout');
$session->release(); // ← セッション書き込み完了・ロック解放

// 以降の重い処理(外部API・DB集計など)はセッションロックなしで実行
echo "重い処理を実行中...\n";
// usleep(500000); // 0.5秒の処理

/*
出力例:
セッションロック解放: 1.23ms 経過時点
書き込み操作: SET: user_id, SET: last_action
結果: 成功
重い処理を実行中...
*/

例2:読み取り専用アクセスの最適化クラス

<?php
/**
 * セッションデータを読み取るだけで書き込まないリクエストに対して
 * session_write_close() で即座にロックを解放するクラス
 *
 * 例:API エンドポイントがセッションのユーザーIDだけ確認して処理する場合
 */
class ReadOnlySessionAccessor
{
    private array $data = [];
    private bool  $loaded = false;

    /**
     * セッションを開始して即クローズし、データをメモリにコピーする
     * read_and_close オプションを使う方法と session_write_close を使う方法の2択
     */
    public function loadAndClose(bool $useReadAndClose = true): void
    {
        if ($useReadAndClose) {
            // PHP 7.0以降:read_and_close オプションで自動的に即クローズ
            session_start(['read_and_close' => true]);
        } else {
            // 手動で write_close を呼ぶ方法(後方互換)
            session_start();
            session_write_close();
        }

        $this->data   = $_SESSION;
        $this->loaded = true;

        echo "セッション読み込み完了・即クローズ\n";
        echo "状態: " . $this->statusLabel(session_status()) . "\n";
    }

    public function get(string $key, mixed $default = null): mixed
    {
        $this->assertLoaded();
        return $this->data[$key] ?? $default;
    }

    public function has(string $key): bool
    {
        $this->assertLoaded();
        return isset($this->data[$key]);
    }

    public function all(): array
    {
        $this->assertLoaded();
        return $this->data;
    }

    public function getUserId(): ?int
    {
        $id = $this->get('user_id');
        return $id !== null ? (int) $id : null;
    }

    public function isAuthenticated(): bool
    {
        return $this->has('user_id') && $this->has('logged_in_at');
    }

    private function assertLoaded(): void
    {
        if (!$this->loaded) {
            throw new \RuntimeException('loadAndClose() を先に呼んでください');
        }
    }

    private function statusLabel(int $status): string
    {
        return match ($status) {
            PHP_SESSION_DISABLED => 'DISABLED',
            PHP_SESSION_NONE     => 'NONE',
            PHP_SESSION_ACTIVE   => 'ACTIVE',
            default              => 'UNKNOWN',
        };
    }
}

session_start();
$_SESSION['user_id']     = 42;
$_SESSION['logged_in_at'] = time();
session_write_close();

// 読み取り専用アクセス
$accessor = new ReadOnlySessionAccessor();
$accessor->loadAndClose();

echo "認証済み: " . ($accessor->isAuthenticated() ? 'YES' : 'NO') . "\n";
echo "ユーザーID: " . $accessor->getUserId() . "\n";

/*
出力例:
セッション読み込み完了・即クローズ
状態: NONE
認証済み: YES
ユーザーID: 42
*/

例3:セッションを使ったジョブキュー処理クラス

<?php
/**
 * 長時間バックグラウンド処理を行うジョブキュークラス
 * ジョブ実行中はセッションロックを保持しないよう write_close を活用する
 */
class SessionAwareJobRunner
{
    private array $results = [];

    public function __construct()
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
    }

    /**
     * ジョブを実行する
     * セッションから入力データを読み込み、ロック解放後にジョブを実行し、
     * 完了後にセッションへ結果を書き戻す
     */
    public function run(string $jobId, callable $job): mixed
    {
        // セッションからジョブパラメータを取得
        $params = $_SESSION['job_params'][$jobId] ?? [];
        echo "ジョブ [{$jobId}] パラメータ読み込み\n";

        // セッションにジョブ開始ステータスを書き込む
        $_SESSION['job_status'][$jobId] = [
            'status'     => 'running',
            'started_at' => time(),
        ];

        // ロックを解放してジョブを実行(他リクエストをブロックしない)
        session_write_close();
        echo "ロック解放: ジョブ実行開始\n";

        // ジョブ実行(時間がかかる処理)
        $startTime = microtime(true);
        try {
            $result           = $job($params);
            $status           = 'completed';
            $this->results[$jobId] = $result;
        } catch (\Throwable $e) {
            $result = null;
            $status = 'failed';
            echo "ジョブエラー: " . $e->getMessage() . "\n";
        }

        $elapsed = round((microtime(true) - $startTime) * 1000, 1);

        // セッションを再オープンして結果を書き込む
        session_start();
        session_write_close(); // ← 再び即クローズ(write だけしてロックを手放す)

        // 実際には再オープン後に書き込みたい場合は write_close を遅らせる
        session_start();
        $_SESSION['job_status'][$jobId] = [
            'status'       => $status,
            'started_at'   => $_SESSION['job_status'][$jobId]['started_at'] ?? time(),
            'completed_at' => time(),
            'elapsed_ms'   => $elapsed,
        ];
        session_write_close();

        echo "ジョブ [{$jobId}] 完了: {$status} ({$elapsed}ms)\n";
        return $result;
    }

    public function getResults(): array
    {
        return $this->results;
    }
}

session_start();
$_SESSION['job_params']['report_gen'] = ['month' => '2025-09', 'format' => 'pdf'];
session_write_close();

$runner = new SessionAwareJobRunner();
$result = $runner->run('report_gen', function (array $params): array {
    // 重い処理(レポート生成など)
    echo "  レポート生成中: {$params['month']} ({$params['format']})\n";
    return ['file' => 'report_2025-09.pdf', 'pages' => 12];
});

print_r($result);

例4:カスタムセッションハンドラとの組み合わせクラス

<?php
/**
 * session_write_close() を活用した接続プール対応のセッションハンドラ
 * write_close 後に DB 接続を返却してコネクションプールの枯渇を防ぐ
 */
class PoolAwarePdoSessionHandler implements \SessionHandlerInterface
{
    private ?\PDO   $pdo         = null;
    private float   $lockAcquiredAt = 0;
    private int     $writeCount  = 0;

    public function __construct(
        private readonly string $dsn,
        private readonly string $user = '',
        private readonly string $pass = ''
    ) {}

    public function open(string $path, string $name): bool
    {
        // session_start() のタイミングで接続を取得
        $this->pdo = new \PDO($this->dsn, $this->user, $this->pass);
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $this->lockAcquiredAt = microtime(true);
        echo "DB接続確立(セッション開始)\n";
        return true;
    }

    public function close(): bool
    {
        // session_write_close() → close() のタイミングで接続を返却
        $held = round((microtime(true) - $this->lockAcquiredAt) * 1000, 2);
        $this->pdo = null; // 接続をプールに返却
        echo "DB接続返却(write_close): {$held}ms 保持, 書き込み{$this->writeCount}回\n";
        $this->writeCount = 0;
        return true;
    }

    public function read(string $id): string|false
    {
        if (!$this->pdo) return '';
        $stmt = $this->pdo->prepare(
            'SELECT data FROM sessions WHERE id = :id AND expires_at > NOW()'
        );
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $row ? $row['data'] : '';
    }

    public function write(string $id, string $data): bool
    {
        if (!$this->pdo) return false;
        $this->writeCount++;
        $ttl  = (int) ini_get('session.gc_maxlifetime');
        $stmt = $this->pdo->prepare(
            'INSERT INTO sessions (id, data, expires_at)
             VALUES (:id, :data, DATE_ADD(NOW(), INTERVAL :ttl SECOND))
             ON DUPLICATE KEY UPDATE data = VALUES(data), expires_at = VALUES(expires_at)'
        );
        return $stmt->execute([':id' => $id, ':data' => $data, ':ttl' => $ttl]);
    }

    public function destroy(string $id): bool
    {
        if (!$this->pdo) return true;
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE id = :id');
        return $stmt->execute([':id' => $id]);
    }

    public function gc(int $max_lifetime): int|false
    {
        if (!$this->pdo) return 0;
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
        $stmt->execute();
        return $stmt->rowCount();
    }
}

/*
$handler = new PoolAwarePdoSessionHandler('mysql:host=localhost;dbname=myapp', 'user', 'pass');
session_set_save_handler($handler, true);
session_start();

$_SESSION['user_id'] = 42;
session_write_close(); // ← close() が呼ばれDB接続を即返却

// 以降の処理はDB接続不要で続行
echo "DB接続は既に返却済み\n";
*/

例5:セッション書き込みのトランザクション管理クラス

<?php
/**
 * session_write_close() を「コミット」として扱い、
 * 複数のセッション変更をアトミックに確定するトランザクションクラス
 */
class SessionWriteTransaction
{
    private array $pending  = [];
    private array $original = [];
    private bool  $active   = false;

    public function __construct()
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
    }

    /**
     * トランザクション開始:現在の状態をスナップショット
     */
    public function begin(): void
    {
        $this->original = $_SESSION;
        $this->pending  = [];
        $this->active   = true;
        echo "トランザクション開始\n";
    }

    /**
     * 変更をペンディングリストに追加する(まだ $_SESSION には書かない)
     */
    public function stage(string $key, mixed $value): void
    {
        $this->assertActive();
        $this->pending[$key] = $value;
        echo "ステージング: {$key}\n";
    }

    /**
     * コミット:ペンディングをセッションに適用し write_close で確定
     */
    public function commit(): bool
    {
        $this->assertActive();

        // ペンディングを $_SESSION に適用
        foreach ($this->pending as $key => $value) {
            $_SESSION[$key] = $value;
        }

        // write_close でストレージに確定・ロック解放
        $result = session_write_close();

        if ($result) {
            echo "コミット成功: " . count($this->pending) . " 件の変更を確定\n";
        } else {
            echo "コミット失敗: セッションへの書き込みエラー\n";
        }

        $this->active  = false;
        $this->pending = [];
        return $result;
    }

    /**
     * ロールバック:ペンディングを捨て、元の状態を write_close で書き込む
     */
    public function rollback(): bool
    {
        $this->assertActive();

        // 元の状態に戻す
        $_SESSION    = $this->original;
        $result      = session_write_close();

        echo "ロールバック: 元の状態に戻して書き込みました\n";

        $this->active  = false;
        $this->pending = [];
        return $result;
    }

    private function assertActive(): void
    {
        if (!$this->active) {
            throw new \RuntimeException('トランザクションが開始されていません。begin() を呼んでください。');
        }
    }

    public function isActive(): bool
    {
        return $this->active;
    }
}

session_start();
$_SESSION['balance'] = 10000;
$_SESSION['history'] = [];
session_write_close();

session_start();
$tx = new SessionWriteTransaction();
$tx->begin();
$tx->stage('balance', 7000);
$tx->stage('history', ['送金 -3000円']);
$tx->stage('last_tx_at', time());
$tx->commit(); // ← write_close で確定

echo "セッション状態: " . (session_status() === PHP_SESSION_ACTIVE ? 'ACTIVE' : 'NONE') . "\n";

/*
出力例:
トランザクション開始
ステージング: balance
ステージング: history
ステージング: last_tx_at
コミット成功: 3 件の変更を確定
セッション状態: NONE
*/

例6:セッション書き込み後の非同期処理クラス

<?php
/**
 * session_write_close() でセッションを確定した後に
 * レスポンスを返しつつバックグラウンド処理を続行するクラス
 *
 * fastcgi_finish_request() が使える環境向けの最適化パターン
 */
class SessionThenBackgroundProcessor
{
    private bool $sessionClosed = false;
    private bool $responseSent  = false;

    public function __construct()
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start([
                'cookie_httponly' => true,
                'cookie_samesite' => 'Lax',
            ]);
        }
    }

    /**
     * セッションを確定してレスポンスをクライアントに送信し、
     * その後バックグラウンドで重い処理を実行する
     */
    public function runWithBackground(
        array    $sessionUpdates,
        string   $responseBody,
        callable $backgroundJob
    ): void {
        // ① セッションを更新
        foreach ($sessionUpdates as $key => $value) {
            $_SESSION[$key] = $value;
        }

        // ② セッションを確定・ロック解放
        session_write_close();
        $this->sessionClosed = true;
        echo "セッション確定\n";

        // ③ レスポンスをクライアントに送信(FastCGI環境)
        if (function_exists('fastcgi_finish_request')) {
            echo $responseBody;
            fastcgi_finish_request();
            $this->responseSent = true;
            echo "[クライアントへのレスポンス送信完了]\n";
        } else {
            // 通常環境でのフォールバック
            echo $responseBody;
        }

        // ④ バックグラウンド処理(クライアントはレスポンス受信済み)
        echo "バックグラウンド処理開始\n";
        $result = $backgroundJob();
        echo "バックグラウンド処理完了: " . json_encode($result, JSON_UNESCAPED_UNICODE) . "\n";
    }

    /**
     * セッションを再オープンしてバックグラウンド処理の結果を書き込む
     */
    public function writeBackgroundResult(string $key, mixed $result): void
    {
        if (!$this->sessionClosed) {
            throw new \RuntimeException('session_write_close() が呼ばれていません');
        }

        session_start();
        $_SESSION[$key] = $result;
        session_write_close();
        echo "バックグラウンド結果をセッションに書き込みました: {$key}\n";
    }
}

$processor = new SessionThenBackgroundProcessor();
$processor->runWithBackground(
    sessionUpdates: [
        'order_submitted' => true,
        'order_at'        => time(),
    ],
    responseBody: "注文を受け付けました\n",
    backgroundJob: function (): array {
        // 在庫更新・メール送信・ログ記録など(クライアントを待たせない)
        echo "  在庫更新中...\n";
        echo "  確認メール送信中...\n";
        return ['inventory_updated' => true, 'email_sent' => true];
    }
);

/*
出力例:
セッション確定
注文を受け付けました
バックグラウンド処理開始
  在庫更新中...
  確認メール送信中...
バックグラウンド処理完了: {"inventory_updated":true,"email_sent":true}
*/

セッション状態の遷移

<?php
var_dump(session_status()); // 1 = PHP_SESSION_NONE

session_start();
var_dump(session_status()); // 2 = PHP_SESSION_ACTIVE

$_SESSION['key'] = 'value';

session_write_close();
var_dump(session_status()); // 1 = PHP_SESSION_NONE ← NONE に戻る

// 再オープン可能
session_start();
var_dump(session_status()); // 2 = PHP_SESSION_ACTIVE
var_dump($_SESSION['key']); // string(5) "value" ← データは残っている

関連関数との比較

関数書き込みロック解放セッション状態ユースケース
session_write_close()✅ する✅ 解放ACTIVE → NONE書き込み完了後の早期解放
session_abort()❌ しない✅ 解放ACTIVE → NONE読み取り専用後のロック解放
session_commit()✅ する✅ 解放ACTIVE → NONEsession_write_close() の別名
session_destroy()❌ しない✅(ファイル削除)ACTIVE → NONEセッション完全破棄(ログアウト)
session_reset()❌ しないACTIVE のまま変更のロールバック(書き込み前)
スクリプト終了✅ 自動✅ 自動デフォルト(推奨されない)

よくある落とし穴

<?php
// ❌ NG:write_close 後に $_SESSION へ書き込んでも反映されない
session_start();
session_write_close();
$_SESSION['new_key'] = 'value'; // ← 次のリクエストで消えている

// ✅ 書き込みたい場合は write_close の前か、再オープン後に書く
session_start();
session_write_close();

session_start();                     // 再オープン
$_SESSION['new_key'] = 'value';      // ← これは反映される
session_write_close();
<?php
// ❌ 注意:session_write_close() を呼ばずに重い処理をするとロックが続く
session_start();
$_SESSION['started'] = true;
// ここで5秒かかる処理... 同じセッションIDの別リクエストは5秒ブロックされる
sleep(5);
// スクリプト終了でようやく解放

// ✅ 書き込みが終わったら即解放する
session_start();
$_SESSION['started'] = true;
session_write_close(); // ← ここで解放
sleep(5); // もうブロックしない
<?php
// ❌ よくある誤解:session_write_close() はセッションを破棄する
session_start();
$_SESSION['user'] = 'alice';
session_write_close(); // データを保存して終了するだけ

// 次のリクエストで session_start() すれば data は残っている
session_start();
echo $_SESSION['user']; // alice ← 残っている

まとめ

項目内容
関数名session_write_close(): bool(別名: session_commit()
主な用途セッションデータを書き込んでロックを解放する
呼び出しタイミングセッションへの書き込みが完了した直後(できるだけ早く)
戻り値成功 true / 失敗 false
呼び出し後の状態PHP_SESSION_NONE(再び session_start() で再開可能)
データは残るか✅ ストレージには保存されている
再オープンsession_start() で再開できる
最大の効果AJAX並列リクエストのブロッキング解消

session_write_close() は「セッションへの書き込みが終わったらすぐ呼ぶ」という習慣をつけるだけで、並列リクエストのパフォーマンスが劇的に改善します。特にAJAXを多用するSPA・進捗状況をポーリングする処理・時間のかかる外部API呼び出しを含むページでは、積極的に活用しましょう。


参考リンク

タイトルとURLをコピーしました