各位同学、各位“搬砖工”、各位被 P0 级 Bug 搞得想辞职的架构师们,大家好!
我是你们的老朋友,一个头发还在,但 sanity(理智)正在逐渐流失的资深 PHP 工程师。今天咱们不聊虚的,不聊框架选型,也不聊微服务是不是包饺子。咱们来聊点“痛”的——聊聊 PHP 的 Session。
是的,那个伴随着你从小白到大神的、粘稠得像胶水一样的 Session。
为什么要聊这个?因为你们中很多人最近都在搞“云原生迁移”。从物理机搬到了 Docker,从 Docker 搬到了 K8s,从 K8s 搬到了 Serverless。原本在物理机上那种“上帝视角”的掌控感没了,取而代之的是“分布式系统”带来的无限焦虑。而 Session,正是这焦虑的核心来源。
今天,我就要带大家用“分布式 Session”这个法宝,把底层那些该死的物理差异、网络延迟、容器抖动统统屏蔽掉。咱们要达到的效果是:不管你在哪台服务器上,不管它是不是刚刚重启过,你的 $_SESSION 都得在那儿,像一颗坚定不移的钉子。
第一部分:物理机的“幸福生活”与云原生的“混乱派对”
首先,咱们得回忆一下物理机时代。那是 PHP 工程师的黄金年代。
那时候,你的 PHP 应用跑在一台孤零零的物理服务器上。这台服务器有自己的硬盘,有自己的内存,甚至有自己独特的味道(如果你够久的话)。
// 物理机时代的 Session
session_start();
$_SESSION['user_id'] = 123;
$_SESSION['username'] = 'ZhangSan';
echo $_SESSION['username']; // 输出 ZhangSan
在这个场景下,PHP 怎么做?它就把 Session 数据序列化成字符串,然后往磁盘上写个文件。文件名大概是 /tmp/sess_5f3a2b1c。如果你读它,PHP 就去读这个文件。
这事儿简单得就像在自家后院藏私房钱。没有网络,没有延迟,没有负载均衡。因为你的应用是单实例的,所以数据始终是本地的。这叫“强一致性”的巅峰体验,虽然没有横向扩展能力,但至少稳!
但是,云原生来了。
云原生是什么?云原生就是“无状态”的代名词。
在云原生架构里,你的 PHP 应用被切成了成千上万个微小的、无状态的实例。它们躺在 Kubernetes 的 Pod 里,睡醒了就干活,干完了就死。如果你刷新一下页面,负载均衡器可能把你从 Pod A 搬到了 Pod B,甚至 Pod C。
这时候,灾难发生了。
Pod A 刚才往 /tmp/sess_xxx 写了数据。但是 Pod B 根本没有这个文件!或者说,Pod B 的文件系统是空的,它根本没权限写这个目录。
当你再次发起请求,PHP 发现 sess_xxx 不存在,于是抛出一堆错误,或者干脆让你重新登录。
这就像什么?这就像你把钥匙藏在冰箱里,结果保安把你从家赶到了酒店。你找得到冰箱吗?找不到!保安会把你锁在门外。
这就是所谓的“粘性会话”或者“会话亲和性”。你可能会想:“简单啊,我让 Nginx 别乱动我,用 ip_hash 不就行了?”
大错特错!
在现代微服务架构里,ip_hash 就是癌症。为什么?
- 容器会漂移。IP 变了,Session 丢了。
- 负载均衡器可能会动态调整权重,用户可能被随机调度到不同的服务器,Session 还是丢了。
- DNS 变更、服务扩缩容,一切都会让
ip_hash变得像个笑话。
所以,我们必须把 Session 从“本地文件系统”这个狭隘的泥潭里拔出来,扔进一个公共的、共享的、远程的地方。这个地方,我们通常选择 Redis。
第二部分:分布式 Session 的核心哲学——抽象层
我们要做的,不是去修改每一个业务代码里的 $_SESSION['key'] = 'value'。那太扯了,那是违反开闭原则的,那是程序员最讨厌的改代码。
我们要做的是改造 PHP 的 Session 处理机制。
PHP 提供了一个名为 SessionHandlerInterface 的接口。它是 PHP Session 机制的“大脑皮层”。只要我们实现这个接口,并告诉 PHP 使用我们的处理器,那么对于业务代码来说,Session 的用法完全不变。
这就是“屏蔽底层系统差异”的精髓。我们通过代码构建了一个适配器层。
业务代码只需要关心:“我存了数据”,而不用担心:“数据存哪儿了,会不会丢”。
第三部分:实战!手写一个 Redis Session Handler
咱们别整那些花里胡哨的废话,直接上代码。我们要实现 RedisSessionHandler 类。
首先,我们需要一个连接 Redis 的客户端。推荐用 predis 或者 redis 扩展。为了性能,这里我推荐使用 PHP 的原生 Redis 扩展,因为它少了一层网络封装,而且性能在 IO 密集型场景下更有优势。
1. 基础架构搭建
<?php
class RedisSessionHandler implements SessionHandlerInterface
{
private $redis;
private $prefix = 'sess:';
private $ttl = 3600; // 默认过期时间 1 小时
public function __construct($host, $port = 6379)
{
$this->redis = new Redis();
// 连接 Redis,这个连接要保持长连接,别老断老连
$this->redis->connect($host, $port, 2.5); // 2.5秒超时,别等太久
// 可以选择开启压缩,减少网络传输
$this->redis->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_ZLIB);
}
// ... 其他方法将在下面实现
}
2. 打开与关闭
打开会话比较简单,就是连接一下 Redis,初始化一下配置。
public function open($savePath, $sessionName)
{
// 这里可以做些检查,比如 Redis 是否可写
return $this->redis ? true : false;
}
public function close()
{
// Redis 连接我们希望保持长连接,所以这里不需要 close Redis
// 如果是短连接模式,这里可以调用 $this->redis->close();
return true;
}
3. 读取
这是最关键的一步。当 PHP 试图读取 Session 数据时,它会调用 read。我们必须从 Redis 里把 Key 对应的 Value 读出来。
public function read($sessionId)
{
$key = $this->prefix . $sessionId;
// 1. 尝试获取数据
$data = $this->redis->get($key);
// 2. 判断是否过期
// Redis 如果没有数据,get 返回 null
// 如果有数据,Redis 会返回字符串。但这里有个坑:PHP 默认会把过期时间也读出来吗?
// 答案是不会,Redis 的 TTL 信息是不在返回值里的。
if ($data === false) {
// 如果不存在,返回空字符串。
// PHP 规定,如果 read 返回空字符串,session_start() 会认为这是一个“新会话”
return '';
}
return $data;
}
4. 写入
当页面执行完毕,PHP 试图把 $SESSION 变量序列化后存入磁盘时,write 方法会被调用。
public function write($sessionId, $data)
{
$key = $this->prefix . $sessionId;
// 1. 序列化数据 (如果 PHP 端没有强制开启 serialize)
// 这里为了兼容性,通常使用 PHP 内置的 serialize
$serializedData = serialize($data);
// 2. 存入 Redis
// 注意:Redis 的 set 方法默认不设置过期时间,除非 Key 不存在。
// 我们必须显式设置 TTL,防止僵尸 Session 占用内存。
$result = $this->redis->setex($key, $this->ttl, $serializedData);
return $result ? true : false;
}
5. 销毁
当用户点击“退出登录”时,或者 Session 过期时。
public function destroy($sessionId)
{
$key = $this->prefix . $sessionId;
return $this->redis->del($key) > 0;
}
6. 垃圾回收
PHP 有一个内置的垃圾回收机制,通过 session.gc_probability 和 session.gc_divisor 来控制触发概率。
public function gc($maxlifetime)
{
// 警告:这是最危险的地方!
// 在 Redis 中,我们不能像文件系统那样遍历目录。
// 如果这里执行 SCAN 或者 KEYS *,在生产环境中会造成巨大的性能雪崩!
// 正确的做法是利用 Redis 的 TTL 机制。
// Redis 的 keys 和 setnx 操作都有 TTL,一旦时间到,Key 自动消失。
return true;
}
}
7. 注册处理器
好了,Handler 写完了。接下来,怎么告诉 PHP 用它?在脚本的最开始,加入这几行代码:
// 1. 初始化 Handler
$handler = new RedisSessionHandler('127.0.0.1', 6379);
// 2. 设置保存处理器
session_set_save_handler($handler, true); // 第三个参数 true 表示关闭自动刷新
// 3. 启动 Session
session_start();
// 4. 放心大胆地使用 $_SESSION 吧!
$_SESSION['test'] = 'Hello Cloud Native!';
echo $_SESSION['test'];
看到没?业务代码里根本没看一眼 Redis 的代码。这就是抽象的力量!
第四部分:分布式 Session 的“坑”与“深水区”
好,代码跑起来了,业务也通顺了。但是,别高兴得太早。分布式 Session 的世界比你想象的要复杂得多。作为一个资深专家,我要告诉你这些“隐秘的角落”。
1. 序列化格式的“血案”
这是 PHP 程序员最痛的一个点。
在旧版本的 PHP 中,php.ini 里有三个配置选项:
session.serialize_handler
它们分别是:
php:PHP 内置的默认序列化格式。它会把key|value包裹起来。php_serialize:纯序列化格式,只有序列化后的字符串,没有分隔符。这是 PHP 7.1+ 推荐的格式。php_binary:二进制格式,Key 的长度 + Key 的内容 + Value 的序列化。这种格式很快,但看不懂。
如果配置不匹配,会发生什么?
假设你的业务代码生成了一个 Session:
$_SESSION['a'] = 1;
如果 serialize_handler 是 php_serialize,Redis 里存的是 a:1:{i:0;s:1:"a";i:1;i:1;}。
如果 serialize_handler 是 php,Redis 里存的是 a|1。
如果在另一台服务器上,PHP 试图读取,但配置却是 php_serialize,它就读不出来,因为它在字符串里找不到 |。
解决方案:
统一全局配置。
在代码开头强制设置:
ini_set('session.serialize_handler', 'php_serialize');
2. Redis 的并发写入锁
再来看 write 方法。
$this->redis->setex($key, $this->ttl, $serializedData);
这里有一个潜在的问题:并发写入。
假设用户正在提交一个表单,需要多次写入 Session(比如用户输入信息 -> 刷新 -> 修改信息 -> 再次提交)。如果在同一毫秒内,请求 A 和请求 B 都到了 write,它们都会执行 setex。
这是原子操作吗?是。
Redis 的 SETEX 是原子的。所以,不会出现数据覆盖的问题。这点大家放心。
3. 并发读写的“竞态条件”
虽然写是原子的,但读写可能不是。
场景:
- 用户打开页面 ->
read()获取 Session ->$_SESSION['count']是 5。 - 用户点击按钮,逻辑是:
$_SESSION['count']++;(内存里加 1)。 - 页面提交,触发
write()。
这里其实没问题,因为 read -> $_SESSION 操作 -> write 是在同一个请求周期内的,PHP 是单线程阻塞的,所以操作是串行的,不会乱。
真正的并发问题出现在 多进程 环境(比如多台 PHP-FPM 进程,或者 Nginx 多个 Worker)。但只要我们用 session_set_save_handler,PHP 的 Session 机制本身就处理了进程间的互斥(通过文件锁机制)。到了 Redis,虽然没有了文件锁,但 setex 原子性保证了数据一致性。
4. TTL 与 GC 的区别
我们之前在 gc 方法里直接返回了 true。这真的是对的吗?
Redis 的 Key 是有过期时间的。只要我们设置了 session.gc_maxlifetime(比如 1440 秒),Redis 就会在 1440 秒后自动删除 Key。
所以,我们不需要在 PHP 层面做遍历删除。这是 Redis 给我们的巨大红利。我们只需要确保 write 方法里正确设置了 setex 的过期时间参数即可。
5. 安全性:Session 劫持
把 Session 存在 Redis 里,意味着 Session ID 实际上只是传输的 Token。真正的内容在 Redis 里。
如果你的 Redis 暴露在了公网,而且没有密码,或者密码是 123456,那么黑客就可以直接扫描你的 Redis 端口,获取所有 Session 数据,进而模拟登录任意用户。
必须做两件事:
- Redis 必须设置密码:
requirepass your_strong_password。 - Session ID 必须安全生成:
session.cookie_httponly = 1(禁止 JS 脚本读取 Cookie),session.cookie_secure = 1(只在 HTTPS 下传输)。
第五部分:性能优化与高可用架构
随着流量增大,单台 Redis 已经扛不住了。我们需要集群。这就涉及到了分布式 Session 的高级玩法。
1. Redis 集群模式
PHP 原生的 Redis 扩展支持集群模式。你需要将 Session ID 的最后一位哈希取模到 Redis 的 16384 个槽位上,然后路由到具体的 Master 节点。
但是,PHP 的原生 Redis 扩展在集群模式下,不支持 MGET 或 MSET。这意味着,如果你读取了多个 Session 键,必须发起多次网络请求。这会极大地增加延迟。
优化方案: 使用支持 Pipeline(管道)或者支持集群的客户端,或者自己写个简单的路由逻辑。
2. Sentinel(哨兵)模式
为了保证高可用,Redis 不能挂。如果 Master 挂了,自动切换到 Slave。这时候,IP 会变。
如果 PHP 连接的是 IP,IP 变了,连接就断了,Session 就挂了。
解决方案:
不要在代码里写死 IP。连接 Sentinel,或者使用 Redis 的 Cluster 自动故障转移。
3. 异步写入:终极性能解法
这是目前大厂(比如淘宝、京东)在 Session 持久化上常用的高级技巧。
痛点:
用户访问网页,我们要做的事情太多了:查数据库、调接口、写日志、写 Session。写 Redis 是个 IO 操作,它会阻塞 PHP 的执行。
如果 Redis 慢了,整个 PHP 页面都会卡死,导致用户超时,前端报错。
方案:
使用消息队列(如 RabbitMQ、Kafka、或 Redis 本身的 List/List/ZSet)。
- 当
write()被调用时,不要直接写 Redis。 - 把 Session 数据序列化后,扔进消息队列。
write()方法立即返回true。- PHP 继续执行后面的逻辑,页面瞬间响应。
然后,启动一个独立的“Session 异步写入守护进程”(后台 Worker)。这个 Worker 每隔几毫秒或者几秒,批量从队列里取数据,写入 Redis。
优点:
- 极速响应:用户页面加载速度几乎不受 Redis 延迟影响。
- 削峰填谷:Redis 不会因为瞬时流量过大而崩掉。
缺点:
- 数据延迟:如果 Worker 挂了,Session 会丢失(虽然概率极低)。
- 代码复杂度:需要引入队列中间件。
第六部分:Session 存 MySQL?认真的吗?
看到这儿,肯定有同学会问:“老哥,既然 Redis 这么好,那我存 MySQL 不是更稳?”
醒醒吧,同学。
Session 的特点是:频繁读写,热点集中,生命周期短。
MySQL 是关系型数据库,它的强项是复杂的关联查询和事务一致性。它的 IO 开销大,主从同步慢,数据落盘需要写日志。
如果让你在双十一的 12 万 QPS 下,每个请求都要去 MySQL 里 INSERT 或 UPDATE 一行 Session 数据,MySQL 瞬间就能给你跪下。
Redis 是内存数据库,基于 IO 多路复用,单线程处理百万级连接,速度飞快。它就是为了缓存和 Session 这种场景而生的。
结论: Session 持久化,首选 Redis。
第七部分:监控与排查
当你把 Session 迁移到 Redis 后,你的运维工作也变了。
- 监控 Redis 内存: Session 数据多了,Redis 内存会爆。你需要设置
maxmemory和maxmemory-policy(比如allkeys-lru,当内存满时,自动删除最久不用的 Session)。 - 监控 Session 数量: 使用
INFO keyspace可以看到 Redis 里有多少个 Key。如果一个用户的 Session 数量异常多(比如每个用户 100 个 Key),说明业务代码里产生了内存泄漏。
常见错误排查:
-
错误:
ERR max number of clients reached。- 原因: 你的 PHP-FPM 进程数太多了,或者连接池没有复用,每个进程都在开新的 Redis 连接,把 Redis 撑爆了。
- 解决: 开启 Redis 连接池,或者减少 PHP-FPM 进程数。
-
错误: Session 丢失。
- 原因: Redis 挂了,或者 PHP-FPM 重启了导致进程 ID 变了?不,Session ID 是存在 Cookie 里的。
- 排查: 打开浏览器的开发者工具,看 Cookie 里的
PHPSESSID。拿着这个 ID 去 Redis 里GET,看看有没有数据。
结语
好了,各位,今天的讲座就到这里。
从物理机到云原生,不仅仅是基础设施的升级,更是架构思维的转变。我们不再依赖单一的物理节点,而是拥抱分布式系统。
通过实现 SessionHandlerInterface,我们用几行优雅的代码,屏蔽了文件系统与远程数据库的差异,让 PHP Session 在容器化、微服务的浪潮中依然坚如磐石。
记住,技术是为了解决问题的。Session 持久化不仅仅是为了不让用户每次刷新都重新登录,更是为了在架构演进的过程中,保持业务逻辑的可移植性和稳定性。
现在,去修改你的代码吧,把那些乱七八糟的文件路径删除,拥抱 Redis,拥抱云原生!
下课!