PHP如何实现分布式锁自动续期避免业务执行中途失效

大家好,我是你们的“锁匠”老王。

今天咱们不聊那些虚头巴脑的架构图,也不谈什么高可用高并发,咱们来聊点硬核的、让人半夜惊醒的——分布式锁

在这个微服务满天飞的时代,分布式锁简直就是程序员的“保命符”。但你有没有想过,如果你的保命符自己“罢工”了,或者在你最关键的时候,它悄悄地“离家出走”了,会发生什么?

今天这场讲座的主题是:PHP如何实现分布式锁自动续期,避免业务执行中途失效

这事儿听起来挺玄乎,其实就是个“租约”问题。来,搬个小板凳坐好,咱们开始这场关于“锁、心跳和防背刺”的技术研讨会。


第一章:锁的“租约”哲学

首先,咱们得搞明白,锁是个什么东西?

在分布式系统里,锁本质上就是一种协议,一种大家约定好的“停战协定”。比如,我现在要把这张优惠券买下来,我得告诉所有人:“我不买这票了,我要锁它,10秒钟,谁也别动,谁动谁是孙子。”

Redis 就是我们这个“租界”的房东。当你用 SET key value NX EX 10 这条命令的时候,你就相当于跟房东签了合同:

  • Key:房门钥匙。
  • NX:只能租一次,没租出去不行。
  • EX 10:租期10秒。

这10秒就是你的“租约时间”。

这时候,业务代码开始执行了。假设你的业务代码是个“重体力劳动者”,它要花15秒才能把那个优惠券扣减完。

问题来了:
第10秒一到,租约到期。房东(Redis)一看:“嘿,这租客咋还没搬走?赶紧清场!”于是,“咔嚓”一声,你的锁没了。

这时候,隔壁老李也来了,他也想买这张票。他一看锁没了,立马租下来。然后他也花15秒办事。

等你们俩都办完事了,数据库里的优惠券就被扣了两次!系统崩溃,这就是典型的“竞态条件”引发的惨剧。

所以,我们今天要解决的核心矛盾就是:业务执行时间可能超过锁的租约时间,且锁在业务执行期间会失效。


第二章:手动续期的“尴尬”尝试

那怎么办?把锁的时间调长点?比如改成60秒?不行,万一业务跑完了锁还没过期,别的进程进不来,资源被长期独占,这叫“死锁”,性能极差。

那我们能不能在业务代码里加个 while 循环,手动续期呢?

看代码:

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// 1. 抢锁,租期3秒
$redis->set('lock_key', 'unique_id', ['NX', 'EX' => 3]);

// 2. 业务逻辑
for ($i = 0; $i < 10000000; $i++) {
    // 假设这事儿得跑很久
}

// 3. 等等,我要续期!
// 当业务跑了一半,比如跑了2秒,锁只剩1秒了
if ($redis->get('lock_key') === 'unique_id') {
    $redis->expire('lock_key', 3); // 再续3秒
    // 继续跑业务...
}

听着好像没问题?大错特错!

这就是我们要说的“非原子性的续期操作”

试想一下这个恐怖的场景:

  1. 时刻 T1:你的代码检查到锁还在,续期成功,锁变成了6秒。
  2. 时刻 T2:就在这一微秒,你的代码突然挂了(比如进程崩溃、服务器宕机、甚至只是个低级别的垃圾回收GC暂停)。
  3. 时刻 T3:Redis 服务器那边,因为你的代码挂了,没有发起新的续期指令。
  4. 时刻 T4:原来的3秒租约到期,锁自动释放。

结果: 锁在业务还没跑完的时候就失效了!你的“手动续期”策略变成了“主动自杀”。

所以,单纯的“检查-续期”流程在分布式环境下是极其脆弱的,它就像一个想给流浪猫喂饭的人,还没喂完,人就没了。


第三章:看门狗——守护进程的艺术

为了解决这个问题,我们的老祖宗(Java界的 Redisson)发明了一个极其优雅的概念——Watchdog(看门狗)

看门狗是干嘛的?它不是来帮你办业务的,它是来监视你的。它趴在墙头上,看着你干活。一旦它发现你要超时了(比如锁快到期了),它就立刻帮你把锁续上。

这就是“自动续期”的核心思想。

3.1 延迟队列策略

最简单的实现方式是利用延迟队列。但这儿有个坑:PHP通常是短生命周期的脚本(SAPI模式),你很难在里面开一个无限循环的 while(true)

所以,对于 PHP 来说,我们通常采用“外部守护进程”或者“事件驱动”的模式。

这里,我推荐大家使用 Swoole 或者 Workerman,因为它们让 PHP 有了“常驻内存”的能力,这才能玩得转分布式锁的自动续期。

假设我们在 Swoole 的环境里实现一个看门狗:

class LockWithWatchdog
{
    private $redis;
    private $lockKey;
    private $value;
    private $ttl; // 锁的原始租约时间
    private $autoRenewInterval; // 续期检查间隔

    public function __construct($redis, $lockKey, $ttl = 10, $interval = 2)
    {
        $this->redis = $redis;
        $this->lockKey = $lockKey;
        $this->value = uniqid(); // 唯一标识,防止误删别人的锁
        $this->ttl = $ttl;
        $this->autoRenewInterval = $interval;
    }

    public function acquire()
    {
        // 1. 尝试加锁
        $isLocked = $this->redis->set($this->lockKey, $this->value, ['NX', 'EX' => $this->ttl]);

        if (!$isLocked) {
            return false;
        }

        // 2. 启动看门狗(这里是伪代码,实际需要结合定时器)
        $this->startWatchdog();

        return true;
    }

    private function startWatchdog()
    {
        // 每隔 autoRenewInterval 秒检查一次
        // 这里使用 Swoole 的 Co::defer 或者 Co::sleep
        Co::repeat($this->autoRenewInterval, function () {
            $this->renewLock();
        });
    }

    private function renewLock()
    {
        // 3. 检查锁还在不在
        $currentVal = $this->redis->get($this->lockKey);

        // 关键点:必须保证 value 的一致性
        if ($currentVal === $this->value) {
            // 锁还在,续期!
            // 注意:这里会有微小的竞态,我们在下一章用 Lua 脚本解决
            $this->redis->expire($this->lockKey, $this->ttl);
            // 继续下一轮检查
        } else {
            // 锁已经被释放了,或者被别人抢走了,看门狗下班
            // 此时业务逻辑也该结束了
        }
    }
}

上面的代码有个小瑕疵。Co::repeat 逻辑虽然简单,但在高并发下,如果业务执行时间极长,看门狗就会一直跑,这就浪费资源。

更优雅的做法是“时间桶”策略。我们设定一个“超时窗口”。比如锁是10秒,看门狗每2秒跑一次。如果业务在第9秒还在跑,看门狗会续期到12秒。如果业务在第11秒才跑完,看门狗在第10秒时可能已经续期了一次,保证了锁不断。

3.2 守护进程脚本(非 Swoole 环境)

如果你的项目不能用 Swoole,那就得写一个独立的 PHP 脚本作为守护进程。这个脚本的作用就是:监听某个 Redis List(比如 lock_task),如果有任务,就去申请锁,执行,续期,然后删除。

这种方式虽然笨重,但胜在通用。不过,这章咱们主要讲自动续期,咱们把目光聚焦在“续期”这个动作本身。


第四章:Lua 脚本——原子性的救赎

回到刚才的代码。get 检查,expire 续期。这中间是有时间差的。

如果 Redis 是单机的还好,如果是主从架构,或者高并发下,这个时间差就是致命的。

  1. Master 收到了你的 get,发现是自己的锁。
  2. Master 还没来得及发 expire 命令,Master 挂了(或者网络抖动)。
  3. Slave 刚好升级为 Master。
  4. 新的 Master 里的锁,过期时间还没被设置(因为上一步挂了),或者 Redis 刚重启,锁还在但时间不对。
  5. 原来的业务代码以为锁还在,续期成功。
  6. 结果:业务逻辑跑完了,但锁没删,锁一直存在直到过期。

为了避免这种“微观”的时间差,我们必须使用 Lua 脚本。Lua 脚本在 Redis 里的执行是原子性的。这意味着,getexpire 必须像一个人一样同时完成,中间不能被任何指令打断。

这是自动续期的黄金标准代码:

-- script: renew_lock.lua
-- KEYS[1]: 锁的 key
-- ARGV[1]: 锁的值(标识符)
-- ARGV[2]: 新的过期时间(秒)

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("expire", KEYS[1], ARGV[2])
else
    return 0
end

对应的 PHP 调用代码:

// 执行 Lua 脚本续期
$luaScript = <<<'LUA'
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("expire", KEYS[1], ARGV[2])
else
    return 0
end
LUA;

// 注意:这里续期时间一般设置为锁的原始 TTL,而不是无限续期
$renewResult = $redis->eval($luaScript, [$lockKey, $lockValue, $originalTtl], 1);

使用了 Lua 脚本,我们就把“检查”和“续期”合二为一了。只要值对得上,续期就执行。这就彻底消除了竞态条件。


第五章:实战——基于 Swoole 的分布式锁完整实现

光说不练假把式。咱们来搞一个生产级的实现。为了方便演示,我们假设环境是 Swoole 4.x+

这个类需要实现以下功能:

  1. 加锁(带初始 TTL)。
  2. 启动看门狗(后台定时续期)。
  3. 执行业务(利用协程避免阻塞)。
  4. 自动释放锁。
<?php

use SwooleCoroutine;
use SwooleTimer;

class AutoRenewLock
{
    private $redis;
    private $lockKey;
    private $lockValue;
    private $ttl; // 初始租约时间
    private $autoRenewTime; // 续期检查间隔(建议小于 TTL 的一半)
    private $isLocked = false;

    // Lua 脚本:原子性续期
    private $renewScript = <<<'LUA'
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("expire", KEYS[1], ARGV[2])
else
    return 0
end
LUA;

    public function __construct($redis, $lockKey, $ttl = 10, $autoRenewTime = 2)
    {
        $this->redis = $redis;
        $this->lockKey = $lockKey;
        $this->ttl = $ttl;
        $this->autoRenewTime = $autoRenewTime;
        $this->lockValue = uniqid() . '_' . getmypid(); // 加入 PID 防止同进程误删
    }

    /**
     * 尝试获取锁
     */
    public function lock(): bool
    {
        $result = $this->redis->set($this->lockKey, $this->lockValue, ['NX', 'EX' => $this->ttl]);
        if ($result) {
            $this->isLocked = true;
            // 注册自动续期定时器
            $this->scheduleRenew();
            return true;
        }
        return false;
    }

    /**
     * 调度定时器
     */
    private function scheduleRenew()
    {
        // 计算定时器间隔。为了避免业务执行完锁还没删导致的资源浪费,
        // 定时器间隔应小于 TTL,但不应太密。
        // 比如锁是10秒,每3秒检查一次。
        $interval = min($this->autoRenewTime, (int)($this->ttl / 3));

        Timer::tick($interval * 1000, function () {
            if (!$this->isLocked) {
                Timer::clearAll(); // 业务结束了,停止续期
                return;
            }

            // 使用 Lua 脚本原子续期
            $renewed = $this->redis->eval($this->renewScript, [$this->lockKey, $this->lockValue, $this->ttl], 1);

            if ($renewed == 0) {
                // 续期失败,说明锁不在了(可能被别人抢走,或主动释放)
                $this->unlock(true); // 强制解锁
                Timer::clearAll();
            }
        });
    }

    /**
     * 业务执行入口
     */
    public function execute(callable $callback)
    {
        if (!$this->lock()) {
            throw new RuntimeException("Failed to acquire lock for key: {$this->lockKey}");
        }

        try {
            // 在协程上下文中执行业务逻辑
            Coroutine::create(function () use ($callback) {
                // 模拟耗时业务
                Coroutine::sleep(5); 
                echo "业务逻辑执行中...n";

                // 这里可以再次调用 callback,或者 callback 内部就是核心逻辑
                // 为了演示,我们在外部调用

                // 模拟业务中途报错,但锁应该还在,直到 finally
                if (rand(0, 10) > 5) {
                    throw new Exception("模拟业务报错");
                }
            });

            // 等待协程结束
            Coroutine::wait();

        } catch (Exception $e) {
            echo "Exception caught: " . $e->getMessage() . "n";
        } finally {
            // 无论成功失败,都要释放锁
            $this->unlock();
        }
    }

    /**
     * 释放锁(必须带值检查,防止误删别人的锁)
     */
    public function unlock($force = false)
    {
        if (!$this->isLocked && !$force) {
            return;
        }

        // Lua 脚本:只有当值匹配时才删除
        $script = <<<'LUA'
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
LUA;

        $this->redis->eval($script, [$this->lockKey, $this->lockValue], 1);
        $this->isLocked = false;
        Timer::clearAll(); // 清理看门狗定时器
    }
}

代码解析:

  1. uniqid() + getmypid():我们在锁的值里加了进程ID。为什么?因为在一个分布式集群里,可能有10个 PHP 实例在跑。如果锁过期了,A进程可能想删锁,结果误删了B进程刚加的锁。加个 ID 是最简单的去重。
  2. Timer::tick:这是 Swoole 的核心,利用协程的非阻塞特性,我们可以在后台悄悄地给锁续命。
  3. Lua 脚本:在 scheduleRenewunlock 中都使用了 Lua。这保证了“检查-操作”的原子性。
  4. finally:无论业务逻辑抛不抛异常,锁必须被释放。这是分布式锁必须遵守的“卫生习惯”。

第六章:高级话题——集群环境下的锁续期

讲了这么多单机版的 Redis 锁,咱们得聊聊集群版

如果你用的是 Redis Cluster,单个 Key 可能会存放在不同的 Slot 上。这时候,简单的 SET key value NX EX 就失效了,因为它不支持跨 Slot 操作。

而且,在集群模式下,主从切换是常态。自动续期在集群模式下有巨大的风险

试想:

  1. 你的客户端在 Master 节点上加了锁并续期了。
  2. Master 挂了,Slave 升级为 Master。
  3. 新的 Master 刚启动,里面的锁数据还没同步过来(或者锁还在但不知道属于谁)。
  4. 此时,旧的客户端以为锁还在,尝试续期(发送 Lua 脚本给新的 Master)。
  5. 问题来了: 如果新的 Master 并不知道这个锁的存在(数据没同步完),续期脚本返回 0(值不匹配),看门狗就会停止,锁还没过期就被“误杀”了。

如何解决?

对于 Redis Cluster,通常的实践是:

  1. 使用带有 WATCH 的 Lua 脚本:在执行业务逻辑时,用 Lua 脚本读取锁,执行业务,释放锁。这样保证了业务代码和锁操作在同一个请求中完成,期间锁不会丢失。
  2. 或者,放弃自动续期:如果你的业务逻辑能控制在 TTL(比如 30 秒)以内,就别用自动续期了。如果业务确实要跑 1 分钟,那就把 TTL 设为 2 分钟。如果业务跑不完,锁丢了,那就丢了。宁可数据不一致,也不能让锁挂掉导致死锁。

但在大多数电商、秒杀场景中,业务逻辑其实很短(几秒到几十秒),只要 TTL 设得合理(比如 30 秒),配合 Lua 脚本读写锁,就不需要那么复杂的自动续期。

只有在业务逻辑极其不确定(比如跑几分钟甚至几十分钟)的场景下,自动续期才是刚需。


第七章:灵魂拷问——为什么要防“中途失效”?

最后,咱们来做一个哲学层面的思考。

你可能会问:“锁过期失效了,数据不就是不一致了吗?那不是更糟糕?”

答案是:不,有时候“锁失效”是正确的行为。

这听起来很反直觉,但请听我狡辩一下(划掉,讲道理):

  • 场景一:你在处理订单支付。锁了订单。如果因为网络问题,订单还没支付完,锁就过期了。这时候,另一个请求进来了,发现锁没了。它应该怎么做?它应该重新尝试加锁,或者提示用户刷新页面
  • 场景二(死锁):如果锁永远不会失效(无限续期),那么一个程序崩溃了没释放锁,这个锁就永久存在了。其他所有请求都被卡住了。这时候,锁失效(被 Redis 自动清理)反而是系统的一种自我修复机制。

所以,自动续期不是为了防止锁消失,而是为了防止“业务没跑完,但锁也没了,导致并发错乱”

如果业务没跑完,锁还在,那叫正常阻塞。
如果业务没跑完,锁也没了,那叫数据损坏。


第八章:总结——锁匠的生存法则

好了,老王今天的讲座就到这里。

咱们回顾一下今天的重点:

  1. TTL 是双刃剑:太短,业务跑不完锁就没了;太长,容易死锁。
  2. 手动续期有风险:在 Redis 中,非原子的检查-续期操作很容易导致竞态条件。
  3. 看门狗是核心:利用后台定时器(如 Swoole Timer)定期执行续期。
  4. Lua 脚本是保命符:必须用 Lua 脚本保证续期操作的原子性,防止误删锁或续期失败。
  5. 集群慎用:Redis Cluster 的主从切换会导致自动续期失效,需谨慎处理。

最后,送给大家一句话:分布式锁的精髓不在于“锁住”,而在于“心跳”。只要心跳还在,你就拥有资源的支配权。

好了,代码拷贝走,锁匠之路,任重道远。大家下课!

(注:本讲座代码基于 Swoole 协程环境,如使用标准 PHP SAPI,需自行实现基于 pcntl_alarm 的定时器或使用外部守护进程)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注