1 分布式锁
1.1 为什么需要分布式锁?
1.1.1 引入业务场景
业务场景:在物流系统中,快递员提交支付请求,由于网络等原因一直没有返回二维码,此时快递员在发起一次请求,这样就会生成2个订单,这就重复了不符合业务场景,因此我们就要在生成交易订单时加锁,若获取到锁就执行,否则就抛出异常。
1.1.2 不添加锁时出现的业务错误
//业务方法,创建订单
@Resource(name = "aliNativePayHandler")
private NativePayHandler nativePayHandler;
/**
* 创建交易单示例代码
*
* @param productOrderNo 订单号
* @return 交易单对象
*/
public TradingEntity createDownLineTrading(Long productOrderNo) {
TradingEntity tradingEntity = new TradingEntity(); //交易订单实体
tradingEntity.setProductOrderNo(productOrderNo);
//基于订单创建交易单
tradingEntity.setTradingOrderNo(IdUtil.getSnowflakeNextId());
tradingEntity.setCreated(LocalDateTime.now());
tradingEntity.setTradingAmount(BigDecimal.valueOf(1));
tradingEntity.setMemo("运费");
//调用第三方支付创建交易
this.nativePayHandler.createDownLineTrading(tradingEntity);
return tradingEntity;
}
//测试方法,模拟多线程创建订单
@Resource
NativePayService nativePayService;
@Test
public void createDownLineTrading() throws Exception {
Long productOrderNo = 1122334455L;
//多线程模拟并发
for (int i = 0; i < 3; i++) {
new Thread(() -> {
//使用加锁的方式添加订单
TradingEntity tradingEntity = nativePayService.createDownLineTrading(productOrderNo);
System.out.println("交易单" + tradingEntity + ",线程id= " + Thread.currentThread().getId());
}).start();
}
//睡眠20秒,等待所有子线程完成
Thread.sleep(20000);
}
注意:可以发现对于一个订单号创建了多个交易对象,这就是并发常见
数据重复问题
。
1.1.3 本地锁与分布式锁
锁的定义:用于保障并发访问时数据的一致性、可见性和安全性。在多线程中为避免数据竞争常使用锁来限制对共享资源的访问。
本地锁:诸如针对Java多线程并发控制的synchronized、Lock本地锁,针对数据库并发访问控制的乐观锁、悲观锁等本地锁,只适用于单体项目,在多个节点的分布式项目中不适用。
分布式锁:诸如Redisson、ZooKeeper、数据库(通过数据库可以实现分布式锁,但是高并发情况下对数据库压力较大,由于数据库存储和管理重要数据,造成软件开发的瓶颈集中在数据库上,所以很少使用)等,适用于分布式多节点锁定。
1.2 分布式锁核心思想
实现分布式锁,可以借助Redis的setnx命令实现,使用该命令时,若key存在,则返回1,若key不存在,则返回0,也就意味着key只能设置一次,假设有多个线程同时设置值,只有一个能设置成功,这样就得到互斥效果,即达到锁的效果。
setnx命令演示:
1.3 基于Redis实现分布式锁
1.3.1 自定义ILock接口
public interface ILock {
/**
* 尝试获取锁
*
* @param name 锁名称
* @param timeoutSec 锁持有超时时间,过期后自动释放
* @return true表示获取锁成功, false表示获取锁失败
*/
boolean tryLock(String name, Long timeoutSec);
/**
* 释放锁
* @param name 锁名称
*/
void unlock(String name);
}
1.3.2 实现ILock接口方法
@Component
public class SimpleRedisILock implements ILock {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "Lock:";
/**
* 获取锁
*
* @param name 锁名称
* @param timeoutSec 锁持有超时时间,过期后自动释放
* @return 是否获取锁成功
*/
@Override
public boolean tryLock(String name, Long timeoutSec) {
//获取线程标识
String threadId = Thread.currentThread().getId() + "";
//获取锁 setIfAbsent() 是SETNX命令在java的体现
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*
* @param name 锁名称
*/
@Override
public void unlock(String name) {
//获取线程标识
String threadId = Thread.currentThread().getId() + "";
//获取锁的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断标示是否一致,即当前线程与拥有锁的线程相同时,才会释放锁
if (threadId.equals(id)) {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
1.3.3 业务方法添加锁
@Service
public class NativePayService {
@Resource(name = "aliNativePayHandler")
private NativePayHandler nativePayHandler;
@Resource
private SimpleRedisILock simpleRedisLock;
/**
* 创建交易单示例代码
*
* @param productOrderNo 订单号
* @return 交易单对象
*/
public TradingEntity createDownLineTrading(Long productOrderNo) {
TradingEntity tradingEntity = new TradingEntity(); //交易订单实体
tradingEntity.setProductOrderNo(productOrderNo);
//基于订单创建交易单
tradingEntity.setTradingOrderNo(IdUtil.getSnowflakeNextId());
tradingEntity.setCreated(LocalDateTime.now());
tradingEntity.setTradingAmount(BigDecimal.valueOf(1));
tradingEntity.setMemo("运费");
//调用第三方支付创建交易
this.nativePayHandler.createDownLineTrading(tradingEntity);
return tradingEntity;
}
/**
* 加锁方式创建交易单示例方法
*
* @param productOrderNo 订单号
* @return 交易单对象
*/
public TradingEntity createDownLineTradingLock(Long productOrderNo) {
//获取锁
String lockName = Convert.toStr(productOrderNo);//hutool字符转换器,转换为字符串
boolean lock = this.simpleRedisLock.tryLock(lockName, 5L); //拥有锁时间,在此时间内,拥有锁的线程执行
if (!lock) {
System.out.println("没有获取到锁,线程id=" + Thread.currentThread().getId());
return null;
}
System.out.println("获取到锁,线程id=" + Thread.currentThread().getId());
//获取到锁后,在创建订单
TradingEntity tradingEntity = createDownLineTrading(productOrderNo);
//释放锁
this.simpleRedisLock.unlock(lockName);
return tradingEntity;
}
}
1.3.4 测试方法
@Resource
NativePayService nativePayService;
@Test
public void createDownLineTrading() throws Exception {
Long productOrderNo = 1122334455L;
//多线程模拟并发
for (int i = 0; i < 3; i++) {
new Thread(() -> {
//使用加锁的方式添加订单
TradingEntity tradingEntity = nativePayService.createDownLineTradingLock(productOrderNo);
System.out.println("交易单" + tradingEntity + ",线程id= " + Thread.currentThread().getId());
}).start();
}
//睡眠20秒,等待所有子线程完成
Thread.sleep(20000);
}
注意:如下对于一个订单只会生成一个交易单,这才符合业务员需求。
1.3.5 基于Redis自定义实现分布式锁问题
问题:
- ① 由于每个线程持有锁时间被设置为5秒,此时,需要思考一个问题,当程序运行时间大于5s时,程序还没结束锁已经释放,其他线程就会获得锁,最终会导致脏数据情况发生。
1.3.6 自定义锁脏数据问题时间线分析
1.3.7 自定义Redis锁其余问题
① 线程拥有锁时间问题:如1.3.6产生的脏数据情况。
② 不是原子性(要么全成功,要么全失败)操作:在高并发情况下,多个线程可能同时执行tryLock()方法,并在执行结果返回前都认为自己已获得锁,从而导致分布式锁失效(解决方法使用WATCH key [key ...]
命令检查锁是否已经被占用,并在获取锁之后理解将锁的状态设置为“已占用”,获取锁说之后通过使用EVAL命令执行Lua脚本,来确保释放锁原子性)。
③ 不可重入:同一把锁无法多次获取同一把锁,在上方1.3.6时间线中,同一线程只能获得锁一次。
④ 不可重试:获取锁只尝试一次就返回false,无重试机制,在上方1.3.6时间线中,同一线程只能得到一次结果。
⑤ 超时释放:锁超时释放虽然可避免死锁,若业务执行时间较长,会导致锁释放,存在安全隐患。
⑥ 主从一致性:在Redis集群中,若主从存在延迟,会导致线程在主节点上成功获取到锁,然而从节点键值未被同步,出现数据不一致情况(解决方法:哨兵等组间通过Redis集群高可用性)。
2 对于自定义锁问题解决——Redisson入门
Redis提供的分布式多有:可重入锁、公平锁、联锁、红锁、读写锁、信号量、可过期性信号量、闭锁。
官网:https://redisson.org/
2.1 Redisson快速实现
2.1.1 导入maven坐标
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
</dependency>
2.1.2 Redisson配置
@Data
@EnableConfigurationProperties(RedisProperties.class)
@Configuration
public class RedissonConfiguration {
@Resource
private RedisProperties redisProperties;
@Bean
public RedissonClient redisSingle() {
Config config = new Config();
SingleServerConfig serverConfig = config.useSingleServer().setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());
if (null != (redisProperties.getTimeout())) {
//设置超时时间
serverConfig.setTimeout(1000 + Convert.toInt(redisProperties.getTimeout().getSeconds()));
}
if (StrUtil.isNotEmpty(redisProperties.getPassword())){
//设置密码
serverConfig.setPassword(redisProperties.getPassword());
}
//创建RedissonClient
return Redisson.create(config);
}
}
2.1.3 测试方法
@Test
public void createDownLineTrading() throws Exception {
Long productOrderNo = 1122334455L;
//多线程模拟并发
for (int i = 0; i < 3; i++) {
new Thread(() -> {
//使用加锁的方式添加订单
TradingEntity tradingEntity = nativePayService.createDownLineTradingRedissonLock(productOrderNo);
System.out.println("交易单" + tradingEntity + ",线程id= " + Thread.currentThread().getId());
}).start();
}
//睡眠20秒,等待所有子线程完成
Thread.sleep(20000);
}
与我们自定义实现锁效果一样:
2.2 开门狗机制
2.2.1 问题引入
在我们使用Redisson分布式锁时,没有指定存储到Redis中锁的有效时间? 那Redis如何处理这个时间问题,什么原理?
说明:
- 在程序中没有指定存储到Redis中锁的有效时间,而是,由Redisson的默认时间30s,其次,当程序执行时间超过30s,锁仍不会自动删除,是因为,Redisson一旦加锁成功会启动“看门狗”(watch dog)机制,当时间每过期1/3时,就检查下,若当前线程还继续持有锁,就会重新刷新到30s,直到最后锁释放。
可以看出Redisson的看门狗机制确保锁不会在业务执行完之前过期。
注意:若获取锁时指定leaseTime,看门狗程序不会生效。
2.2.2 加锁的实现原理
Redisson实现分布式锁原理并非直接采用SETNX命令实现,而是采用Lua脚本实现,主要确保操作原子性。
- 原理:获取自定义名为myLock的锁,其存储的是hash结构数据,大KEY是myLock,小key是< uuid >:< threadId >,value是获取锁的次数,类似结构如下:
2.2.3 Redisson加锁的核心Lua脚本
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
//key[1]:为锁名。即redissonClient.getFairLock("myLock")中的myLock
//ARGV[1]:锁时间爱你,即lock.tryLock(5L,10l,TimeUnit.SECONDS),10*60s,默认internalLockLeaseTime=30s
//ARGV[2]:锁的唯一标识,<uuid>:<threadId>
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
//1.若key不存在,则进行加锁,返回nil,即null
"if (redis.call('exists', KEYS[1]) == 0) then " +
//1.1 对key加锁,并设置加锁次数为1
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
//1.2 设置key的过期时间,单位为毫秒
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//2.通过 大KEY+小key的方式判断锁是否存在,如果存在获取锁的次数+1,重新设置时间,返回n
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
//2.1 加锁次数+1
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
//2.2 重置过期时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//3.兜底处理,锁获取不成功,若锁被其他程序已占用,返回锁剩余时间
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}