分布式锁的应用场景主要包括两类:
分布式锁一般有四种实现方式:
要实现分布式锁,最简单的方式就是直接创建一张锁表,然后通过操作该表中的数据来实现锁。
当要锁住某个方法或资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。
创建这样一张数据库表:
CREATE TABLE `method_lock` (
`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 `uk_method_name_desc` (`method_name `,`desc`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
当想要锁住某个方法时,执行以下SQL:
insert into method_lock(method_name,desc) values (`method_name`,`desc`)
因为对method_name和desc做了唯一性约束,如果有多个请求同时提交到数据库的话,数据库会保证同一个资源只有一个操作可以成功,那么就可以认为操作成功的那个线程获得了该方法的锁,可以继续执行业务逻辑。
当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:
delete from method_lock where method_name ='xxx' and desc='xxxx'
这种实现方式存在的问题:
相应的解决方法:
除了基于数据库表,还可以借助数据库的排他锁来实现分布式的锁。
依旧使用上面创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:
public boolean lock() {
connection.setAutoCommit(false);
/**
* 设置重试次数,防止持续拿不到锁导致服务器资源耗尽
*/
int count = 0;
while (count < 4) {
try {
result = select * from method_lock where method_name = xxxx and desc = xxx for update;
if (result == null) {
return true;
}
} catch (Exception e) {
}
//为空或者抛异常的话都表示没有获取到锁
sleep(1000);
count++;
}
return false;
}
获得排它锁的线程即获得分布式锁。当获取到锁之后,可以继续执行业务逻辑,执行完方法之后,再通过以下方法解锁:
public void unlock(){
connection.commit();
}
针对加锁之后服务宕机,无法释放的问题,使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但还是无法解决数据库单点和可重入问题。
此外,要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。但需要注意的是MySQL会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的:如果MySQL认为全表扫效率更高,比如对一些数据量小的表,MySQL就不会使用索引。这种情况下查询将出现表锁,而不是行锁,这会导致所有sql写操作阻塞。。。
前面基于数据库悲观锁实现的分布式锁,基于数据库也可以使用乐观锁来实现分布式锁(资源表增加version字段)。
SELECT id,version FROM method_lock WHERE method_name ='xxx' and desc='xxxx';
UPDATE method_lock SET version=27, update_time=NOW() WHERE id=xx AND version=30;
基于数据库表做乐观锁的缺点:
数据库实现分布式锁的优点:
直接使用数据库,使用简单且节省运维成本。
数据库实现分布式锁的缺点:
基于Redis实现分布式锁主要有两大类,一类是基于单机,另一类是基于Redis多机。
加锁命令基于Redis命令:SET key value NX EX max-lock-time
。
加锁与解锁的代码示例:
class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static final Long RELEASE_SUCCESS = 1L;
/**
* 尝试获取分布式锁
*
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识 自定义的随机字符串,用于解锁的时候判断是否是当前用户加锁
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
/**
* 释放分布式锁
* 获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
这种实现方式存在的问题:
相应的解决方法:
目前互联网公司在生产环境用的比较广泛的开源框架Redisson很好地解决了锁被提前释放这个问题,非常的简便易用,且支持Redis单实例、Redis主从、Redis Sentinel、Redis Cluster等多种部署架构。
Redisson框架会开启一个定时器的守护线程,每expireTime/3执行一次,去检查该线程的锁是否存在,如果存在则对锁的过期时间重新设置为expireTime,即利用守护线程对锁进行“续命”,防止锁由于过期提前释放。
其实现原理如图所示:
这种实现方式存在的问题和解决方法:
以上两种基于Redis单机实现的分布式锁都存在一个问题:加锁时只作用在一个Redis节点上,即使Redis通过Sentinel或者Cluster保证了高可用,但由于Redis的复制是异步的,Master节点获取到锁后在未完成数据同步的情况下发生故障转移,此时其他客户端上的线程依然可以获取到锁,因此会丧失锁的安全性。
正因为如此,Redis的作者antirez提供了RedLock的算法来实现一个分布式锁。该算法流程是这样的:
假设有 N(N>=5)个Redis节点,这些节点完全互相独立。(不存在主从复制或者其他集群协调机制,确保这N个节点使用与在Redis单实例下相同的方法获取和释放锁)
获取锁的过程,客户端应执行如下操作:
而分布式系统专家Martin针对Redlock提出了一个场景:假设多节点Redis系统有五个节点A/B/C/D/E和两个客户端C1和C2,如果其中一个Redis节点上的时钟向前跳跃会发生什么?
这说明时钟跳跃对于Redlock算法影响较大,这种情况一旦发生,Redlock是没法正常工作的。
对此,Antirez指出Redlock算法对系统时钟的要求并不需要完全精确,只要误差不超过一定范围不会产生影响,在实际环境中是完全合理的,通过恰当的运维完全可以避免时钟发生大的跳动。
更多有关着Martin对Redlock算法的质疑以及Antirez的回应,请查阅参考文档3和4,感兴趣的同学可以阅读一下。同时也可以看参考文档5和6铁蕾大神的文章,更加快捷了解这场争论。
使用Redis实现分布式锁的优点:
相比数据库来,Redis实现分布式锁,可以提供更好的性能。
使用Redis实现分布式锁的缺点:
如果应用场景是为了处理效率提升,协调各个客户端避免做重复的工作,即使锁失效了,发生业务逻辑重复执行也不会有大的影响,则可以使用Redis实现分布式锁。但是如果你的应用场景是为了数据准确性保障,那么用Redis实现分布式锁并不合适(因为Redis集群是AP模型)。为了正确性,需要考虑接口幂等性,同时使用zab(Zookeeper)、raft(etcd)等共识算法的中间件来实现严格意义上的分布式锁。
从Redis 2.6.12版本开始,SET命令的行为可以通过一系列参数来修改:
SET key value EX seconds
的效果等同于执行SETEX key seconds value
。SET key value PX milliseconds
的效果等同于执行 PSETEX key milliseconds value
。SET key value NX
的效果等同于执行SETNX key value
。因为SET命令可以通过参数来实现SETNX、SETEX以及PSETEX命令的效果,所以Redis将来的版本可能会废弃并移除SETNX、SETEX和PSETEX这三个命令。
ZooKeeper是一个分布式协调服务的开源框架。主要用来解决分布式集群中应用系统的一致性的问题。
ZooKeeper本质上是一个分布式的小文件存储系统。提供基于类似于文件系统的目录树方式的数据存储,并且可以对树的节点进行有效管理。
使用ZooKeeper实现分布式锁的过程:
例如:/tmp下的子节点列表为:lock-0000、lock-0001、lock-0002,序号为1的客户端监听序号为0000子节点的删除消息,序号为2的监听序号为0001子节点的删除消息(业务代码执行完结束后删除子节点)。
这种实现方式存在的问题和解决方法:
Apache Curator是一个Zookeeper的开源客户端,它提供了Zookeeper各种应用场景(如共享锁服务、master选举、分布式计数器等)的抽象封装,简化了ZooKeeper的操作。
使用Zookeeper实现分布式锁的优点:
有效的解决单点问题、不可重入问题、非阻塞问题以及锁无法释放的问题。实现起来较为简单。
使用Zookeeper实现分布式锁的缺点:
因为Zookeeper集群采用zab一致性协议,所以高并发场景,性能上不如使用Redis实现分布式锁。
有关etcd的机制以及分布式锁的实现,等小辉写到Service Mesh、K8s和etcd的时候,再将这一块空缺填上。
目前以小辉的了解,生产环境应该很少使用数据库来做分布式锁,即使基于数据库的分布式锁实现比较简单。
目前比较热门的技术选型有基于Redis、Zookeeper和etcd的分布式锁。其中基于Redis单机和Redisson的分布式锁,都属于AP模型;而基于Zookeeper与etcd的分布式锁属于CP模型。
在CAP理论中,由于分布式系统中多节点通信不可避免出现网络延迟、丢包等问题一定会造成网络分区,在造成网络分区的情况下,一般有两个选择:CP或者AP。
对于严格的分布式锁来说,CP模型会更为理想。虽然,基于Redlock实现的分布式锁也可以看做是CP模型,但由于需要部署、维护比较复杂,在生产环境很少被使用。所以在对一致性要求很高的业务场景下(电商、银行支付),一般选择使用Zookeeper或者etcd。如果可以容忍少量数据丢失,出于维护成本等因素考虑,AP模型的分布式锁可优先选择Redis。
参考文档: