首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP Session 存入資料庫完整實現:自定義處理器、分佈式會話與安全加固

     編輯:PHP 相關知識

預設 PHP Session 以檔案儲存於本機,無法支援多伺服器負載均衡、會話共享、集中管理與審計。將 Session 存入資料庫 (MySQL/Redis) 是分佈式架構的標準解法。本文提供基於 SessionHandlerInterface 的完整 PDO/MySQL 實現,並涵蓋表結構、垃圾回收、並發鎖、安全配置與遷移建議。

資料表結構 (MySQL)

CREATE TABLE `sessions` (
    `session_id` VARCHAR(255) NOT NULL PRIMARY KEY,
    `session_data` LONGTEXT NOT NULL,
    `session_expires` INT UNSIGNED NOT NULL, -- UNIX 時間戳
    `user_id` INT UNSIGNED NULL DEFAULT NULL, -- 可選:關聯用戶,便於強制下線
    `last_activity` INT UNSIGNED NOT NULL,
    INDEX `idx_expires` (`session_expires`),
    INDEX `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

完整 SessionHandler 實現 (PDO 版)

<?php
declare(strict_types=1);

class PdoSessionHandler implements SessionHandlerInterface {
    private PDO $pdo;
    private int $maxLifetime;
    private string $table;

    public function __construct(PDO $pdo, array $config = []) {
        $this->pdo = $pdo;
        $this->table = $config['table'] ?? 'sessions';
        $this->maxLifetime = $config['max_lifetime'] ?? (int)ini_get('session.gc_maxlifetime');
        // 確保表存在 (生產環境建議用 Migration 工具)
        $this->ensureTable();
    }

    private function ensureTable(): void {
        $sql = "CREATE TABLE IF NOT EXISTS `{$this->table}` (
            `session_id` VARCHAR(255) NOT NULL PRIMARY KEY,
            `session_data` LONGTEXT NOT NULL,
            `session_expires` INT UNSIGNED NOT NULL,
            `user_id` INT UNSIGNED NULL DEFAULT NULL,
            `last_activity` INT UNSIGNED NOT NULL,
            INDEX `idx_expires` (`session_expires`),
            INDEX `idx_user` (`user_id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
        $this->pdo->exec($sql);
    }

    // 開啟會話 (SessionHandlerInterface 要求)
    public function open(string $path, string $name): bool {
        return true; // PDO 連線已在建構子建立
    }

    public function close(): bool {
        return true;
    }

    // 讀取會話資料
    public function read(string $id): string|false {
        $stmt = $this->pdo->prepare(
            "SELECT `session_data` FROM `{$this->table}` WHERE `session_id` = ? AND `session_expires` > ?"
        );
        $stmt->execute([$id, time()]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row['session_data'] ?? '';
    }

    // 寫入會話資料 (REPLACE INTO 實現 UPSERT)
    public function write(string $id, string $data): bool {
        $expires = time() + $this->maxLifetime;
        $stmt = $this->pdo->prepare(
            "REPLACE INTO `{$this->table}` (`session_id`, `session_data`, `session_expires`, `last_activity`) VALUES (?, ?, ?, ?)"
        );
        return $stmt->execute([$id, $data, $expires, time()]);
    }

    // 銷毀會話
    public function destroy(string $id): bool {
        $stmt = $this->pdo->prepare("DELETE FROM `{$this->table}` WHERE `session_id` = ?");
        return $stmt->execute([$id]);
    }

    // 垃圾回收 (清理過期會話)
    public function gc(int $maxLifetime): int|false {
        $stmt = $this->pdo->prepare("DELETE FROM `{$this->table}` WHERE `session_expires` < ?");
        $stmt->execute([time()]);
        return $stmt->rowCount();
    }

    // 可選:依用戶 ID 清理會話 (強制下線)
    public function destroyByUserId(int $userId): int {
        $stmt = $this->pdo->prepare("DELETE FROM `{$this->table}` WHERE `user_id` = ?");
        $stmt->execute([$userId]);
        return $stmt->rowCount();
    }
}

// 註冊處理器 (必須在 session_start() 之前)
$pdo = new PDO('mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4', 'user', 'pass', [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$handler = new PdoSessionHandler($pdo, ['table' => 'sessions', 'max_lifetime' => 1440]);
session_set_save_handler($handler, true); // true = 關閉時自動調用 write/close
session_start();

// 現在 $_SESSION 讀寫自動同步到資料庫
$_SESSION['user_id'] = 123;
$_SESSION['role'] = 'admin';
?>

關鍵實作細節解析

1. REPLACE INTO vs INSERT ... ON DUPLICATE KEY UPDATE

REPLACE INTO 會先 DELETE 再 INSERT,導致自增 ID 變化、外鍵觸發、效能稍差。生產環境建議改用:

INSERT INTO `sessions` (`session_id`, `session_data`, `session_expires`, `last_activity`)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
    `session_data` = VALUES(`session_data`),
    `session_expires` = VALUES(`session_expires`),
    `last_activity` = VALUES(`last_activity`);

2. 並發競態與鎖

同一 Session ID 並發請求可能導致「讀取舊資料 → 各自修改 → 寫回覆蓋」。解決方案:

  • 資料庫行鎖:SELECT ... FOR UPDATE 在 read() 中鎖定該行,但會嚴重影響並發效能。
  • Redis 原子操作:改用 Redis Session (參見下節),天然支援 Lua 腳本原子讀寫。
  • 樂觀鎖:Session 資料加入 version 欄位,寫入時比對版本號。
  • 接受最終一致性:大多數場景 Session 寫入頻率低,衝突極少,可接受極小概率丟失更新。

3. 垃圾回收 (GC) 機制

  • PHP 預設 session.gc_probability=1, session.gc_divisor=1000 → 0.1% 請求觸發 GC。
  • 高流量系統建議改用 定時任務 (Cron) 定期清理:DELETE FROM sessions WHERE session_expires < NOW(),避免請求執行緒阻塞。

進階:Redis Session Handler (高效能首選)

<?php
// php.ini 或運行時設定
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://127.0.0.1:6379?auth=your_password&database=0&prefix=session:');

// 或程式碼註冊 (需 phpredis 擴展)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('your_password');
$redis->select(0);

session_set_save_handler(
    new class($redis) implements SessionHandlerInterface {
        private Redis $redis;
        private string $prefix = 'session:';
        public function __construct(Redis $redis) { $this->redis = $redis; }
        public function open($p, $n) { return true; }
        public function close() { return true; }
        public function read($id) { return $this->redis->get($this->prefix . $id) ?: ''; }
        public function write($id, $data) { 
            return $this->redis->setex($this->prefix . $id, ini_get('session.gc_maxlifetime'), $data); 
        }
        public function destroy($id) { return $this->redis->del($this->prefix . $id); }
        public function gc($maxLifetime) { return 0; } // Redis 自動過期,無需 GC
    },
    true
);
session_start();
?>

Redis 優勢:記憶體級速度、原子操作、內建 TTL 自動過期、支援 Cluster/哨兵高可用、可共享多應用會話。

安全加固清單

遷移建議:檔案 → 資料庫/Redis 無縫切換

  1. 新增資料庫/Redis Session Handler 代碼。
  2. 雙寫階段:自定義 Handler write() 同時寫入檔案 (舊) 與資料庫 (新),read() 優先讀資料庫,回退檔案。
  3. 觀察錯誤率、效能指標穩定後,切換 session.save_handler 至新 Handler。
  4. 清理舊 Session 檔案目錄。

將 Session 持久化到資料庫/Redis,是邁向水平擴展、高可用架構的關鍵一步。選擇 MySQL 利於審計與關聯查詢,選擇 Redis 追求極致效能與簡潔運維。掌握 SessionHandlerInterface 實現細節,能讓你在遷移、除錯、自定義業務邏輯 (如單點登入、強制下線) 時遊刃有餘。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved
風險防護
Session ID 洩露/偽造啟用 session.use_strict_mode=1、session.use_only_cookies=1、session.cookie_httponly=1、session.cookie_secure=1 (HTTPS)、session.cookie_samesite=Lax/Strict
Session 固定攻擊登入後必須 session_regenerate_id(true) 重新生成 ID
敏感資料存明文Session 僅存用戶 ID、角色等非敏感標識;Token、金鑰加密存儲或放 Redis 並啟用加密傳輸
資料庫連線洩露PDO 異常模式、最小權限資料庫帳號、連線池管理