預設 PHP Session 以檔案儲存 (session.save_handler = files),單機無問題,但多伺服器負載均衡下會導致 Session 不共享、用戶頻繁掉線。將 Session 存入 MySQL/Redis 可實現分散式共享。本文提供 session_set_save_handler() 完整回調實作、PDO 類別封裝、資料表設計、垃圾回收策略與生產環境檢查清單。
CREATE TABLE `php_sessions` (
`id` CHAR(32) NOT NULL PRIMARY KEY, -- Session ID
`data` MEDIUMBLOB NOT NULL, -- 序列化資料
`expires` INT UNSIGNED NOT NULL, -- 過期 Unix 時間戳
`user_id` INT UNSIGNED DEFAULT NULL, -- 關聯用戶 ID(可選)
`user_agent` VARCHAR(255) DEFAULT NULL, -- 綁定 UA 防劫持
`ip` VARCHAR(45) DEFAULT NULL, -- 綁定 IP(可選)
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_expires` (`expires`),
INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
<?php
// session_mysql_handler.php - 引入即用
$dsn = 'mysql:host=127.0.0.1;dbname=session_db;charset=utf8mb4';
$pdo = new PDO($dsn, 'session_user', 'strong_password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$lifetime = ini_get('session.gc_maxlifetime') ?: 1440; // 秒
// 1. open($savePath, $sessionName) -> bool
function sessionOpen($savePath, $sessionName) { return true; }
// 2. close() -> bool
function sessionClose() { return true; }
// 3. read($id) -> string|false
function sessionRead($id) {
global $pdo;
$stmt = $pdo->prepare('SELECT `data` FROM `php_sessions` WHERE `id` = ? AND `expires` > ?');
$stmt->execute([$id, time()]);
$row = $stmt->fetch();
return $row ? $row['data'] : '';
}
// 4. write($id, $data) -> bool
function sessionWrite($id, $data) {
global $pdo, $lifetime;
$expires = time() + $lifetime;
// REPLACE INTO 或 INSERT ... ON DUPLICATE KEY UPDATE
$stmt = $pdo->prepare('
INSERT INTO `php_sessions` (`id`, `data`, `expires`) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `data` = VALUES(`data`), `expires` = VALUES(`expires`), `updated_at` = CURRENT_TIMESTAMP
');
return $stmt->execute([$id, $data, $expires]);
}
// 5. destroy($id) -> bool
function sessionDestroy($id) {
global $pdo;
$stmt = $pdo->prepare('DELETE FROM `php_sessions` WHERE `id` = ?');
return $stmt->execute([$id]);
}
// 6. gc($maxLifetime) -> bool
function sessionGC($maxLifetime) {
global $pdo;
$stmt = $pdo->prepare('DELETE FROM `php_sessions` WHERE `expires` < ?');
return $stmt->execute([time()]);
}
// 註冊處理器(必須在 session_start() 之前)
session_set_save_handler(
'sessionOpen', 'sessionClose', 'sessionRead', 'sessionWrite', 'sessionDestroy', 'sessionGC'
);
// 關鍵:PHP 7.0+ 支援 register_shutdown_function 自動 write/close
session_start();
<?php
namespace App\Session;
use PDO;
use SessionHandlerInterface;
class PdoSessionHandler implements SessionHandlerInterface {
private PDO $pdo;
private int $lifetime;
private string $table;
public function __construct(PDO $pdo, string $table = 'php_sessions', int $lifetime = 0) {
$this->pdo = $pdo;
$this->table = $table;
$this->lifetime = $lifetime ?: (ini_get('session.gc_maxlifetime') ?: 1440);
}
public function open($savePath, $sessionName): bool { return true; }
public function close(): bool { return true; }
public function read($id): string|false {
$stmt = $this->pdo->prepare("SELECT `data` FROM `{$this->table}` WHERE `id` = ? AND `expires` > ?");
$stmt->execute([$id, time()]);
return $stmt->fetchColumn() ?: '';
}
public function write($id, $data): bool {
$expires = time() + $this->lifetime;
$stmt = $this->pdo->prepare("
INSERT INTO `{$this->table}` (`id`, `data`, `expires`) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `data` = VALUES(`data`), `expires` = VALUES(`expires`)
");
return $stmt->execute([$id, $data, $expires]);
}
public function destroy($id): bool {
$stmt = $this->pdo->prepare("DELETE FROM `{$this->table}` WHERE `id` = ?");
return $stmt->execute([$id]);
}
public function gc($maxLifetime): int|false {
$stmt = $this->pdo->prepare("DELETE FROM `{$this->table}` WHERE `expires` < ?");
$stmt->execute([time()]);
return $stmt->rowCount();
}
}
// 使用(Laravel/Symfony/自建容器皆可注入)
$handler = new PdoSessionHandler($pdo);
session_set_save_handler($handler, true); // true = register_shutdown_function
session_start();
<?php
// 在 write() 中加入綁定與樂觀鎖
public function write($id, $data): bool {
$expires = time() + $this->lifetime;
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$userId = $_SESSION['user_id'] ?? null;
// 使用 ROW_LOCK 避免並發寫入覆蓋(InnoDB 行鎖)
$this->pdo->beginTransaction();
try {
// SELECT ... FOR UPDATE 鎖定該行
$stmt = $this->pdo->prepare("SELECT `id` FROM `{$this->table}` WHERE `id` = ? FOR UPDATE");
$stmt->execute([$id]);
$exists = $stmt->fetchColumn();
if ($exists) {
$stmt = $this->pdo->prepare("
UPDATE `{$this->table}`
SET `data` = ?, `expires` = ?, `user_agent` = ?, `ip` = ?, `user_id` = ?, `updated_at` = CURRENT_TIMESTAMP
WHERE `id` = ?
");
$stmt->execute([$data, $expires, $ua, $ip, $userId, $id]);
} else {
$stmt = $this->pdo->prepare("
INSERT INTO `{$this->table}` (`id`, `data`, `expires`, `user_agent`, `ip`, `user_id`)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([$id, $data, $expires, $ua, $ip, $userId]);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
return false;
}
}
// read() 時驗證 UA/IP(防 Session 劫持)
public function read($id): string|false {
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$stmt = $this->pdo->prepare("
SELECT `data` FROM `{$this->table}`
WHERE `id` = ? AND `expires` > ? AND `user_agent` = ? AND `ip` = ?
");
$stmt->execute([$id, time(), $ua, $ip]);
return $stmt->fetchColumn() ?: '';
}
| 項目 | 建議配置 |
|---|---|
| 資料庫連線 | 持久連線 PDO::ATTR_PERSISTENT => true、連線池 (PdoPool/ProxySQL) |
| GC 執行機率 | session.gc_probability = 1, session.gc_divisor = 1000 (1/1000 請求觸發,減少鎖爭用) |
| Session ID 長度 | session.sid_length = 32, session.sid_bits_per_character = 5 (更安全) |
| Cookie 設定 | session.cookie_httponly = 1, session.cookie_secure = 1 (HTTPS), session.cookie_samesite = 'Lax' |
| 嚴格模式 | session.use_strict_mode = 1 (防 Session Fixation) |
| 備選方案 | 高併發首選 Redis Cluster (phpredis)、Memcached,MySQL 適合中小流量/需關聯查詢場景 |
| 監控指標 | Session 表行數、GC 耗時、鎖等待、寫入 QPS |
config/session.php → 'driver' => 'database',自動建表、支援 Redis/Memcached/Array/Cookie。framework.session.handler_id: session.handler.native 或 pdo。session 配置 type: pdo。總結: session_set_save_handler() + MySQL/PDO 實現分散式 Session 共享,核心在於「正確實作六個回調、資料表含過期索引、GC 機率調優、寫入加鎖、讀取驗證 UA/IP」。中大型專案建議直接使用 Redis Cluster 或框架內建驅動,僅在需關聯用戶查詢、審計日誌時選 MySQL。