登录状态老是掉:PHP 会话文件从权限、清理到多进程共享的六层排查

现象:后台保存文章时提示"安全令牌无效",刷新一次就好了,偶尔反复

这类问题在自建站的反馈里出现频率很高,描述通常是:"后台提交表单偶尔报 CSRF 错误,多刷几次就行"、"登录之后跳回登录页,但重新登录又能进"、"购物车里的东西过一夜就空了"。三个看起来不相干的现象,很多情况下是同一个原因:PHP 会话没有稳定地保存下来。

会话问题的难查之处在于它是"偶发"的。你用浏览器在本地测十遍都正常,用户的手机上报一次。或者白天正常,凌晨的定时任务跑完之后开始异常。这类问题不能靠复现,只能靠把会话的完整链路摊开看:PHP 把会话数据写到哪里、谁有权限读写、什么时候被清理、多个进程之间怎么共享。

这篇文章按这个链路走一遍,每一步都给出可以直接执行的判定命令。假设的环境是最常见的形态:Nginx + PHP-FPM + 文件方式存储会话(session.save_handler = files),单机单站点。如果已经用了 Redis 存会话,前四步里有一部分可以跳过,但清理和权限这两部分依然适用。

第一步:确认会话到底存在哪里

不要靠记忆。PHP 的会话目录可能是四个不同的地方,取决于谁配的:

# 1) PHP 编译时的默认值(从 php.ini 或编译参数来)
php -i | grep -E '^session.save_(path|handler)'

# 2) FPM 实际生效的值(命令行和 FPM 的配置可能不同!)
# 建一个临时文件用 FPM 跑,或者直接看 FPM 的状态页
php -r 'echo ini_get("session.save_path"), PHP_EOL;'
# 注意:命令行 php 常读 /etc/php/X.Y/cli/php.ini,
# FPM 读的是 /etc/php/X.Y/fpm/php.ini,两者不是同一个文件

# 3) 看 FPM 池配置里有没有覆盖
grep -rE 'php_(value|admin_value).*session' /etc/php/*/fpm/pool.d/

# 4) 运行时到底写到哪了:直接看文件系统的修改时间
ls -lt /var/lib/php/sessions/ 2>/dev/null | head
ls -lt /tmp/ | grep '^\-.*sess_' | head

第 4 条最可靠。会话文件的名字固定是 sess_ 前缀加一串 ID,看哪个目录里有一批刚刚被修改的 sess_ 文件,那里就是真实的落盘位置。我遇到过三次案例,php -i 显示 /var/lib/php/sessions,但 FPM 池里被 php_value session.save_path 覆盖成了 /tmp,结果 /var/lib/php/sessions 里的文件永远是旧的,排查方向完全跑偏。

补充一个容易漏的点:session.save_path 支持哈希目录层级,写法是 "2;/var/lib/php/sessions",意思是把会话文件按 ID 前两位散到两级子目录里(对应 N;/path 格式,N 表示层级数)。如果配了这种形式,会话文件不在根目录下,而在 sessions/a/1/sess_xxx 这样的位置。用 find 找:

find /var/lib/php/sessions -name 'sess_*' -mmin -10 | head

第二步:检查目录权限——最常见的原因

PHP 写会话失败时的表现很隐蔽:默认情况下 session_start() 只是发一个 warning,然后继续执行,页面上看起来一切正常,只是用户下次请求时没有会话数据。如果你的 display_errors 是关闭的(生产环境应该关闭),你连这条 warning 都看不到,只看到"登录不保持"。

# 看目录的属主和权限
ls -ld /var/lib/php/sessions

# 看 PHP-FPM 以哪个用户运行
ps aux | grep 'php-fpm' | grep -v grep | head -3
# 输出里 master 是 root,worker 才是实际写文件的用户,通常是 www-data

# 用该用户身份实测能否写入(最直接的验证)
sudo -u www-data touch /var/lib/php/sessions/.write-test && echo '可写' \
  && sudo -u www-data rm /var/lib/php/sessions/.write-test

正确的权限组合有两个流派:

  • 目录属主 www-data、权限 700:只有 FPM 用户能进能写。安全性最好,但如果同一台机器上有多个站点用不同用户(多站点隔离),就不能共用同一个目录。
  • 目录属主 root、组 www-data、权限 1733(sticky bit + 其他用户可写但不可读):这是 Debian/Ubuntu 下 php-common 包的默认做法,允许多个 FPM 池共用一个目录,靠 sticky bit 防止互相删文件。

误伤最常发生在下面三种操作之后:

  1. 用 rsync 从别的机器同步整个 /var/lib 或 /tmp 目录。rsync 默认会带 -a(保留属主和权限),源机器上如果属主是 nginx 而本机是 www-data,同步完之后本机 FPM 就没权限了。
  2. 清理磁盘时 chown -R 或 chmod -R 打到了 /var/lib/php。很多人为了修权限问题执行 chmod -R 777 /var/lib/php,这在当时"解决"了问题,但留下了安全隐患,而且下次系统安全扫描或者别人清理时会被改回来,问题复发。
  3. 从宝塔面板或别的面板迁移过来。不同面板默认的会话目录不同(有的用 /tmp,有的用 /www/server/php/var/session),迁移时只搬了代码没搬配置。

修复动作要精确,不要用 -R 乱扫:

# 按目录当前策略修(单个站点独占的情况)
chown -R www-data:www-data /var/lib/php/sessions
chmod 700 /var/lib/php/sessions

# 多站点共用的传统做法(Debian 默认)
chown root:www-data /var/lib/php/sessions
chmod 1733 /var/lib/php/sessions

修完之后必须立刻验证一次真实写入,而不是等用户反馈:

# 用一个临时脚本确认会话真的落地了
cat > /var/www/html/_sess_test.php <<'EOF'
<?php
session_start();
$_SESSION['t'] = time();
echo session_id(), "\n", session_save_path(), "\n";
EOF
curl -s -i https://www.example.com/_sess_test.php | head -20
# 输出里应有 Set-Cookie: PHPSESSID=...
# 然后去会话目录里确认同名 sess_ 文件存在且时间是最新的
ls -l /var/lib/php/sessions/sess_<那个ID>
rm /var/www/html/_sess_test.php    # 测完立刻删

第三步:检查会话文件有没有被"清理"掉

PHP 有一个内置的会话回收机制:每次 session_start() 时,按概率触发一次垃圾回收,把超过 session.gc_maxlifetime 秒没有修改的会话文件删掉。这个机制在两个地方会出问题。

问题一:目录被系统的定时任务清空。这是最凶的一种。很多"优化脚本"或者默认的系统配置里有这样的 cron:

# 检查是否有清理 /tmp 的定时任务
cat /etc/cron.daily/tmpreaper 2>/dev/null
systemctl list-timers --all | grep -iE 'tmp|clean'
grep -rE 'tmpwatch|tmpreaper|find /tmp' /etc/cron* /etc/systemd/system 2>/dev/null

如果会话目录是 /tmp(或者 /var/lib/php/sessions 被某个脚本扫了),而系统有一个"清理 /tmp 下超过 N 天未访问的文件"的任务,那么 session.gc_maxlifetime 设的是"30 天"完全没用——文件在第 2 天就被系统删了。表现出来就是"登录状态过两天就掉"。

问题二:多个 PHP 版本或站点共享同一个会话目录,互相回收。PHP 的 GC 是按目录扫的,A 站点配置 gc_maxlifetime = 1440(24 分钟),它的 GC 跑起来时会把目录里所有超过 24 分钟未修改的文件都删掉——包括 B 站点配了 gc_maxlifetime = 864000 的那些会话。这就是"共享目录 + 不同 gc 配置"的经典串扰。

解法有三个,按推荐程度排:

  1. 每个站点独立 session.save_path,彻底隔离。在 PHP-FPM 池的 php_admin_value 里设,每个池一个目录。这是最干净的做法。
  2. 关闭文件 GC,用独立脚本按站点清理。session.gc_probability = 0 关掉自动回收,然后写一个 cron 用 find -mmin +N -delete 按站点分别清。可控性最好,也便于审计。
  3. 改用 Redis 存会话,用 TTL 做过期,从根上避开文件 GC 的所有问题。代价是多一个依赖。
# 推荐的做法:站点独立目录 + 关闭文件 GC + 自己清理
# /etc/php/8.2/fpm/pool.d/site.conf
php_admin_value[session.save_path] = /var/lib/php/sessions/site-a
php_admin_value[session.gc_probability] = 0

# cron:每天凌晨清掉 3 天前的会话
0 3 * * * find /var/lib/php/sessions/site-a -name 'sess_*' -mtime +3 -delete

注意 gc_probability = 0 之后一定要配自己的清理 cron,否则目录里的文件会一直积累,最终变成"会话目录占了 20GB"的磁盘问题。这个坑和日志不轮转是同一类:关掉了自动机制却没有补上人工机制。

第四步:确认会话 ID 在请求之间真的被传回来了

会话能不能保持,一半看服务端存没存住,另一半看客户端有没有把会话 ID 带回来。判定方法很直接:看响应头和请求头里的 Cookie。

# 用 -c 保存 cookie,-b 回传,模拟两次连续请求
curl -s -c /tmp/c.txt -o /dev/null -D - https://www.example.com/login.php | grep -i set-cookie
curl -s -b /tmp/c.txt -o /dev/null -D - https://www.example.com/admin.php | grep -iE 'set-cookie|location'

# 检查 cookie 属性是否导致浏览器不保存
# 重点看这几个:Secure / HttpOnly / SameSite / Domain / Path / Max-Age

几种常见的"服务端没问题但会话丢了"的情形:

Cookie 的 Path 写成子目录。如果在 /admin/ 下设置了 session_set_cookie_params(['path' => '/admin/']),那么这个 Cookie 只在 /admin/ 下回传,访问 / 或 /api/ 时就没了。表现出来是"后台偶尔掉线"。

Domain 和实际访问的主机名不一致。Cookie 设成 Domain=.example.com,但用户是从 www.example.com 访问,理论上没问题;如果设成 Domain=www.example.com 而用户从裸域访问,Cookie 就不带。反过来更隐蔽:带 www 和不带 www 是两个不同的同源域,会话 ID 在两者之间不共享,用户从搜索引擎点进来是裸域,点了个内部链接跳到 www,会话就"丢"了。这类问题的根治办法是统一 301 到其中一个域名,不要让两个域名都能正常访问。

SameSite 配置和跨站跳转冲突。现代浏览器默认 SameSite=Lax,意味着从外部站点点链接过来时 Cookie 会带上(因为是顶级导航),但从外部站点的表单 POST 过来、或者 iframe 里嵌你的页面时,Cookie 不带。SameSite=None 必须同时有 Secure,且只在 HTTPS 下有效。如果你的站有第三方登录回调(从 OAuth 提供方跳回来),回调那一步经常因为 SameSite 丢会话——症状是"授权成功了但回来还是未登录"。

HTTPS 混用。页面上有一个 http:// 的资源,或者表单 action 用了 http://,提交时 Cookie(带 Secure 属性的)不会发送,服务端就认为你没登录。浏览器控制台会提示"混合内容",但很多人不看。

第五步:多进程与多机部署——写在本地磁盘上的会话天然不共享

单机单进程的场景到这里基本能定位。但如果你的站点满足下面任一条,会话就必须外置:

  • PHP-FPM 有多个 worker 进程(这是默认配置,几乎必然)
  • 用了负载均衡,请求可能被分发到不同机器
  • 部署过蓝绿发布,两台机器同时服务
  • 用了多个域名或多个子目录,但希望用户只登录一次

需要说明的是:多 worker 本身不会导致会话丢失,因为会话文件落在同一个磁盘上,哪个 worker 都能读到。真正的断裂发生在下面三种情况:

情况一:HTTP 会话 + HTTPS 会话落在不同机器。这个少见但存在,尤其是 CDN 只加速了部分路径。

情况二:会话目录在容器里。Docker 容器的文件系统是临时的,容器重启(包括 docker compose up --force-recreate 这种常规操作)之后 /var/lib/php/sessions 里的所有会话全部消失,所有用户被踢下线。这是一个非常容易被忽略的"每次发布所有人掉线"的原因。修法是把会话目录挂成 volume,或者用 Redis 存会话。

# 检查会话目录在容器里是不是可写层
docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'

情况三:部署脚本清空了目录。有些部署流程会在发布前 rm -rf /tmp/* 或者清空 /var/lib/php/sessions,导致每次发布所有人掉线。这在"发布偶尔无感、偶尔全员掉线"的站上很常见。查一下部署脚本里有没有这类清理动作。

用 Redis 存会话的配置很短,值得作为长期方案:

# php.ini 或 FPM 池
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?database=2&timeout=2&prefix=SESS:"

# 或者用 phpredis 的扩展方式(推荐,性能更好)
# 检查扩展是否装了
php -m | grep redis

换成 Redis 之后要注意两件事:一是 Redis 本身要配持久化(否则 Redis 重启全站掉线,虽然是可接受的降级,但要心里有数);二是这个 Redis 实例不能被 FLUSHALL 误伤——如果同一个 Redis 里还存着缓存,缓存清理脚本一不小心就清掉了所有会话。

第六步:安全相关的两处必须检查

会话是身份凭证,配置不当直接从功能问题升级成安全问题。两个必查项:

会话 ID 必须走 Cookie,不能走 URL。PHP 支持把会话 ID 塞进 URL(session.use_trans_sid),这在 20 年前是为了兼容不支持 Cookie 的浏览器,现在纯属安全漏洞——会话 ID 会出现在 Referer 头、浏览器历史、日志里,任何人拿到就能冒用身份。

# 这三项必须是这样
php -i | grep -E 'session.use_(trans_sid|cookies|only_cookies)'
# 期望:trans_sid => Off,cookies => On,use_only_cookies => On

登录后必须换会话 ID。否则存在会话固定攻击:攻击者先用一个已知的会话 ID 访问你的站,然后诱导受害者用这个 ID 登录,登录后就共用了同一个会话。PHP 的正确做法是登录成功后立刻 session_regenerate_id(true),true 参数表示同时删掉旧文件。

// 登录成功后
if (password_verify($input, $user['hash'])) {
    session_regenerate_id(true);   // 关键:换 ID 并销毁旧的
    $_SESSION['uid']  = $user['id'];
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
    $_SESSION['login_at'] = time();
}

同时给 Cookie 加属性,几个 ini 项覆盖不到的部分在代码里设:

session_set_cookie_params([
    'lifetime' => 0,          // 0 = 会话 Cookie,关浏览器即失效
    'path'     => '/',
    'domain'   => '',         // 留空 = 当前主机,不写死
    'secure'   => true,       // 只在 HTTPS 发送
    'httponly' => true,       // JS 读不到,防 XSS 偷 Cookie
    'samesite' => 'Lax',
]);
session_start();

这里有个权衡要说清楚:lifetime => 0 最安全,但用户关掉浏览器就要重新登录,体验差。很多站会设成 7 天或 30 天,这本身不违反安全原则,但必须配合"会话在服务端可以主动失效"——也就是改密码时要把该用户的所有会话清掉。如果是文件存会话,这件事很难做到(要遍历所有会话文件找 uid);用 Redis 就可以给每个用户维护一个会话 ID 集合,改密码时一次删除。这也是很多站从文件迁移到 Redis 的真正原因,不只是性能。

一个可以照着跑的排查脚本

把上面的判定压成一个脚本,出问题时直接跑一遍,五秒钟出结论:

#!/bin/bash
echo "== 生效配置 =="
php -r 'foreach(["session.save_handler","session.save_path","session.gc_maxlifetime",
  "session.gc_probability","session.cookie_httponly","session.use_only_cookies"] as $k)
  printf("%-32s %s\n", $k, ini_get($k));'

echo "== 会话目录 =="
SP=$(php -r 'echo ini_get("session.save_path");' | sed 's/^[0-9]*;//')
ls -ld "$SP" || echo "目录不存在: $SP"

echo "== 最近 5 分钟写入的会话文件数 =="
find "$SP" -name 'sess_*' -mmin -5 2>/dev/null | wc -l

echo "== FPM 运行用户 =="
ps -eo user,cmd | grep 'php-fpm' | grep -v grep | awk '{print $1}' | sort -u

echo "== 清理类定时任务 =="
grep -rlE 'session|/tmp' /etc/cron* /etc/systemd/system/*.timer 2>/dev/null

输出里有两处要重点看:如果"最近 5 分钟写入的会话文件数"是 0,说明要么当前没人访问,要么写入失败了——可以用前面的 _sess_test.php 制造一次访问再跑;如果"FPM 运行用户"和会话目录属主对不上,问题基本就锁定了。

小结:会话问题永远先分"存没存住"和"带没带回来"

排查这类问题的效率,取决于你是不是一开始就把它劈成两半:服务端有数据吗,客户端有 ID 吗。这两半各有自己的判定命令,各有自己的坑:服务端的问题集中在权限、清理、多机共享;客户端的问题集中在 Cookie 属性、域名、协议、跨域。把这两半分开测,十分钟就能定位;混在一起猜,可能查一整天。

顺带说一句,如果你现在还在用文件存会话,而且站点会重启或者会扩容,那迁移到 Redis 是迟早要做的。不是因为文件方式不行,而是因为文件方式隐含了一个前提:"会话文件永远在同一个磁盘上,而且没有人会动它"。这个前提在你第一次做容器化或者第一次加第二台机器的时候就会失效,而失效的时候,用户会以"我被登出了"的形式告诉你。

Last modification:November 11th, 2026 at 01:25 pm

Leave a Comment