SETNX 与 Redlock 之争,PHP 分布式锁为何大多不可靠
PHP 的 Redis 锁很可能存在 3 个缺陷。以下均经实测验证:竞态窗口、跨客户端 DEL、故障转移泄漏。文中给出完整的正确模式。
下面这起分布式锁事故,改变了整个团队对 Redis 实际保证能力的理解。
某电商平台每 5 分钟就要在多个 worker 进程中执行一次库存同步任务。为避免重复执行,团队使用了一个基于 Redis 的锁。这套代码来自多年前 Stack Overflow 上的一份答案,属于当时的标准写法:
if ($redis->setnx('lock:inventory_sync', 1)) {
$redis->expire('lock:inventory_sync', 300); // 5 minute TTL
doInventorySync();
$redis->del('lock:inventory_sync');
}这套代码运行稳定,团队从未质疑过它,成功执行了数千次。
随后一系列异常开始出现。部分库存同步产生了重复更新,商品数量减少的幅度达到预期值的两倍。排查后发现,多个 worker 进程曾同时执行同一个同步任务。可锁的代码明明就在那里,问题究竟出在哪里?
深入排查后发现了多个问题:
SETNX 与 EXPIRE 之间的竞态:这种情况虽然罕见,但偶尔会有 worker 在 SETNX 与 EXPIRE 之间崩溃,导致锁被永久置位。有人注意到这种“卡死的锁”,于是补充了一段人工清理逻辑:若该键存在时间超过 1 小时,就执行 DEL。理论上说得通,但这段清理逻辑运行在完全独立的调度周期上,自身又带有竞态条件。
朴素的 DEL 缺陷:当某个执行缓慢的 worker 的锁 TTL 到期后,另一个 worker 会获取到锁。此时慢 worker 执行完毕,调用
DEL lock:inventory_sync,删除的却是另一个 worker 的锁。第三个 worker 随后也能获取该锁并并发执行,此时 B 与 C 同时持有这把锁。Redis 故障转移:在一次由 Sentinel 触发的故障转移过程中,旧主节点上持有的锁尚未完成复制。新主节点被提升后,另一个 worker 获取到了这把“崭新”的锁,两者于是同时运行。
团队的应对方式是重写锁代码,采用正确的模式:原子化的 SET NX PX、每次获取使用唯一令牌、基于 Lua 脚本的安全释放。事故数量有所下降,但并未彻底消除。真正的正确性需要重新审视问题本身:与其阻止重复执行,不如让同步操作具备幂等性(即重复执行是安全的)。
由此得到的持久教训是:基于 Redis 的分布式锁确实有用,但它带有特定的语义,而大多数教程代码都把语义弄错了。而且即便是实现正确的 Redis 锁,提供的也只是效率保证(通常只有一个持有者),而非正确性保证(始终恰好一个持有者)。若要获得真正的正确性,要么引入共识系统(ZooKeeper、etcd),要么把操作设计为幂等。
本文将梳理 PHP 中常见的错误锁模式、针对具体缺陷的验证演示、正确的实现模式、Redlock 算法及其批评意见,以及各种方案的适用场景。所有结论均在真实的 Redis 7 与 PHP 8.3.6 环境下验证:不具备原子性的 SETNX 与 EXPIRE 组合,一旦进程在两者之间崩溃,会留下 TTL=-1(永久锁);朴素的 DEL 会在不校验归属的情况下删除任何锁;基于 Lua 脚本的释放会先校验令牌归属,再执行删除,行为正确。
速览
SETNX 与 EXPIRE 作为两条命令存在竞态窗口。进程若在两者之间崩溃,锁会被永久置位。验证结果:执行 SETNX 而不执行 EXPIRE 后,TTL 为 -1(无过期时间)。现代模式为
SET key value NX EX 30(单条原子命令)。用 DEL 释放锁是错误做法。它会删除该键上的任意锁,包括在自身 TTL 过期后由其他客户端获取的锁。验证结果:客户端 A 获取锁,TTL 过期,客户端 B 获取锁,客户端 A 调用 DEL,删除了 B 的锁。随后两个客户端可以同时持有锁。
安全释放需要唯一令牌配合 Lua 脚本。每次获取锁都使用唯一令牌,释放前先校验令牌是否匹配,再执行删除。验证结果:客户端 A 尝试释放 B 的锁时被正确拒绝,因为令牌不同。
单实例 Redis 锁在故障转移时会失效。异步复制意味着主节点上的锁在副本被提升时可能尚未同步过去。第二个客户端可以获取到“崭新”的锁,两者同时持有。这是有据可查的失效模式。
Redlock 算法(Antirez 提出)的做法是:从 N 个相互独立的 Redis 实例(通常为 5 个)中的多数实例获取锁。相比单实例 Redis,它在故障转移场景下表现更好,但 Kleppmann 等人正是针对 GC 停顿与时钟漂移场景提出了批评。
真正的正确性需要 fencing token 或共识系统。即操作侧校验的单调递增令牌,或采用 ZooKeeper/etcd/Consul 这类基于共识的锁。Redis 提供效率,共识系统提供正确性。
在 PHP 中应使用 symfony/lock 或类似的成熟库。它能正确处理各种边界情况,支持多种后端,并内置了面向长时持有锁的看门狗模式。
读者将了解到的内容
- SETNX + EXPIRE 为何存在缺陷
- “释放了他人锁”这一缺陷的成因
- 基于 Lua 脚本的安全释放模式
- Redlock 算法及其批评意见
- Redis 锁在何种场景下够用、何种场景下不够用
SETNX + EXPIRE 陷阱
来自早期教程的传统 Redis 锁模式:
<?php
if ($redis->setnx('lock:key', '1')) { // atomic SET-if-not-exists
$redis->expire('lock:key', 30); // set 30-second TTL
try {
doWork();
} finally {
$redis->del('lock:key');
}
}竞态窗口在于:setnx 与 expire 是两次独立的网络往返。如果 PHP 进程在两者之间崩溃、超时或被杀死,锁就会被置位却没有过期时间。
验证结果:
SETNX succeeded — lock acquired
PROCESS CRASHES HERE (no EXPIRE was called)
Lock TTL: -1 (-1 = no expiration = deadlock)该锁将永久存在,无法自动释放。此后每个 worker 都会看到该键已存在,获取失败。这需要人工介入(或者一个自身带有竞态问题的独立清理任务)。
现代的修复方式是使用带选项的原子化 SET:
<?php
// Single atomic command: set-if-not-exists AND expire
if ($redis->set('lock:key', '1', ['NX', 'EX' => 30])) {
try {
doWork();
} finally {
$redis->del('lock:key');
}
}验证结果:
SET NX EX 10: true
TTL: 10 seconds
Value: client-A-token
Second SET NX (should fail): false
Value still: client-A-token第一次 SET NX EX 10 以原子方式成功执行。第二次尝试(在 TTL 到期前)正确失败。TTL 已设置,不存在竞态窗口。
该能力自 Redis 2.6.12(2012 年 12 月)起就已可用。任何把 SETNX 与 EXPIRE 当作两条命令使用的教程代码,都已经落后了十余年。
“释放他人锁”的缺陷
即便使用了原子化的 SET NX EX,释放环节仍存在一个隐蔽缺陷。考虑如下过程:
- 客户端 A 获取锁,TTL = 10 秒
- 客户端 A 变慢(GC 停顿、网络延迟、磁盘 I/O)
- TTL 到期,锁被自动释放
- 客户端 B 获取到这把锁(此时锁刚刚可用)
- 客户端 A 完成工作,调用
DEL lock:key - 客户端 A 刚刚删除了客户端 B 的锁!
- 客户端 C 此时也能获取该锁
- B 与 C 同时“持有”这把锁
验证结果:
Client A: acquired lock, value = 'client-A'
Time passes; lock expires
Client B: acquired lock, value = 'client-B'
Client A releases with DEL → deleted CLIENT B's lock!
TTL now: -2 (-2 = doesn't exist)DEL 命令不会校验锁的归属者,它只是删除键。任何客户端都可能意外地(或恶意地)释放其他客户端的锁。
在生产环境中,这一模式表现为“不知为何有两个 worker 同时处理了同一个任务”之类的缺陷。难以复现,也难以追溯到锁归属问题。
基于 Lua 脚本的安全释放
修复思路是:每次获取锁都使用唯一令牌,并通过 Lua 脚本释放,在删除前先校验令牌:
<?php
$safeReleaseScript = <<<'LUA'
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
LUA;
function acquireLockSafe(Redis $redis, string $key, int $ttlSeconds): ?string {
$token = bin2hex(random_bytes(16)); // unique per acquisition
if ($redis->set($key, $token, ['NX', 'EX' => $ttlSeconds])) {
return $token;
}
return null;
}
function releaseLockSafe(Redis $redis, string $key, string $token, string $script): bool {
$result = $redis->eval($script, [$key, $token], 1);
return $result === 1;
}Lua 脚本在 Redis 服务端原子执行:读取当前值,与预期令牌比较,仅在匹配时删除。检查与删除之间不存在竞态。
验证结果:
Client A acquired with token: bebd26b5...
A's lock expires
Client B acquired with token: 9f3b9b70...
Client A tries to release with own token: NO (correct)
Lock still held by B: YES ✓
Client B releases with own token: YES ✓
Lock now: released ✓客户端 A 尝试释放自己的“旧”令牌时,Lua 脚本发现当前值是 B 的令牌,与 A 的不匹配,因此不执行删除。客户端 B 的锁依然被持有,行为正确。
这一模式对任何基于 Redis 的锁都是必需的。缺少基于令牌的释放机制,就会留下一个隐蔽但真实的缺陷,并在时序敏感的场景中暴露出来。
完整的 RedisLock 类
把正确的模式组合进一个可复用类:
<?php
class RedisLock {
private const RELEASE_SCRIPT = <<<'LUA'
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
LUA;
private const EXTEND_SCRIPT = <<<'LUA'
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
LUA;
private ?string $token = null;
public function __construct(
private Redis $redis,
private string $key,
private int $ttlSeconds,
) {}
public function acquire(): bool {
$token = bin2hex(random_bytes(16));
if ($this->redis->set($this->key, $token, ['NX', 'EX' => $this->ttlSeconds])) {
$this->token = $token;
return true;
}
return false;
}
public function release(): bool {
if ($this->token === null) return false;
$result = $this->redis->eval(self::RELEASE_SCRIPT, [$this->key, $this->token], 1);
if ($result === 1) {
$this->token = null;
return true;
}
return false;
}
public function extend(int $additionalMs): bool {
if ($this->token === null) return false;
$result = $this->redis->eval(
self::EXTEND_SCRIPT,
[$this->key, $this->token, $additionalMs],
1
);
return $result === 1;
}
public function isHeld(): bool {
return $this->token !== null && $this->redis->get($this->key) === $this->token;
}
}验证结果:acquire、isHeld、extend、release 均能在基于令牌的归属校验下正确工作。
关键特性:
- 每次获取使用唯一令牌
- 获取环节使用原子化的 SET NX EX
- 释放环节使用带归属校验的 Lua 脚本
- 可延长 TTL(通过 Lua 完成归属校验)
- 通过
isHeld()校验归属
工作时间超过 TTL 的问题
即便实现正确,仍存在一个根本性限制:如果工作耗时超过 TTL 会怎样?
场景如下:
- 以 30 秒 TTL 获取锁
- 执行工作,耗时 45 秒
- 在那多出的 15 秒里,TTL 已到期
- 另一个客户端获取到锁
- 两者同时“持有”这把锁
解决方案:
使用足够长的 TTL:选择一个明显超过最坏情况工作时长的 TTL。代价是:进程崩溃后,锁会在该时长内持续被持有,延缓恢复。
看门狗线程:在工作进行期间周期性续期。实现复杂,需要处理取消、异常与失败后的清理。
<?php
$lock->acquire();
$watchdogRunning = true;
// Simplified - real implementation needs cancellation
$watchdog = new Thread(function() use ($lock, &$watchdogRunning) {
while ($watchdogRunning) {
sleep(10);
$lock->extend(30000); // extend by 30s
}
});
$watchdog->start();
try {
doLongWork();
} finally {
$watchdogRunning = false;
$watchdog->join();
$lock->release();
}PHP 中的线程处理相当复杂。实践中可采用基于协程的看门狗(Swoole)、基于信号的续期,或者接受这一取舍。
- 面向幂等性设计:如果操作重复执行也是安全的,那么偶发的双重执行就不构成问题。这往往是最佳方案。
Redis 故障转移会破坏单实例锁
标准 Redis 复制是异步的。对主节点的写入会在复制到副本之前就返回确认。故障转移期间,未完成复制的写入会丢失。
失效场景如下:
- 客户端 A:在主节点执行
SET lock:key value NX PX 30000,返回 OK - 主节点在复制到副本之前崩溃
- 副本被提升为新的主节点(其键空间中不含该锁)
- 客户端 B:在新主节点执行
SET lock:key ... NX,成功(键不存在) - A 与 B 同时“持有”这把锁
这并非理论推演,而是 Redis Sentinel 与 Cluster 有据可查的失效模式。单实例 Redis 锁对此不提供任何防护。
影响通常是短暂的(故障转移很快完成)但确实存在。如果工作窗口与故障转移过程重叠,就应预期出现重复执行。
Redlock,Antirez 的回应
Salvatore Sanfilippo(antirez),Redis 的最初作者,专门为解决故障转移问题设计了 Redlock:
算法:
获取当前时间(毫秒)
尝试在全部 N 个 Redis 实例上获取锁(通常为 5 个相互独立的实例)
计算耗时 = 当前时间 − 起始时间
满足以下条件即视为获取成功:
- 从多数实例获取成功(N/2 + 1)且耗时小于 TTL
有效 TTL = 原始 TTL − 耗时
若获取失败:在全部实例上释放锁(包括部分获取成功的实例)
优势:
- 持有锁期间单个 Redis 崩溃不会导致锁丢失(多数实例保护)
- 心智模型简单
- 在多数语言中都有现成库,包括 PHP
PHP 实现:
- ronnylt/redlock-php,参考实现
- 部分框架内置了 Redlock 支持
前提条件:
- 5 个相互独立的 Redis 实例(不是主从关系,而是独立的物理或逻辑实例)
- 各实例之间保持合理的时钟同步
Kleppmann 的批评与 fencing token
Martin Kleppmann 在其 2016 年的博文《How to do distributed locking》中批评了 Redlock,Antirez 随后作出回应。对于任何构建分布式系统的人而言,这场交锋都值得一读。
Kleppmann 的核心关切:
- 时间假设:Redlock 假定时钟漂移与网络延迟有界。而在分布式系统中,这些量很难可靠地界定上界。
- GC 停顿:持有有效锁的客户端可能被垃圾回收暂停,且停顿时间超过锁的 TTL。当它恢复运行时,另一个客户端已经持有该锁,但被停顿的客户端仍认为锁在自己手上。
- 网络分区:情况类似——客户端与 Redis 之间的连接断开,TTL 到期,另一个客户端获取锁,第一个客户端重新加入后仍以为锁在自己手上。
真正的修复方案是 fencing token:每次获取锁都返回一个单调递增的“栅栏令牌”。存储系统在接受写入前校验该令牌。如果某客户端的令牌小于最后被接受的令牌,其写入将被拒绝。
fencing token 模式:
- 客户端 A 获取锁 → 得到 token=42
- 客户端 A 执行慢操作,被暂停
- 客户端 B 获取锁 → 得到 token=43
- 客户端 B 执行操作,以 token=43 写入存储(被接受)
- 客户端 A 恢复运行,以 token=42 写入存储
- 存储拒绝:“最后接受的是 43,拒绝 42”
问题在于:这需要存储系统支持 fencing token,而大多数系统并不原生支持:
- MySQL:无原生支持,需通过版本列实现
- Kafka:支持 consumer generation ID(概念相近)
- ZooKeeper/etcd:为此类场景设计,天然提供 fencing token
- 自研系统:需手动实现版本校验
Antirez 的回应是:Redlock 面向效率,而非严格正确性。若目标是“绝大多数情况下避免重复工作”,Redlock 足够。若目标是“恰好执行一次”,则需要更强的机制。
各模式的适用场景
简单的 Redis 锁(SET NX PX + 令牌释放)适用于:
- 防止定时任务重复运行(偶发重复可以接受)
- 协调后台 worker(“同一批次只由一个 worker 处理”)
- 分布式限流
- 缓存击穿防护
- 任何以效率为目标的同步场景
Redlock 适用于:
- 持有锁期间发生 Redis 故障转移确实是现实顾虑
- 具备部署 5 个以上独立 Redis 实例的基础设施
- 希望获得强于单实例 Redis 的保证,但不需要完整共识
- 愿意承担相应的运维开销
共识系统(ZooKeeper、etcd、Consul)适用于:
- 需要的是正确性而非仅仅是效率
- 金融交易、唯一 ID 生成、领导者选举
- 要求恰好一次语义
- 具备相应基础设施与专业能力
乐观锁(版本列)适用于:
- 操作对象是数据库行
- 争用情况罕见
- 失败重试可以接受
- 数据库是唯一事实来源
幂等设计适用于:
- 操作可以安全地重复执行(INSERT ON CONFLICT UPDATE、upsert)
- 完全不需要加锁
- 条件满足时往往是最佳方案
PHP 库推荐
symfony/lock(多数项目的推荐选择):
- API 设计良好,语义清晰
- 多种后端:Redis、memcached、PostgreSQL、MongoDB、文件、信号量
- 正确处理边界情况(基于令牌的释放、原子化操作)
- 支持阻塞与非阻塞获取
- 面向长时运行锁提供自动续期(看门狗)
- 经过生产环境充分检验
<?php
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
$store = new RedisStore($redis);
$factory = new LockFactory($store);
$lock = $factory->createLock('my-resource', 30); // 30-second TTL
if ($lock->acquire()) {
try {
// do work
} finally {
$lock->release();
}
}php-lock/lock,设计理念不同的替代库。
自研实现,仅当有库无法满足的特定需求、且充分理解其中细节时才考虑。多数自研实现都存在缺陷。
应避免的陷阱
把 SETNX 与 EXPIRE 当作两条命令使用。存在竞态窗口,应改用原子化的 SET NX EX(自 Redis 2.6.12 起可用)。
释放时不校验令牌就直接 DEL。会删除该键上的任意锁,应使用带令牌校验的 Lua 脚本。
跨次获取复用令牌。每次获取都需要唯一令牌(random_bytes 或 UUID)。静态令牌会使安全释放机制失效。
误以为 Redis 锁提供正确性保证。它提供的是效率(通常只有一个持有者)。若要正确性(始终恰好一个持有者),需要共识系统或 fencing token。
不处理故障转移。单实例 Redis 锁在主节点故障转移时会失效。对故障转移安全性有要求时,应使用 Redlock 或共识系统。
用超长 TTL 规避工作时长问题。这是糟糕的取舍:崩溃的进程会长期持有锁,延缓恢复。更好的做法是看门狗模式,或缩短操作时长。
用超短 TTL 假定工作很快完成。同样是糟糕的取舍:正常工作也可能超出 TTL,导致“工作中途丢失锁”的场景。应测试最坏情况并留出余量。
不测试失败场景。分布式锁的缺陷只在罕见事件(崩溃、超时、故障转移)中暴露。应显式测试:杀死 worker、触发故障转移,验证没有重复工作。
不用现成库而自行实现。Redis 锁的细节相当微妙,自研实现通常都有缺陷。除非有特定需求,否则应使用 symfony/lock。
忽视监控。应跟踪锁获取频率、TTL 超限次数、检测到的重复工作,并对异常告警。缺少监控时,隐蔽缺陷会一直潜伏。
把“已获取锁”当作互斥性的保证。从“获取成功”到真正开始工作之间,网络延迟、GC 停顿与进程挂起都可能使该保证失效。若偶发重复可以接受,就应在设计中容忍保证的偶发失效。
迷你问答
SETNX 是否已废弃?
并未废弃,仍可使用。但 SET key value NX EX ttl 是与之等价的现代写法,且无需额外的 EXPIRE 调用。新代码应使用带选项的 SET。
到底需要 Redlock,还是单实例 Redis 锁就够了?
取决于 Redis 的部署形态。单实例 Redis(无复制):一旦失败所有工作都会停止,单实例锁已经足够。带复制与故障转移的 Redis:单实例锁会在故障转移中丢失,应使用 Redlock 或面向幂等性设计。具备自动故障转移的托管 Redis(AWS ElastiCache 等)存在同样的顾虑。
Redlock 在实践中真的有效吗?
就效率而言有效(通常只有一个持有者)。Kleppmann 的批评对正确性场景确实成立,但需要特定条件(长时间 GC 停顿、存在时间偏差的网络分区)才会显现。多数应用不会遇到这些情况,Redlock 在典型的分布式任务协调中运行良好。
什么是 fencing token?
即锁获取过程返回的单调递增数字。客户端把它作为操作的一部分提交给协调系统,系统会拒绝使用更旧令牌的操作。这确保“僵尸”客户端(持有的锁已过期却不自知)无法破坏数据。前提是存储或执行系统提供配套支持。
是否应该用 ZooKeeper 取代 Redis 来做锁?
若要求严格正确性,答案是肯定的。ZooKeeper(或 etcd、Consul)使用共识算法,提供强于 Redis 的保证。代价是运维复杂度更高、单次操作延迟更大。若只是效率型锁,Redis 通常足够且更简单。
锁的 TTL 应该设多长?
既要长到让正常工作可以轻松完成,又要短到崩溃进程不会阻塞他人太久。多数操作的常见区间是 10 至 60 秒。应配合监控来发现 TTL 超限场景。若工作时长波动很大,则采用看门狗模式在执行期间续期。
可以用 Redis 的 MULTI/EXEC 来实现原子化锁操作吗?
MULTI/EXEC 提供事务语义,但对分布式锁本身没有帮助。它用于把命令成组原子执行。锁操作的标准做法是 SET NX EX 与 Lua 脚本。
Redis Cluster 会影响分布式锁的行为吗?
会。Redis Cluster 会把键分散到不同节点,lock:key 会映射到特定分片。该分片发生故障转移时,问题与单实例 Redis 的故障转移相同。跨多个相互独立的 Redis Cluster 部署使用 Redlock 可行,但并非常见做法。
结语
Redis 分布式锁正是那种“教程里跑通了”往往意味着“有隐蔽缺陷尚未暴露”的领域。朴素模式(SETNX + EXPIRE、不做校验的 DEL)具有特定的失效模式,会在生产环境中以罕见时序场景的形式浮现。正确模式(原子化 SET NX EX、带令牌的 Lua 安全释放)虽已确立多年,却未必被普遍采用。
实用建议是:多数 PHP 应用应使用 symfony/lock 或同类方案。若自行实现:获取锁使用 SET key token NX EX ttl(原子化),释放锁使用校验令牌的 Lua 脚本(避免跨客户端误删)。若需应对 Redis 故障转移,应评估 Redlock;若要求严格正确性,应使用共识系统(ZooKeeper、etcd、Consul),或者面向幂等性设计。失败场景必须显式测试——正常路径的测试无法暴露这些缺陷。
这些经过实测验证的行为在此至关重要。不具备原子性的 SETNX + EXPIRE 在进程崩溃后会留下 TTL=-1。朴素的 DEL 可能删除其他客户端的锁。带令牌校验的 Lua 释放会正确拒绝删除归属他人的锁。这些都不是理论顾虑,而正是生产环境中导致“不知为何有两个 worker 同时运行”事故的具体机制。
更深层的启示是:分布式系统有其特定的保证,而理解一个系统不保证什么,与理解它保证什么同样重要。Redis 锁提供效率(通常只有一个持有者),而非正确性(始终恰好一个持有者)。要求正确性的系统需要共识系统或幂等操作。把 Redis 锁当作严格正确性保证的系统,会不断积累“锁没起作用”这一类特定事故。区分“系统可靠运行”与“系统多数时候能跑”的工程纪律,正在于理解真实的保证,而非保证的表象。