ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis与Zookeeper分布式锁实现原理与对比

Redis与Zookeeper分布式锁实现原理与对比

1. 分布式锁的本质与核心诉求

分布式锁是分布式系统中协调多节点访问共享资源的机制。想象一下多个服务实例同时竞争修改数据库同一条记录的场景——没有锁机制就会导致数据错乱。与单机锁不同,分布式锁需要解决网络延迟、节点故障等分布式环境特有的问题。

核心诉求可归纳为三点:

  1. 互斥性:同一时刻只有一个客户端能持有锁
  2. 防死锁:持有锁的客户端崩溃后锁能自动释放
  3. 高可用:锁服务本身需要具备容错能力

提示:评估分布式锁方案时,CAP理论中的CP和AP特性选择会直接影响设计方向。Redis倾向于AP,ZK则更偏向CP。

2. Redis实现分布式锁的演进之路

2.1 基础版:SETNX + EXPIRE

最朴素的实现方式:

// 加锁 Boolean locked = redis.setnx("lock_key", "1"); if(locked) { redis.expire("lock_key", 30); } // 解锁 redis.del("lock_key");

这个方案存在致命缺陷:

  • 加锁和设置过期时间不是原子操作,可能设置过期时间前进程崩溃
  • 可能误删其他客户端的锁(A的锁过期后被B获取,A执行完又删了B的锁)

2.2 改进版:SET扩展命令

Redis 2.6.12后支持扩展参数:

SET lock_key unique_value NX PX 30000
  • NX:等效于SETNX
  • PX:设置毫秒级过期时间
  • unique_value:客户端唯一标识,避免误删

Java实现示例:

String uuid = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent("lock_key", uuid, 30, TimeUnit.SECONDS); // 解锁时先比较value再删除 if(uuid.equals(redisTemplate.opsForValue().get("lock_key"))) { redisTemplate.delete("lock_key"); }

2.3 终极挑战:锁续期问题

即使解决了原子性问题,仍存在:

  1. 业务执行时间超过锁过期时间
  2. 锁被其他客户端获取导致并发问题

解决方案是看门狗机制:启动后台线程定期检查并延长锁过期时间。这正是Redisson的核心设计之一。

3. Redisson分布式锁源码解析

3.1 加锁流程剖析

RedissonLock的核心加锁逻辑:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { // 使用Lua脚本保证原子性 return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command, "if (redis.call('exists', KEYS[1]) == 0) then " + "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 " + "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "return redis.call('pttl', KEYS[1]);", Collections.singletonList(getName()), unit.toMillis(leaseTime), getLockName(threadId)); }

关键设计点:

  • 使用Hash结构存储锁信息,支持可重入
  • 通过Lua脚本保证多个Redis命令的原子性
  • 返回nil表示加锁成功,否则返回剩余TTL

3.2 看门狗机制实现

锁续期的核心代码在RedissonLock#scheduleExpirationRenewal:

protected void scheduleExpirationRenewal(long threadId) { // 每10秒续期一次 Timeout task = commandExecutor.getConnectionManager() .newTimeout(new TimerTask() { @Override public void run(Timeout timeout) { // 续期Lua脚本 RFuture<Boolean> future = commandExecutor.evalWriteAsync( getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return 1; " + "end; " + "return 0;", Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); future.onComplete((res, e) -> { if (e != null) { return; } if (res) { // 递归调用实现周期性续期 scheduleExpirationRenewal(threadId); } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); }

注意:默认锁超时时间是30秒,续期间隔是超时时间的1/3(即10秒)

3.3 解锁时的"脑裂"防护

解锁时除了检查客户端标识,还需要处理一种极端情况:

  1. 客户端A持有锁
  2. 因GC停顿导致看门狗线程暂停
  3. 锁过期后被客户端B获取
  4. 客户端A恢复后执行解锁操作

Redisson通过增加一步GET操作来检测:

if (redis.call('get', KEYS[1]) == ARGV[1]) then return redis.call('del', KEYS[1]) else return 0 end

4. Zookeeper分布式锁实现对比

4.1 临时顺序节点方案

ZK实现分布式锁的标准模式:

  1. 在锁节点下创建临时顺序节点
  2. 获取所有子节点,判断自己是否是最小节点
  3. 如果是则获取锁,否则监听前一个节点的删除事件
  4. 处理完成后删除自己的节点
public void lock() { // 创建临时顺序节点 currentPath = zk.create(basePath + "/lock-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); // 获取并排序所有子节点 List<String> children = zk.getChildren(basePath, false); Collections.sort(children); // 判断自己是否是最小节点 int index = children.indexOf(currentPath.substring(basePath.length() + 1)); if (index == 0) { return; // 获取锁成功 } else { // 监听前一个节点 String prevPath = basePath + "/" + children.get(index - 1); CountDownLatch latch = new CountDownLatch(1); zk.exists(prevPath, event -> { if (event.getType() == EventType.NodeDeleted) { latch.countDown(); } }); latch.await(); } }

4.2 ZK与Redis方案对比

特性Redis方案ZK方案
实现复杂度相对简单较复杂
性能高(内存操作)较低(需要持久化)
可靠性依赖Redis持久化基于ZK的强一致性
锁释放机制依赖超时会话结束自动释放
惊群效应存在(大量客户端重试)避免(顺序监听机制)
适用场景高并发、允许偶尔超时强一致性要求的场景

5. 生产环境中的最佳实践

5.1 锁粒度控制技巧

  • 细粒度锁:按业务ID分段加锁(如order_123_lock)
  • 锁超时时间:根据业务平均耗时动态调整
  • 重试策略:指数退避算法避免集群雪崩
// 分段锁示例 public void updateOrder(Long orderId) { String lockKey = "order_" + orderId + "_lock"; RLock lock = redisson.getLock(lockKey); try { if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // 业务逻辑 } } finally { lock.unlock(); } }

5.2 常见问题排查指南

  1. 锁永远不释放

    • 检查看门狗线程是否正常运行
    • 确认finally块中调用了unlock()
  2. 解锁时报IllegalMonitorStateException

    • 通常是不同线程加锁解锁导致
    • 确保加锁解锁在同一线程执行
  3. Redis主从切换导致锁失效

    • 考虑使用RedLock算法(需至少3个独立Redis实例)
    • 评估业务是否允许极端情况下的短暂不一致
  4. ZK连接断开处理

    • 实现ConnectionStateListener监听会话状态
    • 准备补偿机制处理中间状态

5.3 监控与性能优化

  • Redis锁监控指标:

    • 锁等待时间分布
    • 锁持有时间分布
    • 锁获取失败率
  • ZK锁监控重点:

    • 临时节点数量增长趋势
    • 节点监听队列深度
    • 会话超时告警

我在实际项目中总结的经验是:对于秒杀类场景,Redis分布式锁的性能可以比ZK方案高出一个数量级,但在金融交易等强一致性场景,ZK的可靠性优势更加重要。建议根据业务特点选择技术方案,必要时可以组合使用——比如用Redis锁做第一层过滤,ZK锁做最终一致性控制。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进