Redis 分布式锁实现:从 SET NX 到 Redlock 算法

Redis 分布式锁实现:从 SET NX 到 Redlock 算法

admin
2026-07-24 / 0 评论 / 1 阅读

前言

分布式锁是分布式系统中控制资源访问的核心机制。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);

致命问题setnxexpire 是两条命令,不保证原子性。如果 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节点同意)─────

步骤:

  1. 获取当前时间 T1
  2. 依次向 N 个 Redis 节点请求加锁(相同的 key、value、较短TTL)
  3. 获取当前时间 T2,计算加锁耗时 = T2 - T1
  4. 如果加锁成功节点数 ≥ ⌈N/2⌉ + 1 且加锁耗时 < TTL,则加锁成功
  5. 实际锁有效时间 = TTL - 加锁耗时
  6. 如果加锁失败,向所有节点发送释放锁请求

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 分布式锁的演进路线:

  1. SET NX EX 原子加锁 → 基础方案
  2. Lua 脚本安全释放 → 必须做
  3. 后台续期 → 长任务必须做
  4. 可重入 → 递归/嵌套场景需要
  5. Redlock → 对容错性要求高时考虑

生产推荐:单 Redis + SET NX EX + Lua 释放 + 续期,覆盖 95% 场景。

0

评论 (0)

取消
0:00