session_start() 大概是最被滥用的一个函数。写完这一行,$_SESSION 就能用了,仿佛它是语言内置的魔法。

实际上它做的事不少:读 cookie、打开一个存储、抢一把排他锁、反序列化数据。这四步里每一步都能出问题,而表现往往只是「用户莫名其妙掉登录」。

这篇按这四步的顺序拆,数据都是 PHP 8.3.13 上实跑出来的。

一、session_start() 到底做了什么

1
session_start();

四件事,顺序不能乱:

  1. 拿 ID —— 从 Cookie: PHPSESSID=xxx 里读。没有就生成一个随机的,准备通过 Set-Cookie 回给浏览器
  2. 打开存储 —— 按 session.save_handler(默认 files)和 session.save_path 定位存储介质
  3. 抢锁 —— 对这份会话加排他锁,防止两个请求同时改同一份数据
  4. 读入 —— 读原始字符串,反序列化,塞进 $_SESSION

第 3 步是很多人不知道的,也是最容易在接口里踩到的坑(后面第三节细说)。

session_start 的四步:读 Cookie、打开存储、抢排他锁、反序列化,第三步是大部分麻烦的来源

二、落盘之后长什么样

默认 files handler 会把每个会话写成一个文件,文件名是 sess_ 加会话 ID。实测一下:

1
2
3
4
5
6
7
8
9
10
session_start();
$_SESSION = [
'uid' => 42,
'name' => 'shmil',
'cart' => [1, 2, 3],
'flag' => true,
'none' => null,
'f' => 1.5,
];
session_write_close();

打开 sess_ 看到的就是这串东西:

1
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 是怎么清过期的

1
2
3
session.gc_maxlifetime = 1440   ; 24 分钟没动过就算过期
session.gc_probability = 1
session.gc_divisor = 100

1/100 意味着每次 session_start() 有 1% 概率触发一次全目录扫描,把 mtime 超过 1440 秒的文件删掉。

这套机制在单机小流量下勉强能用,但有两个明显缺陷:

  • 概率触发意味着可能很久不清,磁盘上堆满死掉的会话文件
  • 多台机器各扫各的,谁也清不到对方目录

所以线上更稳的做法是把 GC 关掉,交给定时任务:

1
session.gc_probability = 0
1
2
# crontab:每小时清一次
find /var/lib/php/sessions -name 'sess_*' -mmin +120 -delete

三、那把锁:为什么同一用户的并发请求会排队

files handler 打开会话文件时用的是 flock(LOCK_EX) —— 排他锁。

意思是:同一个 PHPSESSID,同一时刻只允许一个请求持有它。第二个请求会阻塞在 session_start(),直到第一个请求 session_write_close() 或者脚本结束。

这个设计初衷是防止并发写坏数据,但代价很直接。看个真实场景:

1
2
3
4
5
6
7
8
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 等同一把锁

这类问题的解法是「尽快放锁」:

1
2
3
4
5
6
7
8
session_start();

// 读完需要的值,立刻关闭会话
$uid = $_SESSION['uid'] ?? 0;
session_write_close(); // 释放锁,后续接口可以正常读会话

$ch = curl_init('https://example.com/api/profile.php');
// ...

session_write_close() 之后 $_SESSION 变成只读 —— 还能读,但写了不会保存。这点要记住,不然会出现「末尾写了值但没生效」的迷惑现象。

如果这个请求根本不需要写会话,用更轻的:

1
session_abort();   // 直接丢弃,不写回,也不更新 mtime

另外还有一条:大文件上传 / 长轮询接口不要开 session。一个上传请求持有锁 60 秒,这个用户的其它所有请求都会被堵住,体验上就是「页面卡死」。

四、多机部署:files 的天然缺陷

单机没问题,一旦上了负载均衡就立刻暴露:两台机器的 /var/lib/php/sessions 是各自的磁盘,用户的会话只在一台上有。

表现是「刷新几次,有时登录有时掉线」—— 因为请求被轮询到了不同机器。

三个可选方案:

方案做法代价
会话粘滞按 IP 或 cookie 哈希固定到同一台扩容/故障转移时会话全掉
共享文件NFS / GlusterFS 挂同一目录锁竞争放大,慢
集中存储Redis / Memcached需要额外组件,但最干净

生产上基本都选 Redis。配置只要两行:

1
2
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 默认不加锁。

1
redis.session.locking_enabled = 0   ; 默认值

于是这个流程会丢数据:

1
2
3
4
请求 A: 读 $_SESSION['count'] = 1
请求 B: 读 $_SESSION['count'] = 1
请求 A: 写 $_SESSION['count'] = 2
请求 B: 写 $_SESSION['count'] = 2 ← A 的修改没了

两个办法:

开锁(和 files 行为一致,代价是会排队):

1
2
3
redis.session.locking_enabled = 1
redis.session.lock_retries = 10
redis.session.lock_wait_time = 20000 ; 微秒

或者改设计:不要在会话里放需要并发累加的计数器。购物车、浏览量这类数据应该放在业务表里用原子操作(INCR、UPDATE ... SET x = x + 1),会话只存用户身份。

顺带说一个默认开着、但很多人不知道的行为:

1
session.lazy_write = 1

含义是「内容没变化就不写回」。好处是减少无谓的 Redis 请求,坏处是如果你靠「写会话」来刷新过期时间,它不会生效 —— 数据一样就不写,mtime 也就不动。要这种效果得显式改一个值。

六、安全参数:默认值基本都不能用

实测 PHP 8.3.13 CLI 下的默认值:

1
httponly=            samesite=

两个都是空的。也就是说默认情况下会话 Cookie 可以被 JS 读到(XSS 直接偷走),也不限制跨站携带(CSRF 请求会自动带上)。

必须显式配:

1
2
3
4
5
6
7
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。

1
2
3
4
5
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 更合适:

维度SessionJWT
服务端存储需要(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 淘汰了。