分布式锁(一):浅谈分布式锁实现方案

在分布式环境下,很多业务场景中仅仅依靠JVM锁显然无法满足业务需求。故本文介绍几种常见的分布式锁方案

abstract.png

数据库

唯一性约束

该方案核心思想在于在数据库中建立一张锁表,然后利用数据库的唯一性约束实现。当多个服务实例同时操作某个方法时,需先向数据库中的锁表插入一条方法操作记录,其中方法名method_name字段上存在唯一性约束。这样即可保证同时只有一个服务实例可以完成记录插入。其中插入成功者即可视为成功获得锁。释放锁则很简单,在业务执行完毕后,删除表中相应的记录即可

不难看出,该方案思路非常简单。但缺陷其实也不少

  1. 锁的实现依赖于数据库,一旦数据库发生故障,则会导致整个系统不可用
  2. 由于某种原因导致锁未释放,会导致系统发生死锁
  3. 锁不支持重入
  4. 加锁失败即会直接抛出异常,无法进行阻塞等待,即所谓的非阻塞锁
  5. 数据库是磁盘IO,效率相对于内存而言较低

对于上述问题,可以结合实际的业务诉求进行改进、优化

  1. 引入锁超时机制,避免服务节点还未来得及释放锁就发生宕机。导致系统发生死锁
  2. 数据库的锁表可以增加机器标识、线程信息。加锁之前先进行查询,以支持可重入
  3. 加锁过程中引入自旋机制,实现阻塞效果

乐观锁机制

对于需要更新操作的业务场景而言,可以在数据库层面引入乐观锁机制。具体地,首先需要在相应的业务表上增加版本号字段。然后在更新过程中,先读取数据库相应记录的版本号信息,然后将版本号信息作为更新条件进行数据库记录的更新

该方案的缺点:

  1. 对业务侵入性较大,业务表需要增加版本号字段
  2. 高并发场景下,可能导致大量的更新失败

Redis

setnx命令

基于Redis的分布式锁,加锁操作主要依靠Redis的setnx命令进行实现。当key不存在时,则将key的值设为value,并返回1;否则将不做任何动作,并返回0。具体地,一方面可以通过setnx、expire命令实现,并结合Lua脚本保证原子性;另一方面,如果不想使用Lua脚本,则可以使用set命令。Redis从2.6.12版本开始对set命令进行了增强,提供了对NX、EX选项的支持

其中,Key是锁的唯一标识,一般会结合业务进行命名。而Value一般是当前加锁线程生成的标识,可以通过UUID生成。这样在解锁过程中,需要先验证Key对应的Value,确保该锁是当前线程持有的;验证无误后再删除该Key。此举是为了防止出现分布式锁被其他线程误删除的问题。当然在解锁过程中,可通过Lua脚本保障验证、删除这两个操作的原子性。同时为了避免发生死锁情况的发生,需要引入锁超时机制。具体地,即设置Redis Key的TTL过期时间即可

该方案的缺点:

  1. 当Master节点发生宕机、但锁还未来得及同步到Slave节点时。此时进行主从切换后,由于新的Master节点中没有相关锁信息,其他服务实例即会意外发生加锁成功的情形
  2. Redis集群由于网络分区而导致发生脑裂,此时即会出现多个Master节点。一旦各服务实例连接到不同的Master节点,即会出现多个服务实例同时加锁成功的情形

Redission

Redisson是基于Redis的Java驻内存数据网格(In-Memory Data Grid),底层使用Netty进行实现。为开发者提供了一系列分布式的常用工具,使得开发者更专注于业务。其中Redisson提供了相应的分布式锁实现,具体包括:

  • RedissonLock : 分布式非公平可重入互斥锁
  • RedissonFairLock : 分布式公平可重入互斥锁
  • RReadWriteLock : 分布式读写锁
  • RedissonSpinLock : 分布式自旋的非公平可重入互斥锁
  • RedissonCountDownLatch : 分布式闩锁
  • RedissonSemaphore : 分布式信号量
  • RedissonPermitExpirableSemaphore : 分布式支持有效期的信号量

Zookeeper

ZooKeeper作为分布式协调组件,其还可以用于实现分布式锁。其基本原理也很简单,大体思路如下所示

  1. 服务实例在获取锁的时候,会首先在ZooKeeper的指定目录节点下创建临时有序节点
  2. 判断该有序节点是不是该目录节点下序号最小的节点,如果是则表示该服务实例加锁成功;反之,则表示该服务实例加锁失败
  3. 对于加锁失败的服务实例而言,其相应的ZooKeeper临时有序节点会对上一个刚刚比他小的临时有序节点进行注册监听,以实现当被监听的临时有序节点被删除时可以通知当前的临时有序节点。例如,假设存在如下4个节点:node1、node3、node4、node5。则node5节点会去监听node4节点、node4节点会去监听node3节点、node3节点会去监听node1节点
  4. 当持有锁的服务实例释放锁后,其相应的ZooKeeper节点会被删除。进而会通知、唤醒下一个节点,具体地是通知、唤醒正在监听被删除节点的节点。然后被唤醒的节点继续执行Step 2,以判断自己是否为序号最小的节点

在该方案中,由于加锁服务实例创建的节点是临时类型的。该类型节点的特点是一旦连接断开后,则相应的节点会被自动删除。可以看到此举避免了服务实例在宕机后锁无法被释放的情形,即引入了所谓的超时机制避免了死锁的发生。另一方面,节点是有序的,每次锁被释放后,只会唤醒、通知下一个比它序号大的节点,而不是唤醒、通知所有比它序号大的节点来竞争获取锁。这样一方面保证了锁的公平性;另一方面避免了羊群效应,减轻服务器压力

事实上,基于ZooKeeper的分布式锁通常并不需要由我们自行实现。由Netflix开源的ZooKeeper客户端框架——Curator,已经提供了相应的分布式锁实现——InterProcessMutex,其是一个分布式可重入的互斥锁。此外Curator还进一步地提供了非常丰富的分布式锁功能,具体包括:InterProcessReadWriteLock分布式读写锁、InterProcessSemaphoreMutex分布式不可重入的互斥锁、InterProcessSemaphoreV2分布式信号量

0%