etcd 操作并发 锁_Etcd分布式锁:cp分布式锁的最佳实现

本文介绍了从Redis单机分布式锁转向etcd的原因,分析了etcd作为CP分布式锁的优势。阐述了利用etcd租约实现分布式锁的设计思路,包括加锁、解锁过程,以及如何处理租约续期,确保锁的一致性。并提到使用jetcd作为Java客户端与etcd通信。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

67a99c896f6794fc25c5c8d97a69b496.png

引言

目前自研的Redis分布式锁,已可满足大部分场景(非公平+可自动续期+可重入的分布式锁),可投入生产环境的单机环境中使用。但是因为是基于Redis单机的环境,只能用于并发量并不高的场景。随着接入的业务场景扩大,Redis单机已经变得不可靠了,那么接下来给我们的选择只有两种: 1、Redis单机改为集群。 2、改用其他基于一致性算法的实现方式。

方案1有先天性的缺陷,redis集群无法保证一致性问题,在master节点宕机的瞬间,master和slave节点之间的数据可能是不一致的。这将会导致服务a从master节点拿到了锁a,然后master节点宕机,在slave节点尚未完全同步完master的数据之前,服务b将从slave节点上成功拿到同样的锁a。

而在其他基于一致性算法的实现方式上,zk和ectd是不错的选择。然后考虑到zk已廉颇老矣,我们选择了ectd这个后起之秀。

由于在分布式锁的场景内,我们更关注的是锁的一致性,而非锁的可用性,所以cp锁比ap锁更可靠。

设计思路

etcd引入了租约的概念,我们首先需要授予一个租约,然后同时设置租约的有效时间。租约的有效时间我们可以用来作为锁的有效时间。

然后我们可以直接调用etcd的lock功能,在指定的租约上对指定的lockName进行加锁操作。如果当前没有其他线程持有该锁,则该线程能直接持有锁。否则需要等待。这里我们可以将timeout的时间设置为锁的等待时间来实现竞争锁失败等待获取的过程。当然由于网络波动等问题,我建议timeout的时间最少设置为500ms(或你们认为合理的数值)。

然后解锁的过程,我们放弃了etcd的unlock操作,而直接使用了etcd的revoke操作。之所以没采用unlock操作,一是因为unlock所需要的参数是上一步lock操作返回的lockKey,我们并不希望多维护一个字段,二是因为我们最终会执行revoke操作,而revoke操作会将该租约下的所有key都失效,因为我们目前目前设计的是一个租约对应一个锁,不存在会释放其它业务场景中的锁的情况。

此外,为了保证线程在等待获取锁的过程中租约不会过期,所以我们得为这个线程设置一个守护线程,在该线程授予租约后就开启守护线程,定期去判断是否需要续期。

和redis分布式锁不一样的是,redis分布式锁的有效时间是缓存的有效时间,所以可以在获取锁成功后再开启用于续期的守护线程,而etcd分布式锁的有效时间是租约的有效时间,在等待获取锁的过程中可能租约会过期,所以得在获取租约后就得开启守护线程。这样就增加了很多的复杂度。

##具体实现 原生的etcd是通过Go语言来写的,直接在java程序中应用会有一点困难,所以我们直接采用jetcd来作为etcd的客户端,这样在java程序中就可以使用代码方式和etcd服务端通讯。

jetcd提供了LeaseClient,我们可以直接使用grant功能完成授予租约的操作。

public LockLeaseData getLeaseData(String lockName, Long lockTime) { try { LockLeaseData lockLeaseData = new LockLeaseData(); CompletableFuture leaseGrantResponseCompletableFuture = client.getLeaseClient().grant(lockTime); Long leaseId = leaseGrantResponseCompletableFuture.get(1, TimeUnit.SECONDS).getID(); lockLeaseData.setLeaseId(leaseId); CpSurvivalClam cpSurvivalClam = new CpSurvivalClam(Thread.currentThread(), leaseId, lockName, lockTime, this); Thread survivalThread = threadFactoryManager.getThreadFactory().newThread(cpSurvivalClam); survivalThread.start(); lockLeaseData.setCpSurvivalClam(cpSurvivalClam); lockLeaseData.setSurvivalThread(survivalThread); return lockLeaseData; } catch (InterruptedException | ExecutionException | TimeoutException e) { return null; }}复制代码

此外如上所述,我们在获取租约后,开启了CpSurvivalClam的守护线程来定期续期。CpSurvivalClam的实现和我们在redis分布式锁的时候实现大体一致,差别只是将其中的expandLockTime操作改为了etcd中的keepAliveOnce。expandLockTime方法具体如下所示:

/** * 重置锁的有效时间 * * @param leaseId 锁的租约id * @return 是否成功重置 */public Boolean expandLockTime(Long leaseId) { try { CompletableFuture leaseKeepAliveResponseCompletableFuture = client.getLeaseClient().keepAliveOnce(leaseId); leaseKeepAliveResponseCompletableFuture.get(); return Boolean.TRUE; } catch (InterruptedException | ExecutionException e) { return Boolean.FALSE; }}复制代码

然后jetcd提供了LockClient,我们直接可以用lock功能,将leaseId和lockName传入,我们会得到一个在该租约下的lockKey。此外为了保证加锁成功后,租约未过期。我们加了一步timeToLive的操作,用于判断租约在获取锁成功后的是否还存活。如果ttl未大于0,则判断为加锁失败。

/** * 在指定的租约上加锁,如果租约过期,则算加锁失败。 * * @param leaseId 锁的租约Id * @param lockName 锁的名称 * @param waitTime 加锁过程中的的等待时间,单位ms * @return 是否加锁成功 */public Boolean tryLock(Long leaseId, String lockName, Long waitTime) { try { CompletableFuture lockResponseCompletableFuture = client.getLockClient().lock(ByteSequence.from(lockName, Charset.defaultCharset()), leaseId); long timeout = Math.max(500, waitTime); lockResponseCompletableFuture.get(timeout, TimeUnit.MILLISECONDS).getKey(); CompletableFuture leaseTimeToLiveResponseCompletableFuture = client.getLeaseClient().timeToLive(leaseId, LeaseOption.DEFAULT); long ttl = leaseTimeToLiveResponseCompletableFuture.get(1, TimeUnit.SECONDS).getTTl(); if (ttl > 0) { return Boolean.TRUE; } else { return Boolean.FALSE; } } catch (TimeoutException | InterruptedException | ExecutionException e) { return Boolean.FALSE; }}复制代码

解锁过程,我们可以直接使用LeaseClient下的revoke操作,在撤销租约的同时将该租约下的lock释放。

/** * 取消租约,并释放锁 * * @param leaseId 租约id * @return 是否成功释放 */public Boolean unLock(Long leaseId) { try { CompletableFuture revokeResponseCompletableFuture = client.getLeaseClient().revoke(leaseId); revokeResponseCompletableFuture.get(1, TimeUnit.SECONDS); return Boolean.TRUE; } catch (InterruptedException | ExecutionException | TimeoutException e) { return Boolean.FALSE; }}复制代码

然后是统一的CpLock对象,封装了加解锁的过程,对外只暴露execute方法,避免使用者忘记解锁步骤。

public class CpLock { private String lockName; private LockEtcdClient lockEtcdClient; /** * 分布式锁的锁持有数 */ private volatile int state; private volatile transient Thread lockOwnerThread; /** * 当前线程拥有的lease对象 */ private FastThreadLocal lockLeaseDataFastThreadLocal = new FastThreadLocal<>(); /** * 锁自动释放时间,单位s,默认为30 */ private static Long LOCK_TIME = 30L; /** * 获取锁失败单次等待时间,单位ms,默认为300 */ private static Integer SLEEP_TIME_ONCE = 300; CpLock(String lockName, LockEtcdClient lockEtcdClient) { this.lockName = lockName; this.lockEtcdClient = lockEtcdClient; } private LockLeaseData getLockLeaseData(String lockName, long lockTime) { if (lockLeaseDataFastThreadLocal.get() != null) { return lockLeaseDataFastThreadLocal.get(); } else { LockLeaseData lockLeaseData = lockEtcdClient.getLeaseData(lockName, lockTime); lockLeaseDataFastThreadLocal.set(lockLeaseData); return lockLeaseData; } } final Boolean tryLock(long waitTime) { final long startTime = System.currentTimeMillis(); final long endTime = startTime + waitTime * 1000; final long lockTime = LOCK_TIME; final Thread current = Thread.currentThread(); try { do { int c = this.getState(); if (c == 0) { LockLeaseData lockLeaseData = this.getLockLeaseData(lockName, lockTime); if (Objects.isNull(lockLeaseData)) { return Boolean.FALSE; } Long leaseId = lockLeaseData.getLeaseId(); if (lockEtcdClient.tryLock(leaseId, lockName, endTime - System.currentTimeMillis())) { log.info("线程获取重入锁成功,cp锁的名称为{}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值