对于分布式锁可以从几个问题入手:
- 分布式锁应用的场景是什么样的呢?
- 分布式锁的实现是怎么样的呢?
- 分布式锁的实现方案应用有什么优劣势?
- 分布式锁的方案如何选择?等
一、分布式锁概念
1、什么是分布式锁
- 分布式锁在分布式环境中,针对多个进程访问共享资源而提供的同步方案。相对于单进程的锁,分布式锁更加复杂,分布式的不可靠性,需要分布式锁考虑更多的东西。
- 分布式锁作用:保证共享资源的正确性,某一时刻锁定全局资源,让进程串行化执行,能有效避免共享资源被多个进程同时访问时候出现的数据正确性问题。比如防止重复下单、解决业务层幂等问题、解决消息队列重复消费问题、解决数据不一致问题等等
2、分布式锁的设计目标
1、可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
2、这把锁要是一把可重入锁(避免死锁)
3、这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
4、这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
5、有高可用的获取锁和释放锁功能(服务高可用,系统稳健)
6、获取锁和释放锁的性能要好
7、锁的自动续约与自动释放
8、代码高度抽象,业务接入非常简单
9、可视化的管理后台,监控与管理
3、常见分布式锁解决方案
MySql
Zk
Redis
自研分布式锁:如谷歌的Chubby。
etcd
二、基于mysql实现分布式锁
1、基于表主键唯一做分布式锁
原理:利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。(联合主键或者唯一主键但是不能使自增主键)
- 存在的问题
1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
5、这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
- 当然,我们也可以有其他方式解决上面的问题。
1、数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
2、没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
3、非阻塞的?搞一个while循环,直到insert成功再返回成功。
4、非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
5、非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁
- 这种方案总体来说性能依靠于mysql,且会产生锁表等现象,在高并发场景是不适用的。
2、基于数据库排他锁做分布式锁
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁 (注意: InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
思路:我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。
示例:
BEGIN;(确保以下2步骤在一个事务中:)
SELECT * FROM tb_product_stock WHERE product_id=1 FOR UPDATE--->product_id有索引,锁行,无索引,表锁(加锁阶段)
UPDATE tb_product_stock SET number=number-1 WHERE product_id=1--->更新库存
COMMIT;( 解锁阶段)
- 存在的问题
性能依赖于mysql,同时虽然使用了索引,在查询优化的时候未必使用行锁有可能使用了表锁。也有可能引发死锁等其他意外发生
3、基于版本控制实现的乐观锁
- 这个策略源于 mysql 的 mvcc 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。
思路:我们可以对我们的表加一个版本号字段,那么我们查询出来一个版本号之后,update或者delete的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。这样的一个策略很像我们的CAS(Compare And Swap),比较并交换是一个原子操作
示例:
BEGIN;(确保以下2步骤在一个事务中:)
SELECT number FROM tb_product_stock WHERE product_id=1--》查询库存总数,不加锁
UPDATE tb_product_stock SET number=number-1 WHERE product_id=1 AND number=第一步查询到的库存数--》number字段作为版本控制字段(这里版本的控制可以根据业务调整)
COMMIT;
4、mysql作为分布式锁总结
- 优点:直接借助数据库,容易理解。缺点:会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。操作数据库需要一定的开销,性能问题需要考虑。也就是说在高并发场景数据库作为分布式锁并不适用。
三、redis作为分布式锁
- redis实现分布式锁原理主要是利用redis中setNX命令的原子性操作来实现的。
1、基于jedis实现分布式锁
/**
* 基于jedis实现的分布式锁
*/
public class RedisDistributeLockUtils {
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
* @param lockKey
* @param requestId 通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。
* requestId可以使用UUID.randomUUID().toString()方法生成。也就是锁谁加的锁谁来释放
* @param expiredTime
* @return
*/
public boolean getDistributeLock(Jedis jedis, String lockKey, String requestId, Long expiredTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expiredTime);
if (LOCK_SUCCESS.equalsIgnoreCase(result)) {
return true;
}
return false;
}
public boolean releaseDistributeLock(Jedis jedis, String lockKey, String requestId) {
/**
* 1、过程:将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,
* ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
* 2、原理:首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。
* 那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。
*/
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;
}
}
- 上面没有考虑使用高可用情况,在通常的互联网架构中都是使用主从或者集群模式来实现redis的高可用。而集群模式是基于redis分片技术,并不适合作为redis分布式锁高可用方案,因此常用的是redis主从模式,在通常99.9999%的情况下面,redis是没问题,但是当主redis挂掉时候,而主从数据异步进行时候,访问此时从服务器中还没有数据,就会存在锁失效问题。也就是说在极端情况下面会存在数据不一致问题。这也和redis集群是AP模型吻合。
- 在redis官方文档中,推荐使用redlock来保证一致性问题。但是至少需要三个redis主从实例来完成,下面说说redlock算法实现。
2、redlock算法实现分布式锁
- 在java中,实现了redlock锁的开源框架是redisson,redLock实现分布式锁的原理是:假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把N设成5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
1、获取当前时间(单位是毫秒)。
2、轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
3、客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
4、如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
5、如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。
- springboot+redisson实现分布式锁:
(1)、导入包
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.5.0</version>
</dependency>
(2)、配置文件
spring.redisson.password=123456
spring.redisson.clusters=127.0.0.1:6370,127.0.0.1:6371,127.0.0.1:6372
(3)配置redisson
@Configuration
public class RedissonConfig {
@Value("${spring.redisson.clusters}")
private String cluster;
@Value("${spring.redisson.password}")
private String password;
@Bean
public RedissonClient getRedisson(){
String[] nodes = cluster.split(",");
//redisson版本是3.5,集群的ip前面要加上“redis://”,不然会报错,3.2版本可不加
for(int i=0;i<nodes.length;i++){
nodes[i] = "redis://"+nodes[i];
}
RedissonClient redisson = null;
Config config = new Config();
config.useClusterServers() //这是用的集群server
.setScanInterval(2000) //设置集群状态扫描时间
.addNodeAddress(nodes)
.setPassword(password);
redisson = Redisson.create(config);
return redisson;
}
}
(4)分布式锁使用
@Service
public class DistributeLockServiceImpl{
@Autowired
private RedissonClient redissonClient;
private final static String LOCK_KEYP_PREFIX="redisson:lock:test:";
@overiide
public void redissonTest(Long userId) {
//获取key
String redisLockKey=LOCK_KEYP_PREFIX.concat(String.ValueOf(userId));
//获取redisson锁
RLock rlock = redissonClient.getLock(redisLockKey);
//设置锁超时时间,防止异常造成死锁
rlock.lock(20, TimeUnit.SECONDS);
try{
//todo 执行业务逻辑
} catch(Exception e){
//异常处理
}finally{
//释放锁
rlock.unlock();
}
}
}
5、redLock算法实现的分布式锁总结
- redLock实现的分布式锁真的好的。关于redLock实现的锁的论战可以参考:https://juejin.im/post/59f592c65188255f5c5142d2
三、zookeeper实现的分布式锁
- 关于zookeeper实现的分布式锁可以参考博主的另一篇文章:
https://www.jianshu.com/p/fc7ced6357f7
四、基于etcd实现的分布式锁
- etcd和zk一样作为分布式一致性的解决方案,具有zk的所有功能,就是说zk能做的etcd也能做。etcd是基于raft协议的一致性框架,相对paxos算法,更加容易理解,而且etcd提供了http+json的调用格式,更加符合restful风格,还有一点重点的,基于etcd相对zk,性能会更加好。
- 由于对于etcd不是很熟悉,后续学习了在动手写个demo。
五、几种主流的分布锁对比
-
在生产上面对高并发基本不会使用数据作为分布式锁的解决方法,绝大数情况是用redis作为分布式锁的解决方案,但是对于一致性要求特别高的比如交易等系统内就要考虑使用zk或者etcd实现的分布式锁。具体三者的比较如下:
参考:
http://www.importnew.com/27477.html
https://juejin.im/post/59f592c65188255f5c5142d2
https://zhuanlan.zhihu.com/p/42056183
https://juejin.im/post/5bbb0d8df265da0abd3533a5
http://www.spring4all.com/question/158