目录
1、多线程并发问题
2、普通锁
3、分布式锁
4、分布式锁解决方案
4.1、Redis分布式锁(常用)
版本一:setnx key value
版本二:set key value nx ex seconds(解决死锁问题)
版本三:分布式锁需要满足谁申请谁释放,不能释放别人的锁。(解决A释放B的锁的问题)
版本四:使用Lua脚本释放锁
版本五:Redission实现集群下的分布式锁
Jedis实现分布式锁
RedisTemplate实现分布式锁
4.2、数据库分布式锁
基于表记录实现分布式锁
基于乐观锁实现分布式锁
基于悲观锁实现分布式锁
4.3、Zookeeper分布式锁
ZNode节点
ZNode节点类型
watch监听机制
分布式锁-版本一
分布式锁-版本二
1、多线程并发问题
多线程并发情况下,如果存在对共享资源的访问,就需要给共享资源加上锁,保证共享资源同一时间只能被一个线程访问。
举例说明:
以车站卖票举例,车站有5个窗口同时卖票,现在只剩3张票(票总数为count)。
窗口卖票时,都会执行两个动作:1、判断是否有票(判断count值是否为0);2、如果还有票,则卖一张票给用户,并且票总数count-1;如果没有票,告知用户没有票。
此时有5个人(5个线程)同时去5个窗口买票,每个窗口首先判断是否有票,每个窗口同时去读取车票库存,都读取到车票剩余count=3,则5个窗口都认为还有票,于是都会卖一张票给用户,就会导致5个人都能买到票,但实际车票只有3张,造成车票超卖情况。
2、普通锁
在Java单机应用程序中,最常见的就是通过synchronized直接给共享资源的访问加锁,加锁后,同一时间只允许一个线程进行访问。
A获得锁 -> 窗口A请求获取车票库存数 -> 窗口BCDE请求获取车票库存数(被拒绝,因为没有获得锁,阻塞,等待锁的释放) -> 窗口A卖票,库存减一 -> 窗口A释放锁 ->窗口BCDE抢夺锁,抢到锁的窗口进行卖票操作。
普通锁只针对于单机应用情况,而在分布式环境下,每个服务的运行都是独立的,不同服务有可能还会部署在不同的服务器下。此时,如果还是用synchronized锁或ReentrantLock普通锁,就无法对共享资源进行加锁操作,因为这些锁都只能锁住当前JVM环境下的进程(不同JVM里的普通锁是独立的)。此时,就需要使用分布式锁。
3、分布式锁
分布式环境下,我们可以将获取锁和释放锁的操作都交给第三方组件来处理。每一台服务器都将获取锁和释放锁的请求交给第三方组件。获取锁后,才能对分布式环境下的共享资源进行相关操作,操作完后,再对第三方组件发起释放锁的请求。这样就能实现分布式环境下共享资源的安全访问。
分布式锁需要满足以下条件:
- 互斥性:任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。
- 安全性:锁只能被持有该锁的客户端删除,不能由其它客户端删除。
- 死锁:获取锁的客户端因为某些原因(如down机等)而未能释放锁,其它客户端再也无法获取到该锁。
- 容错:当部分节点(redis节点等)down机时,客户端仍然能够获取锁和释放锁。
4、分布式锁解决方案
4.1、Redis分布式锁(常用)
版本一:setnx key value
获取锁:将业务ID作为key,如果key不存在就插入成功,并设置key存活时间并返回1,如果key已经存在就返回0。客户端通过返回值判断是否获得锁,返回值为1表示获得锁。
业务处理。
释放锁:del key。
问题:如果获取锁的服务在获取锁后挂掉,无法主动释放锁,那么锁就会一直被占用,永远也不会被释放,导致死锁。
版本二:set key value nx ex seconds(解决死锁问题)
set key的同时设置key的过期时间,如果服务器无法主动释放做,那么在一定时间后,redis主动将key过期删除,以解决死锁问题。
- SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
- EX seconds − 设置指定的到期时间(以秒为单位)
- PX milliseconds − 设置指定的到期时间(以毫秒为单位)
- NX − 仅在键不存在时设置键
- XX − 只有在键已存在时才设置
问题:服务器A释放服务器B的锁问题。举例说明:
- 设置锁的过期时间为10s,服务的业务处理时间为15s;
- 服务器A获取到锁,处理业务逻辑;
- 10s时,锁过期,被Redis自动删除,但服务器A还在处理业务;
- 10s时,服务器B获取锁,处理业务逻辑;
- 15s时,服务器A业务处理完毕,执行释放锁的操作,但此时锁在服务器B手里,所以服务器A释放的是服务器B的锁。
版本三:分布式锁需要满足谁申请谁释放,不能释放别人的锁。(解决A释放B的锁的问题)
在释放(删除)锁时,分两步操作执行:a、先检查锁是不是属于自己的(比如把服务器信息存到value中);b、如果是自己的,则释放锁;如果不是自己的,则不做任何操作。
问题:检查锁和释放锁是两个操作,不是原子性的。高并发情况下,可能存在检查锁的时候,锁是自己的,释放锁的时候,锁不是自己的这种情况。
版本四:使用Lua脚本释放锁
将版本三中,检查锁和释放锁的操作写在Lua脚本中,Redis执行的Lua脚本,一定是原子性的。
问题:Redis集群环境下,如果主节点还没来得及把刚刚set的数据复制给从节点就挂了,从节点升级为主节点后,就会丢失原主节点set的锁数据,就会出现多个客户端同时持有同一个资源锁的情况。
版本五:Redission实现集群下的分布式锁
过半的节点上操作成功,以解决版本四中的问题。
核心API:lock()和unlock()、看门狗机制。
- 红锁,用于Redis集群,加锁或解锁时,只有集群中过半的Redis操作成功,加解锁才算成功。
- Redisson所有指令都通过lua脚本执行,保证了操作的原子性。
- 看门狗机制,默认检查锁的超时时间是30s,如果服务实例加锁成功后宕机,则30s后自动释放锁。
- 看门狗机制,有锁自动续期功能,如果业务执行时间太长,每隔10s就会自动给锁续上新的30s,避免锁到期,但业务还没执行完的情况发生。
// 1、获取一把红锁锁
RLock lock1 = redisson.getLock("lock1");
RLock lock2 = redisson.getLock("lock2");
RLock lock3 = redisson.getLock("lock3");
RLock lock4 = redisson.getLock("lock4");
RLock lock5 = redisson.getLock("lock5");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
// 2、加锁
// 2.1、 加锁,如果没有手动释放锁,则30s后自动删除。此时,看门狗机制会失效,不会自动续期。
lock.lock(30,TimeUnit.SECONDS);
// 2.2、为加锁等待100秒时间,并在加锁成功10秒钟后自动解开。
lock.tryLock(100, 10, TimeUnit.SECONDS);
// 2.3、 默认加的锁都是30s时间。看门狗机制自动续期。每隔10s续期为30s。
// 加锁业务完成后,不再续期,等待手动解锁或30s后自动删除
lock.lock();
Jedis实现分布式锁
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, "NX", "PX", expireTime);
return LOCK_SUCCESS.equals(result);
}
/**
* 释放分布式锁
*
* @param jedis redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return
*/
// Lua脚本如下
// if redis.call("get",KEYS[1]) == ARGV[1] then
// return redis.call("del",KEYS[1])
// else
// return 0
// end
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
// 使用Lua脚本保证原子性,根据requestId先判断lockKey是否由自己设置,然后再决定是否删除(只能删除自己的锁)
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));
return RELEASE_SUCCESS.equals(result);
}
RedisTemplate实现分布式锁
@Autowired
private RedisTemplate redisTemplate;
private static final Long RELEASE_SUCCESS = 1L;
/**
* 尝试获取分布式锁
*
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超时时间
* @param unit 时间单位
* @return
*/
public static boolean tryGetDistributedLock(String lockKey, String requestId, long expireTime, TimeUnit unit) {
return redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expreTime, unit);
}
/**
* 释放分布式锁
*
* @param lockKey 锁
* @param requestId 请求标识
* @return
*/
public static boolean releaseDistributedLock(String lockKey, String requestId) {
// 使用Lua脚本保证原子性,根据requestId先判断lockKey是否由自己设置,然后再决定是否删除(只能删除自己的锁)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Object execute = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId);
return RELEASE_SUCCESS.equals(execute);
}
4.2、数据库分布式锁
基于表记录实现分布式锁
创建一张表,给表中某个字段(lockName)增加唯一约束。当我们想要获得锁的时候,就可以在该表中增加一条记录,如果insert成功表示获得锁;释放锁的时候就删除这条记录。
步骤1--加锁:INSERT INTO database_lock(lockName) VALUES ('lockName');
步骤2--解锁:DELETE FROM database_lock WHERE lockName='lockName';
问题:
记录不会自动删除,意味着锁没有失效时间,只能手动删除;(可以用定时任务清理过期锁)
数据库需要集群部署、支持数据同步、主备切换;
不具备可重入特性,获取锁后,行数据一直存在;
不具备阻塞特性,获取不到锁会直接返回失败。(可以在业务逻辑里用while和sleep解决)
基于乐观锁实现分布式锁
乐观锁:只在数据更新操作提交的时候进行冲突检测。通常通过增加version版本号列实现乐观锁。在更新前,会先读取数据,记录数据中的版本号(version列),更新时,将读取到的版本号与数据库的版本号做比较,如果一致就更新成功,否则就说明数据已被其他线程更新过,更新失败。
拿库存扣减举例,创建一个商品表有id(商品id)、resource(库存)、version(版本号)三个字段,用户购买时会对resource进行减一操作,同时version加一。那么操作可以简化为如下步骤:
// 乐观锁的实现需要确保表中有相应的数据:
INSERT INTO resource_lock(id, resource, version) VALUES(1, 10, 1);
// 步骤1--获取资源:
SELECT id, resource, version as oldVersion FROM resource_lock WHERE id = 1
// 步骤2--执行业务逻辑
// 步骤3--更新资源:重点在于更新数据时校验数据行版本号是否与之前读取版本号一致
UPDATE resource_lock SET resource = resource - 1 and version = version + 1 WHERE id = 1 AND version = oldVersion
问题:
需要在表中增加额外的字段,增加数据库的冗余;
高并发时,version值在频繁变化,会导致大量数据库更新请求失败,影响系统可用性。
基于悲观锁实现分布式锁
悲观锁,认为每次更新都会发生冲突。通过在查询语句后面增加FOR UPDATE,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。当某条记录被加上悲观锁之后,其它线程也就无法再改行上增加悲观锁。
使用悲观锁时,需要关闭数据库的自动提交属性:SET AUTOCOMMIT = 0。
具体操作步骤如下所示:
// 步骤1-获取锁
SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
// 步骤2-执行业务逻辑
// 步骤3:手动提交事务,释放锁
COMMIT
两个线程A和B:A先于B执行,如果B在A释放锁之前执行步骤1,那么B会被阻塞(长时间阻塞会抛异常),直到A释放锁。
问题:
请求获取锁成功:数据库增加加锁的额外开销;
请求获取锁失败:线程阻塞,等待锁的释放,高并发情况下,可能有大量请求阻塞;
查询时理想情况下是通过索引列加行锁,但是也存在数据库加表锁的情况(Mysql执行引擎认为全表扫描效率更高)。
4.3、Zookeeper分布式锁
ZNode节点
Zookeeper以ZNode节点为基本存储单元,可分为父节点和子节点,按层级存储数据。类似于windows的文件系统,不同的是,Zookeeper下所有节点均为ZNode节点,都可以理解为文件夹,只不过这个文件夹可以存储数据。
ZNode节点类型
- 临时节点:客户端与zookeeper断开连接后,该节点会自动删除
- 临时顺序节点:客户端与zookeeper断开连接后,该节点会自动删除,但是这些节点都是有序排列的。
- 持久节点:客户端与zookeeper断开连接后,该节点依然存在
- 持久顺序节点:客户端与zookeeper断开连接后,该节点依然存在,但是这些节点都是有序排列的。
watch监听机制
客户端连接Zookeeper时,可以注册监听它关心的ZNode节点,当监听的节点发生变化时(更新、删除、增加子节点),Zookeeper会通知客户端。
分布式锁-版本一
通过创建临时节点实现分布式锁。
多个客户端同时去创建同一个临时节点,谁创建成功,谁就能获取锁;获取失败的客户端就监听这个临时节点的变化,等待该临时节点被删除。
举例说明:
- 现有ABCD四个客户端,建立与Zookeeper的连接;
- ABCD同时去创建ZNode临时节点node1,A创建成功,获取锁;BCD创建失败,监听node1节点;
- A断开与Zookeeper的连接,Zookeeper自动删除临时节点node1,BCD被唤醒,同时去创建临时节点node1;
- 重复步骤1-3。
问题:惊群效应
假设有5000个客户端同时创建临时节点,当有一个客户端创建成功后,其余4999个客户端都会监听该临时节点;当该临时节点被删除后,会唤醒4999个客户端,来重新竞争节点的创建,但只有1个能创建成功。(一个节点被释放,却要惊动其余所有客户端?造成Zookeeper压力过大,并且浪费客户端的线程资源。)
分布式锁-版本二
通过创建临时顺序节点实现分布式锁,解决惊群效应问题。
多个客户端同时去一个ZNode节点下创建节点,都能创建成功,只是创建的节点带有顺序编号,比如0001,0002,0003,相当于限定各客户端获取锁的顺序。编号小的先获取锁,未获取到锁的只需要监听他的前一个节点的删除变化。
举例说明:
- 现有ABCD四个客户端建立了与Zookeeper的连接;
- ABCD同时去root节点下分别创建临时顺序节点0001,0002,0003,0004;
- A的序号最小,获取锁;BCD获取失败,根据序号大小,B监听A,C监听B,D监听C;
- A断开与Zookeeper的连接,删除0001节点,B被唤醒,获取锁;
- B断开与Zookeeper的连接,删除0002节点,C被唤醒,获取锁;
- C断开与Zookeeper的连接,删除0003节点,D被唤醒,获取锁。
以上内容为个人学习总结,仅供学习参考,如有问题,欢迎在评论区指出,谢谢!