在单机部署时代,Java 应用只需要 synchronized 关键字或 ReentrantLock 就能保证同一时刻只有一个线程进入临界区。一旦服务以集群方式部署,多个实例运行在不同 JVM 进程中,这些锁只对当前进程内的线程可见,无法阻止另一个节点上的线程同时操作同一份共享资源。订单扣库存、定时任务抢跑、缓存重建等场景都会出现并发覆盖。因此需要引入一个所有实例都能访问的外部协调组件来实现分布式锁。

一、集群环境下分布式锁的基本要求
一个可用的分布式锁不能只是把本地锁换成 Redis 的 setnx 那么简单。它至少要满足互斥性,即任意时刻只有一个客户端能持有锁;同时要避免死锁,持有锁的客户端崩溃后锁不能永远不释放,通常通过过期时间或租约实现;还需要锁标识,释放时只能删除自己加的锁,防止误删其他客户端的锁;在业务执行时间超过锁过期时间时,应有续期机制或足够长的租约;此外分布式锁方案还要考虑高可用,不能因为锁服务单点故障导致全部业务阻塞。
常见的分布式协调组件中,Redis 基于内存和单线程命令,读写性能高,在大部分互联网场景中优先被使用;ZooKeeper 和 etcd 基于一致性协议,顺序性和租约机制更严格,适合对锁安全性要求更高的场景。理解不同方案的底层机制,才能在节点宕机、网络分区、主从切换等故障出现时做出正确取舍。
二、Redis 分布式锁的可靠实现与续期
Redis 实现分布式锁的错误方式很多,典型的是先执行 SETNX 再执行 EXPIRE。两步操作不是原子,如果客户端在两步之间崩溃,锁就没有过期时间,最终变成死锁。正确做法是使用 Redis 2.6.12 以后提供的扩展 SET 命令,通过 NX 表示不存在才写入,PX 表示毫秒级过期时间,同时写入一个客户端唯一标识。
SET order:lock:1001 client-uuid-abc NX PX 30000
上面的命令只有在 key 不存在时才创建并设置 30 秒过期时间。业务执行完成后不能直接 DEL 这个 key,因为锁可能已经过期并被其他客户端重新获取,直接删除会误删别人持有的锁。释放时需要通过 Lua 脚本比较 value 是否一致,只有一致才删除。
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
当业务执行时间可能超过 30 秒时,需要续期。Redisson 等客户端默认提供看门狗机制:锁默认租约 30 秒,后台每 10 秒检查一次,如果当前线程仍然持有锁,则把过期时间重置为 30 秒。这样既避免了业务未执行完锁过期,又不会在客户端崩溃后无限续期。手动指定过期时间时看门狗不生效,需要根据任务最长执行时间设置合理值。
RLock lock = redisson.getLock("order:lock:1001");
try {
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 执行业务逻辑,锁到期前看门狗会自动续期
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
可重入锁在 Redis 中通常使用 Hash 结构配合计数实现,Redisson 已经封装了这一能力。需要注意的是,可重入不代表可以在不同线程或不同进程间重入,释放次数必须与获取次数一致,否则会导致锁被提前释放。
三、Redis 集群故障下的锁丢失与 Redlock 争议
Redis 主从架构有一个天然风险:主节点写入的锁数据默认异步复制给从节点。如果客户端 A 在主节点获取锁成功,此时主节点宕机,锁数据还未同步到从节点,从节点被提升为新主节点,客户端 B 就能在新主节点上以相同的 key 再次获取锁。结果 A 和 B 同时认为自己持有锁,互斥性被破坏。
为了降低这种主从切换导致的锁丢失,Redis 作者提出了 Redlock 算法:客户端向 N 个完全独立的 Redis 主节点依次请求加锁,每个节点使用相同的 key、随机 value 和过期时间;当且仅当在超过 N/2+1 个节点上成功加锁,且总耗时小于锁有效期时,才认为获取成功。锁的实际有效时间要减去加锁总耗时。释放时向所有节点发送 Lua 释放脚本。这样即使个别节点故障,多数节点仍能维持锁的互斥。
不过 Redlock 也一直存在争议。Martin Kleppmann 指出,Redlock 依赖各节点和客户端的时钟大致同步,而生产环境中进程可能因为 GC 停顿、IO 阻塞或虚拟机迁移出现长时间暂停。客户端在获取锁后停顿超过锁过期时间,锁会被自动释放,其他客户端可以获取锁,原客户端恢复后仍然可能继续写数据。解决这类问题不能只靠锁本身,还需要给临界资源增加单调递增的 fencing token。每次获取锁时返回一个全局递增的 token,资源存储拒绝小于已经处理的最大 token 的旧请求。
在实际故障处理中,如果业务对锁安全性要求很高,可以要求 Redis 写入后使用 WAIT 命令同步给至少一个从节点再返回成功,但这会明显增加延迟。另一种更稳妥的做法是改用 etcd 或 ZooKeeper 这类基于共识协议的组件实现锁,它们不会出现主从异步复制带来的锁丢失。
四、ZooKeeper 与 etcd 的锁实现及故障应对
ZooKeeper 实现分布式锁通常使用临时顺序节点。每个客户端在同一个锁目录下创建带 -e -s 参数的节点,ZooKeeper 会返回一个递增序号。客户端获取锁目录下所有子节点,判断自己创建的节点是否是最小序号,如果是则获取锁;如果不是则监听前一个节点,等待前一个节点删除后再判断。临时节点的关键作用在于,客户端会话断开后 ZooKeeper 会自动删除该节点,不会留下永久锁,因此故障客户端不会造成死锁。
InterProcessMutex lock = new InterProcessMutex(curatorClient, "/locks/order");
try {
if (lock.acquire(10, TimeUnit.SECONDS)) {
// 执行业务逻辑
}
} finally {
lock.release();
}
ZooKeeper 方案需要处理两类故障。第一类是客户端与 ZooKeeper 集群之间出现网络分区,客户端认为会话已经超时,但实际 ZooKeeper 集群并没有删除临时节点;第二类是客户端长时间 GC 停顿后恢复,锁可能已经因为会话超时被释放,而客户端继续执行业务。解决思路仍然是使用 fencing token:把客户端创建的节点序号作为单调递增 token,写入共享资源时校验 token 是否大于上一次提交值。
etcd 的分布式锁基于租约 Lease 和事务实现。客户端先创建一个租约并保持心跳,再通过事务在同一 Revision 下创建 key 并比较前缀中的最小 create revision,获取到最小 revision 的客户端获得锁。租约到期后 etcd 会自动删除锁 key。etcd 集群通过 Raft 协议保证多数派一致性,不会出现 Redis 主从异步复制造成的锁丢失,但需要警惕 etcd 集群自身发生脑裂时少数派无法提供写服务,从而影响加锁和续期。
对比来看,Redis 方案性能更高、部署更普遍,适合允许短暂锁冲突或可以通过业务层幂等兜底的场景;ZooKeeper 和 etcd 方案更严格,适合库存扣减、转账、对账等安全性优先的场景。无论选择哪种实现,都应当同时做到锁续期、唯一标识释放、资源层幂等和 fencing token 四层防护,才能在集群故障时把并发风险降到可接受范围。