同一用户并发请求排队、CPU 却不高:PHP 会话锁的持有边界与后端默认值
先看判据
三个特征同时出现,基本可以锁定会话锁:
- 只有同一用户(同一 session id)的并发请求排队,换个账号或换浏览器就正常;
- PHP-FPM worker 被打满,CPU 却不高,因为请求阻塞在锁上,不消耗计算;
- 慢日志里时间花在
session_start()。
只要符合这三条,就不用先查数据库、查网络,直接去看会话文件上的 flock。
证据链:从 worker 到 flock
对卡住的 worker 直接跟踪系统调用:
strace -p
能看到 flock(..., LOCK_EX) 停在会话文件上(生产案例里是 flock(14, LOCK_EX))。确认谁持有:
lsof /var/lib/php/session/sess_xxx
fuser /var/lib/php/session/sess_xxx
lsof | grep sess_ | wc -l
最后一条看 fd 数量,案例里能堆到几千个 session 文件 fd。
默认 files handler 的行为在 php.net「Basic usage」的 Note 里写得很明确:会话通过 session_start() 打开,或被 session.auto_start=1 隐式打开后,PHP 对 /var/lib/php/session/sess_ 取 flock 排他锁,锁一直持续到脚本结束或调用 session_write_close()。两个并发请求里一个拿锁,另一个在 session_start() 阻塞,总耗时变成两者相加。手册把这件事定性为文件会话的既有行为。
持锁边界
session_write_close() 是第一个可调的边界。调用之后 $_SESSION 变量仍在内存里,但后续修改不会被保存,session_regenerate_id() 也会失败(见手册用户注释)。所以读多写少的接口,应该在所有写操作完成后立刻调用它,而不是留到脚本结束。
手册用户注释里有个可直接复现的对照:两个进程跑同一会话,session_start(); sleep(3); session_write_close();,一个 3 秒一个 6 秒;把 session_write_close 提前,两者都是 3 秒。
只读场景在 PHP 7+ 可以一步到位:
session_start(['read_and_close' => true]);
等价于 session_start() + session_write_close(),进来就把锁放掉。
关于 output_buffering:手册用户注释提到它开启时(PHP 7+ 默认开启)会话不会随 session_write_close() 立即关闭,需要 output_buffering = Off 才生效。这是用户注释而非官方正文,改动前建议先实测。
session.lazy_write(PHP 7 默认 1)容易误读。它只省掉“数据没变时重写文件”这一步,锁仍然持有到关闭,不能当并发优化用。
换后端不等于免费
- memcached:
memcached.sess_locking默认on,行为同样阻塞,可以设off。 - phpredis 的 redis session handler:默认不加锁。4.1 起可用
redis.session.locking_enabled = 1显式开启,另有redis.session.lock_expire/lock_retries/lock_wait_time控制过期与重试,这些参数的具体版本默认值需要对照文档自查。 - MySQL 后端(如 Drupal):由
session_set_save_handler自行实现,加不加锁取决于具体实现。
phpredis issue #37 里还讨论到一个细节:服务器或客户端持久连接上做会话锁,可能让会话被永久阻塞,因此锁必须在 open() 获取、close() 释放,且不能挂在持久连接上。
不加锁的代价是丢失更新,不是按 key 合并。ma.ttias.be 给的例子:两个脚本同时读到同一份 session,脚本 1 写 payment_id=1 后 sleep,脚本 2 写 payment_id=5 先保存,脚本 1 后保存整体覆盖,最终留下 1。
Cm_RedisSession 的读锁与改法
Magento 的 Cm_RedisSession 默认实现的是读锁:首次读取时获取、写入时释放,被指导致并发 AJAX(结账、开发模式下的静态文件)排队。issue 里的建议是改成写前加锁、加锁后重新读取并做 diff 合并。
维护者的回应是读时加锁为有意设计,否则会读到 stale 数据,比如一次性错误消息被多个请求重复读到;并认为根本问题在上游不该产生这么多并行会话请求,应尽量做成无状态。最终合入的路径是:handler 以 read-only 启动,处理完用 setReadOnly(false) 关掉只读,再读一次并写回。
Redis 做会话后端的三个约束
- 不要和业务缓存共用 db 编号。误跑一次
FLUSHDB会把所有用户踢下线。常见分法是database=1给 session、database=0给 cache。 - TTL 由
session.gc_maxlifetime决定,gc_probability/gc_divisor对 Redis 没有意义。锁本身用SET NX+ TTL 实现,redis.session.lock_expire控制锁过期,案例里设的是 30 秒。 - 没开持久化就是重启全员掉线,需要开 appendonly。
生产数据
Bitrix 的案例:340 并发用户 × 约 4 个并行 AJAX ≈ 1300 请求,半数阻塞在文件锁上;并发低于 150 时问题不显现。迁到 Redis 后 p95 从 3.8s 降到 0.7s,占用的 FPM worker 从 127/128 降到 38–42/128,504 从约 1100 降到 0。
取舍
锁解决的是“读后写覆盖”的竞态,代价是同会话请求串行化。顺序应该是先缩短持锁时间:写操作尽量前置,紧跟 session_write_close(),只读入口用 read_and_close。确实要换后端时,要么显式开锁保住一致性,要么接受丢更新。