一、什么是分布式锁?
分布式锁是一种用于在分布式系统中实现互斥访问的机制。在分布式环境中,多个节点同时访问共享资源时,为了避免数据不一致或并发冲突的问题,需要确保在同一时间只有一个节点能够获取到锁并执行关键代码块,其他节点需要等待或放弃执行。
分布式锁的主要目标是保证在分布式系统中的多个节点之间实现互斥性和一致性。它可以用于控制对共享资源的访问、避免竞争条件、实现分布式事务等场景。
常见的分布式锁实现方式包括基于数据库、文件系统、内存和消息队列等。此外,还有一些流行的分布式锁框架和技术可供选择,如Apache ZooKeeper、etcd、Consul、Redis和Hazelcast等,它们提供了方便的分布式锁功能和接口,简化了分布式锁的实现过程。
二、使用分布式锁会有哪些问题?
-
死锁:死锁是指多个节点相互等待对方释放锁而无法继续执行的情况。在设计和使用分布式锁时,需要注意避免死锁的发生。可以通过设置合理的超时时间、使用心跳机制检测节点存活性以及设计良好的锁释放策略等方式来预防死锁。
-
锁的粒度:锁的粒度应该尽量小,只锁定必要的共享资源而不是整个系统或大段代码。这样可以提高系统的并发性能,减少锁的竞争和等待时间。
-
锁的性能:分布式锁的性能是一个重要考虑因素。不同的分布式锁实现方式在性能上可能存在差异,需要根据具体场景和需求选择性能较好的实现方式。
-
容错性和高可用性:分布式锁应该具备良好的容错性和高可用性,能够在节点故障或网络分区等情况下继续正常工作。选择分布式锁框架或技术时,需要考虑其对于容错和高可用的支持能力。
-
高并发场景:在高并发场景下,分布式锁的性能和吞吐量是一个关键问题。需要考虑锁的竞争情况、锁的等待时间、锁的粒度等因素,以提高系统的并发性能。
三、分布式锁的实现方案有哪些?
-
基于数据库:可以使用数据库的事务和锁机制来实现分布式锁。通过在数据库中创建一个特定的表或记录作为锁,并使用事务来保证锁的互斥性和一致性。
-
基于文件系统:可以使用文件系统的文件或目录作为锁的标识。通过创建一个特定的文件或目录作为锁,并使用文件系统的原子操作来实现锁的获取和释放。
-
基于内存:可以使用共享内存或分布式缓存来实现分布式锁。通过在内存中创建一个共享变量或对象作为锁,并使用原子操作或分布式锁算法来实现锁的获取和释放。
-
基于消息队列:可以使用消息队列来实现分布式锁。通过向消息队列发送请求消息并设置超时时间,其他节点在获取到锁的节点未能及时续约时可以尝试获取锁。
四、实现分布式锁的常见框架有哪些?
- Apache ZooKeeper:ZooKeeper是一个分布式协调服务,提供了分布式锁的功能。它的有序临时节点特性可以用来实现分布式锁的互斥性和续约机制。
- etcd:etcd是一个分布式键值存储系统,也可以用于实现分布式锁。它提供了分布式锁的原语,可以通过创建和更新键值对来实现锁的获取和释放。
- Consul:Consul是一个服务发现和配置工具,它也提供了分布式锁的功能。通过使用Consul的Session机制,可以创建和维护分布式锁。
- Redis:Redis是一个快速的内存数据存储系统,它的分布式锁功能非常常用。通过Redis的原子操作和过期时间设置,可以实现简单的分布式锁。
- Hazelcast:Hazelcast是一个开源的分布式计算平台,也提供了分布式锁的功能。它提供了基于IMap接口的分布式锁实现,可以在分布式环境下保证锁的互斥性和一致性。
- Apache BookKeeper:Apache BookKeeper是一个分布式日志存储系统,可以用于实现高性能的数据持久化。BookKeeper提供了分布式锁的原语,可以通过创建和更新锁节点来实现续约机制。
- Google Chubby:Google Chubby是Google开发的一个分布式锁服务,用于提供强一致性的分布式锁。Chubby提供了租约(Lease)机制,可以用于实现续约操作。
- Doozerd:Doozerd是一个分布式协调系统,可以用于实现分布式锁和续约机制。Doozerd提供了租约(Lease)的概念,可以通过续约操作延长租约的有效期。
- Curator:Curator是Apache ZooKeeper的一个客户端库,它提供了一组高级API来简化在ZooKeeper上的分布式操作。Curator提供了分布式锁的实现,并支持续约机制。通过使用Curator的InterProcessMutex类,可以方便地实现分布式锁的续约。
五、分布式锁最常用的三种实现方式介绍
1、基于Zookeeper的实现方式
1.1 实现原理
Zookeeper是一个分布式的,开源的分布式应用程序协调服务,是Hadoop和hbase的重要组件。
1.1.1 排它锁
又叫写锁或者独占锁,如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。
其基本思想是创建一个临时有序节点,临时节点保证在客户端断开连接或者超时zookeeper会自动删除该节点,从而避免死锁的发生,有序节点可以实现一种公平锁,加锁顺序按照客户端进程请求顺序进行,加锁的为产生节点序号最小的那个客户端。
加锁:客户端在zookeeper创建临时节点,并判断自己是不是节点序号最小的,如果是则加锁成功,如果不是则watch监听它前一个节点删除事件,当收到前一个节点删除通知时重复前面逻辑,直到加锁完成
解锁:客户端完成业务逻辑,在zookeeper删除自己的临时节点,同时通知它后一个节点获取锁
下面图例是两个客户端A和B加锁解锁的过程:
1.1.2 共享锁
共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。
加锁:
1)客户端通过调用 create 方法创建表示锁的临时顺序节点,如果是读请求,则创建 /lockpath/[hostname]-R-序号 节点,如果是写请求则创建 /lockpath/[hostname]-W-序号节点
2)创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听
3)确定自己的节点序号在所有子节点中的顺序
- 对于读请求:
a. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑
b. 如果有比自己序号小的子节点有写请求,那么等待 - 对于写请求,如果自己不是序号最小的节点,那么等待
4)接收到Watcher通知后,重复步骤2
解锁:与排它锁逻辑一致
1.2 Zookeeper分布式锁存在的问题及解决方案
1.2.1 羊群效应
场景:
“羊群效应"是非公平锁出现的一种现象,实现这种非公平锁当锁释放时,zookeeper服务器会向所有相关的客户端发送通知,同时大量客户端加锁(会拉取节点列表比较大小)请求一拥而上抢占锁,这时候其实只有一个客户端的操作是有效的,其他大量客户端重复的操作都是"无用的”,更为严重的是如果短时间有多个锁被释放的话,那么zookeeper就会发送大量的通知,那么会造成zookeeper服务器性能下降和网络冲击,甚至会导致zookeeper集群不可用。
解决方案:
zookeeper分布式锁采用的是一种公平锁,各客户端按照请求顺序创建临时顺序节点,所以这些节点排序后天然记录了这种请求顺序,可以保证加锁顺序与请求顺序一致,同时在加锁时只监听它前一个节点的删除事件,这样当前一个节点删除释放锁时,只会通知后一个节点去尝试获取锁,大大消除了通知冗余,提高了服务器效率和性能。
1.2.2 死锁问题
zookeeper中的客户端连接有一个session概念,临时节点会在客户端宕机、连接超时异常时自动删除,从而避免死锁问题
1.2.3 锁失效问题
和redis实现的分布式锁一样,基于zookeeper实现的分布式锁,也会有锁失效问题,但是有区别的是zookeeper没有节点过期解锁和续约机制,这就需要自己妥善处理客户端代码。尤其是获取锁的时候,有可能前一个锁持有者客户端暂时发生故障,后面又活过来了,但是此时临时节点已被自动删除,被另外一个客户端获得,这样如果继续执行业务逻辑就会有安全风险,至少在这种异常情况下删除节点时应该记录现场日志,或者在客户端活过来时重建临时节点保持锁的持有,但是重建完成前还有可能出现意外,高并发情况下需要慎重考虑。
1.2.4 脑裂问题
当zookeeper集群部分节点之间失联,或者出现网络分区现象时,会出现脑裂问题。zookeeper属于AP型,采用ZAB共识算法也是基于过半选举机制,与redis的RedLock算法类似,ZooKeeper的Quorum机制可以有效解决分布式锁的脑裂问题。
2、基于Redis缓存的实现方式
-
2.1 分布式锁的实现原理
Redis 实现分布式锁主要利用 Redis 的setnx 命令。setnx 是 SET if not exists的简写。
加锁:客户端使用 SETNX key value 命令尝试设置一个键,其中 key 是锁的名称,value 是一个唯一标识符(例如 UUID),用于标识加锁的客户端。如果键不存在,SETNX 命令会设置键的值并返回 1,表示加锁成功;如果键已存在,SETNX 命令不会改变键的值并返回 0,表示加锁失败。
解锁:使用 Del key 命令,通过删除键值释放锁。释放锁之后,其他客户端可以通过 setnx 命令进行加锁。
-
2.2 Redis 分布式锁的问题及解决方式
-
2.2.1 死锁问题
场景:比如线程A获取锁成功,之后由于出现异常情况(比如阻塞、宕机、网络超时)无法执行Del命令,锁将永远无法释放,导致死锁。
解决方案:获取锁的同时必须给锁设置超时时间(TTL),这样即使线程A 在执行完毕后无法释放锁,其他客户端也可以在锁超时后获得锁。执行 SET key value EX seconds,等同于执行 SETEX key seconds value 或者 执行 SET key value PX milliseconds,等同于执行 PSETEX key milliseconds value
-
2.2.2 锁失效问题
场景1:为了避免死锁,假如线程A获取锁L并设置超时时间10s,但是实际线程A执行耗时却为15s,当锁L过期被释放掉以后,可能会有另一线程B又重新获取了锁L,之后执行系统的逻辑,这样就造成了数据不一致,使锁失去了作用。
解决方案:
Redis 2.6.12之后,Redis扩展了SET命令的参数,可以在SET的同时指定EXPIRE时间,这条操作是原子的。
1)设置合适的超时时间,超时时间>线程执行时间,实际上业务线程执行时间很难准确判定,超时时间设置太大,会降低并发性能,设置太小又会出现不一致,所以还得有下面的超时续约机制
2)设置超时时间,同时增加超时时间的续约机制,超时之后如果业务逻辑还没有处理完,继续设置超时,直到业务逻辑完成之后不再续约从而释放锁,比如开源Redis客户端Redission的分布式锁就实现了续约机制。
场景2:为了避免死锁,假如线程A获取锁L并设置超时时间10s,但是实际县城A执行耗时却为15s,当锁L过期被释放掉以后,在锁续约完成之前,可能会有另一线程B又重新获取了锁L,此时如果线程A完成了业务逻辑,当要释放删除锁L时,就可能删掉了线程B的锁,就会出现误删锁的问题。
解决方案:
在删除锁时,需要先判断一下key对应value的值,如果是当前线程的锁才能删除。
场景3:接场景2情形,先get获取key的值,判断了value值确实是当前要删的锁之后,然后再del删除key,但是在get判断值和del删除key之间的时间空挡,另一个线程重新获取了锁,此时执行删除key又出现误删,释放了其他线程的锁。
解决方案:
必须保证get和del的原子性,Redis 在 2.6 版本推出了 lua 脚本功能,允许开发者使用 Lua 语言编写脚本传到 Redis 中执行,可以使用lua脚本去实现这一组操作,例如微信抢红包功能就使用到了redis的lua这种特性。
Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放。使用lua脚本可以带来操作原子性、减少网络开销、功能复用的好处。
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标识
-- 获取锁中的标示,判断是否与当前线程标识一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
对应的,用java代码调用此脚本的方法如下:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
- 2.2.3 主从一致性(脑裂问题)
场景:在redis集群部署模式下,假如客户端A在master节点获取到锁L,此时master节点宕机挂掉,但是还没来得及将这个锁L同步到其他slave节点,之后redis共识机制选举出了新的master节点,由于此节点上并不存在锁L,所以当另一个客户端B请求获取锁L还是会被批准,这样就出现同一个锁L被两个客户端A和B同时持有,出现安全隐患。
解决方案:为了解决这个问题,我们可以使用 RedLock 算法。RedLock 是 Redis 官方推荐的一种分布式锁实现算法,其基本思想是在多个独立的 Redis 节点上同时尝试获取锁,只有当大多数的 Redis 节点都成功获取到锁时,才认为整个操作成功。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。具体过程如下:
1、第一步是,客户端获取当前时间(t1)。
2、第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
1)加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
2)如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
3、第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
开源项目Redission、Curator的InterProcessMutex、Spring Integration也都实现了Redlock算法。比如下面这个图是Redission的MultiLock锁实现方式:
当我们去设置了多个锁时,redission会将多个锁添加到一个集合中,然后用while循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试.
- 2.2.4 不可重入、不可重试、公平性
Redisson实现了可重入锁与锁重试。
基于Redis的分布式锁,无法保证锁的公平性,也就是无法保证按照客户端的请求顺序加锁,如果你的应用需要公平的分布式锁,你可能需要使用其他的分布式锁实现,例如基于 ZooKeeper 的分布式锁。 - 2.3 Java基于Redis的分布式锁实现
- 2.3.1 基于Jedis的实现
import redis.clients.jedis.Jedis; public class RedisLock { private Jedis jedis; private String lockKey; private String lockValue; private int expireTime; private boolean locked = false; public RedisLock(Jedis jedis, String lockKey, int expireTime) { this.jedis = jedis; this.lockKey = lockKey; this.expireTime = expireTime; this.lockValue = Thread.currentThread().getId() + "-" + System.nanoTime(); } public boolean lock() { long startTime = System.currentTimeMillis(); while (true) { String result = jedis.set(lockKey, lockValue, "NX", "PX", expireTime); if ("OK".equals(result)) { locked = true; return true; } // 如果没有获取到锁,需要稍微等待一下再尝试 try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } // 如果尝试获取锁超过了expireTime,那么返回失败 if (System.currentTimeMillis() - startTime > expireTime) { return false; } } } public void unlock() { if (!locked) { return; } String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; jedis.eval(script, 1, lockKey, lockValue); } }
这个示例并没有实现锁的续期机制。为了实现续期机制,我们需要在另一个线程中定期检查锁的剩余时间,如果剩余时间不足,那么就需要使用 expire 命令来重新设置锁的超时时间。这需要更复杂的代码来实现,例如使用 Java 的 ScheduledExecutorService 来定期执行续期操作。
- 2.3.2 基于Redisson的实现(推荐)
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
@Component
public class RedissonDistributedLocker {
private RedissonClient redissonClient;
@PostConstruct
public void init() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
redissonClient = Redisson.create(config);
}
public void lock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
// Wait for 100 seconds and automatically unlock it after 10 seconds
lock.lock(10, TimeUnit.SECONDS);
}
public void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
lock.unlock();
}
}
Redisson 的 lock 方法会自动续期,只要持有锁的线程还在运行,锁就会一直被续期,直到线程结束或者显式调用 unlock 方法。因此,我们不需要手动实现续期机制。此外,Redisson 的 unlock 方法会检查当前线程是否持有锁,只有持有锁的线程才能释放锁,这解决了误删锁问题。
- 2.3.3 基于Lettuce的实现
Lettuce本身并不提供续约、误删锁、脑裂等问题的实现,需要程序员自己去实现,以下是参考代码:import io.lettuce.core.RedisClient; import io.lettuce.core.RedisURI; import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; import io.lettuce.core.cluster.RedisClusterClient; import io.lettuce.core.cluster.RedisClusterURIUtil; import java.time.Duration; import java.util.UUID; public class DistributedLockExample { private static final String LOCK_KEY = "mylock"; private static final String LOCK_VALUE = UUID.randomUUID().toString(); private static final Duration LOCK_EXPIRATION = Duration.ofSeconds(10); private static final Duration RENEWAL_INTERVAL = Duration.ofSeconds(5); private static final int MAX_RENEWAL_ATTEMPTS = 3; public static void main(String[] args) { // 创建RedisClient或RedisClusterClient,根据你的实际部署情况选择 RedisClient redisClient = RedisClient.create(RedisURI.create("redis://localhost:6379")); // RedisClusterClient redisClient = RedisClusterClient.create(RedisClusterURIUtil.toRedisURI("redis://localhost:7000,localhost:7001")); // 创建连接 StatefulRedisConnection<String, String> connection = redisClient.connect(); // 创建Redis命令对象 RedisCommands<String, String> commands = connection.sync(); // 获取锁并启动续约任务 boolean acquired = acquireLock(commands); if (acquired) { Thread renewalThread = startRenewalThread(commands); try { // 执行需要保护的代码 System.out.println("Lock acquired. Running protected code..."); // ... } finally { // 停止续约任务并释放锁 stopRenewalThread(renewalThread); releaseLock(commands); } } else { System.out.println("Failed to acquire lock."); } // 关闭连接和客户端 connection.close(); redisClient.shutdown(); } private static boolean acquireLock(RedisCommands<String, String> commands) { long startTime = System.currentTimeMillis(); int renewalAttempts = 0; while (true) { String result = commands.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", LOCK_EXPIRATION.getSeconds()); if ("OK".equals(result)) { return true; } else { // 检查是否发生死锁或脑裂问题 if (renewalAttempts >= MAX_RENEWAL_ATTEMPTS || System.currentTimeMillis() - startTime > LOCK_EXPIRATION.toMillis()) { return false; } sleep(RENEWAL_INTERVAL); renewalAttempts++; } } } private static Thread startRenewalThread(RedisCommands<String, String> commands) { Thread renewalThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { sleep(RENEWAL_INTERVAL); commands.expire(LOCK_KEY, LOCK_EXPIRATION.getSeconds()); } }); renewalThread.start(); return renewalThread; } private static void stopRenewalThread(Thread renewalThread) { renewalThread.interrupt(); try { renewalThread.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private static void releaseLock(RedisCommands<String, String> commands) { String luaScript = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end"; commands.eval(luaScript, RedisCommands.ReturnType.BOOLEAN, LOCK_KEY, LOCK_VALUE); } private static void sleep(Duration duration) { try { Thread.sleep(duration.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }
3、基于数据库(唯一索引)的实现方式
这种方式是通过在数据库中创建一个唯一索引的表,然后通过插入一条数据来获取锁,如果插入成功则获取锁成功,否则获取锁失败。释放锁的操作就是删除这条数据。这种方式的优点是实现简单,缺点是性能较低,因为涉及到数据库的操作。