分布式锁:从Redis到Zookeeper的完整方案

前言

分布式锁是分布式系统的基础。synchronized在多台服务器上失效怎么办?如何防止库存超卖? Redis、Zookeeper、数据库三种方案各有优劣,理解它们的原理和坑点才能选对方案。

我并没有能力让你成为分布式专家,我只是想让你彻底理解分布式锁的实现原理、Redis主从切换丢锁的问题、以及Redisson的Watch Dog机制。每个方案都会详细推导,讲透本质。

摘要

从"库存超卖事故"出发,剖析分布式锁的三种实现方案。通过Redis的SETNX原子性、Zookeeper的临时顺序节点、以及Redisson的Watch Dog机制,揭秘分布式环境下的加锁解锁。配合详细推导,给出分布式锁的透彻理解。


一、从库存超卖说起

双十一凌晨0点,秒杀开始3分钟后。

哈吉米接到告警:

🚨 库存超卖!
100件商品,卖出了157件!

查看代码

public boolean seckill(Long productId) {
    // 查询库存
    Stock stock = stockMapper.selectById(productId);
    if (stock.getNum() <= 0) {
        return false;
    }
    
    // 扣减库存(加了synchronized)
    synchronized (this) {
        stock.setNum(stock.getNum() - 1);
        stockMapper.updateById(stock);
    }
    
    return true;
}

查看架构

        【负载均衡】
       /     |     \
  服务器1  服务器2  服务器3
      ↓      ↓       ↓
         【MySQL】

哈吉米:"有3台服务器!synchronized只锁住单台JVM,锁不住多台!"

南北绿豆阿西噶阿西赶来。

南北绿豆:"分布式场景下,synchronized无效,必须用分布式锁。"


二、什么是分布式锁

南北绿豆:"分布式锁就是跨JVM的锁。"

2.1 单机锁 vs 分布式锁

单机锁(synchronized、ReentrantLock):
  作用范围:同一个JVM进程内
  
  3台服务器 = 3个JVM
  → 每台都有自己的锁
  → 锁不住其他服务器 ❌

分布式锁:
  作用范围:所有服务器
  
  实现:外部共享存储(Redis、Zookeeper、数据库)
  → 所有服务器共享同一把锁 ✅

2.2 分布式锁的3个核心要求

要求说明
互斥性同一时刻只有一个客户端能持有锁
防死锁即使持有锁的客户端崩溃,锁也能被释放
高可用锁服务必须高可用(不能单点故障)

哈吉米:"分布式锁的核心是共享存储。"


三、方案1:Redis分布式锁

南北绿豆:"Redis分布式锁最常用,但有很多坑。"

3.1 版本1:SETNX(有问题)

String lockKey = "lock:stock:" + productId;

try {
    // 获取锁
    Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
    if (!success) {
        return false;
    }
    
    // 业务逻辑
    doSeckill(productId);
    
} finally {
    // 释放锁
    redisTemplate.delete(lockKey);
}

问题:没有过期时间

场景:
  服务器A获取锁
  服务器A宕机(finally没执行)
  → 锁永远不释放
  → 死锁 ❌

3.2 版本2:SETNX + EXPIRE(还有问题)

// 获取锁
redisTemplate.opsForValue().setIfAbsent(lockKey, "1");

// 设置过期时间
redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);

问题:不是原子操作

场景:
  SETNX成功
  还没执行EXPIRE,服务器宕机
  → 锁没有过期时间
  → 死锁 ❌

3.3 版本3:SET NX EX(推荐)

String lockKey = "lock:stock:" + productId;
String lockValue = UUID.randomUUID().toString();

try {
    // 原子操作:SET key value NX EX 30
    Boolean success = redisTemplate.opsForValue().setIfAbsent(
        lockKey,
        lockValue,
        30,
        TimeUnit.SECONDS
    );
    
    if (!success) {
        return false;
    }
    
    // 业务逻辑
    doSeckill(productId);
    
} finally {
    // Lua脚本:原子删除
    String script = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('del', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end";
    
    redisTemplate.execute(
        new DefaultRedisScript<>(script, Long.class),
        Collections.singletonList(lockKey),
        lockValue
    );
}

改进

  1. SET NX EX:原子操作 ✅
  2. UUID:防止误删别人的锁 ✅
  3. Lua脚本:原子判断+删除 ✅

问题:业务时间超过锁过期时间

场景:
  服务器A获取锁(过期30秒)
  业务执行了40秒
  → 30秒时锁过期
  → 服务器B获取到锁
  → AB同时执行 ❌

3.4 版本4:Redisson(终极方案)

@Autowired
private RedissonClient redissonClient;

public boolean seckill(Long productId) {
    String lockKey = "lock:stock:" + productId;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 尝试获取锁(等待10秒,锁过期30秒)
        boolean success = lock.tryLock(10, 30, TimeUnit.SECONDS);
        if (!success) {
            return false;
        }
        
        // 业务逻辑(即使执行超过30秒,锁也不会过期)
        doSeckill(productId);
        
        return true;
        
    } catch (InterruptedException e) {
        return false;
    } finally {
        // 释放锁
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

Watch Dog机制

Redisson的自动续期:
1. 获取锁,设置过期时间30秒
2. 启动Watch Dog定时任务(每10秒执行)
3. 检查锁是否还被当前线程持有
4. 如果是,续期到30秒
5. 业务执行完,unlock,停止Watch Dog

结果:即使业务执行1小时,锁也不会过期 ✅

四、方案2:Zookeeper分布式锁

南北绿豆:"Zookeeper分布式锁是强一致性的选择。"

4.1 Zookeeper锁的原理

核心利用临时顺序节点 + 监听机制

详细流程(lockPath = /lock/stock/1001):

客户端1:
  1. 在/lock/stock/1001下创建临时顺序节点
     → /lock/stock/1001/0000000001
  
  2. 获取/lock/stock/1001下的所有子节点
     → [0000000001]
  
  3. 判断自己是否是最小序号
     → 0000000001是最小
     → 获取锁成功 ✅

客户端2:
  1. 在/lock/stock/1001下创建节点
     → /lock/stock/1001/0000000002
  
  2. 获取所有子节点
     → [0000000001, 0000000002]
  
  3. 判断自己是否是最小序号
     → 0000000002不是最小
     → 监听前一个节点:0000000001
  
  4. 等待...

客户端1执行完:
  删除节点:/lock/stock/1001/0000000001
  
Zookeeper通知客户端2:
  "你监听的节点删除了"
  
客户端2:
  重新判断,0000000002现在是最小的
  → 获取锁成功 ✅

关键点

  • lockPath是基础路径
  • 临时顺序节点在lockPath下面创建
  • 节点格式:lockPath/序号

4.2 代码实现

@Autowired
private CuratorFramework zkClient;

public boolean seckill(Long productId) {
    // 基础路径
    String lockPath = "/lock/stock/" + productId;
    
    // Curator的InterProcessMutex封装了上述原理
    InterProcessMutex lock = new InterProcessMutex(zkClient, lockPath);
    
    try {
        // 尝试获取锁(超时10秒)
        if (lock.acquire(10, TimeUnit.SECONDS)) {
            // 业务逻辑
            doSeckill(productId);
            return true;
        } else {
            return false;
        }
    } catch (Exception e) {
        log.error("获取锁失败", e);
        return false;
    } finally {
        try {
            lock.release();
        } catch (Exception e) {
            log.error("释放锁失败", e);
        }
    }
}

说明

InterProcessMutex内部自动完成:
  - 在lockPath下创建临时顺序节点
  - 判断是否最小节点
  - 监听前一个节点
  - 自动续期(通过心跳)
  
开发者只需要:
  tryLock() → 获取锁
  unlock() → 释放锁

优点

  • ✅ 强一致性(CP模型)
  • ✅ 自动防死锁(临时节点,客户端宕机自动删除)
  • ✅ 公平锁(严格按顺序)

缺点

  • ❌ 性能较差(需要多次网络请求)
  • ❌ 依赖Zookeeper集群

五、方案3:数据库分布式锁

南北绿豆:"数据库锁实现简单,但性能最差。"

5.1 实现原理

利用唯一索引

CREATE TABLE distributed_lock (
  lock_key VARCHAR(100) PRIMARY KEY,
  lock_value VARCHAR(100),
  expire_time DATETIME,
  INDEX idx_expire(expire_time)
);

5.2 代码实现

public boolean seckill(Long productId) {
    String lockKey = "stock:" + productId;
    String lockValue = UUID.randomUUID().toString();
    
    try {
        // 尝试获取锁(INSERT)
        DistributedLock lock = new DistributedLock();
        lock.setLockKey(lockKey);
        lock.setLockValue(lockValue);
        lock.setExpireTime(new Date(System.currentTimeMillis() + 30000));
        
        try {
            lockMapper.insert(lock);  // 插入成功 = 获取锁
        } catch (DuplicateKeyException e) {
            return false;  // 主键冲突,锁已存在
        }
        
        // 业务逻辑
        doSeckill(productId);
        
        return true;
        
    } finally {
        // 释放锁
        lockMapper.deleteByKeyAndValue(lockKey, lockValue);
    }
}

优点

  • ✅ 实现简单
  • ✅ 不需要额外组件

缺点

  • ❌ 性能差(每次INSERT/DELETE)
  • ❌ 不支持自动续期
  • ❌ 需要定时任务清理过期锁

六、三种方案完整对比

6.1 性能对比

测试环境:单机Redis、3节点Zookeeper、MySQL 5.7,1000并发

方案平均响应时间QPS说明
Redis(Redisson)12ms~8000性能最好
Zookeeper85ms~1000网络请求多
数据库150ms~600数据库IO慢

:实际性能取决于硬件、网络、并发数


6.2 一致性与可用性

方案一致性可用性分区容错说明
Redis单机单机,不考虑分区
Redis主从主从异步,可能丢锁
Redis ClusterAP模型
ZookeeperCP模型
数据库单机单机,不考虑分区

CAP说明

分布式环境下(网络分区P必然存在):
  只能选CP或AP,不能CA
  
Redis:牺牲一致性,保证可用性(AP)
Zookeeper:牺牲可用性,保证一致性(CP)

单机环境(无分区P):
  可以同时C+A

6.3 选型建议

阿西噶阿西

场景推荐方案原因
秒杀、抢红包Redis(Redisson)性能好,允许极少数不一致
金融交易Zookeeper强一致性,不能出错
低并发数据库实现简单,性能够用
高并发Redis Cluster性能+高可用

南北绿豆:"90%的场景,用Redisson就够了。"


七、分布式锁的常见坑

7.1 坑1:锁没有过期时间

// ❌ 错误
redisTemplate.opsForValue().setIfAbsent(lockKey, "1");

// ✅ 正确
redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);

7.2 坑2:删除了别人的锁

// ❌ 错误
finally {
    redisTemplate.delete(lockKey);  // 可能删除了别人的锁
}

// 场景:
// A获取锁,过期30秒
// 业务执行35秒
// 30秒时,锁过期,B获取锁
// A执行完,删除锁(删除了B的锁)❌

// ✅ 正确(用UUID+Lua脚本)
String uuid = UUID.randomUUID().toString();
setIfAbsent(lockKey, uuid, 30, TimeUnit.SECONDS);
// Lua脚本判断uuid再删除

7.3 坑3:业务执行时间超过锁过期时间

解决:用Redisson的Watch Dog自动续期


7.4 坑4:Redis主从切换丢锁

场景:
1. 客户端A在主节点获取锁
2. 主节点宕机(锁还没同步到从节点)
3. 从节点升级为主节点
4. 客户端B在新主节点获取锁(新主节点没有锁记录)
5. A和B同时持有锁 ❌

解决:Redlock(Redis作者提出,但有争议)

争议

Martin Kleppmann(分布式专家)认为Redlock不安全:
  - 依赖时钟同步(GC暂停可能导致问题)
  - 网络分区时可能失效

Redis作者antirez反驳:
  - 实际场景足够可靠

结论:
  绝对不能出错 → Zookeeper
  一般场景 → Redisson单实例/集群足够

八、分布式锁总结

8.1 核心要点

南北绿豆

  1. 分布式锁:跨JVM的锁,基于共享存储
  2. Redis锁:SET NX EX,用Redisson最省心
  3. Zookeeper锁:临时顺序节点,强一致性
  4. 坑点:过期时间、误删锁、业务超时、主从切换

8.2 最佳实践

阿西噶阿西

生产环境推荐:
  1. 用Redisson(封装完善,自动续期)
  2. 设置合理过期时间(根据业务)
  3. 监控锁的持有时间
  4. 设置锁粒度(按业务ID,不要全局锁)

哈吉米:"理解了分布式锁的原理和坑点,就不会踩坑了。"


参考资料

  • Redisson官方文档
  • 《Redis设计与实现》- 黄健宏
  • Martin Kleppmann - How to do distributed locking