Redis 会话驱动锁机制
解读
国内高并发业务(电商秒杀、直播带货、政务大厅)普遍把 PHP 会话从文件迁移到 Redis,以解决多机共享与水平扩展问题。
当同一用户瞬间发起多条请求(如支付回调 + 轮询),PHP 需要保证“会话数据”这一临界资源在并发读写时不出现脏读、丢失更新或竞态条件。
因此 Redis 会话驱动必须引入“锁机制”:
- 在脚本开始读/写 $_SESSION 之前加锁;
- 脚本结束或异常退出时强制释放;
- 锁超时防止死锁;
- 可重入(同进程多次加锁)或不可重入,依据业务选型;
- 锁粒度控制在“单个 session_id”级别,避免全局阻塞。
面试官问此题,想确认候选人是否理解:
- PHP 默认文件锁(flock)与 Redis 分布式锁的差异;
- 如何利用 Redis 原子指令实现安全、高效、可容灾的会话锁;
- 在 Laravel、ThinkPHP、原生 session.save_handler 中的落地细节;
- 极端场景(锁超时、进程僵死、Redis 主从切换)下的兜底策略。
知识点
- PHP 会话生命周期:session_start() → 读取 → 注册 shutdown 函数 → 脚本结束 → 写回 → 释放锁。
- Redis 锁原子指令:SET key value NX PX milliseconds;Lua 脚本保证“判断+删除”原子性。
- 锁 Key 设计:PHPREDIS_SESSION:{session_id}:lock,TTL 建议 2~5 秒,最大执行时间(max_execution_time)+ 网络延迟冗余。
- 可重入实现:value 用 “UUID+进程 PID+递增计数” 拼接,释放时先校验再减计数,归零才真正 DEL。
- 红锁(Redlock)争议:单节点 Redis 已能满足会话场景,若用 Cluster 需把锁 slot 固定在同一个节点,避免 CROSSSLOT 错误;多节点 Redlock 反而加重延迟。
- 框架封装:
- Laravel:Illuminate\Session\RedisSessionHandler 中
takeLock()使用SET ... NX PX;releaseLock()用 Lua 校验随机串;可配置lottery读写分离。 - ThinkPHP:tp6 的
cache\driver\Redis支持lock/前缀,但默认未开启会话锁,需在session.php增加'redis_session_lock' => true, 'lock_expire' => 3。
- Laravel:Illuminate\Session\RedisSessionHandler 中
- 故障兜底:
- 锁超时后脚本仍在跑,写回时使用
SET key value XX(仅当 key 存在才更新),若 key 已被新进程重建则放弃写回,防止覆盖新数据。 - 注册
register_shutdown_function+pcntl_signal(SIGTERM, ...)保证 FPM 被 kill 时仍尝试释放。
- 锁超时后脚本仍在跑,写回时使用
- 性能调优:
- 读多写少可降级为“读共享锁、写排他锁”,用
GET判断版本号,写时WATCH+ 事务,减少 90% 以上锁竞争。 - 大促前把 TTL 调小、提前压测(wrk + 实际 session 大小),观察
SLOWLOG是否出现SETNX阻塞。
- 读多写少可降级为“读共享锁、写排他锁”,用
答案
“我用 Laravel 为例说明,但底层原理与原生 PHP 一致。
- 加锁:在
session_start()阶段,框架先构造lockKey = 'laravel_session:' . $sessionId . ':lock',然后执行
SET lockKey $random NX PX 3000
如果返回 OK 表示拿到锁;否则自旋 50 次,每次休眠 20 ms,超过 1 秒抛SessionLockException中断请求,防止雪崩。 - 可重入:Laravel 把
$random存在类属性里,同一次请求里再次调用session_start()发现随机串一致,直接计数器 +1,避免重复 SET。 - 释放:请求结束触发
writeClose(),用 Lua 脚本
if redis.call("GET",KEYS[1])==ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end
保证只有加锁者才能删除;同时把计数器减到 0 才真正 DEL。 - 超时兜底:如果脚本因为 FPM 超时被杀,锁 3 秒后自动过期;新进程拿到锁后读到的会话数据可能还是旧版本,但 Laravel 在写回时会先
GET当前 TTL,若 TTL < 0.5 秒则放弃写回并记录日志,防止覆盖。 - 主从切换:Redis 哨兵升主瞬间可能丢锁,但会话锁生命周期短,业务可接受极小概率重复写;若要求强一致,把
redis.conf开启min-replicas-to-write 1,牺牲一点可用性。 - 压测结果:在 4C8G 容器、Redis 单实例、会话大小 2 KB、并发 1000 的场景下,加锁后 99 线延迟从 18 ms 升到 22 ms,QPS 下降 6%,属于可接受范围。”
拓展思考
- 如果业务把会话拆成“读接口”与“写接口”,读接口完全无锁,写接口才排他,能否用 Redis + 版本号实现乐观锁?请给出 Lua 脚本并评估并发度。
- 当 PHP 部署在 Kubernetes 且启用 HPA 弹性伸缩,Pod 被频繁销毁,如何结合
preStop钩子与SIGTERM信号,保证会话锁 100% 释放? - 在 Redis Cluster 模式下,session_id 按 CRC16 落在不同 slot,导致锁 Key 与数据 Key 可能不在同一节点,如何用 Hash Tag
{session_id}强制同槽,并证明其不会引起热点? - 对比 MySQL
SELECT ... FOR UPDATE、文件flock、Redis 分布式锁三种方案,在 10 万并发、会话 1 KB、读写比 9:1 的电商大促场景下,给出选型矩阵(延迟、吞吐、成本、运维复杂度)。