PHP 默認將 Session 存儲在文件系統 (`session.save_handler = files`),單機無問題,但負載均衡多節點、容器化部署、會話共享、審計合規等場景下必須改用數據庫/Redis/集中式存儲。本文基於 `session_set_save_handler()` 實現完整的 MySQL/PostgreSQL 存儲類,覆蓋鎖機制、垃圾回收、防固定攻擊、集群一致性等生產級細節。
interface SessionHandlerInterface {
public function open(string $savePath, string $sessionName): bool;
public function close(): bool;
public function read(string $id): string|false;
public function write(string $id, string $data): bool;
public function destroy(string $id): bool;
public function gc(int $maxLifetime): int|false;
// PHP 7.1+ 新增
public function create_sid(): string;
public function validateId(string $id): bool;
public function updateTimestamp(string $id, string $data): bool;
}
CREATE TABLE `sessions` (
`id` VARCHAR(128) NOT NULL PRIMARY KEY, -- session_id
`data` MEDIUMBLOB NOT NULL, -- 序列化數據
`expiry` INT UNSIGNED NOT NULL, -- 過期時間戳 (expires)
`created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 創建時間
`updated` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`ip` VARBINARY(16) NOT NULL, -- 客戶端 IP (INET6_ATON)
`user_agent` VARCHAR(255) NOT NULL, -- UA 指紋
INDEX `idx_expiry` (`expiry`),
INDEX `idx_ip` (`ip`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -- 二進制比較,區分大小寫
關鍵點:
<?php
/**
* 數據庫 Session 處理器
* 支持:MySQL/PostgreSQL、悲觀鎖防併發寫入、IP/UA 綁定、自定義 ID 生成
*/
class PdoSessionHandler implements SessionHandlerInterface {
private PDO $pdo;
private string $table;
private int $lifetime;
private string $lockMode; // 'SELECT ... FOR UPDATE' / 'GET_LOCK' / 'none'
public function __construct(PDO $pdo, array $options = []) {
$this->pdo = $pdo;
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); // 真實預處理
$this->table = $options['table'] ?? 'sessions';
$this->lifetime = $options['lifetime'] ?? (int)ini_get('session.gc_maxlifetime');
$this->lockMode = $options['lock_mode'] ?? 'for_update'; // for_update | get_lock | none
}
// ===== 接口方法 =====
public function open(string $savePath, string $sessionName): bool {
return true; // PDO 連接在構造時已建立
}
public function close(): bool {
return true; // 持久連接不關閉
}
public function read(string $id): string|false {
$sql = "SELECT `data` FROM `{$this->table}` WHERE `id` = ? AND `expiry` > ? LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$id, time()]);
$row = $stmt->fetch(PDO::FETCH_COLUMN);
return $row !== false ? $row : '';
}
public function write(string $id, string $data): bool {
$expiry = time() + $this->lifetime;
$ip = $this->ipToBinary($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
// 悲觀鎖:SELECT ... FOR UPDATE 串行化寫入,防止併發覆蓋
if ($this->lockMode === 'for_update') {
return $this->writeWithLock($id, $data, $expiry, $ip, $ua);
} elseif ($this->lockMode === 'get_lock') {
return $this->writeWithGetLock($id, $data, $expiry, $ip, $ua);
}
return $this->writeSimple($id, $data, $expiry, $ip, $ua);
}
public function destroy(string $id): bool {
$stmt = $this->pdo->prepare("DELETE FROM `{$this->table}` WHERE `id` = ?");
return $stmt->execute([$id]);
}
public function gc(int $maxLifetime): int|false {
$stmt = $this->pdo->prepare("DELETE FROM `{$this->table}` WHERE `expiry` < ?");
$stmt->execute([time()]);
return $stmt->rowCount();
}
public function create_sid(): string {
// 安全隨機 ID:128 位熵,Base64URL 編碼 (無 +/=)
return rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
}
public function validateId(string $id): bool {
// 僅允許 Base64URL 字符,長度 22 (128bit) 或自定義長度
return (bool)preg_match('/^[A-Za-z0-9_-]{22,128}$/', $id);
}
public function updateTimestamp(string $id, string $data): bool {
// PHP 7.1+ session_update_timestamp() 調用
$expiry = time() + $this->lifetime;
$stmt = $this->pdo->prepare("UPDATE `{$this->table}` SET `data`=?, `expiry`=?, `updated`=NOW() WHERE `id`=?");
return $stmt->execute([$data, $expiry, $id]);
}
// ===== 寫入策略實現 =====
private function writeWithLock(string $id, string $data, int $expiry, string $ip, string $ua): bool {
try {
$this->pdo->beginTransaction();
// 鎖定行 (若不存在則鎖定 gap,防止幻讀)
$stmt = $this->pdo->prepare("SELECT `id` FROM `{$this->table}` WHERE `id` = ? FOR UPDATE");
$stmt->execute([$id]);
$upsert = $this->pdo->prepare("
INSERT INTO `{$this->table}` (`id`,`data`,`expiry`,`ip`,`user_agent`)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE `data`=VALUES(`data`), `expiry`=VALUES(`expiry`), `updated`=NOW()
");
$ok = $upsert->execute([$id, $data, $expiry, $ip, $ua]);
$this->pdo->commit();
return $ok;
} catch (Throwable $e) {
$this->pdo->rollBack();
error_log("Session write lock failed: ".$e->getMessage());
return false;
}
}
private function writeWithGetLock(string $id, string $data, int $expiry, string $ip, string $ua): bool {
// MySQL GET_LOCK() 名稱鎖,釋放自動/超時釋放
$lockName = "session_{$id}";
$this->pdo->exec("SELECT GET_LOCK('{$lockName}', 5)") or return false;
try {
$upsert = $this->pdo->prepare("
INSERT INTO `{$this->table}` (`id`,`data`,`expiry`,`ip`,`user_agent`)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE `data`=VALUES(`data`), `expiry`=VALUES(`expiry`), `updated`=NOW()
");
return $upsert->execute([$id, $data, $expiry, $ip, $ua]);
} finally {
$this->pdo->exec("SELECT RELEASE_LOCK('{$lockName}')");
}
}
private function writeSimple(string $id, string $data, int $expiry, string $ip, string $ua): bool {
$stmt = $this->pdo->prepare("
INSERT INTO `{$this->table}` (`id`,`data`,`expiry`,`ip`,`user_agent`)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE `data`=VALUES(`data`), `expiry`=VALUES(`expiry`), `updated`=NOW()
");
return $stmt->execute([$id, $data, $expiry, $ip, $ua]);
}
private function ipToBinary(string $ip): string {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return inet_pton($ip); // 16 字節
}
return inet_pton($ip); // IPv4 也返回 16 字節 (::ffff:x.x.x.x)
}
}
<?php
// bootstrap.php / public/index.php 最頂部
require 'vendor/autoload.php';
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4',
'user', 'pass',
[PDO::ATTR_PERSISTENT => true, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$handler = new PdoSessionHandler($pdo, [
'lifetime' => 1800, // 30 分鐘
'lock_mode' => 'for_update', // 高併發推薦
'table' => 'sessions',
]);
// 關鍵:在 session_start() 之前註冊
session_set_save_handler($handler, true); // true = 使用 OOP 接口
// 安全配置
ini_set('session.use_strict_mode', '1'); // 拒絕未初始化 ID
ini_set('session.use_only_cookies', '1'); // 僅 Cookie 傳輸 ID
ini_set('session.cookie_httponly', '1'); // JS 不可訪問
ini_set('session.cookie_secure', '1'); // 僅 HTTPS (生產必須)
ini_set('session.cookie_samesite', 'Lax'); // CSRF 防護
ini_set('session.gc_probability', '1');
ini_set('session.gc_divisor', '100'); // 1% 概率觸發 GC
ini_set('session.sid_length', '32'); // ID 長度
ini_set('session.sid_bits_per_character', '5'); // Base32 編碼
session_start();
// 會話劫持檢測 (可選)
if (isset($_SESSION['_fingerprint'])) {
$fp = hash('sha256', $_SERVER['REMOTE_ADDR'].$_SERVER['HTTP_USER_AGENT']);
if ($_SESSION['_fingerprint'] !== $fp) {
session_destroy(); // 強制失效
header('Location: /login?expired=1');
exit;
}
} else {
$_SESSION['_fingerprint'] = hash('sha256', $_SERVER['REMOTE_ADDR'].$_SERVER['HTTP_USER_AGENT']);
}
| 方案 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|
| MySQL + 行鎖 | ACID、工具成熟、支持複雜查詢/審計 | 寫入延遲較高、連接數限制、單點寫入瓶頸 | 中小型、強一致性要求、審計合規 |
| Redis (原生/Cluster) | 極快 (微秒)、原生 TTL 自動過期、支持 Lua 腳本原子操作 | 數據易失 (需 AOF/RDB)、無原生查詢、內存成本高 | 高併發、大規模、可容忍極少數據丟失 |
| MySQL 主從 + 讀寫分離 | 讀擴展、寫入單點 | 主庫寫入仍是瓶頸、複製延遲 | 讀多寫少 |
| 分庫分表 (按 session_id hash) | 寫入水平擴展 | 跨分片查詢難、GC 複雜、運維成本高 | 超大規模 (億級會話) |
| etcd/Consul (服務發現+會話) | 強一致、TTL、Watch 變更通知 | 吞吐低、僅適合元數據/配置 | 微服務網關會話同步 |
class RedisSessionHandler implements SessionHandlerInterface {
private Redis $redis;
private string $prefix = 'sess:';
private int $lifetime;
public function __construct(Redis $redis, int $lifetime = 1800) {
$this->redis = $redis; $this->lifetime = $lifetime;
}
public function read(string $id): string|false {
$data = $this->redis->get($this->prefix.$id);
return $data !== false ? $data : '';
}
public function write(string $id, string $data): bool {
// SETEX 原子設置值與過期
return $this->redis->setex($this->prefix.$id, $this->lifetime, $data);
}
public function destroy(string $id): bool {
return (bool)$this->redis->del($this->prefix.$id);
}
public function gc(int $maxLifetime): int|false {
return 0; // Redis 自動過期,無需 GC
}
// ... 其他接口方法
}
自定義 Session Save Handler 是 PHP 應用架構師的必修課。從文件到數據庫,再到 Redis、分佈式一致性協議,核心始終是狀態的序列化、存儲、並發控制、生命周期管理。選擇適合業務規模的方案,建立完善的監控與應急預案,才能支撐高可用的用戶會話體系。