大家好,我是你们的“锁匠”老王。
今天咱们不聊那些虚头巴脑的架构图,也不谈什么高可用高并发,咱们来聊点硬核的、让人半夜惊醒的——分布式锁。
在这个微服务满天飞的时代,分布式锁简直就是程序员的“保命符”。但你有没有想过,如果你的保命符自己“罢工”了,或者在你最关键的时候,它悄悄地“离家出走”了,会发生什么?
今天这场讲座的主题是: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秒
// 继续跑业务...
}
听着好像没问题?大错特错!
这就是我们要说的“非原子性的续期操作”。
试想一下这个恐怖的场景:
- 时刻 T1:你的代码检查到锁还在,续期成功,锁变成了6秒。
- 时刻 T2:就在这一微秒,你的代码突然挂了(比如进程崩溃、服务器宕机、甚至只是个低级别的垃圾回收GC暂停)。
- 时刻 T3:Redis 服务器那边,因为你的代码挂了,没有发起新的续期指令。
- 时刻 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 是单机的还好,如果是主从架构,或者高并发下,这个时间差就是致命的。
- Master 收到了你的
get,发现是自己的锁。 - Master 还没来得及发
expire命令,Master 挂了(或者网络抖动)。 - Slave 刚好升级为 Master。
- 新的 Master 里的锁,过期时间还没被设置(因为上一步挂了),或者 Redis 刚重启,锁还在但时间不对。
- 原来的业务代码以为锁还在,续期成功。
- 结果:业务逻辑跑完了,但锁没删,锁一直存在直到过期。
为了避免这种“微观”的时间差,我们必须使用 Lua 脚本。Lua 脚本在 Redis 里的执行是原子性的。这意味着,get 和 expire 必须像一个人一样同时完成,中间不能被任何指令打断。
这是自动续期的黄金标准代码:
-- 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+。
这个类需要实现以下功能:
- 加锁(带初始 TTL)。
- 启动看门狗(后台定时续期)。
- 执行业务(利用协程避免阻塞)。
- 自动释放锁。
<?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(); // 清理看门狗定时器
}
}
代码解析:
uniqid()+getmypid():我们在锁的值里加了进程ID。为什么?因为在一个分布式集群里,可能有10个 PHP 实例在跑。如果锁过期了,A进程可能想删锁,结果误删了B进程刚加的锁。加个 ID 是最简单的去重。Timer::tick:这是 Swoole 的核心,利用协程的非阻塞特性,我们可以在后台悄悄地给锁续命。- Lua 脚本:在
scheduleRenew和unlock中都使用了 Lua。这保证了“检查-操作”的原子性。 finally块:无论业务逻辑抛不抛异常,锁必须被释放。这是分布式锁必须遵守的“卫生习惯”。
第六章:高级话题——集群环境下的锁续期
讲了这么多单机版的 Redis 锁,咱们得聊聊集群版。
如果你用的是 Redis Cluster,单个 Key 可能会存放在不同的 Slot 上。这时候,简单的 SET key value NX EX 就失效了,因为它不支持跨 Slot 操作。
而且,在集群模式下,主从切换是常态。自动续期在集群模式下有巨大的风险。
试想:
- 你的客户端在 Master 节点上加了锁并续期了。
- Master 挂了,Slave 升级为 Master。
- 新的 Master 刚启动,里面的锁数据还没同步过来(或者锁还在但不知道属于谁)。
- 此时,旧的客户端以为锁还在,尝试续期(发送 Lua 脚本给新的 Master)。
- 问题来了: 如果新的 Master 并不知道这个锁的存在(数据没同步完),续期脚本返回 0(值不匹配),看门狗就会停止,锁还没过期就被“误杀”了。
如何解决?
对于 Redis Cluster,通常的实践是:
- 使用带有
WATCH的 Lua 脚本:在执行业务逻辑时,用 Lua 脚本读取锁,执行业务,释放锁。这样保证了业务代码和锁操作在同一个请求中完成,期间锁不会丢失。 - 或者,放弃自动续期:如果你的业务逻辑能控制在 TTL(比如 30 秒)以内,就别用自动续期了。如果业务确实要跑 1 分钟,那就把 TTL 设为 2 分钟。如果业务跑不完,锁丢了,那就丢了。宁可数据不一致,也不能让锁挂掉导致死锁。
但在大多数电商、秒杀场景中,业务逻辑其实很短(几秒到几十秒),只要 TTL 设得合理(比如 30 秒),配合 Lua 脚本读写锁,就不需要那么复杂的自动续期。
只有在业务逻辑极其不确定(比如跑几分钟甚至几十分钟)的场景下,自动续期才是刚需。
第七章:灵魂拷问——为什么要防“中途失效”?
最后,咱们来做一个哲学层面的思考。
你可能会问:“锁过期失效了,数据不就是不一致了吗?那不是更糟糕?”
答案是:不,有时候“锁失效”是正确的行为。
这听起来很反直觉,但请听我狡辩一下(划掉,讲道理):
- 场景一:你在处理订单支付。锁了订单。如果因为网络问题,订单还没支付完,锁就过期了。这时候,另一个请求进来了,发现锁没了。它应该怎么做?它应该重新尝试加锁,或者提示用户刷新页面。
- 场景二(死锁):如果锁永远不会失效(无限续期),那么一个程序崩溃了没释放锁,这个锁就永久存在了。其他所有请求都被卡住了。这时候,锁失效(被 Redis 自动清理)反而是系统的一种自我修复机制。
所以,自动续期不是为了防止锁消失,而是为了防止“业务没跑完,但锁也没了,导致并发错乱”。
如果业务没跑完,锁还在,那叫正常阻塞。
如果业务没跑完,锁也没了,那叫数据损坏。
第八章:总结——锁匠的生存法则
好了,老王今天的讲座就到这里。
咱们回顾一下今天的重点:
- TTL 是双刃剑:太短,业务跑不完锁就没了;太长,容易死锁。
- 手动续期有风险:在 Redis 中,非原子的检查-续期操作很容易导致竞态条件。
- 看门狗是核心:利用后台定时器(如 Swoole Timer)定期执行续期。
- Lua 脚本是保命符:必须用 Lua 脚本保证续期操作的原子性,防止误删锁或续期失败。
- 集群慎用:Redis Cluster 的主从切换会导致自动续期失效,需谨慎处理。
最后,送给大家一句话:分布式锁的精髓不在于“锁住”,而在于“心跳”。只要心跳还在,你就拥有资源的支配权。
好了,代码拷贝走,锁匠之路,任重道远。大家下课!
(注:本讲座代码基于 Swoole 协程环境,如使用标准 PHP SAPI,需自行实现基于 pcntl_alarm 的定时器或使用外部守护进程)