分布式锁刚毕业的时候遇到过,不过当时不是很感兴趣,因为锁前面被分布式锁挡住了。修图后一下子就高级了。到现在为止,我只停留在回调方法上,并没有详细梳理相关的概念。这次,让我们解释一下这个概念。本文需要Zookeeper和Redis的基础。如果没有,我可以挖金文章:《Zookeeper学习笔记(一)基本概念和简单使用》这个公众号包含《Redis学习笔记(一) 初遇篇》这个还没有被迁移到公众号当我们谈论锁的时候,我们在谈论什么?写这篇文章的时候,我首先想到的是现实世界中的锁,如下:现实世界中的锁是为了保护资源而设计的。持有钥匙的人被视为资源的拥有者,可以获得被锁保护的资源。现实世界中的大部分锁都是基于这种设计的,比如门锁是为了防止门内的资源被盗,手机上的指纹锁是为了保护手机的资源。锁在软件界是个什么概念?难道也是为了实现资源的保护而生的?在某种程度上,可以这样理解。以多线程下卖票为例,如果不加锁,则可以实现两个线程共同卖一张票的情况。所以我们在取票和减去总票数的操作中加入了synchronized。publicclassTicketSellimplementsRunnable{//总共100张票privateinttotal=2000;@Overridepublicvoidrun(){while(total>0){//total--此操作不是原子操作,可能会被阻塞中断。//一个线程可能还没有拉取完成减法操作,时间片耗尽。当线程B进来读取total的值时,会出现//两个线程卖一张票System.out.println(Thread.currentThread().getName()+"Selling:"+total--);}}}publicstaticvoidmain(String[]args){TicketSellticketSell=newTicketSell();Threada=newThread(ticketSell,"a");Threadb=newThread(ticketSell,"b");a.开始();b.start();}为了避免这种情况,我们可以使用悲观锁synchronized和ReentrantLock来锁定代码块,避免超卖或重复购买。原因是我们开的两个线程都属于一个JVM进程,total也位于JVM进程中,而JVM可以使用锁来保护这个变量。那么如果我们把这个票数移到数据库的内表中,我们加锁的锁还会起作用吗?它一定不起作用。原因是JVM进程由JVM进程管理,数据库属于另外一个进程。JVM锁无法锁定另一个进程的变量。接下来,我们将总票数移入数据库,重写售票程序。首先,我们准备一张桌子。这里,为了省事,我们用我手上最大的Student作为总票数。建表语句如下:CREATETABLE`student`(`id`int(11)NOTNULL,`name`varchar(255)CHARACTERSETlatin1COLLATElatin1_swedish_ciNULLDEFAULTNULL,`number`int(255)NULLDEFAULTNULLCOMMENT'学号',PRIMARYKEY(`id`)USINGBTREE)ENGINE=InnoDBCHARACTERSET=utf8mb4COLLATE=utf8mb4_general_ciROW_FORMAT=Dynamic;我们表中当前最大的数字是5,id是5。我们模拟售票变量如下:@RestControllerpublicclassTicketSellController{@AutowiredprivateStudentInfoDaostudentInfoDao;@GetMapping("sell")publicStringsellOne()抛出异常{Studentstudent=studentInfoDao.selectById(5);整数=student.getNumber();//线程模拟五秒,模拟业务运行TimeUnit.SECONDS.sleep(5);student.setNumber(--number);studentInfoDao.updateById(学生);返回“更新成功”;}}在postman中启动两个请求,你会发现我们发送了两个请求,实际上数据库中只少了一张ticket。这个TickSellController也可以部署在多台机器上。这里我们分析一下synchronized,保证TickSell不会超卖这种情况:synchronized有互斥,线程进入synchronized修改的代码块,没有执行完毕。其他线程进入就会被阻塞。那么线程访问数据库数据时如何实现同步的效果呢?我们目前的目标是synchronized的加强版。有人想到了SELECT...FORUPDATE,但是如果你尝试一下,你会发现这不是一个可行的操作。原因是这个加锁时间以MySQL中最长的事务加锁时间为准,数据库没有提供相应的方法让我们查询对应的锁是否可用。如果两个事务都执行selectforupdate。确实只有一个事务会执行成功,但是另一个事务会等待另一个事务完成再执行。我们期望的方法是在获取锁的时候检查上面是否有锁。如果有锁,这时候我们可以参考synchronized锁升级,使用两种策略。首先是不断地重新获取锁。一种是在获取锁失败时被阻塞,等待锁持有者唤醒。那么selectforupdate就不行了,数据库有唯一索引,所以我们可以建一个表,表中的唯一索引就是商品的数量,所以卖票的时候,先根据数量往数据库中插入一条记录和product表的id,如果失败,说明抢锁失败。但是这个表应该怎么设计呢?为每个需要控制的表创建一个表并不常见。公用表应该有哪些字段?第一个是身份证。在现代高级语言中,方法是以方法为单位的,所以需要一个方法字段。这对于单个应用来说其实已经足够了,但是如果我们拆分应用,它不一定是微服务,那么不同的应用中可能会出现不同的项目名,存在相同的方法名。所以需要有一个项目名称字段。但是不同项目中相同的方法名可能会操作不同的资源,所以这里还需要一个资源ID。但是如果有一个应用的集群部署,那么这里我们还需要一个机器ip。其实这里还有另外一幕。同学们可能没有想到我们的锁应该也支持可重入,也就是说我们设计的synchronized设计也是支持可重入的,也就是锁持有者再次申请锁后应该可以重新获得锁,实际场景如下://这里只是为了说明锁重入的必要性,这里没有设置递归结束条件publicvoiddistributedLock(){Locklock=newReentrantLock();分布式锁();所以这里我们还需要记录持有锁的线程和重入次数,所以最终的建表脚本如下:DROPTABLEIFEXISTS`distributed_Lock`;CREATETABLE`distributed_Lock`(`id`intNOTNULL,`lock_key`varchar(100)NOTNULL,`thread_id`intNOTNULL,`entry_count`intNOTNULL,`host_ip`varchar(30)NOTNULLPRIMARYKEY(`id`),UNIQUEKEY`lock_key`(`lock_key`)USINGBTREE)ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;lock_key由当前项目名-方法名-资源名组成。所以我们目前获取锁的逻辑如下。首先判断是否有锁。如果有锁,再判断是不是自己的锁。如果不是您自己的锁,请重试。如果没有锁,则尝试加锁,如果加锁失败,则进入重试。可能有同学看到这里会问,不是已经判断没有锁了吗,怎么会锁失败呢。我们的操作是这样的:#如果没有找到,说明没有锁select*fromdistributed_LockwherelockKey=''#Statement1#然后把锁插入distributed_Lock#Statement2可能在两个线程之间很短,然后执行statement1,both得到的是没有锁,然后只有一条记录会成功插入到唯一索引中。如何重试?其实这里也可以使用重试器。重试策略有两种:重试失败,重试。直到达到最大重试次数。重试失败,稍等片刻,重试。获取锁,重试我们这里已经大致设计好了,那么如何释放锁呢?我们在加锁,操作完成后直接释放?那么如果成功加锁,那么应用就宕机了,所以为了追求我们系统的高可用,我们需要准备一个定时任务来释放锁,引入新的工具来解决这个问题。其实这个工具也会带来新的问题,比如我们在这种情况下使用定时任务来避免死锁,但是如何判断是否发生了死锁呢?那么我们的表中还需要存储一个锁持有时间吗?然后当时间快到的时候,会更新锁。如果我们要使用数据库进行资源控制,那么对应编程语言级别的工具类的组成有以下几个组成部分:lockretrylockrenewal分布式锁其实我们上面讨论的是基于数据库的分布式锁执行。上面我们使用唯一索引来保护资源,那么什么是分布式锁:分布式锁是一种控制分布式系统之间共享资源同步访问的方式。在分布式系统中,经常需要协调它们的动作。如果不同系统或同一系统的不同主机共享一个或一组资源,在访问这些资源时往往需要互斥,以防止相互干扰,保证一致性。在这种情况下,你需要使用分布式锁。上面我们基于数据库实现分布式锁,步骤多,成本高。这是分布式锁的一个使用场景:秒杀减少库存等类似业务防止超卖的情况。我们还使用分布式锁来防止缓存崩溃。比如缓存中的一个key过期了,为了避免对这个key的访问命中数据库,我们可以使用分布式锁作为控制,保证一次访问命中数据库,其他访问失败。等到数据库加载到缓存中再释放锁。为了保证接口的幂等形式的重复提交,分布式锁也可以解决这种场景。在界面添加分布式锁,第二次访问发现有锁提示已经提交。在上面的任务调度中,我们讨论了使用定时任务来防止死锁,但是定时任务所在的应用也有可能挂掉,所以为了追求高可用我们可以多部署几个,但是只有一个可以运行。其实我们经常使用Redis和Zookeeper来实现分布式锁。原因是基于相对较高的内存性能和丰富的特性,我们可以不用花费太多精力去构建分布式锁。用Redis实现分布式锁上面我们在讨论用数据库实现分布式锁的时候提到过,为了防止加锁成功,锁还没有释放成功,应用就崩溃了,导致死锁。为了追求高可用,我们引入了定时任务来扫描这些异常的锁占用并释放锁。Redis只是有一个设置key缓存时间的命令,还是原子的。所以我们不必担心死锁。但是设置多长时间是另一个问题。我们的希望是对的,所以这里引入锁更新,也就是超过锁缓存时间的三分之一还没有释放锁,然后为这个锁时间更新锁。我们现在面临的另一个问题是如何部署Redis:单机的劣势很明显,这个Redis一不小心就crash了,整个分布式锁不可用。为了追求高可用,Sentinel多部署了几个Redis。当主节点不可用时,它会自动选择从节点提升为主节点。但是还有一个问题。如果我刚刚写到master节点,master节点在同步完成之前就宕机了。这不是又不行了吗?RedLock大名鼎鼎的红锁,如果一个master节点不够用,那就加几个master节点,一个一个上锁。只要有一半以上的锁被加锁,就说明加锁成功,等到释放的时候,再一个一个释放。这样的话,即使一个主节点宕机了,还有其他的备胎。看似完美解决了问题,但实际上还是有漏洞。这个漏洞我们不可能一篇文章说清楚,以后再说。这里我们只是做一个简单的了解。我们要不要从零开始实现一个基于Redis的分布式锁?当然不是,基本上主流的高级语言都有很好封装的实现。这里我们选择介绍Java领域的RedisSession。标准实现-java第一步还是引入maven依赖:
