「用户刚写完一篇文章,点了保存,结果被踢回登录页,内容全没了」——这类投诉在个人站长群里几乎每周都有。多数时候不是浏览器的问题,而是服务端对「会话」的处理有断层:PHP 默认的 session 文件过期就删,Redis 里没设 TTL 或者设得太短,负载均衡后面多台机器各存各的。这篇从最常见的 PHP 站点场景出发,讲清会话在生命周期里的几个断点,以及怎么做到「换机器、重启服务、升级代码都不掉登录」。
一、先把会话拆成四段,再找断点
「登录状态」这件事在服务端其实由四段组成,任何一段断了,用户就会看到「被退出」:
- 凭证:cookie 里的 session id(或 token);
- 存储:session id 指向的服务端数据(文件 / Redis / 数据库);
- 校验:每次请求如何验证这个 id 仍然有效;
- 续期:过期时间如何滑动或固定。
排查掉登录,永远先问一句:是 session id 变了(cookie 丢/被换),还是 id 没变但存储里查不到(数据没了)?这两种原因的修法完全不同。最省事的判断方法是在日志里把 session_id() 的前 8 位打出来,对比出问题前后的值。
二、PHP 文件会话的三个默认坑
坑一:session.gc_maxlifetime 和 gc_probability 不同步
PHP 默认把 session 存成文件,过期清理由「垃圾回收」完成,触发概率由 session.gc_probability / session.gc_divisor 控制(默认 1/100,即每次请求有 1% 概率跑 GC)。而 GC 的判据是文件的 mtime 早于 session.gc_maxlifetime(默认 1440 秒,24 分钟)—— 注意,是文件最后修改时间,不是「最后一次访问」。PHP 只有在 session 数据发生变化时才写文件,用户如果一直只是浏览、没有写入 session,文件 mtime 不变,24 分钟后就被 GC 删了。
; 常见误配:把过期时间调到 7 天,但 GC 仍按 24 分钟判
session.gc_maxlifetime = 604800
; 结果:session 数据早被删,但 cookie 还活着 —— 表现为「随机掉登录」
; 正确做法之一:GC 交给系统定时任务,PHP 不自己跑
session.gc_probability = 0
; 然后 crontab 每天跑一次清理,按 7 天判
# 0 3 * * * find /var/lib/php/sessions -type f -mtime +7 -delete另一条更省事的做法:用 Redis 存 session,把过期交给 Redis 的 TTL,彻底绕开文件 GC 这套逻辑。
坑二:session.save_path 的目录权限被收紧
升级系统、装安全加固脚本、或者改 PHP-FPM 运行用户之后,/var/lib/php/sessions 的属主可能还是 apache:apache,而 FPM 跑在 www-data 下。表现是「登录成功但下一次请求就退出」—— 因为 session 文件写不进去(或读不出来),PHP 每次都给一个新的空 session。
# 一眼确认:跑 FPM 的用户对 session 目录有没有读写权限
ps -o user,cmd -C php-fpm8.2 | head -3
ls -ld /var/lib/php/sessions
sudo -u www-data test -w /var/lib/php/sessions && echo writable || echo NOT-writable
# 看 PHP 实际报错(不要只看 access log)
tail -50 /var/log/php8.2-fpm.log | grep -i 'session\|permission'这一类问题的隐蔽点在于它不报错:session_start() 失败只产生一条 warning,前端看到的只是「没登录」。
坑三:cookie 的 Domain 和 Secure 配置不一致
站点从 http 迁到 https、或者加了 www 前缀之后,如果 cookie 是在旧域名下发下去的,用户在新域名上就带不上这个 cookie。典型现象是「从搜索引擎点进来是未登录,从收藏夹点进来是登录」—— 两个入口的域名不同。
; 三个必须对齐的项
session.cookie_domain = .example.com ; 带前导点,主域和 www 共享
session.cookie_secure = 1 ; 仅 https 下发(上线前先确认全站 https)
session.cookie_samesite = Lax ; 防 CSRF,同时不挡正常的站内跳转
session.cookie_httponly = 1 ; JS 读不到,降低 XSS 下的损失
session.use_strict_mode = 1 ; 拒绝客户端自造的 session id
; 迁域名期间可以临时放宽(过渡期结束后改回)
session.cookie_domain = .old-example.comuse_strict_mode 那一行很值得开:不开的话,攻击者可以自己指定一个 session id 塞给用户,造成会话固定(Session Fixation)。开了之后,未初始化的 id 会被 PHP 直接丢弃并重新生成。
三、多机部署:会话必须外置
只要后端不止一台,文件 session 就必然出问题:用户第一次请求落到 A,session 写在 A 的本地磁盘;第二次请求被负载均衡打到 B,B 上没有这个文件,用户就掉线。表现为「刷新几下就退出,再刷新又好了」—— 典型的概率性掉登录。
两种解法,按规模选:
- 会话粘滞(sticky):负载均衡按 cookie 把同一用户固定到同一台。配置简单,但一台挂掉,上面的用户全部掉线,且扩缩容时会大面积掉。
- 会话外置(推荐):session 统一存 Redis / Memcached,任何一台机器都能读。个人站用一台小内存 VPS 跑 Redis 就够。
PHP 侧换成 Redis 只需要改 php.ini 加装扩展:
; 前提:pecl install redis,并在 php.ini 里启用 extension=redis.so
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?database=2&timeout=2&prefix=zz_sess:"
session.gc_probability = 0 ; 过期交给 Redis TTL
session.gc_maxlifetime = 604800 ; 会被传给 Redis 作为 TTL注意 prefix= 这个参数:同一台 Redis 可能被多个站点共用,不加前缀会互相覆盖。database=2 也是同样的隔离目的。
验证是否真的写进 Redis:
redis-cli -n 2 --scan --pattern 'zz_sess:*' | head -5
redis-cli -n 2 ttl "zz_sess:$(redis-cli -n 2 --scan --pattern 'zz_sess:*' | head -1 | sed 's/^zz_sess://')"TTL 应该是 604800 附近(减掉已经流逝的时间)。如果是 -1(永不过期),说明 gc_maxlifetime 没生效,Redis 里的 key 会无限堆积,几个月后内存打满 —— 这是最容易埋下的雷。
四、部署与重启:三个会踢人的动作
代码发布和运维操作是掉登录的第二大来源。以下三个动作每一次都会让一批用户掉线,做之前要清楚代价:
1. 重置 PHP 的 session 目录
有些部署脚本会在发布前 rm -rf /var/lib/php/sessions/* 图个干净。这一条下去,所有在线用户立刻全掉。真要清理,按 mtime 删超过 N 天的,不要清空。
2. 改了 session.name
session.name 决定 cookie 的名字(默认 PHPSESSID)。改成 ZZSESS 之类做「安全加固」,老用户的旧 cookie 会被直接忽略,全员掉线。改之前先确认没有别的依赖,改完接受一次性的全员掉登录。
3. 升级 PHP 大版本后 session 存到了新目录
PHP 8.1 升 8.2,session.save_path 默认路径跟着版本变了(比如 /var/lib/php/sessions 的属组或子目录不同),旧 session 全读不到。升级前先 php -i | grep save_path 记录两边路径,必要时在配置里显式写死同一个目录。
五、前端也要配合:401 不要直接跳登录
服务端做对了,前端一个错误的重定向照样能把用户「踢」出去。最常见的错误写法是:任何请求返回 401,就 location.href = '/login'。
问题在于 401 可能来自一次后台的轮询、一个已经过期的埋点请求、甚至一张需要鉴权的图片。用户正在写文章,后台某个 5 秒轮询超时返回 401,页面直接被跳走,输入内容丢失。
// 正确的做法:区分「当前页面需要鉴权」和「后台请求失败」
async function fetchWithAuth(url, opts) {
const res = await fetch(url, {credentials: 'same-origin', ...opts});
if (res.status === 401) {
const isPageNav = opts && opts.pageNav === true; // 只有页面级跳转才跳登录
if (isPageNav) {
location.href = '/login?next=' + encodeURIComponent(location.pathname);
}
throw new Error('UNAUTH'); // 后台请求失败只抛出,交给调用方处理
}
return res;
}
// 更稳的一层:草稿本地留一份,掉线也不丢内容
function autosaveDraft(text) {
try { localStorage.setItem('draft_' + location.pathname, text); } catch (e) {}
}本地草稿这一条看起来和会话无关,但它是「掉登录」真实损失的唯一兜底。用户抱怨的通常不是「被退出」,而是「退出后内容没了」。这两件事分开解决,投诉会立刻少一半。
六、一个可以直接跑的自检脚本
把上面这些判据固化成一个脚本,每次改配置后跑一次,比人肉回忆靠谱:
#!/bin/bash
# 会话健康检查:跑在站点服务器上
SITE=${1:-https://www.example.com}
JAR=$(mktemp)
echo "== 1. 登录后 cookie 是否正常下发"
curl -s -c "$JAR" -o /dev/null -w 'setcookie=%{http_code}\n' "$SITE/login"
echo "== 2. cookie 属性(看 HttpOnly / Secure / SameSite)"
grep -v '^#' "$JAR" | awk '{print $6, $4}' | tail -3
echo "== 3. 连续两次请求 session 是否稳定"
ID1=$(curl -s -b "$JAR" -c "$JAR" "$SITE/" -D - -o /dev/null | grep -i 'Set-Cookie' | head -1)
ID2=$(curl -s -b "$JAR" -c "$JAR" "$SITE/" -D - -o /dev/null | grep -i 'Set-Cookie' | head -1)
[ "$ID1" == "$ID2" ] && echo "stable" || echo "CHANGED -> 每次请求都发新 session,检查 save_path 权限"
rm -f "$JAR"第 3 项是最有价值的判据:正常情况下,第二次请求不应该再收到 Set-Cookie(或者收到的值不变)。如果每次都变,说明服务端根本没读到上一次的 session,直接去查 save_path 权限和 Redis 连通性。
七、会话过期时间到底该设多长
「多久过期」这个问题没有标准答案,但有几个明确的边界,定的时候别越线。
太短(比如 30 分钟)的代价是真实的:用户写一篇长文章、开着一个后台页面、去泡了杯咖啡回来,点保存就被踢出去。内容没了,投诉就来了。太长的代价同样真实:用户在公司电脑或者网吧登录过一次,忘了退出,cookie 一直在,任何拿到那台机器的人都能直接进后台。
比较稳妥的分层做法是按「能做什么」来区分,而不是按「登录时长」一刀切:
- 普通浏览:7 天不活动才过期,够用且不烦人。
- 后台管理操作:要求 30 分钟内有过一次输入密码的强验证,超时就要求重新确认。这一层可以只用一个额外的
last_verify_at时间戳,不必真的开两套 session。 - 敏感操作(改邮箱、改密码、删数据):每次都重新验证密码,不看 session 新旧。
实现上就是把「会话有效期」和「强验证有效期」拆成两个时间戳,登录时同时写入,敏感操作时只检查后者:
// 登录时
$_SESSION['uid'] = $uid;
$_SESSION['last_verify_at'] = time();
// 敏感操作前
function require_fresh_verify(int $window = 1800): void {
if (time() - ($_SESSION['last_verify_at'] ?? 0) > $window) {
header('Location: /reauth?next=' . urlencode($_SERVER['REQUEST_URI']));
exit;
}
}这样做的好处是:日常使用完全无感,只有真正危险的入口才多问一句。比「把整站 session 缩到 30 分钟」友好得多,也比「一律 7 天、什么都不管」安全得多。
还有一个容易被忽略的点:过期的判定要以后端为准。cookie 的 Expires 只是给浏览器的提示,用户可以自己改,也可以带着一个早就过期的 id 手工发请求。所有校验都必须在服务端做,cookie 过期时间只影响「浏览器愿不愿意继续带」,不能当作安全边界。
八、小结
会话掉线看起来五花八门,归到最后只有三类:存储没写进去、写进去了读不到、读到了但过期规则不对。第一类查权限和 Redis 连通性,第二类查多机是否共享存储,第三类查 gc_maxlifetime 与 TTL 是否一致。前端再补一条「本地草稿 + 区分 401 场景」,用户体验上就基本不会再有「内容全没了」的投诉。这几件事做完,剩下的就是把自检脚本挂进日常巡检,改配置后必跑一次。