Redis分布式锁

总结摘要
Redis分布式锁

基础实现

如何用 Redis 实现分布式锁?核心步骤是什么?

一句话原理:Redis 分布式锁基于“占用式”思想,利用 SET NX EX 原子命令抢占锁标识,通过唯一 Value 防止误删,并结合 Lua 脚本确保“判断+删除”解锁过程的原子性。

一句话源码:在 db.c 中,SET NX 命令本质上调用 setKey 函数,在 lookupKeyWrite 查找 Key 不存在时执行 dbAdd 写入并关联 setExpire 过期时间,从而实现互斥。

一句话项目/场景:在电商秒杀扣减库存场景中,使用 SET lock:stock UUID NX EX 10 加锁,业务执行完毕后通过 Lua 脚本校验 UUID 并 DEL,严防超卖与死锁。


详细实现步骤

1. 核心实现步骤(加锁、解锁、续期)

第一步:加锁(原子性设置) 使用 Redis 的 SET 命令扩展参数,保证“设置 Key”与“设置过期时间”的原子性,避免客户端崩溃导致死锁。

  • 命令SET lock_key unique_value NX PX 30000
  • 参数解析
    • NX (Not Exist):仅当 Key 不存在时才设置成功,保证互斥性。
    • PX 30000:设置过期时间为 30 秒(毫秒级),防止客户端宕机无法解锁造成死锁。
    • unique_value:必须是全局唯一的标识(如 UUID + 线程 ID),用于解锁时校验锁的归属,防止误删其他线程的锁。

第二步:解锁(Lua 脚本保证原子性) 解锁必须包含“判断锁归属”和“删除锁”两步,这两步必须原子执行。如果先 Get 判断再 Del,中间可能发生锁过期被其他线程抢占的情况,导致误删。

  • Lua 脚本
    1
    2
    3
    4
    5
    
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
  • 执行:将脚本发送给 Redis 执行,Redis 保证 Lua 脚本执行期间不执行其他命令。

第三步:锁续期(看门狗机制) 如果业务执行时间超过了锁的过期时间,锁会自动释放,导致并发安全问题。需要启动一个后台线程(看门狗),定时检测业务是否执行完,若未完成则延长锁的过期时间。

  • 机制:每隔过期时间的 1/3 时间检查一次,如果 Key 存在且 Value 一致,则执行 PEXPIRE 重置过期时间。

进阶方案:Redisson 框架

在实际项目中不建议手动实现,推荐使用官方推荐的 Redisson 框架,它封装了上述所有逻辑:

  • 可重入锁:基于 Hash 结构存储,字段为线程 ID,Value 为重入次数。
  • 看门狗:默认 30 秒过期,后台自动续期。
  • 高可用:支持 RedLock 算法(在 Redis Cluster 模式下向多个主节点申请锁),解决单点故障问题。

常见误区与避坑

  • 误区 1:使用 SETNX + EXPIRE 分开执行。
    • 后果:若 SETNX 成功后程序崩溃,EXPIRE 未执行,导致死锁。
    • 纠正:必须使用 SET NX EX 复合命令或 Lua 脚本合并执行。
  • 误区 2:解锁时直接 DEL Key。
    • 后果:可能删掉其他线程已经获取的锁。
    • 纠正:必须先 GET 判断 Value 是否为自己持有,再 DEL,且用 Lua 脚本保证原子性。
  • 误区 3:锁的超时时间设置过短或过长。
    • 后果:过短导致业务未执行完锁即失效;过长导致服务宕机后恢复时间过长。
    • 纠正:使用 Redisson 的“看门狗”自动续期机制,或根据业务 P99 耗时合理估算并预留 Buffer。

SETNX 命令的用法?为什么需要结合 EXPIRE 设置过期时间?

一句话原理SETNX 是“SET if Not eXists”的缩写,仅当 Key 不存在时才设值,用于实现互斥占用;其必须结合 EXPIRE 是为了防止客户端获取锁后宕机无法释放,导致 Key 永久存在引发死锁。

一句话源码:在 Redis 源码 db.c 中,SETNX 命令处理函数先调用 lookupKeyWrite 查找 Key,若返回 NULL(不存在),则执行 dbAdd 写入数据库并返回 1,否则直接返回 0。

一句话项目/场景:在 Redis 2.6.12 之前的版本中,需分别执行 SETNXEXPIRE,存在原子性问题;现代生产环境应直接使用 SET key value EX seconds NX 复合命令,从协议层面规避死锁风险。


详细解析

SETNX 命令用法

  • 基本语法SETNX key value
  • 返回值
    • 1:设置成功(Key 之前不存在)。
    • 0:设置失败(Key 已经存在,未执行任何操作)。
  • 示例
    1
    2
    3
    4
    
    redis> SETNX lock:product_101 "client_A"  # 返回 1,加锁成功A
    redis> SETNX lock:product_101 "client_B"  # 返回 0,加锁失败,已被 A 占用
    redis> DEL lock:product_101               # 释放锁
    redis> SETNX lock:product_101 "client_B"  # 返回 1,加锁成功

为什么必须结合 EXPIRE

核心原因是为了解决死锁问题

  • 场景描述
    1. 客户端 A 执行 SETNX lock_key value,成功获取锁。
    2. 客户端 A 在执行业务逻辑时,程序崩溃或网络中断,未能执行删除锁(DEL)的指令。
    3. 此时 lock_key 永远存在于 Redis 中。
    4. 客户端 B 及后续所有客户端尝试 SETNX 都会返回 0,永远无法获取锁,系统陷入死锁状态。
  • 解决方案: 设置过期时间 EXPIRE lock_key seconds,即使客户端 A 宕机,Redis 也会在超时后自动删除 Key,释放锁资源,允许其他客户端继续获取。

进阶:原子性问题与最佳实践

虽然 SETNX + EXPIRE 能解决死锁,但两条命令分开发送存在隐患。

  • 潜在风险: 如果 SETNX 执行成功后,客户端还没来得及执行 EXPIRE 就宕机了,依然会导致死锁(Key 存在但无过期时间)。
  • 最佳实践(现代标准做法): 从 Redis 2.6.12 版本开始,SET 命令增加了扩展参数,可以原子性地完成“设值 + 不存在才设 + 设过期时间”三个动作。
    • 命令SET key value [EX seconds] [PX milliseconds] [NX|XX]
    • 示例
      1
      2
      
      # 等同于 SETNX + EXPIRE,且是原子操作
      SET lock:product_101 "client_A" EX 30 NX
    • 优势:一条命令完成加锁与设过期时间,无需 Lua 脚本即可保证原子性,是当前实现分布式锁的标准做法。

如何保证加锁和设置过期时间的原子性?(SET key value NX PX 30000)

一句话原理:通过 Redis 2.6.12 版本增强的 SET 命令扩展参数,将“互斥占位(NX)”与“过期时间设置(EX/PX)”合并为一条原子指令,在服务端单次执行中完成,杜绝了指令分拆导致的“加锁成功但宕机未设过期”的死锁风险。

一句话源码:在源码 db.csetGenericCommand 函数中,逻辑会先检查 nx 参数,若 Key 存在则直接返回,若不存在则调用 setKey 写入,紧接着调用 setExpire 设置过期时间,整个过程在单线程事件循环中一气呵成。

一句话项目/场景:在生产环境代码中,直接使用 stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS),该方法底层即封装了 SET NX EX 原子命令,彻底避免了老旧方案中先 SETNXEXPIRE 的并发隐患。

如何实现可重入锁?需要额外记录什么信息?

一句话原理:可重入锁通过关联“线程标识”与“重入计数器”,允许同一线程多次获取同一把锁而不会自我死锁,其核心在于识别锁持有者身份并对计数进行递增或递减操作。

一句话源码:Redis 实现通常采用 Hash 结构存储,Key 为锁名称,Field 为线程唯一标识(UUID + ThreadID),Value 为重入次数;加锁逻辑需通过 Lua 脚本保证原子性:先判断 Key 是否存在,不存在则创建;若存在则判断 Field 是否为当前线程,若是则对 Value 执行 HINCRBY 递增。

一句话项目/场景:在秒杀业务中,如果加锁的方法 A 内部调用了同样需要该锁的方法 B,可重入锁机制会识别出当前线程已持有锁,直接将计数器加 1 并允许执行,避免了不可重入锁导致的线程自我阻塞死锁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- KEYS[1]: 锁名称
-- ARGV[1]: 过期时间
-- ARGV[2]: 线程唯一标识

-- 第一步:判断锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,创建 Hash,设置 Field(线程ID) 和 Value(计数1),并设置过期时间
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

-- 第二步:锁存在,判断是否是当前线程持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 是自己持有的,重入次数 +1,并刷新过期时间
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

-- 第三步:锁存在,但被别人持有,返回剩余过期时间
return redis.call('pttl', KEYS[1]);

高级问题

分布式锁的释放需要注意什么?为什么要用 Lua 脚本保证原子性?

一句话原理:释放锁必须遵循“先检查持有者身份,再执行删除”的逻辑,以防止误删其他线程的锁;使用 Lua 脚本是因为它能将“判断”和“删除”两步操作在服务端合并为单一原子指令,杜绝并发环境下的竞态条件。

一句话源码:在 Redis 执行 Lua 脚本时,会严格串行处理 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end 这段逻辑,确保在读取 Key 值到删除 Key 期间,不会有其他命令插入执行。

一句话项目/场景:在电商抢购场景中,若线程 A 的锁超时释放后被线程 B 获取,线程 A 恢复后如果不校验身份直接 Del,就会误删 B 的锁导致 B 业务失控;Lua 脚本确保了只有锁的真正持有者才能释放锁,严格避免了误删风险。

什么是 RedLock 算法?它解决了什么问题?有什么争议?

一句话原理:RedLock(红锁)通过在多个(通常为 5 个)完全独立的 Redis 主节点上同时加锁,并遵循“半数以上成功且耗时小于 TTL”的规则,实现了比单节点或主从架构更严格的分布式锁安全性。

一句话源码:核心逻辑在客户端实现,循环遍历 N 个节点发送 SET NX 指令,通过记录 startTimeendTime 计算耗时,仅当 (successCount >= N/2 + 1) && (costTime < ttl) 时才判定加锁成功,否则向所有节点发起解锁。

一句话项目/场景:适用于对数据一致性要求极高的金融或交易扣款场景,解决了传统 Redis 主从异步复制导致的主从切换期间锁丢失(双主问题)问题;但因依赖系统时钟存在安全性争议,高性能场景多被 ZooKeeper 或优化后的分布式算法替代。

如果持有锁的线程执行时间超过锁的过期时间,如何处理?(锁续期)

一句话原理:通过“看门狗”机制进行自动续期,即额外启动一个后台线程,定期检测业务是否执行完毕,若未完毕则重置锁的过期时间,防止任务未完锁先丢。

一句话源码:在 Redisson 实现中,客户端加锁成功后会启动一个 ScheduledTask,每隔过期时间的 1/3(默认 10 秒)执行 renewExpirationAsync,发送 Lua 脚本判断锁是否仍由当前线程持有,若是则执行 PEXPIRE 重置有效期。

一句话项目/场景:在执行耗时不确定的数据处理任务时,不设置过长的固定过期时间,而是利用 Redisson 的默认看门狗机制,既避免了死锁风险,又确保了长任务不会被锁超时中断导致并发异常。

Redis 分布式锁和 ZooKeeper 分布式锁的对比?各自优缺点?

1. 核心原理对比

一句话原理:Redis分布式锁基于内存存储过期时间,通过SET NX原子命令实现互斥;ZooKeeper分布式锁基于临时顺序节点Watch机制,通过节点最小序号判定和事件监听实现公平锁,两者在"性能"与"可靠性"的权衡上截然不同。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ============ Redis分布式锁(单节点实现) ============
// 获取锁(原子操作)
SET resource_name my_random_value NX PX 30000  // NX: 不存在才设置,PX: 过期时间30秒

// 释放锁(Lua脚本保证原子性)
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

// ============ ZooKeeper分布式锁(临时顺序节点) ============
// 1. 创建临时顺序节点
String lockPath = zk.create("/locks/lock-", data, 
    ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);

// 2. 获取所有子节点
List<String> children = zk.getChildren("/locks", false);
Collections.sort(children);

// 3. 判断是否是最小节点
int index = children.indexOf(lockPath.substring(lockPath.lastIndexOf('/') + 1));
if (index == 0) {
    // 获得锁
} else {
    // 监听前一个节点
    String prevPath = "/locks/" + children.get(index - 1);
    zk.exists(prevPath, true);  // 注册Watcher
}

项目场景:在电商秒杀系统中,用Redis锁处理高频库存扣减,追求极致性能;在分布式任务调度中,用ZooKeeper锁保证主节点选举的绝对可靠,避免脑裂问题。

2. 优缺点详细对比

一句话原理:Redis锁性能高、实现简单,但存在主从切换锁丢失风险;ZooKeeper锁可靠性强、天然公平,但性能较低、部署复杂,二者在CAP理论中分别偏向AP和CP。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
 * 优缺点对比表
 */
// ============ Redis分布式锁 ============
// 优点:
// - 性能极高(基于内存,QPS可达10万+)
// - 实现简单,API友好
// - 支持自动过期,避免死锁
// - Redlock算法可提供更高可靠性

// 缺点:
// - 主从异步复制可能导致锁丢失(主宕机,从未同步)
// - 需自行处理锁续期(Watchdog机制)
// - 非公平锁,可能产生饥饿
// - 依赖时钟同步假设

// Redis锁丢失场景演示
// 1. 客户端A在主节点获取锁
// 2. 主节点宕机,锁数据未同步到从节点
// 3. 从节点升级为主节点
// 4. 客户端B也能获取同一把锁 → 锁失效!

// ============ ZooKeeper分布式锁 ============
// 优点:
// - 强一致性(ZAB协议保证)
// - 临时节点自动清理,无需设置过期时间
// - 天然公平锁(按节点序号顺序)
// - Watch机制避免惊群效应
// - 客户端断开连接自动释放锁

// 缺点:
// - 性能较低(磁盘写操作,QPS约几千)
// - 部署维护复杂(需要独立集群)
// - 可能存在"羊群效应"(需优化为监听前一个节点)
// - 会话超时时间难权衡

// ZooKeeper锁改进实现(避免惊群)
public class ZooKeeperLock {
    private String currentPath;
    private String prevPath;
    
    public void lock() throws Exception {
        currentPath = zk.create("/locks/lock-", null, 
            ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
        
        List<String> children = zk.getChildren("/locks", false);
        Collections.sort(children);
        
        if (currentPath.equals("/locks/" + children.get(0))) {
            return;  // 获得锁
        }
        
        // 只监听前一个节点,而非所有节点
        int i = Collections.binarySearch(children, 
            currentPath.substring(currentPath.lastIndexOf('/') + 1));
        prevPath = "/locks/" + children.get(i - 1);
        zk.exists(prevPath, true);  // 注册Watcher
        
        synchronized (this) {
            wait();  // 等待前一个节点释放
        }
    }
}

项目场景:在金融交易系统中,要求锁的可靠性极高,即使性能稍低也必须选择ZooKeeper;在社交APP点赞计数中,偶尔的锁失效可以接受,优先选择Redis追求高性能。

3. 高级方案:Redlock与Curator

一句话原理:针对Redis主从问题,Redlock算法通过向多个独立Redis节点同时获取锁,超过半数成功则视为成功,将可靠性从单点提升到分布式级别;ZooKeeper有Curator框架封装了锁的实现,提供可重入锁、读写锁等高级特性。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// ============ Redlock算法(Redis官方推荐) ============
public class Redlock {
    private static final int N = 5;  // 5个独立Redis节点
    private List<RedisClient> clients;
    
    public boolean tryLock(String key, String value, int ttl) {
        int successCount = 0;
        long start = System.currentTimeMillis();
        
        // 1. 向所有节点尝试获取锁
        for (RedisClient client : clients) {
            if (client.setnxex(key, value, ttl)) {
                successCount++;
            }
        }
        
        // 2. 检查是否超过半数且耗时小于TTL
        long cost = System.currentTimeMillis() - start;
        if (successCount >= N/2 + 1 && cost < ttl) {
            return true;  // 获得锁
        }
        
        // 3. 失败则释放所有节点锁
        for (RedisClient client : clients) {
            client.del(key);
        }
        return false;
    }
}

// ============ Curator(ZooKeeper客户端封装) ============
public class CuratorLockDemo {
    private CuratorFramework client;
    private InterProcessLock lock;
    
    public void init() {
        client = CuratorFrameworkFactory.newClient(
            "zk1:2181,zk2:2181,zk3:2181",
            new ExponentialBackoffRetry(1000, 3));
        client.start();
        
        // 创建可重入分布式锁
        lock = new InterProcessMutex(client, "/locks/my_lock");
    }
    
    public void doWithLock() {
        try {
            if (lock.acquire(10, TimeUnit.SECONDS)) {
                try {
                    // 业务逻辑
                } finally {
                    lock.release();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

项目场景:在跨机房部署的金融核心系统中,使用Redlock算法在5个机房的Redis节点获取锁,即使单个机房故障也不影响锁服务;在数据迁移工具中,用Curator的InterProcessMutex轻松实现可重入分布式锁,代码量减少80%。

完整对比与选型指南

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
/**
 * Redis vs ZooKeeper 分布式锁完整指南
 */
public class DistributedLockGuide {
    
    /**
     * 1. 详细对比表
     */
    public class ComparisonTable {
        // 维度            Redis锁                        ZooKeeper锁
        // 一致性          最终一致性                     强一致性(ZAB)
        // 性能           极高(10万+ QPS)               中等(几千 QPS)
        // 可靠性          主从切换可能丢锁                无丢锁风险
        // 公平性          非公平锁                       公平锁(节点顺序)
        // 自动释放        过期时间                        临时节点自动删除
        // 阻塞机制        自旋重试                         Watch通知
        // 实现复杂度      简单                            复杂
        // 部署成本       轻量级                          独立集群
        // 高级特性        Redlock                        可重入锁、读写锁
    }
    
    /**
     * 2. 选型决策树
     */
    public class DecisionTree {
        // 1. 对性能要求是否极高(>1万 QPS)?
        //    ├─ 是 → Redis(单节点或Redlock)
        //    └─ 否 → 转到2
        
        // 2. 是否要求绝对可靠(金融交易)?
        //    ├─ 是 → ZooKeeper(强一致性)
        //    └─ 否 → 转到3
        
        // 3. 是否需要公平锁?
        //    ├─ 是 → ZooKeeper
        //    └─ 否 → Redis
        
        // 4. 现有基础设施?
        //    ├─ 已有Redis集群 → Redis
        //    └─ 已有ZooKeeper → ZooKeeper
    }
    
    /**
     * 3. 适用场景示例
     */
    public class RealScenarios {
        
        // 场景1:秒杀库存扣减(选Redis)
        // 原因:10万QPS,允许极小概率的锁失效
        
        // 场景2:分布式定时任务调度(选ZooKeeper)
        // 原因:需要严格保证只有一个节点执行
        
        // 场景3:配置中心主节点选举(选ZooKeeper)
        // 原因:强一致性保证,避免脑裂
        
        // 场景4:分布式限流计数器(选Redis)
        // 原因:高性能,Lua脚本原子操作
        
        // 场景5:跨行转账防重(选ZooKeeper)
        // 原因:资金操作必须绝对可靠
    }
    
    /**
     * 4. 面试必备
     */
    public class InterviewQA {
        
        // Q: Redis锁如何避免锁被其他客户端释放?
        // A: 使用随机value+Lua脚本,只删除自己设置的锁
        
        // Q: ZooKeeper锁的"惊群效应"如何解决?
        // A: 每个节点只监听前一个节点,而非所有节点
        
        // Q: Redlock算法靠谱吗?
        // A: 学术界有争议,但工业界广泛应用
        
        // Q: 锁超时时间设置多少合适?
        // A: 略大于最大业务执行时间 + 缓冲
    }
}

/**
 * 总结表
 * 
 * 方案          性能       可靠性      公平性      适用场景
 * Redis单节点    极高       中          低         缓存、计数器、非核心
 * Redis Redlock 高        较高         低         跨机房、高可用要求
 * ZooKeeper     中        极高         高         交易、选举、核心业务
 * 
 * 最佳实践:
 * 1. 业务核心选ZK,性能核心选Redis
 * 2. Redis锁一定要用Lua脚本释放
 * 3. ZooKeeper锁要用Curator框架
 * 4. 始终设置合理的超时时间
 */

// 面试金句
// "Redis和ZooKeeper分布式锁就像'赛车'和'装甲车':
//  Redis是'赛车'——速度极快(性能),
//  但发生事故时可能翻车(主从切换丢锁);
//  ZooKeeper是'装甲车'——稳重可靠(强一致),
//  但开不快(性能较低),油耗也高(部署复杂)。
//  在秒杀系统中我开'赛车',因为要的就是快;
//  在资金结算中我开'装甲车',因为绝对安全比速度更重要。
//  理解这个区别才能在业务和技术之间做出正确权衡"