基于中间件的分布式锁的 3 种实现方案

前言

今天跟大家探讨一下分布式锁的设计与实现,希望对大家有帮助,如果有不正确的地方,欢迎指出,一起学习,一起进步。

分布式锁概述

我们的系统都是分布式部署的,日常开发中,秒杀下单、抢购商品等等业务场景,为了防止库存超卖,都需要用到分布式锁。

分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。

业界流行的分布式锁实现,一般有这 3 种方式:

  • 基于数据库实现的分布式锁

  • 基于 Redis 实现的分布式锁

  • 基于 Zookeeper 实现的分布式锁

基于数据库的分布式锁

| 数据库悲观锁实现的分布式锁

可以使用 select ... for update 来实现分布式锁。我们自己的项目,分布式定时任务,就使用类似的实现方案,我给大家来展示个简单版的哈。

表结构如下:


    
    
  1. CREATE  TABLE `t_resource_lock` (
  2.   `key_resource`  varchar( 45COLLATE utf8_bin  NOT  NULL  DEFAULT  '资源主键',
  3.   `status`  char( 1COLLATE utf8_bin  NOT  NULL  DEFAULT  '' COMMENT  'S,F,P',
  4.   `lock_flag`  int( 10) unsigned  NOT  NULL  DEFAULT  '0' COMMENT  '1是已经锁 0是未锁',
  5.   `begin_time` datetime  DEFAULT  NULL COMMENT  '开始时间',
  6.   `end_time` datetime  DEFAULT  NULL COMMENT  '结束时间',
  7.   `client_ip`  varchar( 45COLLATE utf8_bin  NOT  NULL  DEFAULT  '抢到锁的IP',
  8.   ` timeint( 10) unsigned  NOT  NULL  DEFAULT  '60' COMMENT  '方法生命周期内只允许一个结点获取一次锁,单位:分钟',
  9.    PRIMARY KEY (`key_resource`)  USING BTREE
  10. ) ENGINE =InnoDB  DEFAULT CHARSET =utf8  COLLATE =utf8_bin

加锁 lock 方法的伪代码如下:


    
    
  1. @Transcational  //一定要加事务
  2. public boolean lock(String keyResource,int time){
  3.    resourceLock =  'select * from t_resource_lock where key_resource =' #{keySource}' for update';
  4.     try{
  5.      if(resourceLock== null){
  6.        //插入锁的数据
  7.       resourceLock =  new ResourceLock();
  8.       resourceLock.setTime(time);
  9.       resourceLock.setLockFlag( 1);   //上锁
  10.       resourceLock.setStatus(P);  //处理中
  11.       resourceLock.setBeginTime( new Date());
  12.        int count =  "insert into resourceLock"
  13.        if(count== 1){
  14.           //获取锁成功
  15.           return  true;
  16.       }
  17.        return  false;
  18.    }
  19.    } catch(Exception x){
  20.        return  false;
  21.    }
  22.     //没上锁并且锁已经超时,即可以获取锁成功
  23.     if(resourceLock.getLockFlag== '0'&& 'S'. equals(resourceLock.getstatus)
  24.     &&  new Date()>=resourceLock.addDateTime(resourceLock.getBeginTime(,time)){
  25.       resourceLock.setLockFlag( 1);   //上锁
  26.       resourceLock.setStatus(P);  //处理中
  27.       resourceLock.setBeginTime( new Date());
  28.        //update resourceLock;
  29.        return  true;
  30.    } else  if( new Date()>=resourceLock.addDateTime(resourceLock.getBeginTime(,time)){
  31.       //超时未正常执行结束,获取锁失败
  32.       return  false;
  33.    } else{
  34.       return  false;
  35.    } 
  36. }
解锁 unlock 方法的伪代码如下:

    
    
  1. public  void  unlock( String v,status){
  2.       resourceLock. setLockFlag( 0);   //解锁
  3.       resourceLock. setStatus(status);  S:表示成功,F表示失败
  4.        //update resourceLock;
  5.        return ;
  6. }

整体流程:


    
    
  1. try{
  2. if( lock(keyResource,time)){  //加锁
  3.    status = process(); //你的业务逻辑处理。
  4.  }
  5. finally{
  6.     unlock(keyResource,status);  //释放锁
  7. }

其实这个悲观锁实现的分布式锁,整体的流程还是比较清晰的。

就是先 select ... for update 锁住主键 key_resource 那个记录,如果为空,则可以插入一条记录,如果已有记录判断下状态和时间,是否已经超时。这里需要注意一下哈,必须要加事务。

| 数据库乐观锁实现的分布式锁

除了悲观锁,还可以用乐观锁实现分布式锁。乐观锁,顾名思义,就是很乐观,每次更新操作,都觉得不会存在并发冲突,只有更新失败后,才重试。它是基于 CAS 思想实现的。我以前的公司,扣减余额就是用这种方案。

搞个 version 字段,每次更新修改,都会自增加一,然后去更新余额时,把查出来的那个版本号,带上条件去更新,如果是上次那个版本号,就更新,如果不是,表示别人并发修改过了,就继续重试。

大概流程如下:

查询版本号和余额:

select version,balance from account where user_id ='666';

    
    

假设查到版本号是 oldVersion=1。

逻辑处理,判断余额:


    
    
  1. if(balance<扣减金额){
  2.     return
  3. }
  4. left_balance = balance - 扣减金额;

进行扣减余额:


    
    
  1. update account  set balance =  #{left_balance} ,version = version+1 where version 
  2. #{oldVersion} and balance>= #{left_balance} and user_id ='666';

大家可以看下这个流程图哈:

这种方式适合并发不高的场景,一般需要设置一下重试的次数。

基于 Redis 实现的分布式锁

Redis 分布式锁一般有以下这几种实现方式:

  • setnx+expire

  • setnx+value 值是过期时间

  • set 的扩展命令(set ex px nx)

  • set ex px nx+校验唯一随机值,再删除

  • Redisson

  • Redisson+RedLock

| setnx+expire

聊到 Redis 分布式锁,很多小伙伴反手就是 setnx+expire,如下:


    
    
  1. if(jedis. setnx(key,lock_value) ==  1){  //setnx加锁
  2.     expire(key, 100);  //设置过期时间
  3.      try {
  4.          do something   //业务处理
  5.     } catch(){
  6.     }
  7.    finally {
  8.        jedis. del(key);  //释放锁
  9.     }
  10. }

这段代码是可以加锁成功,但是你有没有发现问题,加锁操作和设置超时时间是分开的。

假设在执行完 setnx 加锁后,正要执行 expire 设置过期时间时,进程 crash 掉或者要重启维护了,那这个锁就长生不老了,别的线程永远获取不到锁啦,所以分布式锁不能这么实现!

| setnx+value 值是过期时间

如下:


    
    
  1. long expires = System.currentTimeMillis() + expireTime;  //系统时间+设置的过期时间
  2. String expiresStr =  String.valueOf(expires);
  3. // 如果当前锁不存在,返回加锁成功
  4. if (jedis.setnx(key, expiresStr) ==  1) {
  5.          return  true;
  6. // 如果锁已经存在,获取锁的过期时间
  7. String currentValueStr = jedis. get(key);
  8. // 如果获取到的过期时间,小于系统当前时间,表示已经过期
  9. if (currentValueStr !=  null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
  10.       // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
  11.      String oldValueStr = jedis.getSet(key, expiresStr);
  12.      if (oldValueStr !=  null && oldValueStr.equals(currentValueStr)) {
  13.           // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
  14.           return  true;
  15.     }
  16. }
  17. //其他情况,均返回加锁失败
  18. return  false;
  19. }

日常开发中,有些小伙伴就是这么实现分布式锁的,但是会有这些缺点:

  • 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步。

  • 没有保存持有者的唯一标识,可能被别的客户端释放/解锁。

  • 锁过期的时候,并发多个客户端同时请求过来,都执行了 jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。

| set 的扩展命令(set ex px nx)

这个命令的几个参数分别表示什么意思呢?

SET key value [EX seconds] [PX milliseconds] [NX|XX]

    
    

跟大家复习一下:

  • EX second :设置键的过期时间为 second 秒。

  • PX millisecond :设置键的过期时间为 millisecond 毫秒。

  • NX :只在键不存在时,才对键进行设置操作。

  • XX :只在键已经存在时,才对键进行设置操作。


    
    
  1. if(jedis. set(key, lock_value,  "NX""EX"100s) ==  1){  //加锁
  2.      try {
  3.          do something   //业务处理
  4.     } catch(){
  5.   }
  6.    finally {
  7.        jedis.del(key);  //释放锁
  8.     }
  9. }

这个方案可能存在这样的问题:

  • 锁过期释放了,业务还没执行完。

  • 锁被别的线程误删。

有些伙伴可能会有个疑问,就是锁为什么会被别的线程误删呢?假设并发多线程场景下,线程 A 获得了锁,但是它没释放锁的话,线程 B 是获取不到锁的,所以按道理它是执行不到加锁下面的代码滴,怎么会导致锁被别的线程误删呢?

假设线程 A 和 B,都想用 key 加锁,最后 A 抢到锁加锁成功,但是由于执行业务逻辑的耗时很长,超过了设置的超时时间 100s。

这时候,Redis 就自动释放了 key 锁。这时候线程 B 就可以加锁成功了,接下啦,它也执行业务逻辑处理。假设碰巧这时候,A 执行完自己的业务逻辑,它就去释放锁,但是它就把 B 的锁给释放了。

| set ex px nx+校验唯一随机值,再删除

为了解决锁被别的线程误删问题。可以在 set ex px nx 的基础上,加上个校验的唯一随机值,如下:


    
    
  1. if(jedis. set(key, uni_request_id,  "NX""EX"100s) ==  1){  //加锁
  2.      try {
  3.          do something   //业务处理
  4.     } catch(){
  5.   }
  6.    finally {
  7.         //判断是不是当前线程加的锁,是才释放
  8.         if (uni_request_id. equals(jedis. get(key))) {
  9.           jedis.del(key);  //释放锁
  10.         }
  11.     }
  12. }

在这里,判断当前线程加的锁和释放锁不是一个原子操作。如果调用 jedis.del() 释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

一般可以用 lua 脚本来包一下。lua 脚本如下:


    
    
  1. if redis. call( 'get',KEYS[1]) == ARGV[1] then 
  2.     return redis. call( 'del',KEYS[1]) 
  3. else
  4.     return  0
  5. end;

这种方式比较不错了,一般情况下,已经可以使用这种实现方式。但是还是存在:锁过期释放了,业务还没执行完的问题。

| Redisson

对于可能存在锁过期释放,业务没执行完的问题。我们可以稍微把锁过期时间设置长一些,大于正常业务处理时间就好啦。

如果你觉得不是很稳,还可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

当前开源框架 Redisson 解决了这个问题。可以看下 Redisson 底层原理图:

只要线程一加锁成功,就会启动一个 watch dog 看门狗,它是一个后台线程,会每隔 10 秒检查一下,如果线程 1 还持有锁,那么就会不断的延长锁 key 的生存时间。因此,Redisson 就是使用 watch dog 解决了锁过期释放,业务没执行完问题。

| Redisson+RedLock

前面六种方案都只是基于 Redis 单机版的分布式锁讨论,还不是很完美。因为 Redis 一般都是集群部署的:

如果线程一在 Redis 的 master 节点上拿到了锁,但是加锁的 key 还没同步到 slave 节点。恰好这时,master 节点发生故障,一个 slave 节点就会升级为 master 节点。

线程二就可以顺理成章获取同个 key 的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,Redis 作者 antirez 提出一种高级的分布式锁算法:Redlock。

它的核心思想是这样的:部署多个 Redis master,以保证它们不会同时宕掉。并且这些 master 节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个 master 实例上,是与在 Redis 单实例,使用相同方法来获取和释放锁。

我们假设当前有 5 个 Redis master 节点,在 5 台服务器上面运行这些 Redis 实例。

RedLock 的实现步骤:

  • 获取当前时间,以毫秒为单位。

  • 按顺序向 5 个 master 节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为 10 秒,则超时时间一般在 5-50 毫秒之间,我们就假设超时时间是 50ms 吧)。如果超时,跳过该 master 节点,尽快去尝试下一个 master 节点。

  • 客户端使用当前时间减去开始获取锁时间(即步骤 1 记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是 5/2+1=3 个节点)的 Redis master 节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)

  • 如果取到了锁,key 的真正有效时间就变啦,需要减去获取锁所使用的时间。

  • 如果获取锁失败(没有在至少 N/2+1 个 master 实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的 master 节点上解锁(即便有些 master 节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

简化下步骤就是:

  • 按顺序向 5 个 master 节点请求加锁

  • 根据设置的超时时间来判断,是不是要跳过该 master 节点。

  • 如果大于等于 3 个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。

  • 如果获取锁失败,解锁!

Redisson 实现了 redLock 版本的锁,有兴趣的小伙伴,可以去了解一下哈!

Zookeeper 分布式锁

在学习 Zookeeper 分布式锁之前,我们复习一下 Zookeeper 的节点哈。

Zookeeper 的节点 Znode 有四种类型:

  • 持久节点:默认的节点类型。创建节点的客户端与 zookeeper 断开连接后,该节点依旧存在。

  • 持久节点顺序节点:所谓顺序节点,就是在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号,持久节点顺序节点就是有顺序的持久节点。

  • 临时节点:和持久节点相反,当创建节点的客户端与 zookeeper 断开连接后,临时节点会被删除。

  • 临时顺序节点:有顺序的临时节点。

Zookeeper 分布式锁实现应用了临时顺序节点。这里不贴代码啦,来讲下 ZK 分布式锁的实现原理吧。

| ZK 获取锁过程

当第一个客户端请求过来时,Zookeeper 客户端会创建一个持久节点 locks。如果它(Client1)想获得锁,需要在 locks 节点下创建一个顺序节点 lock1。

如下图:

接着,客户端 Client1 会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock1 是不是排序最小的那一个,如果是,则成功获得锁。

这时候如果又来一个客户端 client2 前来尝试获得锁,它会在 locks 下再创建一个临时节点 lock2。

客户端 client2 一样也会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock2 是不是最小的,此时,发现 lock1 才是最小的,于是获取锁失败。

获取锁失败,它是不会甘心的,client2 向它排序靠前的节点 lock1 注册 Watcher 事件,用来监听 lock1 是否存在,也就是说 client2 抢锁失败进入等待状态。

此时,如果再来一个客户端 Client3 来尝试获取锁,它会在 locks 下再创建一个临时节点 lock3。

同样的,client3 一样也会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock3 是不是最小的,发现自己不是最小的,就获取锁失败。

它也是不会甘心的,它会向在它前面的节点 lock2 注册 Watcher 事件,以监听 lock2 节点是否存在。

| 释放锁

我们再来看看释放锁的流程,Zookeeper 的客户端业务完成或者发生故障,都会删除临时节点,释放锁。

如果是任务完成,Client1 会显式调用删除 lock1 的指令。

如果是客户端故障了,根据临时节点得特性,lock1 是会自动删除的。

lock1 节点被删除后,Client2 可开心了,因为它一直监听着 lock1。

lock1 节点删除,Client2 立刻收到通知,也会查找 locks 下面的所有临时顺序子节点,发下 lock2 是最小,就获得锁。

同理,Client2 获得锁之后,Client3 也对它虎视眈眈:

  • Zookeeper 设计定位就是分布式协调,简单易用。如果获取不到锁,只需添加一个监听器即可,很适合做分布式锁。

  • Zookeeper 作为分布式锁也缺点:如果有很多的客户端频繁的申请加锁、释放锁,对于 Zookeeper 集群的压力会比较大。

三种分布式锁对比

| 数据库分布式锁实现

优点:简单,使用方便,不需要引入 Redis、Zookeeper 等中间件。

缺点:

  • 不适合高并发的场景

  • db 操作性能较差

| Redis 分布式锁实现

优点:

  • 性能好,适合高并发场景

  • 较轻量级

  • 有较好的框架支持,如 Redisson

缺点:

  • 过期时间不好控制

  • 需要考虑锁被别的线程误删场景

| Zookeeper 分布式锁实现

缺点:

  • 性能不如 Redis 实现的分布式锁

  • 比较重的分布式锁。

优点:

  • 有较好的性能和可靠性

  • 有封装较好的框架,如 Curator

| 对比汇总

如下:

  • 从性能角度(从高到低)Redis > Zookeeper >= 数据库

  • 从理解的难易程度角度(从低到高)数据库 > Redis > Zookeeper

  • 从实现的复杂性角度(从低到高)Zookeeper > Redis > 数据库

  • 从可靠性角度(从高到低)Zookeeper > Redis > 数据库

    [原文链接](https://blog.csdn.net/weixin_42073629/article/details/124901801)
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
分布式数据库架构是指在多个数据库之间分配和存储数据的一架构方式。它将数据分散存储在多个节点上,以提高系统的可扩展性、可用性和性能。 Mycat是一开源的分布式数据库中间件,它基于MySQL实现分布式数据库功能。它具有负载均衡、数据分片和数据复制等特性,能够有效地分配和管理数据。它还提供了分布式事务的支持,保证了数据的一致性和可靠性。 在企业实践中,分布式数据库架构和Mycat中间件被广泛应用于大规模业务系统中。首先,它能够解决单点故障问题,提高系统的可用性。将数据分散存储在多个节点上,当某个节点发生故障时,其他节点可以继续提供服务,保证系统的正常运行。 其次,分布式数据库架构和Mycat中间件能够提高系统的性能。通过数据分片和负载均衡的技术,可以将负载均匀地分散到多个节点上,避免单个节点的资源瓶颈问题,提高系统的处理能力和响应速度。 此外,分布式数据库架构还能够实现数据的弹性扩展。当业务量增加时,可以根据需要新增节点,实现系统的水平扩展。同时,通过数据复制技术,可以提供数据的冗余存储,保证数据的安全性和完整性。 总的来说,分布式数据库架构和Mycat中间件为企业提供了一可靠、高性能的数据存储和管理方案。它能够适应大规模业务系统的需求,提供可伸缩性和弹性扩展能力,成为企业在数据管理方面的重要工具。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值