前言
分布式锁是分布式系统中控制资源访问的核心机制。Redis 因其单线程特性和高性能,是实现分布式锁的首选方案。本文将从最简单的实现出发,逐步演进到生产级的 Redlock 算法。
一、为什么需要分布式锁
1.1 单机锁的问题
// 单机环境:PHP文件锁或flock可以解决
$fp = fopen('/tmp/lock.txt', 'w+');
flock($fp, LOCK_EX); // 加锁
// 执行业务逻辑
flock($fp, LOCK_UN); // 释放锁
fclose($fp);但在分布式环境中,请求可能被分配到不同服务器,文件锁无法跨机器生效。
1.2 分布式锁的核心要求
| 要求 | 说明 |
|---|---|
| 互斥性 | 同一时刻只有一个客户端持有锁 |
| 避免死锁 | 锁必须有过期时间,防止持有者宕机导致永久锁死 |
| 解铃还须系铃人 | 只有锁的持有者才能释放锁 |
| 高可用 | 锁服务自身不能成为单点 |
二、Redis 分布式锁演进
2.1 版本一:SETNX(初版)
// 最简单的实现
$lockKey = 'lock:order:1001';
// 尝试加锁
$redis->setnx($lockKey, 1); // 返回1表示成功,0表示失败
// 设置过期时间(防死锁)
$redis->expire($lockKey, 30);
// 业务逻辑
processOrder();
// 释放锁
$redis->del($lockKey);致命问题:setnx 和 expire 是两条命令,不保证原子性。如果 setnx 成功后服务崩溃,锁将永远不会过期,导致死锁。
2.2 版本二:SET NX EX(推荐基础版)
$lockKey = 'lock:order:1001';
$lockValue = uniqid('', true); // 生成唯一标识
// 一条命令完成加锁+过期,原子操作
$result = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 30]);
if ($result) {
try {
// 获取锁成功,执行业务
processOrder();
} finally {
// 释放锁
$redis->del($lockKey);
}
} else {
// 获取锁失败
throw new Exception('系统繁忙,请稍后重试');
}改进:SET key value NX EX 30 是原子操作,解决了死锁问题。
遗留问题:释放锁时直接 DEL,可能删掉别人的锁。
2.3 版本三:锁标识 + Lua 释放(安全版)
$lockKey = 'lock:order:1001';
$lockValue = uniqid('', true);
// 加锁
$result = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 30]);
if (!$result) {
throw new Exception('获取锁失败');
}
try {
processOrder();
} finally {
// 用Lua脚本安全释放锁:只有value匹配才删除
$luaScript = "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
$redis->eval($luaScript, [$lockKey, $lockValue], 1);
}为什么需要 Lua 脚本?
时间线:
T1: 客户端A GET lock → value=A
T2: 锁过期,自动删除
T3: 客户端B SET lock NX → 成功,value=B
T4: 客户端A DEL lock → 删掉了B的锁!Lua 脚本保证了 GET + 判断 + DEL 三步原子执行。
2.4 版本四:锁续期(生产可用版)
业务执行时间可能超过锁的过期时间,需要后台续期:
class RedisLock {
private $redis;
private $lockKey;
private $lockValue;
private $ttl = 30;
private $renewTimer = null;
public function lock($key, $ttl = 30) {
$this->lockKey = $key;
$this->lockValue = uniqid('', true);
$this->ttl = $ttl;
// 尝试加锁,最多重试3次
for ($i = 0; $i < 3; $i++) {
$result = $this->redis->set(
$this->lockKey,
$this->lockValue,
['NX', 'EX' => $this->ttl]
);
if ($result) {
$this->startRenew();
return true;
}
usleep(100000); // 100ms后重试
}
return false;
}
/**
* 后台续期(守护进程/定时器)
* 每隔 TTL/3 秒续期一次
*/
private function startRenew() {
$renewInterval = intval($this->ttl / 3);
// 使用Swoole定时器(或PCNTL信号)
if (function_exists('swoole_timer_tick')) {
$this->renewTimer = swoole_timer_tick(
$renewInterval * 1000,
function() {
$this->renew();
}
);
}
}
private function renew() {
$luaScript = "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
";
$this->redis->eval(
$luaScript,
[$this->lockKey, $this->lockValue, $this->ttl],
1
);
}
public function unlock() {
// 停止续期
if ($this->renewTimer) {
swoole_timer_clear($this->renewTimer);
}
// 安全释放
$luaScript = "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
$this->redis->eval(
$luaScript,
[$this->lockKey, $this->lockValue],
1
);
}
}2.5 版本五:可重入锁
同一线程多次获取同一把锁不应死锁:
class ReentrantLock {
private $redis;
/**
* 加锁(可重入)
* 使用Hash结构:key=锁名, field=客户端ID, value=重入次数
*/
public function lock($lockKey, $clientId, $ttl = 30) {
$luaScript = "
-- 锁不存在或当前客户端已持有
if redis.call('exists', KEYS[1]) == 0 or
redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
-- 重入次数+1
redis.call('hincrby', KEYS[1], ARGV[1], 1)
-- 设置过期时间
redis.call('expire', KEYS[1], ARGV[2])
return 1
else
return 0
end
";
return $this->redis->eval(
$luaScript,
[$lockKey, $clientId, $ttl],
1
) === 1;
}
/**
* 释放锁(可重入)
* 重入次数-1,减到0时删除锁
*/
public function unlock($lockKey, $clientId) {
$luaScript = "
-- 不是锁的持有者
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
return nil
end
-- 重入次数-1
local count = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if count > 0 then
-- 还有重入次数,续期
redis.call('expire', KEYS[1], 30)
return count
else
-- 重入次数归零,删除锁
redis.call('del', KEYS[1])
return 0
end
";
return $this->redis->eval(
$luaScript,
[$lockKey, $clientId],
1
);
}
}
// 使用
$lock = new ReentrantLock();
$clientId = 'client-' . getmypid();
$lock->lock('lock:order', $clientId); // count=1
$lock->lock('lock:order', $clientId); // count=2(可重入)
$lock->unlock('lock:order', $clientId); // count=1
$lock->unlock('lock:order', $clientId); // count=0, 锁删除三、Redlock 算法
3.1 为什么需要 Redlock
单 Redis 实例的分布式锁有一个问题:如果 Master 宕机,锁数据还没同步到 Slave,新 Master 上没有锁信息,其他客户端可以加锁成功,导致互斥性被破坏。
Redlock 由 Redis 作者提出,通过多个独立 Redis 实例投票解决此问题。
3.2 算法流程
客户端 Redis集群
│
│── 加锁请求 ──→ Node1 ──→ 成功 (1/5)
│── 加锁请求 ──→ Node2 ──→ 成功 (2/5)
│── 加锁请求 ──→ Node3 ──→ 成功 (3/5) ← 超过半数
│── 加锁请求 ──→ Node4 ──→ 超时
│── 加锁请求 ──→ Node5 ──→ 超时
│
│ 加锁耗时 = 50ms
│ 锁TTL = 30s
│ 实际有效时间 = 30s - 50ms = 29.95s
│
│←── 加锁成功(3/5节点同意)─────步骤:
- 获取当前时间 T1
- 依次向 N 个 Redis 节点请求加锁(相同的 key、value、较短TTL)
- 获取当前时间 T2,计算加锁耗时 = T2 - T1
- 如果加锁成功节点数 ≥ ⌈N/2⌉ + 1 且加锁耗时 < TTL,则加锁成功
- 实际锁有效时间 = TTL - 加锁耗时
- 如果加锁失败,向所有节点发送释放锁请求
3.3 PHP 实现
class Redlock {
private $servers = [];
public function __construct(array $redisServers) {
foreach ($redisServers as $server) {
$redis = new Redis();
$redis->connect($server['host'], $server['port']);
if (isset($server['password'])) {
$redis->auth($server['password']);
}
$this->servers[] = $redis;
}
}
public function lock($resource, $ttl = 10000) {
$lockValue = bin2hex(random_bytes(16));
$successCount = 0;
$startTime = microtime(true) * 1000;
// 向所有节点请求加锁
foreach ($this->servers as $redis) {
try {
$result = $redis->set(
$resource,
$lockValue,
['NX', 'PX' => $ttl]
);
if ($result) {
$successCount++;
}
} catch (Exception $e) {
// 节点不可用,继续尝试其他节点
}
}
$elapsed = microtime(true) * 1000 - $startTime;
$quorum = floor(count($this->servers) / 2) + 1;
// 判断是否加锁成功
if ($successCount >= $quorum && $elapsed < $ttl) {
// 返回锁信息(包含实际有效时间)
return [
'key' => $resource,
'value' => $lockValue,
'validity_time' => $ttl - intval($elapsed),
];
}
// 加锁失败,释放所有已加的锁
$this->unlock($resource, $lockValue);
return false;
}
public function unlock($resource, $lockValue) {
$luaScript = "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
foreach ($this->servers as $redis) {
try {
$redis->eval($luaScript, [$resource, $lockValue], 1);
} catch (Exception $e) {
// 忽略
}
}
}
}
// 使用
$redlock = new Redlock([
['host' => '192.168.1.10', 'port' => 6379, 'password' => 'pass1'],
['host' => '192.168.1.11', 'port' => 6379, 'password' => 'pass2'],
['host' => '192.168.1.12', 'port' => 6379, 'password' => 'pass3'],
['host' => '192.168.1.13', 'port' => 6379, 'password' => 'pass4'],
['host' => '192.168.1.14', 'port' => 6379, 'password' => 'pass5'],
]);
$lock = $redlock->lock('lock:order:1001', 10000); // TTL=10秒
if ($lock) {
try {
// 执行业务逻辑(注意在 validity_time 内完成)
processOrder();
} finally {
$redlock->unlock($lock['key'], $lock['value']);
}
} else {
throw new Exception('获取分布式锁失败');
}3.4 Redlock 的争议
Martin Kleppmann(《DDIA》作者)曾质疑 Redlock:
- 时钟漂移可能导致锁失效
- GC 暂停可能导致客户端持有过期锁
实际建议:
- 如果对正确性要求极高(如金融场景),用 ZooKeeper 或 etcd
- 如果是普通业务场景(如防重复下单),单 Redis + Lua 足够
- Redlock 适合需要比单 Redis 更高可靠性,但又不想引入 ZK 的场景
四、实战:防重复下单
class OrderService {
private $redis;
/**
* 防重复下单
*/
public function createOrder($userId, $productId, $quantity) {
$lockKey = "lock:order:{$userId}:{$productId}";
$lockValue = uniqid('', true);
// 加锁(10秒过期)
$locked = $this->redis->set(
$lockKey,
$lockValue,
['NX', 'EX' => 10]
);
if (!$locked) {
throw new Exception('请勿重复提交,10秒后重试');
}
try {
// 1. 检查库存
$stock = $this->redis->get("stock:{$productId}");
if ($stock < $quantity) {
throw new Exception('库存不足');
}
// 2. 扣减库存
$this->redis->decrBy("stock:{$productId}", $quantity);
// 3. 创建订单
$orderId = $this->db->insert('orders', [
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
]);
// 4. 发送延迟消息(30分钟未支付自动取消)
$this->mq->publish('order_timeout', [
'order_id' => $orderId,
], 1800);
return $orderId;
} catch (Exception $e) {
// 回滚库存
$this->redis->incrBy("stock:{$productId}", $quantity);
throw $e;
} finally {
// 安全释放锁
$lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
$this->redis->eval($lua, [$lockKey, $lockValue], 1);
}
}
}五、各方案对比
| 方案 | 互斥性 | 防死锁 | 容错性 | 性能 | 复杂度 |
|---|---|---|---|---|---|
| SETNX+EXPIRE | ✅ | ❌ | ❌ | 高 | 低 |
| SET NX EX | ✅ | ✅ | ❌ | 高 | 低 |
| SET NX EX + Lua释放 | ✅ | ✅ | ❌ | 高 | 中 |
| 锁续期 | ✅ | ✅ | ❌ | 高 | 中高 |
| 可重入锁 | ✅ | ✅ | ❌ | 高 | 高 |
| Redlock | ✅ | ✅ | ✅ | 中 | 高 |
总结
Redis 分布式锁的演进路线:
SET NX EX原子加锁 → 基础方案- Lua 脚本安全释放 → 必须做
- 后台续期 → 长任务必须做
- 可重入 → 递归/嵌套场景需要
- Redlock → 对容错性要求高时考虑
生产推荐:单 Redis + SET NX EX + Lua 释放 + 续期,覆盖 95% 场景。
评论 (0)