PHP 默認文件存儲 Session 面臨小文件海量 IO、分布式共享難、統計在線用戶不便三大痛點。文章提供了一個基於 MySQL + session_set_save_handler 的完整類實現。本文將其重構為**生產級高可用方案**,覆蓋表設計優化、鎖機制、集群同步、安全加固、監控指標與現代框架整合。
CREATE TABLE `php_sessions` (
`session_id` VARBINARY(128) NOT NULL COMMENT '二進制存儲,節省空間、區分大小寫',
`user_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '關聯用戶 ID,建立索引便於踢下線/統計',
`data` MEDIUMBLOB NOT NULL COMMENT '序列化數據,MEDIUMBLOB 16MB 足夠',
`created_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
`expires_at` TIMESTAMP(6) NOT NULL COMMENT '絕對過期時間,索引加速 GC',
`ip_address` VARBINARY(16) NULL DEFAULT NULL COMMENT 'INET6_ATON 存儲 IPv4/IPv6',
`user_agent` VARCHAR(512) NULL DEFAULT NULL,
PRIMARY KEY (`session_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_expires_at` (`expires_at`),
INDEX `idx_updated_at` (`updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
COMMENT='PHP Session 存儲表';
關鍵點:VARBINARY 存 ID (避免字符集排序開銷)、expires_at 絕對時間索引 (GC 極快)、user_id 可空 (遊客會話)、MEDIUMBLOB 避免 TEXT 字符集轉換、TIMESTAMP(6) 微秒精度防併發衝突。
SessionHandlerInterface)
<?php
declare(strict_types=1);
namespace App\Session;
use SessionHandlerInterface;
use PDO;
use RuntimeException;
use Throwable;
class PdoSessionHandler implements SessionHandlerInterface
{
private PDO $pdo;
private int $gcProbability = 1; // 1% 概率觸發 GC (配合 session.gc_divisor=100)
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->gcProbability = $options['gc_probability'] ?? 1;
}
public function open(string $path, string $name): bool { return true; }
public function close(): bool { return true; }
public function read(string $id): string|false
{
$stmt = $this->pdo->prepare(
"SELECT `data` FROM `php_sessions` WHERE `session_id` = ? AND `expires_at` > NOW(6) FOR UPDATE"
);
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_COLUMN);
return $row !== false ? $row : '';
}
public function write(string $id, string $data): bool
{
$expiresAt = (new DateTime())->modify("+{$this->getLifetime()} seconds")->format('Y-m-d H:i:s.u');
$userId = $this->extractUserId($data); // 從序列化數據解析 user_id (可選)
$sql = "INSERT INTO `php_sessions` (`session_id`, `user_id`, `data`, `expires_at`, `ip_address`, `user_agent`)
VALUES (?, ?, ?, ?, INET6_ATON(?), ?)
ON DUPLICATE KEY UPDATE
`user_id` = VALUES(`user_id`),
`data` = VALUES(`data`),
`expires_at` = VALUES(`expires_at`),
`updated_at` = NOW(6),
`ip_address` = VALUES(`ip_address`),
`user_agent` = VALUES(`user_agent`)";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute([
$id,
$userId,
$data,
$expiresAt,
$_SERVER['REMOTE_ADDR'] ?? null,
$_SERVER['HTTP_USER_AGENT'] ?? null,
]);
}
public function destroy(string $id): bool
{
$stmt = $this->pdo->prepare("DELETE FROM `php_sessions` WHERE `session_id` = ?");
return $stmt->execute([$id]);
}
public function gc(int $maxLifetime): int|false
{
// 僅按概率執行,避免每請求掃表
if (mt_rand(1, 100) > $this->gcProbability) return 0;
$stmt = $this->pdo->prepare("DELETE FROM `php_sessions` WHERE `expires_at` <= NOW(6)");
$stmt->execute();
return $stmt->rowCount();
}
private function getLifetime(): int
{
return (int) ini_get('session.gc_maxlifetime');
}
private function extractUserId(string $data): ?int
{
// 簡易解析 PHP 序列化格式: user_id|i:123;
if (preg_match('/user_id\|i:(\d+);/', $data, $m)) {
return (int) $m[1];
}
return null;
}
// === 業務擴展方法 ===
public function getByUserId(int $userId): array
{
$stmt = $this->pdo->prepare("SELECT * FROM `php_sessions` WHERE `user_id` = ? AND `expires_at` > NOW(6)");
$stmt->execute([$userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function revokeUserSessions(int $userId, ?string $exceptId = null): int
{
$sql = "DELETE FROM `php_sessions` WHERE `user_id` = ?";
$params = [$userId];
if ($exceptId) {
$sql .= " AND `session_id` != ?";
$params[] = $exceptId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
}
<?php
// bootstrap/session.php
use App\Session\PdoSessionHandler;
$pdo = new PDO(
"mysql:host=127.0.0.1;dbname=app;charset=utf8mb4",
'session_user', 'strong_pass',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$handler = new PdoSessionHandler($pdo, ['gc_probability' => 5]); // 5% GC 概率
// 關鍵:必須在 session_start() 之前註冊
session_set_save_handler($handler, true); // true = 使用 OOP 介面
session_start();
| 架構 | Session 一致性 | 延遲 | 成本 | 推薦場景 |
|---|---|---|---|---|
| 單主 MySQL + 讀寫分離 | 強一致 (寫主讀從) | 低 | 中 | 中小型、讀多寫少 |
| MySQL Group Replication / InnoDB Cluster | 強一致 (多主同步) | 中 | 高 | 金融、核心業務 |
| Redis Cluster (主流) | 最終一致 (異步複製) | 極低 | 中 | 高併發、微服務、雲原生 |
| Redis + MySQL 雙寫 (雙活) | 強一致 (MySQL 兜底) | 低 | 高 | 零容忍丟失、合規要求 |
現代首選:Redis Cluster (phpredis + session.save_handler = redis),配合 Lua 腳本實現原子鎖、Lua GC、Pipeline 批量操作。MySQL 方案適合「已有 MySQL、不想引入 Redis、數據量 < 1000 萬、需 SQL 查詢分析」場景。
session.sid_length = 48, session.sid_bits_per_character = 6 (PHP 7.1+)。session.cookie_httponly=1, session.cookie_secure=1 (HTTPS), session.cookie_samesite=Strict (或 Lax)。session.use_strict_mode=1, session.use_only_cookies=1。session.serialize_handler = php_serialize + 自定義加密序列化器。ip_address/user_agent 一致,不一致銷毀會話 (需權衡移動端體驗)。session_regenerate_id(true)。
# HELP php_sessions_active 當前活躍會話數
# TYPE php_sessions_active gauge
php_sessions_active 12345
# HELP php_sessions_gc_deleted_last GC 最後一次清理數量
# TYPE php_sessions_gc_deleted_last gauge
php_sessions_gc_deleted_last 37
# HELP php_sessions_write_latency_seconds 寫入延遲
# TYPE php_sessions_write_latency_seconds histogram
php_sessions_write_latency_seconds_bucket{le="0.005"} 1200
php_sessions_write_latency_seconds_bucket{le="0.01"} 1250
...
將 Session 遷移到 MySQL 解決了文件存儲的三大痛點,但引入了數據庫連接開銷、鎖競爭、單點故障新挑戰。本文提供的 PdoSessionHandler 通過 FOR UPDATE 行鎖、ON DUPLICATE KEY UPDATE 原子寫入、概率 GC、expires_at 索引、預處理語句、IP/UA 記錄 等技術,構建了生產級可用方案。對於高併發/微服務場景,**強烈建議直接上 Redis Cluster**;MySQL 方案作為「不引入新組件、需 SQL 審計、數據量可控」的優秀備選。