分布式锁
简介
分布式锁是单机锁的一种扩展,为了控制分布式系统之间同步访问共享资源的一种方式。
特性
为了确保分布式锁安全可用,我们至少要确保锁的实现同时满足以下几个条件:
- 互斥:在任意时刻,只有一个客户端能持有锁;
- 无死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;
- 可重入:同一个线程多次获取同一把锁;
- 高可用:不会因为单点问题导致客户端就不可以加锁和解锁;
- 不越权:加锁和解锁必须是同一个客户端,客户端不能释放别人的锁;
- 阻塞和非阻塞:阻塞锁没有获取到继续等待;非阻塞锁没有获取到直接返回失败;
实现
基于数据库做分布式锁
利用唯一索引
-
使用步骤
- 创建一张表methodLock,其中字段method_name为唯一索引
-
执行插入语句,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
-
方法执行完成,执行delete释放锁
delete from methodLock where method_name ='method_name'
-
问题
- 强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。(解决方案:主备数据库,数据同步,一旦挂掉快速切换到备库上)
- 没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。(解决方案:定时任务,每隔一定时间把数据库中的超时数据清理一遍)
- 非阻塞的,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。(解决方案:搞一个while循环,直到insert成功再返回成功)
- 非重入,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。(解决方案:在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息)
利用排他锁
-
使用步骤
-
使用for update增加排他锁(仅适用于InnoDB且必须在事务块BEGIN/COMMIT中,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁)
connection.setAutoCommit(false); select * from methodLock where method_name=xxx for update;
-
获得排它锁的线程即可获得分布式锁
-
执行完业务逻辑后,通过以下方法释放锁
connection.commit();
-
问题
- 数据库单点和可重入问题无法解决
- 一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆
- 事务有超时时间的,过了超时时间自动回滚,会导致锁的释放
Redis实现分布式锁
基于setnx()、expire()实现
setnx()
原子方法,setnx(key, value),如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。
expire()
setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。
-
使用步骤
- setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
- expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
- 执行完业务代码后,可以通过 delete 命令删除 key。
-
问题
- setnx和expire是两条命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。(解决方案:Redis 2.6.12以上版本为set指令增加了可选参数,SET key value [EX seconds] [PX milliseconds] [NX|XX],可以取代setnx和expire)
-
del导致误删,获得锁的线程A执行很慢,锁的超时时间已到仍在执行中,此时释放锁。B线程也获得了该锁开始执行。此时A执行完毕调用del命令删除的其实是B线程的锁。(解决方案:加锁时把当前线程ID作为value记入缓存,删除之前验证value和当前线程ID是否一致,set(key,threadId ,30,NX))
if(threadId .equals(redisClient.get(key))){ del(key) }
-
判断和释放锁是两个独立操作,不是原子性。(解决方案:用lua脚本来实现)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
-
第三点解决了误删和原子性的问题,但是防止不了执行时间过长,锁自动expire,其它线程也获得锁,导致并发操作。(解决方案:守护线程来为快过期的锁,并仍然在执行的线程续航。守护线程从第29秒开始执行,每20秒执行一次expire)
Zookeeper分布式锁
基于zookeeper临时节点
-
使用步骤
- 客户端调用create()方法创建名为“locknode”的节点,节点的类型为EPHEMERAL;
- 如果znode节点创建成功,就表示客户端获得了锁并可以继续执行临界区中的代码;
- 如果znode节点创建失败,就监听znode节点的变化,并在检测到znode节点被删除时再次创建节点来获得锁。
- 如果要实现一个非阻塞锁的话,当znode节点创建失败时,就直接返回失败而不是去监听。
-
问题
- ZooKeeper的会话与服务端是通过心跳保持连接的,当心跳超时或者链接丢失的时候客户端的请求会抛出Connection Loss异常,ZooKeeper客户端会进行自动重连,所以这种情况我们往往需要进行重试;
- 集群规模较大的情况,会出现羊群效应;
“羊群效应”:如果有大量的客户端都尝试对同一个资源加锁,即对同一个znode节点设计监视点,当锁被释放、znode节点被删除时,ZooKeeper服务端会产生一个尖峰的通知,该尖峰可能会导致网络的阻塞,引起一系列的问题,这种现象就是羊群效应。另一方面,唤醒全部的客户端,而实际上它们之间只会有一个能成功加锁,也是一种不合理的方式。
基于zookeeper临时有序节点
-
使用步骤
- 客户端调用create()方法创建名为“locknode/lock”的节点,节点的类型为EPHEMERAL_SEQUENTIAL;
- 客户端调用getChildren(“locknode”)方法来获取所有已经创建的子节点;
- 客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点序号最小,那么就认为这个客户端获得了锁;
- 如果在步骤3中发现自己并非所有子节点中最小的,说明自己还没有获取到锁。此时客户端需要找到比自己小的那个节点,然后对其调用exist()方法注册事件监听。
- 当这个被关注的节点被移除了,客户端会收到相应的通知。这个时候客户端需要再次调用getChildren(“locknode”)方法来获取所有已经创建的子节点,确保自己确实是最小的节点了,然后进入步骤3;
方案对比
- mysql实现简单,不需要引入第三个应用,但是分布式系统大多数瓶颈都在数据库,使用数据库会增加负担;
- redis实现简单,并且性能很好,引入集群可以提高可用性,同时定期失效的机制可以解决因网络抖动锁删除失败的问题。
- zookeeper实现偏重较复杂,需要频繁创建和删除节点,需要利用watcher机制,实现不好的话还会引起“羊群效应”,并且需要单独维护一套Zookeeper集群成本较高。