「分布式技术」分布式锁的最佳实践

文章介绍了分布式锁在分布式系统中的必要性,详细讨论了基于数据库(乐观锁、悲观锁、InnoDB排他锁)和基于缓存(Redis)以及Zookeeper的实现方式,并分析了各自的优缺点。乐观锁适用于单数据更新,悲观锁适用于资源锁,Redis锁提供高性能但有数据丢失风险,Zookeeper锁则强调强一致性但性能较低。最后,文章总结了各种方案的适用场景和权衡。
摘要由CSDN通过智能技术生成

「分布式技术」分布式锁的最佳实践

参考&鸣谢

分布式最佳实践:分布式锁

几种分布式锁的实现方式

分布式锁的几种实现方式~

一、为什么需要分布式锁

在传统的单体服务中,我们经常会遇到多线程对于单一资源的抢占导致的线程安全问题以及对数据库数据操作的一致性问题,如果是在单体系统中,我们可以很方便的使用编程语言提供的锁以及数据库事务来解决这些问题。

一旦单体系统转为分布式架构,那么本地事务和线程锁就无法满足跨进程的锁效果;分布式锁则是用于进程间同步访问共享资源的一种方式,通过全局共享来实现全局锁的效果,保证数据的一致性。

总的来说,在分布式系统中,当我们期望一个操作(一个请求、一个方法、一个数据库操作…)在整个系统中同一时间只能有一个线程执行,那我们就需要用到分布式锁; 抽象来看就是两个场景:

  • 单一资源的数据变更:比如对共享存储数据(数据库、缓存…)进行修改,多线程的互斥
  • access token:对于多个资源的原子性操作,期望整个业务逻辑就是单一线程执行保持一致性,在入口处就锁住

分布式锁应该具备的特性:

  • 原子性:在分布式系统中,一个方法在同一时间只能被一个线程执行
  • 阻塞性:在没获取到锁时可以进行阻塞也可以返回失败
  • 高可用:能够正确的获取锁和释放锁,且具备锁失效的能力
  • 高性能:获取锁与释放锁的性能保障
  • 可重入:能够具备可重入特性

二、实现方式

基于数据库实现

乐观锁实现

先去干,能不能干,能不能干成先不管,这就是乐观心态。在开发过程中,乐观锁用的非常多,比如典型的 CAS ;在不加锁的情况下保证数据的一致性。

使用方式也很简单,只需要在表中添加一个版本号的字段,每次对数据进行修改的时候,通过版本来确定是否能够更新 update xx set version = OLD_VERSION+1 where id = ID and version = OLD_VERSION , 如果更新不成功,客户端可以选择是否重试。当然,需要加上索引。

可见这种方式的优势其实很明显,不加锁,使用简单。但也有一些局限性

  • 只能支持单数据更新的一致性(对于数据的插入可以通过唯一索引来解决
  • 由于是乐观锁(先干,在检查),也就意味着可能活干完咯,发现更新不了,浪费了计算资源
  • 无法支持 access token
悲观锁实现

先自我审查自己能不能干,能不能干成,如果答案是no,那么就等着(阻塞)或先溜(返回),这就是悲观心态。悲观锁在 access token 模式更加适用。

使用方式同样很好理解(这只是基于数据库的悲观锁的一种实现方式)

  1. 有一张 资源锁 表,表中包含 字段,并需要加上唯一索引
  2. 当有线程想要获取某个锁时,只需要在 资源锁 表中插入一条数据
  3. 如果插入成功,表示获取锁成功,插入失败则表示锁已经被占用
  4. 业务执行完释放锁,删除对应的锁记录即可
// 1. 创建资源锁表
CREATE TABLE `resource_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `lock_name` varchar(64) NOT NULL COMMENT '锁名',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

//2. 获取锁(插入成本表示获取到锁
INSERT INTO resource_lock (lock_name) VALUES ('lockName');

//3. 释放锁
delete from resource_lock where lock_name ='lockName';
复制代码

乍一看好像很简单,如果程序一直保证正确执行,这种方式好像也行,但没如果… 对于一个分布式系统,服务宕机是会出现的,所以还需要考虑一些新的可能发生的问题

  • 没有失效机制:持有锁的线程所在的服务宕机了,还没来的及释放锁怎么办? 可以通过在表中新增过期时间,写一个定时任务定期删除过期锁
  • 不可重入:需要在表中新增线程信息,重入的时候先查询是否存在锁
  • 不支持锁阻塞:需要编写相应的逻辑
  • 基于数据库实现,那么数据库的可用性就需要得到保证,而且在并发大的时候,对于数据库的性能的影响问题

这么一分析…为了确保悲观锁的功能完整性,实现也会越来越复杂…

InnoDB排他锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:

public boolean lock(){
    connection.setAutoCommit(false)
    while(true){
        try{
            result = select * from methodLock where method_name=xxx for update;
            if(result==null){
                return true;
            }
        }catch(Exception e){

        }
        sleep(1000);
    }
    return false;
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

public void unlock(){
    connection.commit();
}

通过connection.commit()操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  • 阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接解决数据库单点和可重入问题。

这里还可能存在另外一个问题,虽然我们对method_name 使用了唯一索引,并且显示使用for update来使用行级锁。但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。

还有一个问题,就是我们要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆


基于Redis :AP架构

什么是CP架构和AP架构?

我们知道一个分布式系统不可能同时满足数据一致性(consistency)、服务可用性(availability)、分区容错性(partition-tolerance)。

现实情况下,我们面对的是一个不可靠的网络、有一定概率宕机的设备,这两个因素都会导致Partition,因而分布式系统实现中 P 是一个必须项,而不是可选项。

对于分布式系统工程实践,CAP理论更合适的描述是:在满足分区容错的前提下,没有算法能同时满足数据一致性和服务可用性。

因此,我们需要在C和A之间进行取舍:

  • CP架构(刚性事务):如果要满足数据的强一致性,就必须在一个服务数据库锁定的同时,对分布式服务下的其他服务数据资源同时锁定。等待全部服务处理完业务,才可以释放资源。此时如果有其他请求想要操作被锁定的资源就会被阻塞,这样就是满足了CP。达到了强一致性和弱可用性。
  • AP架构(柔性事务):如果要满足服务的的强可用性,每个服务就可以各自独立执行本地事务,而无需相互锁定其他服务的资源。在各个服务的事务尚未完全处理完毕时,如果去访问数据库,可能会遇到各个节点数据不一致的情况。然后我们还需要一些措施,使得经过一段时间后,各个节点的数据最终达到一致性。这样就是满足了AP。达到了弱一致性(最终一致性)和强可用性。

既然想到存储用缓存来做,那必然想到的第一个就是 Redis 了,Redis 也很给力,可以很好的支撑分布式锁的能力,提供了比较好用的命令

  • setnx: 当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。
  • expire: 为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。

大致的流程如下图

image-20230313001215567

  1. client A 和 client B 同时执行 setnx("lock",UUID) 尝试获取到锁,Redis 的实现保证了只会有一个 client 成功,假如 client A 运气好成功了
  2. client A 紧接着马上设置一个过期时间expire("lock",10)
  3. client A 继续执行业务逻辑
  4. 执行完业务逻辑后释放锁

如果程序能够正常走,好像也没什么问题…但我们知道分布式架构中,网络是不可靠的,如果在设置过期时间前 client B 挂掉咯,那就 GG 了,因为没有设置过期时间,那就成死锁了… 就像下面这样

image-20230313001647138

所以我们需要保证 setnxexpire 的原子性。在 Redis 2.6.12 之后增强了 setnx 命令,可以同时设置过期时间,从而保证原子性。

image-20230313001736195

解决了死锁问题,再来看看过期时间的问题,我们如何判断我们应该设置多长时间的过期时间?

  • 设置短了,业务逻辑可能还没执行完,锁被释放了,被其它线程获取执行
  • 设置长了,需要业务逻辑处理完了自己释放锁(同样会存在线程挂掉的情况)

其实我们想要到达一种效果,如果能够自动续期,锁快要过期了,但是业务操作还没有处理完,就自动对锁进行续期。Java 中的 Redisson 客户端就通过 watch dog 机制(守护线程)来支持这个功能。

通过 Redisson 客户端获取锁时会创建一个守护线程,通过守护线程来定期 check 过期时间,如果业务逻辑还在运行,那么就会续时。如果程序宕机,那么守护线程也会一起挂掉,redis 中的锁也将不会再次续时,最后过期。从而自动实现续期且不会出现死锁的问题。

简单回顾一下,我们解决了

  • 获取锁和设置过期时间的原子性问题
  • 过期时间自动续时的问题

在单机模式下看起来已经没什么问题了。而在生产环境下一般都会是集群模式,比如哨兵模式。得益于 Redis 的 AP 架构,选择了可用性,使得其性能非常好,但也正是因为AP架构,可能会导致数据丢失的情况。

image-20230313001748679

  1. client A 获取锁成功
  2. master 节点在同步锁信息到 slave 节点时,master 宕机,信息没有向 slave 节点同步成功
  3. slave 节点通过选举成为 master 节点
  4. client B 再次获取相同的锁,发现 slave 节点上并没有其它线程占用,所以也获取到了锁
  5. client A 和 client B 获取到了相同的锁

当然,这个是非常极端的情况下会出现的问题;虽然 Redis 之父 Antirez 提出来了分布式锁的一种 「健壮」 的实现算法 RedLock,但依旧还是会有新的问题,比如节点奔溃重启、时钟跳跃…

总的来看,基于 Redis 实现分布式锁是很常用的,性能也比较高,满足绝大部分业务场景,如果我们能够接受非常极端情况下带来的锁丢失问题,Redis 分布式锁是个很好的选择。


基于Zookeeper :CP 架构

Zookeeper 是一种提供「分布式服务协调」的中心化服务,是以 Paxos 算法为基础实现的。Zookeeper 采用的是 CP 架构,选择了强一致性,这也就意味着不会像 Redis 那样出现数据丢失的情况(主从切换时),但为了实现强一致性,那么性能肯定是要比 Redis 差一些。

使用 Zookeeper 来实现分布式锁是比较简单的

  1. client 会在 Zookeeper 中创建一个临时节点,比如`/zk/lock
  2. 如果获取成功,那么 client 会创建一个 session 保持和 Zookeeper 临时节点的关联
  3. client 处理业务逻辑
  4. client 处理完业务逻辑后删除 临时节点,关闭 session

如果 client 宕机,那么 session 就会结束,临时节点也会自动删除,其它 client 就可以创建 lock 节点。

session 的维护是依赖于 client 的定时心跳来维护的,也就是说,如果 client 没有及时的给 Zookeeper 发送心跳检查,那么 Zookeeper 就会认为这个 session 已经过期了,就会删除调临时节点。比如出现长时间的 GC 或者长时间的网络延迟,都可能会导致临时节点被删除的可能。

对于 Zookeeper 来说,实现分布式锁从使用者角度来看比较简单,不需要考虑太多的东西,比如过期时间的设置。但维护成本会比较高,性能相对 Redis 也会差一些,以及可能会出现长时间失联导致的节点数据丢失的问题。


三、三种方案的比较

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

从理解的难易程度角度(从低到高)

数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)

Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)

缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库


四、总结

  1. 优先使用基于数据库的乐观锁
  2. 如果期望更高的性能且能够接受极少数情况的锁丢失,那么优先选择 Redis
  3. 如果期望尽可能的避免锁丢失,优先选择 Zookeeper,且考虑 GC 时间和 心跳检查的设置
  4. 在分布式系统中极端情况下,分布式锁都不太可靠,所以需要我们在业务层面的入口也相应的隔离,在真的发生了锁丢失导致的数据不一致的情况做对应的补偿
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FrozenPenguin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值