預設 session.save_handler = files 將 Session 存為文件,存在明顯短板:
通過實現 SessionHandlerInterface 並註冊 session_set_save_handler(),可將 Session 存儲重定向到Redis、MySQL、MongoDB、Etcd、自定義加密文件等任意介質。
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;
}
class RedisSessionHandler implements SessionHandlerInterface {
private Redis $redis;
private string $prefix;
private int $ttl;
public function __construct(Redis $redis, string $prefix = 'sess:', int $ttl = 1800) {
$this->redis = $redis;
$this->prefix = $prefix;
$this->ttl = $ttl;
}
public function open($savePath, $sessionName): bool { return true; }
public function close(): bool { return true; }
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->ttl, $data);
}
public function destroy(string $id): bool {
return (bool)$this->redis->del($this->prefix . $id);
}
public function gc(int $maxLifetime): int|false {
// Redis 自動過期,無需手動 GC
return true;
}
// 額外業務方法
public function getAllSessions(): array {
$keys = $this->redis->keys($this->prefix . '*');
return $keys ?: [];
}
public function getSessionData(string $id): ?array {
$data = $this->read($id);
return $data ? @unserialize($data) : null;
}
public function deleteUserSessions(int $userId): int {
$deleted = 0;
foreach ($this->getAllSessions() as $key) {
$data = $this->getSessionData(str_replace($this->prefix, '', $key));
if (isset($data['user_id']) && $data['user_id'] === $userId) {
$this->destroy(str_replace($this->prefix, '', $key));
$deleted++;
}
}
return $deleted;
}
}
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 集群模式: $redis->connect('redis-cluster', 6379); 或使用 RedisCluster
$handler = new RedisSessionHandler($redis, 'myapp:sess:', 1800);
// PHP 7.0+ 推薦寫法
session_set_save_handler($handler, true); // true = register_shutdown_function
// 或
ini_set('session.save_handler', 'user');
session_set_save_handler($handler);
session_start();
// 正常使用 $_SESSION
$_SESSION['user_id'] = 123;
CREATE TABLE `sessions` (
`id` VARCHAR(128) NOT NULL PRIMARY KEY,
`data` BLOB NOT NULL,
`expires` INT UNSIGNED NOT NULL,
`user_id` INT UNSIGNED NULL,
`last_activity` INT UNSIGNED NOT NULL,
INDEX `idx_expires` (`expires`),
INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
class MySQLSessionHandler implements SessionHandlerInterface {
private MySQLi $db;
private int $ttl;
public function __construct(MySQLi $db, int $ttl = 1800) {
$this->db = $db;
$this->ttl = $ttl;
}
public function open($savePath, $sessionName): bool { return true; }
public function close(): bool { return true; }
public function read(string $id): string|false {
$stmt = $this->db->prepare("SELECT `data` FROM `sessions` WHERE `id` = ? AND `expires` > UNIX_TIMESTAMP()");
$stmt->bind_param("s", $id);
$stmt->execute();
$stmt->bind_result($data);
$stmt->fetch();
$stmt->close();
return $data ?? '';
}
public function write(string $id, string $data): bool {
$expires = time() + $this->ttl;
$userId = $_SESSION['user_id'] ?? null;
$stmt = $this->db->prepare("
INSERT INTO `sessions` (`id`, `data`, `expires`, `user_id`, `last_activity`)
VALUES (?, ?, ?, ?, UNIX_TIMESTAMP())
ON DUPLICATE KEY UPDATE
`data` = VALUES(`data`),
`expires` = VALUES(`expires`),
`user_id` = VALUES(`user_id`),
`last_activity` = UNIX_TIMESTAMP()
");
$stmt->bind_param("ssisi", $id, $data, $expires, $userId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
public function destroy(string $id): bool {
$stmt = $this->db->prepare("DELETE FROM `sessions` WHERE `id` = ?");
$stmt->bind_param("s", $id);
$result = $stmt->execute();
$stmt->close();
return $result;
}
public function gc(int $maxLifetime): int|false {
$stmt = $this->db->prepare("DELETE FROM `sessions` WHERE `expires` < UNIX_TIMESTAMP()");
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
return $affected;
}
}
class EncryptedFileSessionHandler implements SessionHandlerInterface {
private string $savePath;
private string $key; // 32 字節密鑰
public function __construct(string $savePath, string $key) {
$this->savePath = rtrim($savePath, '/') . '/';
$this->key = $key;
if (!is_dir($this->savePath)) mkdir($this->savePath, 0700, true);
}
private function getFilePath(string $id): string {
// 安全文件名:僅允許字母數字
$safeId = preg_replace('/[^a-zA-Z0-9,-]/', '', $id);
return $this->savePath . 'sess_' . $safeId;
}
private function encrypt(string $data): string {
$iv = random_bytes(16);
$ciphertext = openssl_encrypt($data, 'aes-256-gcm', $this->key, OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $tag . $ciphertext);
}
private function decrypt(string $data): string {
$raw = base64_decode($data);
$iv = substr($raw, 0, 16);
$tag = substr($raw, 16, 16);
$ciphertext = substr($raw, 32);
return openssl_decrypt($ciphertext, 'aes-256-gcm', $this->key, OPENSSL_RAW_DATA, $iv, $tag);
}
public function open($savePath, $sessionName): bool { return true; }
public function close(): bool { return true; }
public function read(string $id): string|false {
$file = $this->getFilePath($id);
if (!file_exists($file)) return '';
$encrypted = file_get_contents($file);
try {
return $this->decrypt($encrypted);
} catch (Exception) {
return ''; // 解密失敗視為無效
}
}
public function write(string $id, string $data): bool {
$file = $this->getFilePath($id);
$encrypted = $this->encrypt($data);
// 原子寫入
$tmp = $file . '.tmp';
file_put_contents($tmp, $encrypted);
rename($tmp, $file);
return true;
}
public function destroy(string $id): bool {
$file = $this->getFilePath($id);
return file_exists($file) ? unlink($file) : true;
}
public function gc(int $maxLifetime): int|false {
$deleted = 0;
foreach (glob($this->savePath . 'sess_*') as $file) {
if (filemtime($file) + $maxLifetime < time()) {
unlink($file);
$deleted++;
}
}
return $deleted;
}
}
// config.php
return [
'session' => [
'handler' => env('SESSION_HANDLER', 'redis'), // file | mysql | redis | encrypted
'redis' => ['host' => '127.0.0.1', 'port' => 6379, 'prefix' => 'app:sess:', 'ttl' => 1800],
'mysql' => ['table' => 'sessions', 'ttl' => 1800],
'file' => ['path' => '/var/lib/php/sessions', 'encrypt_key' => ''],
],
];
// bootstrap.php
$config = require 'config.php';
$sessionCfg = $config['session'];
switch ($sessionCfg['handler']) {
case 'redis':
$redis = new Redis();
$redis->connect($sessionCfg['redis']['host'], $sessionCfg['redis']['port']);
$handler = new RedisSessionHandler($redis, $sessionCfg['redis']['prefix'], $sessionCfg['redis']['ttl']);
break;
case 'mysql':
$db = new MySQLi(...);
$handler = new MySQLSessionHandler($db, $sessionCfg['mysql']['ttl']);
break;
case 'encrypted':
$handler = new EncryptedFileSessionHandler($sessionCfg['file']['path'], $sessionCfg['file']['encrypt_key']);
break;
default: // file
ini_set('session.save_path', $sessionCfg['file']['path']);
$handler = null; // 使用內建 files handler
}
if ($handler) {
session_set_save_handler($handler, true);
}
session_start();
// 存儲為 Hash,支持字段級操作
$this->redis->hset($key, 'data', serialize($data));
$this->redis->hset($key, 'user_id', $userId);
$this->redis->hset($key, 'ip', $_SERVER['REMOTE_ADDR']);
$this->redis->expire($key, $this->ttl);
// 查詢用戶所有會話
$keys = $this->redis->keys($this->prefix . '*');
foreach ($keys as $key) {
if ($this->redis->hget($key, 'user_id') == $targetUserId) {
// 踢出、統計等
}
}
public function write(string $id, string $data): bool {
$lockKey = $this->prefix . 'lock:' . $id;
$lock = $this->redis->set($lockKey, 1, ['nx', 'ex' => 10]); // 10秒鎖
if (!$lock) return false; // 獲取鎖失敗
try {
return $this->redis->setex($this->prefix . $id, $this->ttl, $data);
} finally {
$this->redis->del($lockKey);
}
}
| 指標 | Redis 獲取方式 | 告警閾值建議 |
|---|---|---|
| 活躍會話數 | DBSIZE 或 KEYS sess:* 計數 | > 預估峰值 80% |
| 內存佔用 | INFO memory | > 可用內存 70% |
| 過期/淘汰率 | INFO stats expired_keys, evicted_keys | evicted_keys > 0 即告警 |
| 慢查詢 | SLOWLOG GET | > 10ms |
自定義 Session Handler 是 PHP 應用從單機走向分佈式、高可用、可審計的關鍵一步。Redis Handler 為首選(高性能、原生過期、豐富數據結構),MySQL Handler 適合強合規審計場景,加密文件 Handler 適合無外部依賴的安全敏感環境。通過統一接口封裝、配置驅動切換、擴展業務方法(踢出用戶、在線統計),即可構建企業級會話管理基礎設施。