預設 PHP Session 以檔案儲存於本機,無法支援多伺服器負載均衡、會話共享、集中管理與審計。將 Session 存入資料庫 (MySQL/Redis) 是分佈式架構的標準解法。本文提供基於 SessionHandlerInterface 的完整 PDO/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;
<?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';
?>
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`);
同一 Session ID 並發請求可能導致「讀取舊資料 → 各自修改 → 寫回覆蓋」。解決方案:
SELECT ... FOR UPDATE 在 read() 中鎖定該行,但會嚴重影響並發效能。version 欄位,寫入時比對版本號。session.gc_probability=1, session.gc_divisor=1000 → 0.1% 請求觸發 GC。DELETE FROM sessions WHERE session_expires < NOW(),避免請求執行緒阻塞。<?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/哨兵高可用、可共享多應用會話。
| 風險 | 防護 |
|---|---|
| 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 異常模式、最小權限資料庫帳號、連線池管理 |