欢迎光临
我们一直在努力

分布式锁实现:从 Redis 到 ZooKeeper 的选型与生产级方案

分布式锁实现:从 Redis 到 ZooKeeper 的选型与生产级方案

cover

一、并发竞争与数据一致性:分布式锁要解决的根本问题

在分布式系统中,多个服务实例可能同时访问共享资源——扣减库存、发放优惠券、配置变更推送等场景都需要互斥访问。单机环境下用 synchronized 或 ReentrantLock 即可解决,但在跨进程、跨机器的分布式环境下,这些本地锁机制完全失效。

分布式锁的核心挑战在于:网络不可靠、时钟不同步、进程随时可能崩溃。一个设计不当的分布式锁,在正常情况下看似工作正常,一旦出现网络分区或 GC 停顿,就可能导致锁无法释放、死锁或多个客户端同时持有锁。这类问题在线上极难复现,却可能在流量高峰时集中爆发,造成超卖、数据错乱等严重后果。

二、分布式锁底层机制:三种实现方案的原理与对比

分布式锁的主流实现方案有三种:基于 Redis 的 RedLock、基于 ZooKeeper 的临时顺序节点、基于 etcd 的租约机制。下图展示了三种方案的核心机制:

flowchart LR
subgraph Redis方案
R1[客户端A SETNX 获取锁] –> R2[设置过期时间 防死锁]
R2 –> R3[执行业务逻辑]
R3 –> R4[LUA脚本 原子释放锁]
end

subgraph ZK方案
Z1[客户端A 创建临时顺序节点] –> Z2[Watch前一个节点]
Z2 –> Z3[前一个节点删除 获得锁]
Z3 –> Z4[会话断开 节点自动删除]
end

subgraph etcd方案
E1[客户端A 申请租约] –> E2[Put key with lease]
E2 –> E3[执行业务逻辑]
E3 –> E4[撤销租约 释放锁]
end

style R1 fill:#f99,stroke:#333
style Z1 fill:#9f9,stroke:#333
style E1 fill:#99f,stroke:#333

2.1 Redis 分布式锁:SETNX + 过期时间的原子性保障

Redis 锁的核心是 SET key value NX PX timeout 命令,NX 保证互斥,PX 设置过期防止死锁。释放锁时必须用 Lua 脚本校验 value,防止误删其他客户端的锁。Redis 锁的优势是性能极高,单次加锁耗时在亚毫秒级;劣势是在 Redis 主从切换时可能出现锁丢失。

2.2 ZooKeeper 分布式锁:临时顺序节点与 Watch 机制

ZK 锁的实现基于临时顺序节点:每个客户端在锁目录下创建一个临时顺序节点,节点序号最小的获得锁;其他客户端 Watch 自己前一个节点,前一个节点删除时被唤醒。ZK 锁的优势是强一致性(ZAB 协议保证),客户端会话断开时临时节点自动删除,不会出现锁无法释放的情况;劣势是性能较低,每次加锁需要多次网络交互。

2.3 etcd 分布式锁:租约机制与 Revision 排序

etcd 锁基于租约(Lease)机制:客户端创建租约,将 Key 绑定到租约上,租约到期自动删除。通过 Revision 排序实现公平锁。etcd 锁的优势是支持租约续期和 TTL,且基于 Raft 协议保证强一致性。

三、生产级分布式锁实现

3.1 Redis 分布式锁:Redisson 的看门狗机制

/**
* 基于 Redisson 的分布式锁封装——看门狗自动续期
* 为什么用 Redisson 而非自己写 Lua 脚本?
* 因为锁续期(看门狗)的实现极其复杂:
* 需要在业务执行期间定时续约,业务完成后精准停止,
* 还要处理续约失败、客户端崩溃等边界情况
*/
@Component
public class RedisDistributedLock {

private final RedissonClient redissonClient;

/**
* 加锁并执行业务逻辑
* 为什么默认不指定 leaseTime?
* 不指定 leaseTime 时 Redisson 会启动看门狗,
* 每 10 秒(lockWatchdogTimeout/3)自动续期一次,
* 避免业务未执行完锁就过期的问题
*/
public <T> T executeWithLock(String lockKey, long waitTime, TimeUnit unit,
Supplier<T> task) {
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
// waitTime: 最大等待获取锁的时间
// leaseTime: 不传则启动看门狗自动续期
acquired = lock.tryLock(waitTime, -1, unit);
if (!acquired) {
throw new DistributedLockException(
"获取分布式锁失败: " + lockKey);
}
return task.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new DistributedLockException("获取锁被中断", e);
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}

/**
* 可重入锁嵌套场景——库存扣减 + 订单创建
* 为什么需要可重入?因为业务方法之间可能存在调用关系,
* 不可重入锁会导致同一线程死锁
*/
public void deductStockWithReentrant(Long skuId, int count) {
String stockLock = "stock:lock:" + skuId;
executeWithLock(stockLock, 3, TimeUnit.SECONDS, () -> {
// 第一层锁:库存扣减
int remain = stockMapper.deduct(skuId, count);
if (remain < 0) {
throw new ServiceException("库存不足");
}
// Redisson 可重入锁:同一线程可再次获取同一把锁
String orderLock = "order:lock:" + skuId;
executeWithLock(orderLock, 3, TimeUnit.SECONDS, () -> {
orderMapper.create(skuId, count);
return null;
});
return null;
});
}
}

3.2 ZooKeeper 分布式锁:Curator InterProcessMutex

/**
* 基于 Curator 的 ZK 分布式锁——强一致性场景首选
* 为什么在库存扣减场景选择 ZK 锁而非 Redis 锁?
* 因为库存是资金相关数据,强一致性优先于性能,
* ZK 的 ZAB 协议保证锁状态在多数节点确认后才返回成功
*/
@Component
public class ZkDistributedLock {

private final InterProcessMutex mutex;

public ZkDistributedLock(CuratorFramework zkClient, String lockPath) {
this.mutex = new InterProcessMutex(zkClient, lockPath);
}

public <T> T executeWithLock(long timeout, TimeUnit unit, Supplier<T> task) {
try {
// Curator 的 InterProcessMutex 底层实现:
// 1. 创建临时顺序节点 /lock/lock-0000000001
// 2. 获取所有子节点并排序
// 3. 如果自己是最小序号,获得锁
// 4. 否则 Watch 前一个节点,等待通知
if (!mutex.acquire(timeout, unit)) {
throw new DistributedLockException("ZK 锁获取超时");
}
return task.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new DistributedLockException("获取锁被中断", e);
} catch (Exception e) {
throw new DistributedLockException("ZK 锁异常", e);
} finally {
try {
if (mutex.isAcquiredInThisProcess()) {
mutex.release();
}
} catch (Exception e) {
// 释放锁失败时记录日志,不阻塞业务
log.warn("ZK 锁释放异常", e);
}
}
}
}

3.3 分布式锁的幂等性保障

/**
* 幂等性校验——分布式锁的必要补充
* 为什么有了分布式锁还需要幂等校验?
* 因为锁可能因 GC 停顿或网络超时意外释放,
* 客户端重试时可能绕过已失效的锁,导致重复执行
*/
@Component
public class IdempotentService {

private final RedisTemplate<String, String> redisTemplate;

/**
* 幂等校验:基于请求唯一 ID 的去重
* 为什么用 SETNX 而非先查后写?
* 因为先查后写在并发场景下存在竞态条件,
* 两个请求可能同时查询到"不存在"然后都执行
*/
public boolean checkAndMark(String requestId, Duration ttl) {
String key = "idempotent:" + requestId;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", ttl);
return Boolean.TRUE.equals(success);
}

public void executeIdempotent(String requestId, Duration ttl,
Runnable task) {
if (!checkAndMark(requestId, ttl)) {
log.info("重复请求被拦截: {}", requestId);
return;
}
task.run();
}
}

四、架构权衡:三种分布式锁的选型决策

Redis 锁的局限:Redis 主从异步复制导致锁可能在主从切换时丢失。RedLock 算法通过多节点投票解决此问题,但增加了延迟(需向 5 个节点依次加锁)。Antirez 与 Martin Kleppmann 的著名论战揭示了 Redis 锁在极端场景下的理论缺陷。在实际生产中,如果业务能容忍极低概率的锁失效(如非资金场景),Redis 锁的性价比最高。

ZK 锁的局限:性能是 ZK 锁的最大短板。每次加锁需要 2-3 次网络往返(创建节点 + 获取子节点列表 + 设置 Watch),在低延迟要求的场景下不适用。ZK 集群的写入吞吐量受限于 Leader 节点,通常在万级 TPS。此外,ZK 的 Session 超时机制可能导致长 GC 停顿后锁被误释放。

etcd 锁的局限:etcd 在国内的使用生态不如 Redis 和 ZK 成熟,客户端库和运维工具相对匮乏。etcd 的性能介于 Redis 和 ZK 之间,适合已有 etcd 基础设施的 K8s 环境。

选型建议:非资金场景选 Redis(性能优先),资金相关场景选 ZK(一致性优先),K8s 原生环境选 etcd(基础设施复用)。无论选择哪种方案,都必须配合幂等校验,因为没有任何分布式锁能提供 100% 的互斥保证。

五、总结

分布式锁的实现需要在性能与一致性之间做出权衡。Redis 锁性能最优但存在极端场景下的锁丢失风险,ZK 锁一致性最强但性能受限,etcd 锁介于两者之间。生产环境中,锁的选型应基于业务对一致性的容忍度:资金场景必须用强一致性方案,普通业务场景用 Redis 锁即可。无论选择哪种方案,幂等性校验都是不可或缺的兜底机制。落地时建议封装统一的锁接口,屏蔽底层实现差异,便于后续根据业务需求切换锁方案。

赞(0)
未经允许不得转载:171主机测评 » 分布式锁实现:从 Redis 到 ZooKeeper 的选型与生产级方案
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址