Session 是 HTTP 無狀態協議下維持用戶狀態的核心機制。許多開發者遇到「Session 過期時間設定不生效」、「多台伺服器 Session 不共享」、「關閉瀏覽器 Session 遺失」等問題。本文深入剖析 PHP Session 底層運作原理 (GC 機制、儲存處理器),並提供生產級解決方案。
| 參數 | 預設值 | 作用層面 | 關鍵說明 |
|---|---|---|---|
session.gc_maxlifetime | 1440 (24分) | 伺服器端資料存活秒數 | GC 掃描時,mtime - atime > gc_maxlifetime 即刪除。不保證精確過期,取決於 GC 執行機率。 |
session.cookie_lifetime | 0 (瀏覽器關閉即失效) | 客戶端 Cookie 存活秒數 | 設為 0 表示「Session Cookie」,瀏覽器進程結束銷毀。設為秒數 (如 86400) 則為「持久 Cookie」。 |
session.gc_probability / gc_divisor | 1 / 100 | GC 啟動機率 | 每請求 probability/divisor (1%) 機率觸發 GC。高併發下可調大 (如 10/100),但增加 IO 開銷。 |
session.save_path | /tmp (系統臨時目錄) | Session 檔案儲存路徑 | 關鍵:多站點共用 /tmp 會導致 GC 互相誤刪。必須為每站點/應用設定獨立目錄。 |
session.use_strict_mode | 0 (PHP 7.1+ 建議 1) | 安全模式 | 啟用後拒絕未初始化的 Session ID,防止 Session Fixation 攻擊。 |
根因: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 檔案洩露隱私。
實作 SessionHandlerInterface (PHP 5.4+) 或 session_set_save_handler() (舊版),將 Session 寫入中心化儲存,解決分散式部署共享問題。
<?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();
?>
<?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_regenerate_id(true),銷毀舊 Session 防止 Fixation。session.cookie_httponly=1、session.cookie_secure=1 (僅 HTTPS)、session.cookie_samesite=Lax (或 Strict)。session.use_strict_mode=1 (PHP 7.1+),拒絕非法 SID。總結: Session 過期問題 90% 由
save_path共用導致。生產環境請:1) 獨立save_path或 2) 採用 Redis/Memcached 集中儲存。永久登入請使用獨立長效 Token 機制,**切勿**單純拉長session.gc_maxlifetime和cookie_lifetime,這會嚴重增加 Session 劫持風險與儲存壓力。