为什么会话丢、附件传不上、大文件传一半:先搞清「跨节点」这件事
很多站长把网站从一台服务器扩到两台之后,会遇到一组看起来很杂的故障:用户明明刚登录,刷新一下又变回未登录;后台传一张 5MB 的封面图,进度条卡在 60% 然后报错;会员上传一个 200MB 的视频,服务器日志里却只留下半截记录。这些现象在单机时代几乎不会出现,一旦「多节点 + 负载均衡」上线就开始冒头。本文只讲这一类问题:请求在节点之间来回跳,导致状态没有跟着走,以及一套可落地的修复顺序。
需要先建立一个判断标准:只要你的后端有超过一个进程或一台机器处理同一个用户的同一段会话,就必须假设「这次请求和上次请求可能落在不同地方」。凡是把状态存在「本机」的设计,在这种前提下都是不可靠的。下面从最容易踩的地方开始,逐层排查。
第一层:负载均衡的会话保持,为什么它只是止痛药
最常见的「急救」手段是在 Nginx 上开 ip_hash 或 sticky:
upstream app_backend {
ip_hash; # 同一来源 IP 固定落到同一台
server 10.0.0.11:9000;
server 10.0.0.12:9000;
}它确实能让大部分用户「暂时不丢会话」,但你要清楚它的三个边界,否则后面的故障会更难查。
- NAT 出口会让大量用户共享同一个 IP。 运营商侧、公司出口、手机基站的出口 IP 都是共享的,ip_hash 会把这些用户全压到同一台,负载均衡反而变歪。
- 节点一挂,会话全丢。 用户被重新哈希到另一台,另一台上根本没有他的会话文件,登录态直接消失。用 ip_hash 保住的会话,在故障切换时是最先崩的。
- 它不解决上传问题。 上传中断往往不是「落错节点」,而是下文第三层要讲的传输链路问题。开了 sticky 之后现象消失,很可能只是巧合(后端恰好变快),隐患还在。
结论:ip_hash 可以作为过渡期的止血手段,但把它当成最终方案,等于把一个迟早要爆的问题藏了起来。正确的方向是把状态从「本机」搬到一个所有节点都能访问的公共服务上。
第二层:会话共享,Redis 的正确与错误用法
PHP 默认把 session 以文件形式存在 /var/lib/php/sessions,每个节点各存一份,这正是跨节点丢会话的根因。把它改成 Redis 是标准做法,但有四个细节决定成败。
其一,session.save_path 的格式必须带认证与库号。 常见错误是只写 IP 端口,Redis 设了密码就连不上,表现是「所有用户每次请求都新建会话」,也就是每次刷新都是未登录:
; php.ini 或 pool 配置中
session.save_handler = redis
session.save_path = "tcp://10.0.0.20:6379?auth=YourRedisPass&database=1"注意 PHP 配置里如果需要写多个参数,用的是 & 连接,例如再拼上 prefix=WEB1_。参数名写错不会报错,但对应功能会静默失效。
其二,键前缀要区分环境。 同一台 Redis 上如果既有生产库又有测试库,不加前缀会让测试登录把生产用户顶掉。用 session.save_path 里的 prefix 或应用层统一加前缀。
其三,并发写同一键会丢更新。 PHP 的 session 文件默认会加锁串行处理同一会话的多个请求,换成 Redis 之后锁没了,页面里同时发出的 5 个 Ajax 请求如果都修改 session,最后写入的会覆盖前四个。解决方式是在应用层对会话写入做 session_write_close() 提前释放,或者干脆不要把可变状态塞进 session,改成 Redis 里的业务键。
其四,Redis 挂了不能把登录一起带走。 会话存储变成单点后,Redis 一重启全站掉线。至少要做两件事:给 tcp:// 换成多节点(Redis 哨兵或在 save_path 里配置多个地址),以及把 Redis 的 appendonly yes 打开避免重启丢数据。
# 验证会话是否真的写进 Redis,而不是还在本机
redis-cli -h 10.0.0.20 -a YourRedisPass -n 1 --scan --pattern 'PHPREDIS_SESSION:*' | head
redis-cli -h 10.0.0.20 -a YourRedisPass -n 1 ttl PHPREDIS_SESSION:xxxx如果这个命令一条都扫不出来,而用户却说「能登录」,那说明你的 PHP 用的是另一条路径上的 session,或者应用自己实现了 token 登录 —— 先把真实机制确认清楚再改配置,否则会改错对象。
第三层:大文件上传中途断掉,问题常在 Nginx 与 PHP 两层参数打架
上传中断有一个非常典型的共性:小文件一切正常,超过某个体积就必挂。这个「某个体积」几乎总能对应到某一层的限制值上,所以排查方法是反推阈值,而不是瞎改配置。按顺序检查下面六个参数,它们的生效层级不同,缺一个都会前功尽弃。
# Nginx 层(http / server / location 均需确认,大的不一定能覆盖小的)
client_max_body_size 512m; # 不设默认 1m,超限直接 413
client_body_timeout 300s; # 上传慢时不要过早掐断
proxy_read_timeout 300s; # 反代到 PHP-FPM 时,等待上游的时间
send_timeout 300s;
# PHP 层(php.ini 或 FPM pool)
upload_max_filesize = 512M; # 单个文件上限
post_max_size = 520M; # 必须大于 upload_max_filesize
max_execution_time = 300
max_input_time = 300最容易踩的坑是 post_max_size 必须比 upload_max_filesize 略大。表单除了文件本身还有字段开销,两者设成完全相等时,边界文件会在表单解析阶段被整包丢弃,表现是 $_FILES 为空、PHP 不报错、前端也不知道为什么。另一个坑是 Nginx 与 PHP-FPM 之间的中间层:如果有 CDN 或 WAF 在前面,它们各自也有限制,逐层核对才能定位到底是哪一层返回了 413。
排查时不要只盯着应用日志,请求被 Nginx 拦掉时根本不会进到 PHP。要看的是:
# 谁返回了 413 / 499 / 504,看这一行就知道断在哪一层
tail -f /var/log/nginx/access.log | grep -E ' (413|499|504) '
# FPM 是否收到请求
tail -f /var/log/php*-fpm.log
# 检查 body 临时目录是否有空间(上传大文件时会占满 client_body_temp)
df -h /var/lib/nginx/body /tmp还有一类中断和参数无关:上传过程中触发了磁盘写满或 inode 耗尽。Nginx 会把请求体先缓存在 client_body_temp,PHP 再写一次到 tmp,等于同一份数据落盘两次。磁盘紧张时大文件上传是最先失败的功能,df -h 与 df -i 必须一起看。
第四层:状态外置之后,还有一类「看似丢状态」的假象
把 session 搬走、上传参数调好之后,仍可能遇到用户反馈「登录状态时有时无」。这时候要怀疑的不是状态存储,而是多个节点之间代码或配置不一致。典型场景:
- 其中一台节点的
.env里APP_KEY或加密盐值和另一台不同,导致 Cookie 在另一台上解不开。 - 一台节点开了 HSTS 或强制 HTTPS,另一台没开,用户在两台之间跳转时 Cookie 的 Secure 属性不一致,浏览器直接不发。
- 站点域名同时存在
www与裸域两个版本,Cookie 的 Domain 属性只覆盖其中一个。
解决这类问题的效率最高手段是把「哪台节点处理的这次请求」暴露出来。在响应头里加一个节点标识,问题现场立刻定位:
# Nginx 中给每台机器一个可辨识的标识
add_header X-Served-By $hostname always;
add_header X-Node-Env $APP_ENV always;然后在用户报错时让他把浏览器开发者工具里的 X-Served-By 发过来。如果同一个故障只在某一台出现,问题立刻收敛到单机配置差异,不用再猜。
一张可直接照着走的排查清单
把上面的内容压缩成可以贴在运维手册里的顺序,遇到「多节点状态丢失类」故障时按这个顺序走,效率最高:
- 确认会话真实存储位置:扫 Redis 键,或看
php -i | grep session.save_path,先确认机制再改。 - 确认负载均衡策略:是否有 ip_hash / sticky,出口 IP 是否被 NAT 共享,节点故障时会话会怎样。
- 确认上传链路的六层参数:Nginx 三个超时 + client_max_body_size,PHP 三个大小限制,逐层核对是否被中间层截断。
- 确认磁盘容量与 inode:
df -h与df -i一起看,大文件上传失败往往先是磁盘问题。 - 确认节点间配置一致性:Cookie 域、加密密钥、HTTPS 强制策略是否逐台一致。
- 加上节点标识响应头,把「哪台机器」变成可观测信息,为下一次故障省掉一轮猜测。
总结
跨节点故障的共同点是:问题不在某一台机器上,而在「请求不一定落到同一台」这个前提上。因此修复思路永远是先把状态从单机挪到共享服务(会话进 Redis),再把请求链路上每一层的限制值理顺(Nginx 与 PHP 六个参数、磁盘容量),最后把节点身份变成可观测信息,让下一次故障能在几分钟内定位而不是靠猜。ip_hash 与 sticky 不是方案,它们只是让问题延后出现的止痛药;真正省事的做法是一开始就把状态外置,并且给每台节点一个可识别的标识。这样即便再扩到三台、五台,你也不用重新发明一遍排查流程。