在 ThinkPHP 面试中,一旦面试官问到“高并发下如何防止库存超卖”“如何保证订单幂等性”“定时任务重复执行怎么办”这类问题,分布式锁几乎是绕不开的考点。很多候选人对 Redis 的 SET NX EX 命令背得滚瓜烂熟,但真正追问到“在 TP 项目里你怎么落地”“锁误删怎么处理”“Redis 主从切换锁会不会丢”时,就开始含糊其辞。
这篇文章从面试实战角度出发,把分布式锁在 ThinkPHP 项目中的实现讲透,既覆盖原理,也给出可直接落地的代码。
一、为什么需要分布式锁
单机环境下,用 PHP 的 flock 或者文件锁就能解决并发问题。但现在的 TP 项目基本都是多机部署,负载均衡后面挂着 3 台、5 台甚至更多 PHP-FPM 容器。此时文件锁只在单台机器内有效,跨机器完全失效。
分布式锁要解决的核心问题是:在分布式系统中,多个进程/线程对同一资源的互斥访问。
面试中常被提到的典型场景:
- 秒杀扣库存,防止超卖
- 订单支付回调的幂等处理
- 定时任务多机部署时只允许一台执行
- 缓存击穿时只放一个请求去查数据库
二、基于 Redis 的分布式锁实现
Redis 因为性能高、命令原子性好,是 TP 项目中最常用的分布式锁方案。下面给出一个完整的实现类。
2.1 加锁的核心命令
// 正确的加锁方式:SET key value NX EX seconds
$redis->set($key, $uniqueValue, ['NX', 'EX' => $expire]);
这里有两个关键点面试官一定会问:
第一,为什么 value 要用唯一值?
因为释放锁时需要判断“这把锁是不是我加的”。如果直接用 DEL 删除,可能误删别人的锁。比如 A 的锁过期后 B 拿到了锁,此时 A 执行完业务去 DEL,删掉的就是 B 的锁。
第二,为什么必须用 SET NX EX 而不是 SETNX + EXPIRE?
因为 SETNX 和 EXPIRE 是两条命令,不是原子的。如果 SETNX 成功后进程崩溃,EXPIRE 没执行,锁就永远不会释放,形成死锁。
2.2 释放锁的 Lua 脚本
释放锁必须保证“判断 + 删除”的原子性,所以要借助 Lua:
$lua = <<<LUA
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
LUA;
$redis->eval($lua, [$key, $uniqueValue], 1);
2.3 完整的锁类封装
<?php
namespace app\common\library;
use think\facade\Cache;
class RedisLock
{
protected $redis;
protected $key;
protected $token;
protected $expire;
public function __construct($key, $expire = 10)
{
$this->redis = Cache::store('redis')->handler();
$this->key = 'lock:' . $key;
$this->expire = $expire;
$this->token = uniqid('', true) . mt_rand();
}
public function acquire()
{
$result = $this->redis->set(
$this->key,
$this->token,
['NX', 'EX' => $this->expire]
);
return $result !== false && $result !== null;
}
public function release()
{
$lua = <<<LUA
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
LUA;
return $this->redis->eval($lua, [$this->key, $this->token], 1);
}
}
在业务中的使用:
$lock = new RedisLock('order_stock_' . $goodsId, 10);
if (!$lock->acquire()) {
return json(['code' => 0, 'msg' => '操作过于频繁,请稍后再试']);
}
try {
// 执行扣库存等业务逻辑
$this->deductStock($goodsId);
} finally {
$lock->release();
}
注意 try...finally 的写法,保证业务抛异常时锁也能释放。
三、面试高频追问点
3.1 锁过期了业务还没执行完怎么办
这是 Redis 分布式锁最经典的问题。比如锁设了 10 秒,但业务执行了 15 秒,锁提前释放,其他请求就能拿到锁,互斥性被破坏。
解决方案是锁续期(看门狗机制)。思路是加锁成功后启动一个子进程或定时器,每隔一段时间检查业务是否还在执行,如果是就把锁的过期时间重置。Redisson 框架就是这么做的。
在 PHP 中实现续期比较麻烦,因为 PHP 没有常驻线程。常见的折中方案是:合理评估业务耗时,把过期时间设得足够长,同时配合业务侧的幂等兜底。
3.2 Redis 主从切换导致锁丢失
如果 Redis 是主从架构,客户端在主节点加锁成功,但主节点还没来得及同步到从节点就宕机了,从节点升级为主节点后,锁就“消失”了,其他客户端可以再次加锁。
针对这个问题,Redis 官方提出了 RedLock 算法:向多个独立的 Redis 实例依次加锁,超过半数成功才算加锁成功。但 RedLock 本身也存在争议(Martin Kleppmann 和 antirez 的著名论战),实际项目中用得不多。
面试时的标准回答是:如果业务对一致性要求极高,建议用 ZooKeeper 或 etcd 实现分布式锁;如果只是防重复、防并发,Redis 锁配合幂等设计已经够用。
3.3 为什么不用数据库实现分布式锁
数据库也能做分布式锁,比如用唯一索引:
INSERT INTO `lock_table` (`lock_key`, `expire_time`) VALUES ('order_123', ...)
插入成功即加锁,删除即释放。但缺点是性能差、有锁表风险,高并发场景下数据库压力大。所以 TP 项目中除非没有 Redis,否则不推荐。
四、TP 框架中的实践建议
- 统一封装锁工具类,放在
app/common/library下,避免业务代码里到处写 Redis 命令。 - 锁的粒度要细,用业务 ID 作为 key 的一部分,比如
lock:order:10086,而不是一把大锁锁所有订单。 - 一定要设置过期时间,防止死锁。
- 释放锁放在 finally 中,防止异常导致锁泄漏。
- 配合幂等设计,分布式锁不是万能的,数据库唯一索引、状态机判断才是最后一道防线。
五、总结
分布式锁是 ThinkPHP 面试中区分初级和中级工程师的重要考点。回答时不要只停留在“用 Redis 的 SETNX”,而要讲清楚:为什么需要分布式锁、加锁释放锁的原子性如何保证、锁过期和主从切换的边界问题如何处理。
能把 SET NX EX + Lua 释放 + 续期思路 + 幂等兜底这条链路讲完整,基本就能让面试官满意了。如果在项目中真正落地过,再结合具体业务场景讲一讲踩过的坑,那就是加分项。
未经允许不得转载:任鹏个人博客 » ThinkPHP 面试精讲:分布式锁在 TP 项目中的实现

