ThinkPHP 面试精讲:分布式锁在 TP 项目中的实现

在 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 框架中的实践建议

  1. 统一封装锁工具类,放在 app/common/library 下,避免业务代码里到处写 Redis 命令。
  2. 锁的粒度要细,用业务 ID 作为 key 的一部分,比如 lock:order:10086,而不是一把大锁锁所有订单。
  3. 一定要设置过期时间,防止死锁。
  4. 释放锁放在 finally 中,防止异常导致锁泄漏。
  5. 配合幂等设计,分布式锁不是万能的,数据库唯一索引、状态机判断才是最后一道防线。

五、总结

分布式锁是 ThinkPHP 面试中区分初级和中级工程师的重要考点。回答时不要只停留在“用 Redis 的 SETNX”,而要讲清楚:为什么需要分布式锁、加锁释放锁的原子性如何保证、锁过期和主从切换的边界问题如何处理。

能把 SET NX EX + Lua 释放 + 续期思路 + 幂等兜底这条链路讲完整,基本就能让面试官满意了。如果在项目中真正落地过,再结合具体业务场景讲一讲踩过的坑,那就是加分项。

未经允许不得转载:任鹏个人博客 » ThinkPHP 面试精讲:分布式锁在 TP 项目中的实现

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏