Redis 会话驱动锁机制

解读

国内高并发业务(电商秒杀、直播带货、政务大厅)普遍把 PHP 会话从文件迁移到 Redis,以解决多机共享与水平扩展问题。
当同一用户瞬间发起多条请求(如支付回调 + 轮询),PHP 需要保证“会话数据”这一临界资源在并发读写时不出现脏读、丢失更新或竞态条件。
因此 Redis 会话驱动必须引入“锁机制”:

  1. 在脚本开始读/写 $_SESSION 之前加锁;
  2. 脚本结束或异常退出时强制释放;
  3. 锁超时防止死锁;
  4. 可重入(同进程多次加锁)或不可重入,依据业务选型;
  5. 锁粒度控制在“单个 session_id”级别,避免全局阻塞。
    面试官问此题,想确认候选人是否理解:
  • PHP 默认文件锁(flock)与 Redis 分布式锁的差异;
  • 如何利用 Redis 原子指令实现安全、高效、可容灾的会话锁;
  • 在 Laravel、ThinkPHP、原生 session.save_handler 中的落地细节;
  • 极端场景(锁超时、进程僵死、Redis 主从切换)下的兜底策略。

知识点

  1. PHP 会话生命周期:session_start() → 读取 → 注册 shutdown 函数 → 脚本结束 → 写回 → 释放锁。
  2. Redis 锁原子指令:SET key value NX PX milliseconds;Lua 脚本保证“判断+删除”原子性。
  3. 锁 Key 设计:PHPREDIS_SESSION:{session_id}:lock,TTL 建议 2~5 秒,最大执行时间(max_execution_time)+ 网络延迟冗余。
  4. 可重入实现:value 用 “UUID+进程 PID+递增计数” 拼接,释放时先校验再减计数,归零才真正 DEL。
  5. 红锁(Redlock)争议:单节点 Redis 已能满足会话场景,若用 Cluster 需把锁 slot 固定在同一个节点,避免 CROSSSLOT 错误;多节点 Redlock 反而加重延迟。
  6. 框架封装:
    • Laravel:Illuminate\Session\RedisSessionHandler 中 takeLock() 使用 SET ... NX PXreleaseLock() 用 Lua 校验随机串;可配置 lottery 读写分离。
    • ThinkPHP:tp6 的 cache\driver\Redis 支持 lock/ 前缀,但默认未开启会话锁,需在 session.php 增加 'redis_session_lock' => true, 'lock_expire' => 3
  7. 故障兜底:
    • 锁超时后脚本仍在跑,写回时使用 SET key value XX(仅当 key 存在才更新),若 key 已被新进程重建则放弃写回,防止覆盖新数据。
    • 注册 register_shutdown_function + pcntl_signal(SIGTERM, ...) 保证 FPM 被 kill 时仍尝试释放。
  8. 性能调优:
    • 读多写少可降级为“读共享锁、写排他锁”,用 GET 判断版本号,写时 WATCH + 事务,减少 90% 以上锁竞争。
    • 大促前把 TTL 调小、提前压测(wrk + 实际 session 大小),观察 SLOWLOG 是否出现 SETNX 阻塞。

答案

“我用 Laravel 为例说明,但底层原理与原生 PHP 一致。

  1. 加锁:在 session_start() 阶段,框架先构造 lockKey = 'laravel_session:' . $sessionId . ':lock',然后执行
    SET lockKey $random NX PX 3000
    如果返回 OK 表示拿到锁;否则自旋 50 次,每次休眠 20 ms,超过 1 秒抛 SessionLockException 中断请求,防止雪崩。
  2. 可重入:Laravel 把 $random 存在类属性里,同一次请求里再次调用 session_start() 发现随机串一致,直接计数器 +1,避免重复 SET。
  3. 释放:请求结束触发 writeClose(),用 Lua 脚本
    if redis.call("GET",KEYS[1])==ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end
    保证只有加锁者才能删除;同时把计数器减到 0 才真正 DEL。
  4. 超时兜底:如果脚本因为 FPM 超时被杀,锁 3 秒后自动过期;新进程拿到锁后读到的会话数据可能还是旧版本,但 Laravel 在写回时会先 GET 当前 TTL,若 TTL < 0.5 秒则放弃写回并记录日志,防止覆盖。
  5. 主从切换:Redis 哨兵升主瞬间可能丢锁,但会话锁生命周期短,业务可接受极小概率重复写;若要求强一致,把 redis.conf 开启 min-replicas-to-write 1,牺牲一点可用性。
  6. 压测结果:在 4C8G 容器、Redis 单实例、会话大小 2 KB、并发 1000 的场景下,加锁后 99 线延迟从 18 ms 升到 22 ms,QPS 下降 6%,属于可接受范围。”

拓展思考

  1. 如果业务把会话拆成“读接口”与“写接口”,读接口完全无锁,写接口才排他,能否用 Redis + 版本号实现乐观锁?请给出 Lua 脚本并评估并发度。
  2. 当 PHP 部署在 Kubernetes 且启用 HPA 弹性伸缩,Pod 被频繁销毁,如何结合 preStop 钩子与 SIGTERM 信号,保证会话锁 100% 释放?
  3. 在 Redis Cluster 模式下,session_id 按 CRC16 落在不同 slot,导致锁 Key 与数据 Key 可能不在同一节点,如何用 Hash Tag {session_id} 强制同槽,并证明其不会引起热点?
  4. 对比 MySQL SELECT ... FOR UPDATE、文件 flock、Redis 分布式锁三种方案,在 10 万并发、会话 1 KB、读写比 9:1 的电商大促场景下,给出选型矩阵(延迟、吞吐、成本、运维复杂度)。