要实现分布式锁,最简单的⽅方式可能就是直接创建⼀一张锁表,然后通过操作该表中的数据来实现了了。
当我们要锁住某个⽅法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。
比如创建这样一张数据库表:
CREATE TABLE `methodLock` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的⽅方法名', `desc` varchar(1024) NOT NULL DEFAULT '备注信息', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,⾃自动⽣生成', PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的⽅方法';
当我们想要锁住某个方法时,执行以下SQL:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执方法体内容。
当⽅法执行完毕之后,想要释放锁的话,需要执⾏行行以下sql:
delete from methodLock where method_name ='method_name'
上面说到这种方式基本废弃,那么这种简单的实现会存在哪些问题呢?
伪代码如下:
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,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程将无法再在该行行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执⾏方法的业务逻辑,执行完之后,通过connection.commit()操作来释放锁。 这种方法可以有效的解决上⾯提到的⽆法释放锁和阻塞锁的问题。
阻塞锁? for update语句会在执行成功后⽴即返回,在执行失败时⼀直处于阻塞状态,直到成功。锁定之后 服务宕机,⽆法释放?使⽤这种⽅式,服务宕机之后数据库会自己把锁释放掉。但是还是⽆法直接解决数据库单点和可重⼊问题。
public void unlock(){ connection.commit(); }
说了这么多,我们总结下数据库方式实现。
总结 这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
优点: 直接借助数据库,容易理解。
缺点: 会有各种各样的问题,在解决问题的过程中会使整个⽅案变得越来越复杂。 操作数据库需要一定的开销,性能问题也需要考虑。
redis实现分布式锁在电商开发中是使用的较为成熟和普遍的一种方式,利用redis本身特性及锁特性。如高性能(加、解锁时高性能),可以使用阻塞锁与非阻塞锁。不能出现死锁。通过搭建redis集群高可用性(不能出现节点 down 掉后加锁失败)。
尝试写伪代码增加理解,我们先看这种方式的分布式锁如何抢占。
/** * @param key 锁的key * @param lockValue 锁的value * @param timeout 允许获取锁的时间,超过该时间就返回false * @param expire key的缓存时间,也即一个线程⼀次持有锁的时间, * @param sleepTime 获取锁的线程循环尝试获取锁的间隔时间 * @return */ public boolean tryLock(String key, String lockValue, Integer timeout, Integer expire, Integer sleepTime) { int st = (sleepTime == null) ? DEFAULT_TIME : sleepTime; //允许获取锁的时间,默认30秒 int expiredNx = 30; final long start = System.currentTimeMillis(); if (timeout > expiredNx) { timeout = expiredNx; } final long end = start + timeout * 1000; // 默认返回失败 boolean res ; //如果尝试获取锁的时间超过了了允许时间,则直接返回 while (!(res = this.lock(key, lockValue, expire))) { if (System.currentTimeMillis() > end) { break; } try { // 线程sleep,避免过度请求Redis,该值可以调整 Thread.sleep(st); } catch (InterruptedException e) { } } return res; }
上⾯的讨论中我们有一个⾮常重要的假设:Redis是单点的。如果Redis是集群模式,我们考虑如下场景:
客户端1和客户端2同时持有了同一个资源的锁,锁不再具有安全性。根本原因是Redis集群不是强⼀致性的。
那么怎么保证强⼀致性呢— Redlock算法
假设客户端1从Master获取了锁。 这时候Master宕机了,存储锁的key还没有来得及同步到Slave上。 Slave升级为Master。 客户端2从新的Master获取到了对应同一个资源的锁。
redLock实现步骤:
但是这种办法就天衣无缝吗?缺点在哪里?
假设客户端1在获得锁之后发生了很长时间的GC pause,在此期间,它获得的锁过期了,⽽客户端2获得了锁。当客户端1从GC pause中恢复过来的时候,它不知道⾃己持有的锁已经过期了,它依然发起了写数据请求,⽽这时锁实际上被客户端2持有,因此两个客户端的写请求就有可能冲突(锁的互斥作⽤失效了)。
优点:性能好
原理
多个进程内同一时间都有线程在执行方法m,那么锁就一把,你获得了锁得以执行,我就得被阻塞,那你执行完了怎么来唤醒我呢?因为你并不知道我被阻塞了,你也就不能通知我" 嗨,小橘,我用完了,你用吧 "。你能做的只有用的时候设置锁标志,用完了再取消你设置的标志。我就必须在阻塞的时候隔一段时间主动去看看,但这样总归是有点麻烦的,最好有人来通知我可以执行了。
而zookeeper对于自身节点的两大特性解决了这个问题
节点是什么?
节点是zookeeper中数据存储的基础结构,zk中万物皆节点,就好比java中万物皆对象是一样的。zk的数据模型就是基于好多个节点的树结构,但zk规定每个节点的引用规则是路径引用。每个节点中包含子节点引用、存储数据、访问权限以及节点元数据等四部分。
zk中节点有类型区分吗?
有。zk中提供了四种类型的节点,各种类型节点及其区别如下:
持久节点(PERSISTENT):节点创建后,就一直存在,直到有删除操作来主动清除这个节点
持久顺序节点(PERSISTENT_SEQUENTIAL):保留持久节点的特性,额外的特性是,每个节点会为其第一层子节点维护一个顺序,记录每个子节点创建的先后顺序,ZK会自动为给定节点名加上一个数字后缀(自增的),作为新的节点名。
临时节点(EPHEMERAL):和持久节点不同的是,临时节点的生命周期和客户端会话绑定,当然也可以主动删除。
临时顺序节点(EPHEMERAL_SEQUENTIAL):保留临时节点的特性,额外的特性如持久顺序节点的额外特性。
如何操作节点?
节点的增删改查分别是createdeletesetDatagetData,exists判断节点是否存在,getChildren获取所有子节点的引用。
上面提到了节点的监听者,我们可以在对zk的节点进行查询操作时,设置当前线程是否监听所查询的节点。getData、getChildren、exists都属于对节点的查询操作,这些方法都有一个boolean类型的watch参数,用来设置是否监听该节点。一旦某个线程监听了某个节点,那么这个节点发生的creat(在该节点下新建子节点)、setData、delete(删除节点本身或是删除其某个子节点)都会触发zk去通知监听该节点的线程。但需要注意的是,线程对节点设置的监听是一次性的,也就是说zk通知监听线程后需要改线程再次设置监听节点,否则该节点再次的修改zk不会再次通知。
两种方案其实都是可行的,但是使用锁的时候一定要去规避死锁。方案一看上去是没问题的,用的时候设置标识,用完清除标识,但是要是持有锁的线程发生了意外,释放锁的代码无法执行,锁就无法释放,其他线程就会一直等待锁,相关同步代码便无法执行。方案二也存在这个问题,但方案二可以利用zk的临时顺序节点来解决这个问题,只要线程发生了异常导致程序中断,就会丢失与zk的连接,zk检测到该链接断开,就会自动删除该链接创建的临时节点,这样就可以达到即使占用锁的线程程序发生意外,也能保证锁正常释放的目的。
那要是zk挂了怎么办?sad,zk要是挂了就没辙了,因为线程都无法链接到zk,更何谈获取锁执行同步代码呢。不过,一般部署的时候,为了保证zk的高可用,都会使用多个zk部署为集群,集群内部一主多从,主zk一旦挂掉,会立刻通过选举机制有新的主zk补上。zk集群挂了怎么办?不好意思,除非所有zk同时挂掉,zk集群才会挂,概率超级小。
/** * 尝试加锁 * @return */ public boolean tryLock() { // 创建临时顺序节点 if (this.currentPath == null) { // 在lockPath节点下面创建临时顺序节点 currentPath = this.client.createEphemeralSequential(LockPath + "/", "orangecsong"); } // 获得所有的子节点 List<String> children = this.client.getChildren(LockPath); // 排序list Collections.sort(children); // 判断当前节点是否是最小的,如果是最小的节点,则表明此这个client可以获取锁 if (currentPath.equals(LockPath + "/" + children.get(0))) { return true; } else { // 如果不是当前最小的sequence,取到前一个临时节点 // 1.单独获取临时节点的顺序号 // 2.查找这个顺序号在children中的下标 // 3.存储前一个节点的完整路径 int curIndex = children.indexOf(currentPath.substring(LockPath.length() + 1)); beforePath = LockPath + "/" + children.get(curIndex - 1); } return false; } /** * 等待锁 */ private void waitForLock() { // cdl对象主要是让线程等待 CountDownLatch cdl = new CountDownLatch(1); // 注册watcher监听器 IZkDataListener listener = new IZkDataListener() { @Override public void handleDataDeleted(String dataPath) throws Exception { System.out.println("监听到前一个节点被删除了"); cdl.countDown(); } @Override public void handleDataChange(String dataPath, Object data) throws Exception { } }; // 监听前一个临时节点 client.subscribeDataChanges(this.beforePath, listener); // 前一个节点还存在,则阻塞自己 if (this.client.exists(this.beforePath)) { try { // 直至前一个节点释放锁,才会继续往下执行 cdl.await(); } catch (InterruptedException e) { e.printStackTrace(); } } // 醒来后,表明前一个临时节点已经被删除,此时客户端可以获取锁 && 取消watcher监听 client.unsubscribeDataChanges(this.beforePath, listener); }
优点:⾼可用性,数据强一致性。多进程共享、可以存储锁信息、有主动通知的机制。