后端笔记01 | 分布式锁实现与思考

参考资料:

JavaGuide:分布式锁

JavaGuide:分布式锁的实现方案总结

阿里云开发者:分布式锁实现原理与最佳实践

字节跳动技术团队:聊聊分布式锁

分布式锁

关键要点

  1. 分布式锁的对哪些场景的必要性,分布式锁和本地锁的区别,分布式锁具备的条件
  2. 实现分布式锁的技术方案及其区别总结
  3. Redis实现分布式锁实现方式、存在问题、解决方案
  4. ZooKeeper分布式锁的原理、实现步骤。

‍分布式锁的简要介绍

什么是分布式锁?
  1. 举例场景:

商品库存扣减(秒杀活动、抽奖奖品)、外卖订单,多个线程同时访问共享的资源

  1. 可能导致的问题,假设在共享资源时不进行资源的互斥访问

比如超卖问题(每个用户代表一个线程),扣减库存时不是原子性操作,多个线程同时操作会出现超卖。

  1. 此类问题的解决方案:

对共享资源进行互斥访问锁(具体讲,是悲观锁)是一个通用的解决方案。

  • 单体应用(此处不叫分布式锁,叫本地锁)

使用本地锁 + 数据库中的行锁解决

  • 分布式应用(基于 MySQL、Redis 和 ZooKeeper)

注意:一般不考虑用 MySQL,有锁表风险。

① 使用数据库中的乐观锁,加一个 version 字段,利用 CAS 来实现,会导致大量的 update 失败

② 使用数据库维护一张锁的表 + 悲观锁 select,使用 select for update 实现

③ 使用 Redis 的 setNX 实现分布式锁

④ 使用 zookeeper 的 watcher + 有序临时节点来实现可阻塞的分布式锁

⑤ 使用 Redisson 框架内的分布式锁(比如可重入锁 RLock)来实现

⑥ 使用 curator 框架内的分布式锁来实现

  1. 什么是悲观锁?
  • 首先,总是假设最坏的情况:认为共享资源被访问就会出现问题
  • 因此,对这种情况的操作是: 获取资源操作都加上锁
  • 总结: 共享资源被加上锁,一次只给一个线程使用,其它线程想拿到这个资源就会被阻塞,等上一个持有者释放完再把资源转让给其它线程。
  1. 对锁的分类?
  • 根据单机或者分布式,分为本地锁和分布式锁。
  • 单机多线程->本地锁 -> 获取本地锁,访问共享资源:

通常使用 ReentrantLock 类、synchronized 关键字实现。

用于控制 JVM 进程内的多个线程对本地共享资源的访问。

  • 分布式系统多个 JVM 进程 -> 分布式锁:

分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上,多个 JVM 进程共享一份资源,使用本地所无法实现资源的互斥访问。

  1. 分布式锁应该具备的条件?
  • 基本条件

互斥(最基本的功能) :任何时刻锁只能被一个线程持有。

高可用

  • 容错: 锁出现问题,可以自动切换到另一个锁服务
  • 避免死锁: 如果释放锁的逻辑出现问题,最终一定还是被释放,不影响其它进程对共享资源的访问,一般通过超时机制(设置 TTL/Time to Live)实现。

③ 可重入:一个节点获取锁之后,还可以再次获取。

  • 好的分布式锁还需要实现的条件

① 高性能: 获取和释放锁操作能够快速完成,且不应对系统性能造成过大影响。

② 非阻塞: 获取不到锁不会无限等待,避免影响正常系统运行。

  1. 不同技术方案实现分布式锁的区别

① 数据库:操作简单,但是数据库压力大、操作性能差,有缩表风险。

② redis: 易于理解,适用并发量大的场景,但需要自己实现、不支持阻塞;而 Redisson 相对 Jedis 更多应用在分布式场景,提供锁方法,可阻塞。

③ Zookeeper: 适用高可靠、并发量不太高的场景,支持阻塞,但 Zookeeper 的程序比较复杂

④ Curator:提供锁方法,但要求强一致性、慢。

分布式锁常见实现方案总结

1. 基于 Redis 实现分布式锁
  1. 实现方式
  • 实现的核心在于”互斥“。
  • 实现互斥的 redis 命令: SETNX(SET if Not eXists ,对应 Java 的 setIfAbsent),表示 key 不存在,设置 key 值;存在,无动作。
SETNX lockKey uniqueValue
(integer) 1
SETNX lockKey uniqueValue
(integer) 0
  • 释放锁的操作DEL。
DEL lockKey
(integer) 1
  • 该方式实现的缺点

导致分布式锁存在的一些问题(比如死锁),另外这个不是原子性操作。

死锁问题: 应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放, 进而造成共享资源无法再被其他线程/进程访问

  1. 注意细节

redis实现分布式锁可能存在误删他人的锁、锁过期、分布式集群下的可靠性问题

  • 删除锁注意误删问题(释放锁时误删别人的锁)

为了防止误删到其他的锁,可以通过设置唯一的 value 值,判断是否与设置相同,来完成释放锁。

Jedis 的做法

用随机字符串 requestId,随机是为了保证 requestId 全局唯一。

// 加锁
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime)
if ("OK".equals(result)) {
    return true;
}
return false;
// 释放锁
if (jedis.get(lockKey).equals(requestId)) {
    jedis.del(lockKey);
    return true;
}
return false;

该做法的缺点是, 不是原子性操作。获取锁的值、判断是否是自己的锁、删除锁的是三个操作,并发条件下一旦发生阻塞,可能导致锁的判断是在客户端,但删除在服务端的问题。

Lua 脚本

使用 Lua 脚本通过 key 对应的 value(这是一个唯一值,通过判断 key 对应的 Value 值是否与要删除的 value 相等) 来判断。

为什么选用 Lua 脚本?

保证解锁操作的原子性。 因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

Lua 脚本设计

requestId 作为 ARGV[1]的值,lockKey 作为 KEYS[1]的值

// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

  • 解决分布式锁可能存在的死锁问题:给锁设置过期时间和续期操作

① 解释命令

NX: 设置 key 对应的值不存在才能 SET 成功

EX: expire time,设置过期时间,单位为秒;如果设置过期时间用的是 PX,其单位是毫秒

127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK

 127.0.0.1:6379> SET lockKey requestId NX PX 30000

② 需要确保问题

保证这是一个原子性操作。

③ 解决方案可能存在的漏洞:源于过期时间的设计长短问题

如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。

④ 解决上面锁提前过期问题:Redisson 实现锁续期

什么是 Redisson?

一个开源的 Java 语言 Redis 客户端

Redission 跟自动续期的关系是什么?

其分布式锁自带自动续期机制,原理是提供了一个专门用来监控和续期锁的 WatchDog( 看门狗)。

续期的条件是什么,流程是什么?

加锁时没有指定加锁时间时会启用 watchdog 机制。

默认加锁 30 秒,每 10 秒钟检查一次,如果存在就重新设置 过期时间为 30 秒。

watchdog 机制的实现特点是什么?

调用 Lua 脚本实现续期,保证续期操作的原子性。

举例:分布式系统中用 Redisson 实现分布式锁

Rlock:可重入锁。

什么是可重入锁?

在一个线程中可以多次获取同一把锁,如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入


实现可重入锁的核心思路是什么?

核心思路是获取锁的时候判断是否为自己的锁,如果是,则不再重新获取。

// 获取指定的key
Rlock lock = redisson.getLock(“key");
try{
    // 加锁,未指定过期时间,watchDog会自动续期
    lock.lock();

}finally{
    // 释放锁
    lock.unlock();
}

注意:如果指定了过期时间则不具备 WatchDog 自动续期机制。

lock.lock(10, TimeUnit.SECONDS);

  • 解决 Redis 分布式集群情况下分布式锁的可靠性

为什么要进行集群化部署?

为了避免单点故障,生产环境下的 Redis 服务通常是集群化部署的。

集群化部署下的分布式锁可能出现的问题?

Redis 主从复制是异步的。

可能出现的情况: 主节点获取到锁,在没有同步到其它节点的情况下,

Redis 主节点宕机, 新的主节点仍然可以获取锁,多个应用服务就可以同时获取到锁。

如何保证:

  • RedLock 算法,但实际项目不建议使用。原因是实现比较复杂,性能比较差。
  • 考虑基于 Redis 主从复制 + 哨兵模式实现分布式锁。

2. 基于 Zookeeper 实现分布式锁
  1. zookeeper 和 redis 的区别是什么?

zookeeper 可靠性相对更高,功能层面有 Watch 机制(用来实现公平的分布式锁,但性能比较差)。

  1. 实现分布式锁的原理是什么?

临时顺序节点和 Watcher(事件监听器)。

2.1 什么是 znode?

ZooKeeper 中数据的最小单元。

2.2 znode 有什么分类?

  • 持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机(断开客户端连接不会删除),直到将其删除。
  • 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。
  • 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001/node1/app0000000002
  • 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。

2.3 为什么用临时顺序节点?

不会发生死锁问题,原因是:

临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。

当客户端发生异常没来得及释放锁时,失效节点会自动删除。

对比 Redis 避免死锁的方法是什么?

Redis 通过过期时间避免锁无法释放导致的死锁,

ZooKeeper 是直接利用临时节点的特性。

2.4 为什么要对前一个节点进行监听?

① 监听的是什么?

前一个节点的删除事件。

② 监听的作用是什么?

同一时间段内,多个客户端同时获取锁,只有一个获取成功,获取失败的客户端不会不停地循环去尝试加锁,而是在前一个节点中注册事件监听器。

当前一个节点对应的客户端释放锁之后,通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll ),让它尝试去获取锁,然后就成功获取锁了。

2.5 什么是事件监听器 watcher?

客户端在指定节点上注册 Watcher,当特定事件触发时,ZooKeeper 的服务器端将事件通知到客户端。

该机制是 ZooKeeper 实现分布式协调服务的重要特性。

  1. 实现分布式锁的步骤是什么?

① 多线程并发创建瞬时节点

多线程并发创建瞬时节点(比如/lock)的时候,得到有序的序列,序号最小的线程可以获得锁 (实现公平锁);

② 监听机制

其他的线程监听自己序号的前一个序号。前一个线程执行结束之后删除自己序号的节点;

③ 得到通知,继续执行

下一个序号的线程得到通知,继续执行;

以此类推,创建节点的时候,就确认了线程执行的顺序。

你可能感兴趣的:(后端开发笔记,分布式,后端,redis)