分布式锁要解决的核心问题是:在多个进程或服务同时操作共享资源时,如何保证只有一个执行体进入临界区。PHP应用在遇到秒杀扣库存、重复提交、定时任务多实例抢跑等场景时,通常会先想到Redis的命令,比如用 SET key value NX PX 5000 来加锁。这个思路在单实例Redis上没有问题,但它把可靠性完全寄托在单个进程上。一旦Redis主节点挂了,从节点顶上,先前写入的锁很可能没有同步过来,第二个请求就会误以为自己拿到了锁。

正因如此,Redis作者antirez提出了RedLock算法,它不依赖单一Redis实例的主从切换,而是把锁分散到多个独立Redis节点上,按多数派确认。这样做虽然增加了复杂度,但在故障场景下能明显降低锁丢失的概率。接下来我们先从单实例方案的硬伤说起,再拆解RedLock的具体流程和PHP实现。
一、单实例Redis锁为什么在故障切换时不可靠
常见的Redis分布式锁实现是客户端向一个Redis节点发送这样一条命令:SET lock_key unique_value NX PX 10000。其中NX保证只有不存在时才能写入,PX设置10秒过期时间,unique_value是每个客户端生成的唯一标识。释放锁的时候,客户端先读取锁的值,判断是否与自己保存的唯一标识一致,一致才执行DEL操作。因为读取和删除是两个独立命令,实际工程中必须用Lua脚本把比较和删除合并为原子操作,否则可能出现误删别人锁的问题。
这套逻辑在单实例、单进程的Redis上已经足够安全。但在主从复制架构下,Redis的复制是异步的。也就是说,主节点写入锁数据后并不会等待从节点确认,而是立即返回成功。假设主节点在写入锁后突然宕机,哨兵将一个从节点提升为主节点,但那个从节点可能还没有复制到刚刚写入的锁数据。此时另一个客户端向新主节点发送同样的加锁命令,就会成功获得锁,于是两个客户端同时进入临界区。这种问题在库存扣减、优惠券领取等场景下会造成超卖或多发。
此外,单实例锁还存在锁过期时间无法自动续期的问题。如果业务执行时间超过了预设的10秒,锁会被Redis自动删除,其他客户端便可以提前进入。而要解决这个问题,要么把过期时间设置得足够长,要么引入额外的续期机制。这些坑单实例方案自身很难完全规避,于是多节点投票的RedLock算法开始进入开发者视野。
二、RedLock算法的核心流程拆解
RedLock的思路不是去强化Redis主从复制的同步性,而是让客户端同时向N个完全独立的Redis主节点申请锁。官方推荐N等于5,因为这是一个平衡可用性和性能的奇数。加锁成功需要满足两个条件:一是客户端在超过半数的节点上成功写入锁,例如5个节点中至少3个成功;二是客户端完成全部加锁尝试所用的总耗时必须小于锁的初始有效时间。只有这两个条件同时成立,才认为锁获取成功。
具体执行时,客户端先记录当前时间,然后按照固定顺序向5个节点依次发送SET命令,使用相同的key和随机生成的value,并设置同样的锁过期时间,例如10秒。每个节点独立响应,不依赖其他节点。全部请求结束后,客户端计算总耗时。如果成功节点数达到3个,并且总耗时小于10秒,那么锁的实际剩余有效时间就是10秒减去总耗时。客户端在业务执行期间只能使用这个剩余有效时间,而不是原始的10秒。如果成功节点数不足3个,或者剩余有效时间小于等于0,客户端必须向所有节点发送释放请求,包括那些已经成功写入锁的节点。
释放锁时同样不能简单DEL,必须携带唯一标识做原子校验。如果某个客户端在加锁过程中已经写入了节点A,但后续节点失败,最终判定加锁失败,却只释放了成功节点而遗漏了节点A,那么节点A上的锁会一直存活到自动过期。因此释放逻辑要对全部节点执行,Lua脚本先比较value是否匹配,匹配才删除。另外RedLock算法本身不包含自动续期功能,如果业务执行时间接近锁的剩余有效时间,需要开发者自己实现续期,或者一开始就设置更长的TTL。
三、PHP实现RedLock的关键代码与解析
下面给出一个可运行的PHP RedLock简化实现,使用phpredis扩展连接多个独立Redis节点。代码中5个节点分别使用不同端口模拟,实际生产环境应当部署在不同机器或不同故障域中。加锁方法会记录起始时间,逐个连接节点并尝试执行set操作,任何一个节点连接异常都会跳过,避免单点阻塞整个加锁流程。
<?php
class RedLock
{
private $servers;
public function __construct(array $servers)
{
$this->servers = $servers;
}
public function lock($resource, $ttl = 10000)
{
$token = bin2hex(random_bytes(16));
$start = microtime(true) * 1000;
$successCount = 0;
foreach ($this->servers as $server) {
try {
$redis = new Redis();
$redis->connect($server['host'], $server['port'], 200);
$result = $redis->set($resource, $token, ['nx', 'px' => $ttl]);
if ($result) {
$successCount++;
}
$redis->close();
} catch (Exception $e) {
// 单个节点连接失败直接跳过
}
}
$elapsed = microtime(true) * 1000 - $start;
$validity = $ttl - $elapsed;
if ($successCount >= floor(count($this->servers) / 2 + 1) && $validity > 0) {
return ['token' => $token, 'validity' => $validity];
}
$this->unlock($resource, $token);
return false;
}
public function unlock($resource, $token)
{
$script = '
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
';
foreach ($this->servers as $server) {
try {
$redis = new Redis();
$redis->connect($server['host'], $server['port'], 200);
$redis->eval($script, [$resource, $token], 1);
$redis->close();
} catch (Exception $e) {
// 释放失败可稍后重试
}
}
}
}
$servers = [
['host' => '127.0.0.1', 'port' => 6379],
['host' => '127.0.0.1', 'port' => 6380],
['host' => '127.0.0.1', 'port' => 6381],
['host' => '127.0.0.1', 'port' => 6382],
['host' => '127.0.0.1', 'port' => 6383],
];
$redLock = new RedLock($servers);
$lock = $redLock->lock('order:pay:1001', 10000);
if ($lock) {
try {
// 执行业务逻辑
} finally {
$redLock->unlock('order:pay:1001', $lock['token']);
}
}
这段代码的核心在于成功判定。多数派阈值通过floor(count($servers) / 2 + 1)计算,5个节点得到3。加锁总耗时$elapsed必须小于初始TTL,否则即使成功数量够了也不能认为锁有效。lock方法返回的validity是扣掉耗时后的剩余有效时间,实际业务应当尽量在这个窗口内完成。如果加锁失败,会立即调用unlock向所有节点发送释放脚本,保证后续重试不受残留锁影响。
工程实践中还要考虑连接超时和节点故障隔离。上面代码将连接超时设为200毫秒,生产环境可以搭配Redis的读写超时参数。对于一些瞬时抖动,可以增加有限次重试,但每次重试都要重新计算剩余有效时间,不能用第一次的TTL继续判断。此外,所有Redis节点必须是完全独立的进程,不能是同一主从集群中的多个角色,也不能共用同一台物理机,否则一个机架掉电会让整个RedLock失效。
四、RedLock的争议与工程落地建议
RedLock并不是没有争议。分布式系统研究者Martin Kleppmann曾公开指出,RedLock依赖系统时钟来保证锁的过期时间,如果客户端发生GC停顿、CPU满载或时钟漂移,锁可能在业务执行期间提前过期,而客户端自己却不知道。他还认为,如果分布式锁被用来保护共享资源的一致性,单纯加锁是不够的,还需要fencing token机制。所谓fencing token就是一个单调递增的令牌,资源服务在收到请求时检查令牌是否最新,旧令牌的写入请求会被拒绝,从而避免脑裂场景下的数据错误。
Redis作者antirez则反驳称,任何依赖租约的分布式锁都会受到时钟和调度停顿的影响,不能只针对RedLock苛刻要求。这场争论的结论并不是RedLock完全不可用,而是提醒开发者要区分业务场景。如果锁丢失只会导致很小的性能浪费或偶发重复计算,RedLock足够用;如果业务对并发冲突零容忍,例如资金扣减、库存扣减等,应该优先选择ZooKeeper或etcd这类基于一致性协议的分布式锁实现,它们在节点故障时不会像Redis那样出现异步复制丢数据的问题。
对于PHP开发者来说,引入RedLock时还应当加入监控指标,比如加锁成功率、加锁平均耗时、节点响应P99等。当发现某个节点延迟明显升高,应及时摘除或更换。锁的过期时间一般设置为业务执行时间的2到3倍,同时为长任务设计续期逻辑。PHP常驻进程模式下可以借助定时器实现续期,传统FPM模式下则可以通过将锁拆分为多个阶段来避免单次占用过久。
五、常见问题排查与调优方向
如果日志中出现加锁成功但业务执行到一半时报锁失效,通常原因是业务耗时超过了剩余有效时间。此时不要盲目增大TTL,而应该先分析业务慢的原因,比如数据库慢查询、外部接口超时。只有确认业务本身需要较长时间,才可以调整TTL或引入续期。另一个常见问题是解锁返回0,这表示当前客户端持有的锁已经过期并被其他客户端重新获取,此时业务必须做幂等或回滚处理。
连接超时设置不合理也会导致大量加锁失败。RedLock需要等待多个节点响应,如果一个节点卡住200毫秒,总耗时很容易超过TTL。建议把单节点连接超时控制在100到300毫秒之间,并对Redis节点做健康检查。重试次数不宜过多,因为重试会放大总耗时,反而降低成功率。更好的做法是快速失败后由上层业务做短时间随机等待再重新加锁。
最后要注意节点独立性。有些团队为了省资源,在一台机器上启动多个Redis进程,或者使用多个Redis实例但共享同一个物理磁盘,这样节点之间并不独立,同机宕机时所有节点会同时不可用。RedLock的前提是节点故障彼此独立,因此5个节点应当分布在不同机器、不同机架或不同可用区。否则算法的多数派优势就不存在了。
PHP分布式锁Redis RedLock算法分布式锁实现修改时间:2026-09-23 02:26:51