分布式锁的实现与陷阱:从Redisson到etcd再到DLM的演进分析

分布式锁的实现与陷阱:从Redisson到etcd再到DLM的演进分析
分布式锁的实现与陷阱从Redisson到etcd再到DLM的演进分析一、分布式锁的核心挑战单机环境下synchronized和ReentrantLock足以解决并发安全。但在分布式系统中多个服务实例竞争同一个共享资源时需要一个外部的、所有实例都能访问的协调者来充当锁的角色。分布式锁需要满足三个核心属性互斥性同一时刻只有一个客户端持有锁防死锁持有锁的客户端崩溃后锁能自动释放容错性锁服务本身不能是单点故障从Redis单实例锁到Redisson的WatchDog再到etcd的Lease机制分布式锁的演进本质上是在解决**锁的可靠性与系统复杂度之间的权衡**。graph TB subgraph 演进路线 A[Redis单实例锁br/SETNX EXPIRE] -- B[Redissonbr/WatchDog续期] B -- C[Redlockbr/多Redis实例] C -- D[etcd锁br/Lease Transaction] D -- E[DLM服务br/Chubby/ZooKeeper] end subgraph 核心问题 Q1[锁超时释放br/导致并发写入] Q2[主从切换br/锁丢失] Q3[时钟漂移br/租约误判] Q4[公平性br/与活锁] end A -.-|未解决| Q1 B -.-|缓解| Q1 B -.-|未解决| Q2 C -.-|争议中| Q2 C -.-|未解决| Q3 D -.-|解决| Q1 D -.-|解决| Q2 E -.-|解决| Q3 E -.-|解决| Q4二、Redis单实例锁的局限性2.1 最基础的实现及其缺陷// 危险的实现SETNX和EXPIRE是两个命令非原子 public boolean tryLock_dangerous(String key, String value, int expireSeconds) { Boolean result redisTemplate.opsForValue() .setIfAbsent(key, value); // SETNX if (Boolean.TRUE.equals(result)) { redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); // EXPIRE return true; } return false; } // 致命缺陷如果在SETNX之后、EXPIRE之前进程崩溃锁永不释放改进后使用原子命令public boolean tryLock(String key, String value, int expireSeconds) { // SET key value NX EX expireSeconds —— 原子操作 Boolean result redisTemplate.opsForValue() .setIfAbsent(key, value, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(result); }但即使使用了原子命令单实例Redis锁仍然存在严重问题超时时间的困境设置太短业务没执行完锁就过期设置太长崩溃后锁长时间不释放主从切换丢锁主节点写入锁后宕机从节点提升前未同步到该锁新主可以接受另一个客户端的加锁请求2.2 锁释放的安全问题直接DEL key可能误删其他客户端的锁// 危险的释放 public void unlock_dangerous(String key) { redisTemplate.delete(key); // 可能删了别人的锁 } // 安全的释放Lua脚本保证原子判断 public void unlock(String key, String value) { 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), List.of(key), value ); }三、Redisson的WatchDog续期机制与红锁争议3.1 WatchDog自动续期Redisson通过WatchDog机制解决超时时间难以设置的问题// Redisson内部WatchDog的简化逻辑 public class WatchDog { private static final long LOCK_RENEW_INTERVAL_MS 10_000; // 10s续期 private static final long DEFAULT_LOCK_TIME_MS 30_000; // 默认30s public void scheduleRenewal(RLock lock, long threadId) { Timeout renewalTask lock.newTimeout(task - { if (lock.isHeldByThread(threadId)) { // 续期到30s从当前时间算起 renewExpiration(lock.getKey(), DEFAULT_LOCK_TIME_MS); // 递归调度下一次续期 scheduleRenewal(lock, threadId); } }, LOCK_RENEW_INTERVAL_MS, TimeUnit.MILLISECONDS); } }使用示例Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient redisson Redisson.create(config); RLock lock redisson.getLock(order:create:12345); try { // 不传leaseTime则启用WatchDog默认30s每10s续期 lock.lock(); // 业务逻辑... } finally { lock.unlock(); } // 指定leaseTime则禁用WatchDog lock.lock(5, TimeUnit.SECONDS); // 5s后自动释放不续期WatchDog解决了锁超时释放的问题但底层仍是单实例Redis主从切换丢锁的风险依然存在。3.2 Redlock算法及其争议Redlock通过在N个独立的Redis实例通常是5个上获取锁来提升可靠性客户端尝试在5个Redis实例上依次加锁 ├─ 设置相同的key random value ├─ 每个实例的超时时间远小于锁的总TTL └─ 在超过半数的实例上成功 → 获得锁Martin Kleppmann《设计数据密集型应用》作者对Redlock提出了核心质疑时钟跳跃如果某个Redis实例的时钟发生跳跃租约提前过期两个客户端可能同时认为自己持有锁GC暂停客户端获取锁后发生长时间的GC暂停stop-the-world锁在Redis端过期另一个客户端获取了锁Redlock的作者Antirez的回应要点时钟跳跃是运维问题而非算法问题GC暂停可以通过合理的fencing token递增的锁版本号来防护。工程实践结论对于非严格一致性要求的场景如防止重复计算Redlock足够对于金融级一致性场景应使用etcd或ZooKeeper。3.3 Fencing Token模式解决锁过期后误操作的最有效方案——给每个锁持有者发放递增的tokenpublic class FencingLockService { private final AtomicLong tokenGenerator new AtomicLong(0); public LockResult acquireWithToken(String resource) { long token tokenGenerator.incrementAndGet(); boolean locked lockService.tryLock(resource, token); return new LockResult(locked, token); } } // 存储层校验token public void updateResource(String resource, long token, String newValue) { String script local current_token redis.call(GET, KEYS[1] .. :token) if not current_token or tonumber(current_token) tonumber(ARGV[1]) then redis.call(SET, KEYS[1] .. :token, ARGV[1]) redis.call(SET, KEYS[1], ARGV[2]) return 1 end return 0 ; redisTemplate.execute(new DefaultRedisScript(script, Long.class), List.of(resource), String.valueOf(token), newValue); }存储层拒绝低版本token的写入这样即使锁过期导致两个客户端并发只有持有最新token的那一个能成功写入。四、etcd的Lease与事务实现4.1 etcd锁的设计原理etcd使用Lease租约 Transaction实现分布式锁天然避免Redis方案的缺陷public class EtcdLockService { private final Client etcd; private final long leaseTTL 30; public LockResult tryLock(String key, String ownerId) { // 1. 创建一个租约 long leaseId etcd.getLeaseClient().grant(leaseTTL).get().getID(); // 2. 启动KeepAlive心跳续期 etcd.getLeaseClient().keepAlive(leaseId, new StreamObserver() { Override public void onNext(LeaseKeepAliveResponse response) { // 心跳成功 } Override public void onError(Throwable t) { // 心跳失败 → 锁即将释放 } }); // 3. 事务如果key不存在创建并绑定租约 Txn txn etcd.getKVClient().txn() .If(new Cmp( ByteSequence.from(key, StandardCharsets.UTF_8), Cmp.Op.EQUAL, CmpTarget.createRevision(0) // key不存在revision0 )) .Then(Op.put( ByteSequence.from(key, StandardCharsets.UTF_8), ByteSequence.from(ownerId, StandardCharsets.UTF_8), PutOption.newBuilder().withLeaseId(leaseId).build() )) .Else(Op.get( ByteSequence.from(key, StandardCharsets.UTF_8), GetOption.DEFAULT )); TxnResponse result txn.get(); if (result.isSucceeded()) { return LockResult.success(leaseId); } // 锁被占用释放租约 etcd.getLeaseClient().revoke(leaseId); return LockResult.failed(); } public void unlock(long leaseId) { etcd.getLeaseClient().revoke(leaseId); } }etcd锁的核心优势租约自动过期客户端崩溃后KeepAlive停止租约到期后锁自动释放事务原子性key的创建、租约绑定由etcd的MVCC事务保证原子性一致性读etcd的Raft共识协议保证了不会出现主从切换丢锁4.2 公平锁的实现与活锁防护4.2.1 公平锁的实现多个客户端竞争同一把锁时如果不加控制可能导致某些客户端被饿死。公平锁通过排队机制解决public class FairDistributedLock { private final String lockKey; private final String queueKey; private final StringRedisTemplate redis; public boolean tryLock(String clientId, long timeoutMs) { // 使用Redis List作为等待队列 redis.opsForList().rightPush(queueKey, clientId); long deadline System.currentTimeMillis() timeoutMs; while (System.currentTimeMillis() deadline) { // 检查是否轮到自己队首是自己 String head redis.opsForList().index(queueKey, 0); if (!clientId.equals(head)) { Thread.sleep(100); continue; } // 轮到自己尝试获取锁 if (Boolean.TRUE.equals( redis.opsForValue().setIfAbsent(lockKey, clientId, Duration.ofSeconds(30)))) { redis.opsForList().leftPop(queueKey); // 出队 return true; } } // 超时从队列中移除自己 redis.opsForList().remove(queueKey, 1, clientId); return false; } }4.2.2 活锁的陷阱活锁Livelock是指多个客户端不断重试获取锁但因为互相干扰而都无法成功。一个典型的活锁场景客户端A尝试获取锁 → 成功 客户端B尝试获取锁 → 失败 客户端A释放锁 客户端B尝试获取锁 → 成功 客户端C尝试获取锁 → 失败 客户端B释放锁 客户端C尝试获取锁 → 成功 ...循环但每个客户端都只能执行极短时间解决活锁的方法是引入随机退避Exponential Backoff Jitterpublic class BackoffRetry { private static final long INITIAL_BACKOFF_MS 10; private static final long MAX_BACKOFF_MS 1000; public static void backoff(int retryCount) { long delay Math.min( INITIAL_BACKOFF_MS * (1L retryCount), // 指数增长 MAX_BACKOFF_MS ); // 添加随机抖动delay ∈ [delay/2, delay] long jittered ThreadLocalRandom.current() .nextLong(delay / 2, delay 1); try { Thread.sleep(jittered); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }随机抖动确保多个客户端不会在同一时刻一起重试从根本上打破活锁循环。五、总结分布式锁的选型需要匹配场景的一致性要求方案适用场景可靠性复杂度单Redis WatchDog防止重复计算、幂等性低主从切换丢锁低Redlock多Redis缓存更新、任务调度中时钟依赖中etcd配置管理、选主高Raft共识中ZooKeeper/Chubby金融交易、元数据管理最高高核心工程原则永远使用Fencing Token——无论用哪种锁实现在业务层增加版本校验是最后一道防线锁的粒度要尽可能小——锁住一行数据而非整张表锁住一个用户而非全部用户超时时间是兜底而非业务逻辑——依赖超时释放的锁设计本质上有缺陷应优先使用续期机制随机退避是防活锁的基本功——不加Jitter的重试策略在生产环境中迟早会出问题最重要的认知分布式锁不能解决所有分布式一致性问题它只是在特定约束下提供大概率互斥的工具。当你的业务场景要求绝对互斥时应该考虑的是幂等设计、状态机复制、或直接使用支持ACID的分布式数据库。