session_start() 大概是最被滥用的一个函数。写完这一行,$_SESSION 就能用了,仿佛它是语言内置的魔法。
实际上它做的事不少:读 cookie、打开一个存储、抢一把排他锁、反序列化数据。这四步里每一步都能出问题,而表现往往只是「用户莫名其妙掉登录」。
这篇按这四步的顺序拆,数据都是 PHP 8.3.13 上实跑出来的。
一、session_start() 到底做了什么
session_start();四件事,顺序不能乱:
- 拿 ID —— 从
Cookie: PHPSESSID=xxx里读。没有就生成一个随机的,准备通过Set-Cookie回给浏览器 - 打开存储 —— 按
session.save_handler(默认files)和session.save_path定位存储介质 - 抢锁 —— 对这份会话加排他锁,防止两个请求同时改同一份数据
- 读入 —— 读原始字符串,反序列化,塞进
$_SESSION
第 3 步是很多人不知道的,也是最容易在接口里踩到的坑(后面第三节细说)。
session_start 的四步:读 Cookie、打开存储、抢排他锁、反序列化,第三步是大部分麻烦的来源二、落盘之后长什么样
默认 files handler 会把每个会话写成一个文件,文件名是 sess_ 加会话 ID。实测一下:
session_start();
$_SESSION = [
'uid' => 42,
'name' => 'shmil',
'cart' => [1, 2, 3],
'flag' => true,
'none' => null,
'f' => 1.5,
];
session_write_close();打开 sess_ 看到的就是这串东西:
uid|i:42;name|s:5:"shmil";cart|a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}flag|b:1;none|N;f|d:1.5;这个格式叫 php 序列化处理器(session.serialize_handler = php),规则很简单:
| 片段 | 含义 |
|---|---|
uid│ | 键名,后面跟竖线(ASCII 0x7C)分隔 |
i:42; | integer,42 |
s:5:"shmil"; | string,长度 5 |
a:3:{...} | array,3 个元素,内部又是键值对 |
b:1; | boolean true |
N; | null |
d:1.5; | double |
注意三点:
长度必须精确。 s:5:"shmil" 里的 5 是字节数。如果键值里出现中文,s: 后面得是 UTF-8 的字节数而不是字数 —— 手动改这个文件时写错长度,整个会话会被判为损坏,直接变空。
null 也会被存。 none|N; 占着位置,不是「不存在」。所以判断登录态要用 isset() 或 empty(),别指望 array_key_exists 能区分出「登出时删掉了」和「登出时设成了 null」。
文件里没有过期时间。 过期完全靠两件事:文件的 mtime,加上 GC 的清理。这就引出了下一个问题。
GC 是怎么清过期的
session.gc_maxlifetime = 1440 ; 24 分钟没动过就算过期
session.gc_probability = 1
session.gc_divisor = 1001/100 意味着每次 session_start() 有 1% 概率触发一次全目录扫描,把 mtime 超过 1440 秒的文件删掉。
这套机制在单机小流量下勉强能用,但有两个明显缺陷:
- 概率触发意味着可能很久不清,磁盘上堆满死掉的会话文件
- 多台机器各扫各的,谁也清不到对方目录
所以线上更稳的做法是把 GC 关掉,交给定时任务:
session.gc_probability = 0# crontab:每小时清一次
find /var/lib/php/sessions -name 'sess_*' -mmin +120 -delete三、那把锁:为什么同一用户的并发请求会排队
files handler 打开会话文件时用的是 flock(LOCK_EX) —— 排他锁。
意思是:同一个 PHPSESSID,同一时刻只允许一个请求持有它。第二个请求会阻塞在 session_start(),直到第一个请求 session_write_close() 或者脚本结束。
这个设计初衷是防止并发写坏数据,但代价很直接。看个真实场景:
session_start();
// 页面里调用了自己站点的另一个接口
$ch = curl_init('https://example.com/api/profile.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch); // ← 卡在这里 30 秒后超时
$_SESSION['last_sync'] = time();profile.php 也要 session_start(),但锁被外层请求占着 —— 自己把自己锁死了,只能等超时。
文件锁导致的自我死锁:请求 A 持有排他锁,它自己发起的请求 B 阻塞在 session_start 等同一把锁这类问题的解法是「尽快放锁」:
session_start();
// 读完需要的值,立刻关闭会话
$uid = $_SESSION['uid'] ?? 0;
session_write_close(); // 释放锁,后续接口可以正常读会话
$ch = curl_init('https://example.com/api/profile.php');
// ...session_write_close() 之后 $_SESSION 变成只读 —— 还能读,但写了不会保存。这点要记住,不然会出现「末尾写了值但没生效」的迷惑现象。
如果这个请求根本不需要写会话,用更轻的:
session_abort(); // 直接丢弃,不写回,也不更新 mtime另外还有一条:大文件上传 / 长轮询接口不要开 session。一个上传请求持有锁 60 秒,这个用户的其它所有请求都会被堵住,体验上就是「页面卡死」。
四、多机部署:files 的天然缺陷
单机没问题,一旦上了负载均衡就立刻暴露:两台机器的 /var/lib/php/sessions 是各自的磁盘,用户的会话只在一台上有。
表现是「刷新几次,有时登录有时掉线」—— 因为请求被轮询到了不同机器。
三个可选方案:
| 方案 | 做法 | 代价 |
|---|---|---|
| 会话粘滞 | 按 IP 或 cookie 哈希固定到同一台 | 扩容/故障转移时会话全掉 |
| 共享文件 | NFS / GlusterFS 挂同一目录 | 锁竞争放大,慢 |
| 集中存储 | Redis / Memcached | 需要额外组件,但最干净 |
生产上基本都选 Redis。配置只要两行:
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?database=2&prefix=app:&timeout=2"save_path 里的查询串支持这些参数:
| 参数 | 作用 |
|---|---|
host / port | 用 tcp://host:port 形式写在前面 |
database | 选第几个库,别和缓存挤在 db0 |
auth | 密码,写成 auth=xxx |
prefix | 键前缀,多应用共用一台 Redis 时必须加 |
timeout | 连接超时(秒) |
要求:装了 phpredis 扩展(extension=redis.so)。predis 这类纯 PHP 客户端在这里用不上,因为 handler 是扩展层实现的。
顺手加一条运维建议:Redis 的 maxmemory-policy 不要设成 allkeys-lru。会话被 LRU 淘汰掉的后果是「用户随机掉登录」,比缓存穿透难查得多。给会话单独一个 db,策略设 volatile-lru 或不淘汰。
五、并发覆盖:Redis 也不加锁
换成 Redis 之后锁的问题反而变严重了,因为 files 至少有 flock,而 phpredis 的 session handler 默认不加锁。
redis.session.locking_enabled = 0 ; 默认值于是这个流程会丢数据:
请求 A: 读 $_SESSION['count'] = 1
请求 B: 读 $_SESSION['count'] = 1
请求 A: 写 $_SESSION['count'] = 2
请求 B: 写 $_SESSION['count'] = 2 ← A 的修改没了两个办法:
开锁(和 files 行为一致,代价是会排队):
redis.session.locking_enabled = 1
redis.session.lock_retries = 10
redis.session.lock_wait_time = 20000 ; 微秒或者改设计:不要在会话里放需要并发累加的计数器。购物车、浏览量这类数据应该放在业务表里用原子操作(INCR、UPDATE ... SET x = x + 1),会话只存用户身份。
顺带说一个默认开着、但很多人不知道的行为:
session.lazy_write = 1含义是「内容没变化就不写回」。好处是减少无谓的 Redis 请求,坏处是如果你靠「写会话」来刷新过期时间,它不会生效 —— 数据一样就不写,mtime 也就不动。要这种效果得显式改一个值。
六、安全参数:默认值基本都不能用
实测 PHP 8.3.13 CLI 下的默认值:
httponly= samesite=两个都是空的。也就是说默认情况下会话 Cookie 可以被 JS 读到(XSS 直接偷走),也不限制跨站携带(CSRF 请求会自动带上)。
必须显式配:
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Lax"
session.sid_length = 48
session.sid_bits_per_character = 6逐条说:
use_strict_mode —— 只接受 PHP 自己生成的会话 ID。不开启的话,攻击者可以预先塞一个自己知道的 ID 给受害者(会话固定攻击),受害者登录后攻击者直接用同一个 ID 就进了。
use_only_cookies —— 禁止从 URL 读 ?PHPSESSID=。默认是 1,但有些老代码手动传过,值得确认一下。
cookie_secure —— 只在 HTTPS 下发送。全站 HTTPS 的现在应该无条件开。
cookie_samesite —— Lax 能挡掉绝大多数跨站 POST,对正常跳转没影响。
sid_length = 48 + bits_per_character = 6 —— 默认 26 位、5 bit/字符,约 130 bit 熵;改成 48 × 6 是 288 bit,碰撞和猜测基本不可能。长度变了之后老会话会失效,第一次上线选个低峰期。
除了配置,代码里还有一件事必须做:登录成功后换 ID。
if (password_verify($input, $row['password_hash'])) {
session_regenerate_id(true); // 参数 true = 删掉旧会话文件
$_SESSION['uid'] = $row['id'];
$_SESSION['login_at'] = time();
}true 这个参数很关键。不传的话旧会话文件还留在磁盘上,攻击者如果已经拿到旧 ID 依然能用。
七、什么时候根本不该用 Session
Session 的本质是「服务端状态 + 一个指针」。它天然有状态,也就天然难扩展。下面这些场景用 JWT 或签名 Cookie 更合适:
| 维度 | Session | JWT |
|---|---|---|
| 服务端存储 | 需要(Redis/文件) | 不需要 |
| 主动踢人下线 | 删会话即可,立即生效 | 需要黑名单,否则要等过期 |
| 多域名/多服务共享 | 要共享存储 | 天然可跨服务 |
| 单次请求开销 | 一次 Redis 读 | 一次签名验签(CPU) |
| 体积 | Cookie 里只有一个 ID | 整个 payload 在 Cookie/Header 里 |
选型上没有绝对答案:
- 后台管理、单体应用 → Session,简单,能立刻踢人
- 对外 API、多服务、移动端 → JWT,无状态
- 既要无状态又要能踢人 → 短期 access token(15 分钟)+ 可撤销的 refresh token
真正容易出错的是两套混用:既带 Cookie 又带 Bearer,鉴权逻辑里 if 判断写反,就会出现「登出后还能访问」这类问题。
八、小结
session_start()= 读 ID + 开存储 + 抢排他锁 + 反序列化,锁这一步最容易埋坑- 文件内容形如
uid|i:42;name|s:5:"shmil";...,长度是字节数不是字数,且不含过期时间 - 同一会话的并发请求会串行;不要在持锁期间做 curl、上传、长任务,读完就
session_write_close() - 多机必须共享存储,Redis 是标准答案,记得配
database和prefix - Redis handler 默认不加锁,计数器别放会话里
httponly/samesite/secure/use_strict_mode默认都不安全,必须显式打开- 登录成功后
session_regenerate_id(true),参数别省
下次再遇到「用户莫名掉登录」,按这个顺序查:先看 Cookie 有没有发出去,再看多机是不是各自为政,最后看是不是被 LRU 淘汰了。