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

PHP Session 生命週期深度解析:GC 機制、分散式儲存與永久登入實現

     編輯:PHP 綜合知識

Session 是 HTTP 無狀態協議下維持用戶狀態的核心機制。許多開發者遇到「Session 過期時間設定不生效」、「多台伺服器 Session 不共享」、「關閉瀏覽器 Session 遺失」等問題。本文深入剖析 PHP Session 底層運作原理 (GC 機制、儲存處理器),並提供生產級解決方案。

核心配置參數與交互關係

參數預設值作用層面關鍵說明
session.gc_maxlifetime1440 (24分)伺服器端資料存活秒數GC 掃描時,mtime - atime > gc_maxlifetime 即刪除。不保證精確過期,取決於 GC 執行機率。
session.cookie_lifetime0 (瀏覽器關閉即失效)客戶端 Cookie 存活秒數設為 0 表示「Session Cookie」,瀏覽器進程結束銷毀。設為秒數 (如 86400) 則為「持久 Cookie」。
session.gc_probability / gc_divisor1 / 100GC 啟動機率每請求 probability/divisor (1%) 機率觸發 GC。高併發下可調大 (如 10/100),但增加 IO 開銷。
session.save_path/tmp (系統臨時目錄)Session 檔案儲存路徑關鍵:多站點共用 /tmp 會導致 GC 互相誤刪。必須為每站點/應用設定獨立目錄。
session.use_strict_mode0 (PHP 7.1+ 建議 1)安全模式啟用後拒絕未初始化的 Session ID,防止 Session Fixation 攻擊。

為什麼 gc_maxlifetime 設定常「不生效」?

根因:GC 掃描的是 save_path 目錄下所有 Session 檔案,不區分應用。

場景:
- 站點 A: gc_maxlifetime = 7200 (2小時), save_path = /tmp
- 站點 B: gc_maxlifetime = 1440 (24分), save_path = /tmp (預設同目錄)

流程:
1. 用戶訪問站點 B,觸發 GC (1% 機率)。
2. GC 掃描 /tmp,發現站點 A 的 Session 檔案修改時間超過 1440 秒。
3. GC 直接刪除該檔案 → 站點 A 用戶莫名被登出。

解決方案:隔離儲存路徑 (最簡單有效)

<?php
// 方案 1:啟動 Session 前設定 (需目錄存在且可寫)
ini_set('session.save_path', __DIR__ . '/../var/sessions'); 
// 或在 php.ini / .user.ini / httpd.conf 設定
// session.save_path = "/var/lib/php/sessions/site_a"

// 方案 2:使用 session_save_path() 函數 (同效)
session_save_path(__DIR__ . '/../var/sessions');
session_start();
?>

權限要求:目錄擁有者為 PHP-FPM 運行用戶 (www-data),權限 700 或 770,防止其他用戶讀取 Session 檔案洩露隱私。

進階方案:自定義儲存處理器 — 資料庫 / Redis

實作 SessionHandlerInterface (PHP 5.4+) 或 session_set_save_handler() (舊版),將 Session 寫入中心化儲存,解決分散式部署共享問題。

Redis 實作 (推薦:高效能、原子操作、自動過期)

<?php
class RedisSessionHandler implements SessionHandlerInterface {
    private Redis $redis;
    private string $prefix = 'sess:';
    private int $ttl = 1800; // 30分鐘,對應 gc_maxlifetime
    
    public function __construct(Redis $redis, int $ttl = 1800, string $prefix = 'sess:') {
        $this->redis = $redis;
        $this->ttl = $ttl;
        $this->prefix = $prefix;
    }
    
    public function open($savePath, $sessionName): bool { return true; }
    public function close(): bool { return true; }
    
    public function read($id): string|false {
        $data = $this->redis->get($this->prefix . $id);
        return $data !== false ? $data : '';
    }
    
    public function write($id, $data): bool {
        // SETEX 原子設值+過期,避免競態條件
        return $this->redis->setex($this->prefix . $id, $this->ttl, $data);
    }
    
    public function destroy($id): bool {
        return (bool)$this->redis->del($this->prefix . $id);
    }
    
    public function gc($maxlifetime): int|false {
        // Redis 自動過期刪除,無需手動掃描
        // 此處可掃描統計或清理孤兒 Key (無 TTL 的)
        return 0;
    }
}

// 註冊並啟動
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$handler = new RedisSessionHandler($redis, 1800);
session_set_save_handler($handler, true); // true = 關閉時自動註冊 shutdown function
session_start();
?>

MySQL 實作 (參考舊代碼邏輯修正)

<?php
// 建表
// CREATE TABLE `sessions` (
//   `id` VARCHAR(128) NOT NULL PRIMARY KEY,
//   `data` TEXT NOT NULL,
//   `expiry` INT UNSIGNED NOT NULL,
//   INDEX `idx_expiry` (`expiry`)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

class PdoSessionHandler implements SessionHandlerInterface {
    private PDO $pdo;
    private int $ttl;
    
    public function __construct(PDO $pdo, int $ttl = 1800) {
        $this->pdo = $pdo;
        $this->ttl = $ttl;
    }
    
    public function read($id): string|false {
        $stmt = $this->pdo->prepare("SELECT data FROM sessions WHERE id = ? AND expiry > UNIX_TIMESTAMP()");
        $stmt->execute([$id]);
        return $stmt->fetchColumn() ?: '';
    }
    
    public function write($id, $data): bool {
        $expiry = time() + $this->ttl;
        $sql = "INSERT INTO sessions (id, data, expiry) VALUES (?, ?, ?)
                ON DUPLICATE KEY UPDATE data = VALUES(data), expiry = VALUES(expiry)";
        return $this->pdo->prepare($sql)->execute([$id, $data, $expiry]);
    }
    
    public function destroy($id): bool {
        return $this->pdo->prepare("DELETE FROM sessions WHERE id = ?")->execute([$id]);
    }
    
    public function gc($maxlifetime): int|false {
        $stmt = $this->pdo->prepare("DELETE FROM sessions WHERE expiry < UNIX_TIMESTAMP()");
        $stmt->execute();
        return $stmt->rowCount();
    }
    // open/close 返回 true
}
?>

「永久登入」/「記住我」正確實現

原理:長效 Token (Persistent Token) + 短效 Session。Token 儲存於資料庫,綁定用戶、裝置、過期時間、用途。

資料表設計

CREATE TABLE `persistent_tokens` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `user_id` BIGINT UNSIGNED NOT NULL,
  `selector` CHAR(24) NOT NULL UNIQUE,      -- 公開標識 (用於查詢)
  `validator_hash` CHAR(64) NOT NULL,       -- 驗證值哈希 (SHA-256)
  `user_agent` VARCHAR(255),
  `ip_address` VARBINARY(16),               -- IPv6 兼容
  `expires_at` DATETIME NOT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX `idx_user_expires` (`user_id`, `expires_at`)
);

登入流程

<?php
// 用戶勾選「記住我」登入成功後
function issuePersistentToken(PDO $pdo, int $userId, string $userAgent, string $ip): array {
    $selector = bin2hex(random_bytes(12));     // 24 字符,公開
    $validator = random_bytes(32);             // 32 字節,私密
    $validatorHash = hash('sha256', $validator);
    $expires = date('Y-m-d H:i:s', time() + 30 * 86400); // 30 天
    
    $pdo->prepare("INSERT INTO persistent_tokens (user_id, selector, validator_hash, user_agent, ip_address, expires_at) VALUES (?, ?, ?, ?, INET6_ATON(?), ?)")
        ->execute([$userId, $selector, $validatorHash, $userAgent, $ip, $expires]);
    
    // 返回給前端設置 Cookie (HttpOnly, Secure, SameSite=Lax/Strict)
    return [
        'selector' => $selector,
        'validator' => bin2hex($validator), // 僅此次傳輸,不再存儲明文
    ];
}

// 自動登入中介軟 (每請求檢查)
function autoLoginFromToken(PDO $pdo, array $cookie): ?int {
    if (empty($cookie['selector']) || empty($cookie['validator'])) return null;
    
    $stmt = $pdo->prepare("SELECT user_id, validator_hash FROM persistent_tokens WHERE selector = ? AND expires_at > NOW()");
    $stmt->execute([$cookie['selector']]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    
    if ($row && hash_equals($row['validator_hash'], hash('sha256', hex2bin($cookie['validator'])))) {
        // 驗證成功:延長過期時間 (滑動窗口)、記錄活動、返回 user_id
        $pdo->prepare("UPDATE persistent_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE selector = ?")
            ->execute([$cookie['selector']]);
        return (int)$row['user_id'];
    }
    return null;
}
?>

安全加固清單

  • Session ID 重生:權限變更 (登入、提權) 後必須 session_regenerate_id(true),銷毀舊 Session 防止 Fixation。
  • Cookie 旗標:session.cookie_httponly=1、session.cookie_secure=1 (僅 HTTPS)、session.cookie_samesite=Lax (或 Strict)。
  • 嚴格模式:session.use_strict_mode=1 (PHP 7.1+),拒絕非法 SID。
  • 傳輸加密:全站 HTTPS,防止 Session ID 竊聽。
  • 定期輪換:長效 Token 定期強制失效 (如修改密碼、登出所有裝置)。

總結: Session 過期問題 90% 由 save_path 共用導致。生產環境請:1) 獨立 save_path 或 2) 採用 Redis/Memcached 集中儲存。永久登入請使用獨立長效 Token 機制,**切勿**單純拉長 session.gc_maxlifetime 和 cookie_lifetime,這會嚴重增加 Session 劫持風險與儲存壓力。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved