1 什么是分布式
主要指集群模式下,多个相同服务同时开启。
很多时候我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,通过 Java 提供的并发 API 我们可以解决,但是在分布式环境下,就没有那么简单啦。
- 分布式与单机情况下最大的不同在于其不是多线程而是
多进程
。 - 多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
2 什么是分布式锁
- 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
- 与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。(分布式情况下之所以问题变得复杂,主要就是需要考虑到网络的延时和不可靠。)
- 分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存,而是公共内存。如 Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。
3 我们需要怎样的分布式锁
- 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器-上的一个线程执行。
- 这把锁要是一把可重入锁(避免死锁)
- 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
- 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
- 有高可用的获取锁和释放锁功能
- 获取锁和释放锁的性能要好
4 分布式锁实现方式
a 基于数据库实现分布式锁
(1)表锁
创建一张表,当要操作某些资源时,先锁住这些资源,即把这些'资源记录'往表里面插入,解锁时删掉这些记录即可。
CREATE TABLE `order` ( //实现分布式锁的表
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_no` int(11) DEFAULT NULL comment `锁住的订单号资源`,
PRIMARY KEY (`id`),
unique key `unique_order_no`(`order_no`)
)ENGINE = INNODB
可知order_no为唯一性约束,当想锁住某个orderNo时->先把它插入表中->当有多个相同的order_no提交到数据库,只有一个能成功->想释放锁时,删除该条记录。可以先检查某个order_no是否在表中,不存在则插入,“检查+插入”应该放到同一个事务中:
java代码实现:
@Transactional //“检查+插入”应该放到同一个事务中
public boolean addOrder(int orderNo) {
if(orderMapper.selectOrder(orderNo)==null){ //检查
//order表不存在该条记录则插入,表示orderNo订单号被锁定
int result = orderMapper.addOrder(orderNo);
if(result>0){
return true;
}
}
return false;
}
public void fun(int orderNo){
if(addOrder(orderNo)) {//拿到分布式锁
//业务处理
...
//处理完删除order表的orderNo记录,表示释放分布式锁
orderMapper.delete(orderNo);
}
}
(2)数据库乐观锁
乐观锁通常实现基于数据版本(version)的记录机制实现的。
比如有一张红包表(t_bonus),有一个字段(left_count)记录礼物的剩余个数,用户每领取一个奖品,对应的left_count减1,在并发的情况下如何要保证left_count不为负数,乐观锁的实现方式为在红包表上添加一个版本号字段(version),默认为0。
异常情况下(没有考虑分布式场景):
-- 可能会发生的异常情况
-- 线程1查询,当前left_count为1,则有记录
select * from t_bonus where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,也有记录
select * from t_bonus where id = 10001 and left_count > 0
-- 线程1完成领取记录,修改left_count为0,
update t_bonus set left_count = left_count - 1 where id = 10001
-- 线程2完成领取记录,修改left_count为-1,产生脏数据
update t_bonus set left_count = left_count - 1 where id = 10001
通过乐观锁实现
-- 添加版本号控制字段
ALTER TABLE table ADD COLUMN version INT DEFAULT '0' NOT NULL AFTER t_bonus;
-- 线程1查询,当前left_count为1,则有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程1,更新完成后当前的version为1235,update状态为1,更新成功
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
-- 线程2,更新由于当前的version为1235,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
b 基于Redis的分布式锁
redis基础
SETNX命令(SET if Not eXists)
语法:SETNX key value
功能:原子性操作,当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。
Expire命令
语法:expire(key, expireTime)
功能:key设置过期时间
GETSET命令
语法:GETSET key value
功能:将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。
GET命令
语法:GET key
功能:返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。
DEL命令
语法:DEL key [KEY …]
功能:删除给定的一个或多个 key ,不存在的 key 会被忽略。
使用redis的setnx()、expire()方法,用于分布式锁
- setnx(lockkey, 1) 如果返回0,则说明占位失败;如果返回1,则说明占位成功
- expire()命令对lockkey设置超时时间,为的是避免死锁问题。
- 执行完业务代码后,可以通过delete命令删除key。
public boolean fun(Jedis jedis,String key,String value,int expireTime){
Long r=jedis.setnx(key,value);//若key不存在则保存(key,value)并返回1,代表拿到分布式锁;若key已存在则设置失败并返回0,代表分布式锁已被占用
if(r==1){ //拿到分布式锁
//设置锁的过期时间:从当前时点开始经过expireTime(s)之后该key失效,key-value会被删除,代表分布式锁被释放;
jedis.expire(key,expireTime);
return true;//拿到true可以向下执行业务操作
}
return false;
}
弊端:
在第一步setnx执行成功后,在expire()命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题。
解决
redis2.6.12之后提供一次性完成setnx和expireTime设置操作
//redis2.6.12之后:setnx提供了expireTime参数,可以一步设置过期时间
public boolean fun(Jedis jedis,String key,String value,int expireTime){
String r=jedis.setnx(key,value,"NX","PX",expireTime);//分布式锁已被占用
if("OK".equals(e)){ //拿到分布式锁
//设置过期时间释放分布式锁
jedis.expire(key,expireTime);
return true;
}
}
- 拿到锁之后,如果进行业务操作时间太长而expireTime设置太短,则会造成原来只有拿到锁才能执行的代码块失效,解决:设置expireTime之后,马上启动一个定时器,在expireTime快要到来之前,用lua原子性脚本删除原锁并重新设置新锁和新的到期时间(删除原key-value,再重新setnx设key-value和expireTime);
- 在redis集群下,主节点负责写,从节点负责读,由于主从复制是异步的,所以中间有一定的时间差,如果客户A从主节点获取到锁之后,还没来得及同步信息到其他的从节点,主节点就挂了,这时客户B再拿锁就会从 从节点获取到这个把锁,此时两个客户同时获取到了锁,解决:(待讨论)
- 锁的可重入问题,因为setnx是保证锁唯一的,如果拥有锁的进程想再次获取到该锁,就会失败,解决:当前进程用lua原子脚本把原锁删了,重新设置新锁+新expireTime(删除原key-value,再重新setnx设key-value和expireTime);
Redission
Redission 为 Redis 官网分布式解决方案
https://github.com/redisson/redisson#quick-start
引入maven依赖
<!-- https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.1</version>
</dependency>
快速开始Redission
Redission分布式锁的实现方式
Redission原理
Redission工具类
import java.util.concurrent.TimeUnit;
import com.caisebei.aspect.lock.springaspect.lock.DistributedLocker;
import org.redisson.api.RLock;
/**
* redis分布式锁帮助类
* @author grm
*
*/
public class RedissLockUtil {
private static DistributedLocker redissLock;
public static void setLocker(DistributedLocker locker) {
redissLock = locker;
}
/**
* 加锁
* @param lockKey
* @return
*/
public static RLock lock(String lockKey) {
return redissLock.lock(lockKey);
}
/**
* 释放锁
* @param lockKey
*/
public static void unlock(String lockKey) {
redissLock.unlock(lockKey);
}
/**
* 释放锁
* @param lock
*/
public static void unlock(RLock lock) {
redissLock.unlock(lock);
}
/**
* 带超时的锁
* @param lockKey
* @param timeout 超时时间 单位:秒
*/
public static RLock lock(String lockKey, int timeout) {
return redissLock.lock(lockKey, timeout);
}
/**
* 带超时的锁
* @param lockKey
* @param unit 时间单位
* @param timeout 超时时间
*/
public static RLock lock(String lockKey, TimeUnit unit ,int timeout) {
return redissLock.lock(lockKey, unit, timeout);
}
/**
* 尝试获取锁
* @param lockKey
* @param waitTime 最多等待时间
* @param leaseTime 上锁后自动释放锁时间
* @return
*/
public static boolean tryLock(String lockKey, int waitTime, int leaseTime) {
return redissLock.tryLock(lockKey, TimeUnit.SECONDS, waitTime, leaseTime);
}
/**
* 尝试获取锁
* @param lockKey
* @param unit 时间单位
* @param waitTime 最多等待时间
* @param leaseTime 上锁后自动释放锁时间
* @return
*/
public static boolean tryLock(String lockKey, TimeUnit unit, int waitTime, int leaseTime) {
return redissLock.tryLock(lockKey, unit, waitTime, leaseTime);
}
}
Redission优点
支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构,基于Redis 所以具有Redis 功能使用的封装,功能齐全。
在springboot 中单机及哨兵自动装配
/**
* 哨兵模式自动装配
* @return
*/
@Bean
@ConditionalOnProperty(name="redisson.master-name")
RedissonClient redissonSentinel() {
Config config = new Config();
SentinelServersConfig serverConfig = config.useSentinelServers().addSentinelAddress(redssionProperties.getSentinelAddresses())
.setMasterName(redssionProperties.getMasterName())
.setTimeout(redssionProperties.getTimeout())
.setMasterConnectionPoolSize(redssionProperties.getMasterConnectionPoolSize())
.setSlaveConnectionPoolSize(redssionProperties.getSlaveConnectionPoolSize());
if(StringUtils.isNotBlank(redssionProperties.getPassword())) {
serverConfig.setPassword(redssionProperties.getPassword());
}
return Redisson.create(config);
}
/**
* 单机模式自动装配
* @return
*/
@Bean
@ConditionalOnProperty(name="redisson.address")
RedissonClient redissonSingle() {
Config config = new Config();
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(redssionProperties.getAddress())
.setTimeout(redssionProperties.getTimeout())
.setConnectionPoolSize(redssionProperties.getConnectionPoolSize())
.setConnectionMinimumIdleSize(redssionProperties.getConnectionMinimumIdleSize());
if(StringUtils.isNotBlank(redssionProperties.getPassword())) {
serverConfig.setPassword(redssionProperties.getPassword());
}
return Redisson.create(config);
}
Redission缺点
导致脏数据的产生
如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。
redis cluster或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
c 基于zookeeper的分布式锁
(1)利用节点名称的唯一性来实现独占锁
ZooKeeper机制规定同一个目录下只能有一个唯一的文件名,zookeeper上的一个znode看作是一把锁,通过createznode的方式来实现。所有客户端都去创建/lock/${lock_name}_lock节点,最终成功创建的那个客户端也即拥有了这把锁,创建失败的可以选择监听继续等待,还是放弃抛出异常实现独占锁。
(2)利用临时顺序节点控制时序实现
/lock已经预先存在,所有客户端在它下面创建临时顺序编号目录节点,和选master一样,编号最小的获得锁,用完删除,依次方便。
算法思路:对于加锁操作,可以让所有客户端都去/lock目录下创建临时顺序节点,如果创建的客户端发现自身创建节点序列号是/lock/目录下最小的节点,则获得锁。否则,监视比自己创建节点的序列号小的节点(比自己创建的节点小的最大节点),进入等待。
对于解锁操作,只需要将自身创建的节点删除即可。