前言

最近在做的项目中,需要从数据库中批量读取记录,然后供分布式应用进行读取一条记录,处理后删除。因系统是分布式的,如何保证缓存中一条数据被使用一次,如何保证数据不做重复入缓存,在这方面做了下研究,及思考,分享给各位!

首先对于锁先做一个说明:

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

 

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁

 

不管是悲观锁和乐观锁都是一种锁的思想,在不同的语言,不同的应用中都有不同的实现!

java 中悲观锁:

synchronized

synchronized 关键字修饰的类,方法,对象,在同一时间紧允许一个线程去访问,一个线程持有,访问完成释放锁。

ReentrantLock

只有获取到当前锁的线程,可以进行后续访问,否则等待锁!

 

java 中的乐观锁:

ConcurrentHashMap 分段锁技术,在读取中是不判断锁的,写操做需要先获取锁,然后才能做put操作

java 本身提供的锁,针对的是单机,即在同一个jvm中的多线程进行线程安全控制,对于分布于不同主机的相同应用,各个应用本身的锁只在各个应用生效。打个比方:A 主机的应用W,和B 主机的应用W ,A主机上的锁和B主机一点关系没有,各玩个的。

这就是问什么要说分布式锁了

在目前的无状态服务调用中,前端的请求即可能发送到A ,也可能请求到B,如果A,B 同时对一个数据操作,就存在问题了!

同样的,分布式系统中,对锁的实现主要是通过第三方存储来实现的!根据自己的业务,可以按照悲观锁和乐观锁的定义来实现锁!

比较常用的解决方法:

第一种: 使用redis的setnx()、expire()、get()、getset()方法

设计思路: 设置锁健,及通过设置一个key值,作为锁,key存在在表示有锁,key不存在则表示无锁!

1. setnx(lockkey, 当前时间+过期超时时间) ,如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向2。

      2. get(lockkey)获取值oldExpireTime ,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向3。

      3. 计算newExpireTime=当前时间+过期超时时间,然后getset(lockkey, newExpireTime) 会返回当前lockkey的值currentExpireTime。

      4. 判断currentExpireTime与oldExpireTime 是否相等,如果相等,说明当前getset设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。

      5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

第二种:使用memcached的add()方法

设计思路: 设置锁健,及通过设置一个key值,作为锁,key存在在表示有锁,key不存在则表示无锁!

对于使用memcached的add()方法做分布式锁,这个在互联网公司是一种比较常见的方式,而且基本上可以解决自己手头上的大部分应用场景。在使用这个方法之前,只要能搞明白memcached的add()和set()的区别,并且知道为什么能用add()方法做分布式锁就好。如果还不知道add()和set()方法,请直接百度吧,这个需要自己了解一下。

      我在这里想说明的是另外一个问题,人们在关注分布式锁设计的好坏时,还会重点关注这样一个问题,那就是是否可以避免死锁问题???!!!

      如果使用memcached的add()命令对资源占位成功了,那么是不是就完事儿了呢?当然不是!我们需要在add()的使用指定当前添加的这个key的有效时间,如果不指定有效时间,正常情况下,你可以在执行完自己的业务后,使用delete方法将这个key删除掉,也就是释放了占用的资源。但是,如果在占位成功后,memecached或者自己的业务服务器发生宕机了,那么这个资源将无法得到释放。所以通过对key设置超时时间,即便发生了宕机的情况,也不会将资源一直占用,可以避免死锁的问题。

第三种:使用zookeeper

方法1: 使用zookeeper节点名称唯一性,用于分布式锁zookeeper抽象出来的节点结构是一个和文件系统类似的小型的树状的目录结构,同时zookeeper机制规定:同一个目录下只能有一个唯一的文件名。例如:我们在zookeeper的根目录下,由两个客户端同时创建一个名为/myDistributeLock,只有一个客户端可以成功。

      上述方案和memcached的add()方法、redis的setnx()方法实现分布式锁有着相同的思路。这样的方案实现起来如果不考虑搭建和维护zookeeper集群的成本,由于正确性和可靠性是zookeeper机制自己保证的,实现还是比较简单的。

方法2: 使用zookeeper临时顺序节点,用于分布式锁:

 

      在讨论这套方案之前,我们有必要先“吹毛求疵”般的说明一下使用zookeeper节点名称唯一性来做分布式锁这个方案的缺点。比如,当许多线程在等待一个锁时,如果锁得到释放的时候,那么所有客户端都被唤醒,但是仅仅有一个客户端得到锁。在这个过程中,大量的线程根本没有获得锁的可能性,但是也会引起大量的上下文切换,这个系统开销也是不小的,对于这样的现象有一个专业名词,称之为“惊群效应”。

     我们首先说明一下zookeeper的顺序节点、临时节点和watcher机制:

     所谓顺序节点,假如我们在/myDisLocks/目录下创建3个节点,zookeeper集群会按照发起创建的顺序来创建节点,节点分别为/myDisLocks/0000000001、/myDisLocks/0000000002、/myDisLocks/0000000003。

     所谓临时节点,临时节点由某个客户端创建,当客户端与zookeeper集群断开连接,则该节点自动被删除。

     所谓对于watcher机制, 所谓watcher机制,你可以简单一点儿理解成任何一个连接zookeeper的客户端可以通过watcher机制关注自己感兴趣的节点的增删改查,当这个节点发生增删改查的操作时,会“广播”自己的消息,所有对此感兴趣的节点可以在收到这些消息后,根据自己的业务需要执行后续的操作。

     具体的使用步骤如下:

      1. 每个业务线程调用create()方法创建名为“/myDisLocks/thread”的节点,需要注意的是,这里节点的创建类型需要设置为EPHEMERAL_SEQUENTIAL,即节点类型为临时顺序节点。此时/myDisLocks节点下会出现诸如/myDisLocks/thread0000000001、/myDisLocks/thread0000000002、/myDisLocks/thread0000000003这样的子节点。

     2. 每个业务线程调用getChildren(“myDisLocks”)方法来获取/myDisLocks这个节点下所有已经创建的子节点。

      3. 每个业务线程获取到所有子节点的路径之后,如果发现自己在步骤1中创建的节点的尾缀编号是所有节点中序号最小的,那么就认为自己获得了锁。

      4. 如果在步骤3中发现自己并非是所有子节点中序号最小的,说明自己还没有获取到锁。使用watcher机制监视比自己创建节点的序列号小的节点(比自己创建的节点小的最大节点),进入等待。比如,如果当前业务线程创建的节点是/myDisLocks/thread0000000003,那么在没有获取到锁的情况下,他只需要监视/myDisLocks/thread0000000002的情况。只有当/myDisLocks/thread0000000002获取到锁并释放之后,当前业务线程才启动获取锁,这样可以避免一个业务线程释放锁之后,其他所有线程都去竞争锁,引起不必要的上下文切换,最终造成“惊群现象”。

     5. 释放锁的过程相对比较简单,就是删除自己创建的那个子节点即可。

      注意: 这个方案实现的分布式锁还带着一点儿公平锁的味道!为什么呢?我们在利用每个节点的序号进行排队以此来避免进群现象时,实际上所有业务线程获得锁的顺序就是自己创建节点的顺序,也就是哪个业务线程先来,哪个就可以最快获得锁。

第四种: 基于数据库资源表做乐观锁,用于分布式锁

一般适用于对数据更新的分布式架构中,防止脏写,脏读的问题!

一般是通过为数据库表添加一个 “version”字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。

在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。

具体步骤:

1: 查询数据库记录获取版本号

2: 根据版本号进行数据更新,如果更新失败,则说明有其他线程更新了数据,如果更新生成,则表示无问题!

 

总结

目前比较常用的第一种,第二种,实现简单,在分布式应用中基本上都会存在缓存系统进行优化高并发访问,缓存实现分布式锁最是方便快捷!

第三种,可以作为了解,在使用到zookeeper的系统中,可以使用

 

第四种,如果不是非常适合的话,不建议使用,容易出现脏读,而且使用的是数据库连接,销号资源!