1. 问题介绍
在用户抢单或者商品售卖的过程中,正常情况下是一人一件,但是当网络流量剧增时多个用户同时抢到一个商品应该如何分配?假设这样一个场景A商品库存是100个,但是秒杀的过程中,一共卖出去500个A商品。对于卖家来说,这就是超售。造成的经济损失应该由谁来承担?所以这类问题必需从根源解决!!
这就是该问题的简单描述,有了解过的小伙伴肯定知道应该用并发技术来解决,但是对应这种高并发的场景要怎么操作呢?下面来介绍几种常见的解决方法
2. 技术
2.1 设置事务的隔离级别×
那就是设置事物的隔离级别为Serializable。这个事物隔离级别非常严格,因为多个事物并发执行,对同一条记录修改,就会出现超售现象。所以干脆,咱们就禁止事物的并发执行吧。Serializable就是让数据库,串行执行事物,一个事物执行完,才能执行下一个事物,这种办法确实解决了超售的问题。
但是磁盘的IO速度比内存和CPU慢多了。所以说,串行化执行事务会让事务堆积指数递增,这在用户角度来说中是绝对无法忍受的。这种办法在技术上最稳妥,但是业务上不可行!
2.2 乐观锁
我们在数据表上面添加一个乐观锁字段,数据类型是整数的,用来记录数据更新的版本号,这个跟SVN机制很像。乐观锁是一种逻辑锁,他是通过版本号来判定有没有更新冲突出现。
比如说,现在A商品的乐观锁版本号是0,现在有事务1来抢购商品了。事务1记录下版本号是0,等到执行修改库存的时候,就把乐观锁的版本号设置成1。但是事务1在执行的过程中,还没来得及执行UPDATE语句修改库存。这个时候事务2进来了,他执行的很快,直接把库存修改成99,然后把版本号变成了1。这时候,事务1开始执行UPDATE语句,但是发现乐观锁的版本号变成了1,这说明,肯定有人抢在事务1之前,更改了库存,所以事务1就不能更新,否则就会出现超售现象。
但是由于其中大量的回滚操作,使得乐观锁不适用于冲突频率高时读写操作,而且乐观锁具有写倾斜和无法解决幻读问题
“幻读”问题:乐观锁无法解决“幻读”问题,即一个事务在读取某些数据后,另一个事务插入了一些新数据,导致第一个事务在后续的操作中看到了它之前没有读取到的数据。
写倾斜:在乐观锁机制下,可能会出现多个事务同时读取同一条记录,然后基于读取的数据进行修改,最后在提交时发生冲突,只有一个事务能够成功,其他事务需要重试,这种现象称为写倾斜。
2.3 分布式锁
在微服务架构中最常见的就是分布式事务锁,像是之前学习过synchronized 及lock锁,但是在微服务环境中就不可以使用(本地锁只在JVM中生效)了,那么怎么办呢?我可以使用分布式锁,分布式的实现方式多种多样,常见的分布式说可以基于以下集中方式实现:
2.3.1 基于 Redis 做分布式锁
基于 REDIS 的 SETNX()、EXPIRE() 方法做分布式锁
- setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
- expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
- 执行完业务代码后,可以通过 delete 命令删除 key
//对应redis中SETNX()操作,只是redisTemplate对其做了一层封装暴露出来setIfAbsent方法
//同时设置过期时间,防止异常导致死锁无法释放
Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent("lock", "lock", 10, TimeUnit.SECONES);
//可以获取到锁才进来操作
if(ifAbsent){
//并发业务操作。。。
//最后删除分布式锁,其他线程可以重新获得
redisTemplate.delete("lock");
}
但是这种情况会导致误删锁的问题 ,导致其他线程面临无锁的状态并发漏洞!!
解决方法在删之前判断是否是自己的锁,即添加一个UUID或者是任意一个唯一标识来确定该锁是自己的锁,删之前做判断就可以了,但是还是没有解决原子性(在判断通过进入删除前锁刚好过期,且锁刚好被另一个线程获取到),最终版本通过lua脚本解决,具体代码如下:
/**
* 采用SpringDataRedis实现分布式锁
* 原理:执行业务方法前先尝试获取锁(setnx存入key val),如果获取锁成功再执行业务代码,业务执行完毕后将锁释放(del key)
*/
@Override
public void testLock() {
//锁值设置为uuid
String uuid = UUID.randomUUID().toString();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
if(flag){
//获取锁成功,执行业务代码。。。
//将锁释放 判断uuid,redis执行lua脚本保证原子,lua脚本执行会作为一个整体执行
//执行脚本参数 参数1:脚本对象封装lua脚本,参数二:lua脚本中需要key参数(KEYS[i]) 参数三:lua脚本中需要参数值 ARGV[i]
// 先创建脚本对象 DefaultRedisScript泛型脚本语言返回值类型 Long 0:失败 1:成功
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 设置脚本文本
String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
redisScript.setScriptText(script);
// 设置响应类型
redisScript.setResultType(Long.class);
stringRedisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
}else{
try {
//睡眠
Thread.sleep(100);
//自旋重试
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2.3.2 基于 REDISSON 做分布式锁
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
官方文档地址:https://github.com/Redisson/Redisson/wiki
Github 地址:https://github.com/Redisson/Redisson
redisson 是 redis 官方的分布式锁组件。上面的代码没有接耦合,锁的代码和业务代码混在一起了需要解耦合!!
2.3.2.1 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
</dependency>
2.3.2.2 配置RedissonClient
import lombok.Data;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
/**
* redisson配置信息
*/
@Data
@Configuration
@ConfigurationProperties("spring.data.redis") //读取nacos配置文件
public class RedissonConfig {
private String host;
private String password;
private String port;
private int timeout = 3000;
private static String ADDRESS_PREFIX = "redis://";
/**
* 自动装配
*
*/
@Bean
RedissonClient redissonSingle() {
Config config = new Config();
if(!StringUtils.hasText(host)){
throw new RuntimeException("host is empty");
}
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(ADDRESS_PREFIX + this.host + ":" + port)
.setTimeout(this.timeout);
if(StringUtils.hasText(this.password)) {
serverConfig.setPassword(this.password);
}
return Redisson.create(config);
}
}
注意:这里读取了一个名为RedisProperties的属性,因为我们引入了SpringDataRedis,Spring已经自动加载了RedisProperties,并且读取了配置文件中的Redis信息。
2.3.2.3 修改实现类(其中定义三种锁的加锁操作)
@Autowired
private RedissonClient redissonClient;
/**
* 使用Redison实现分布式锁
* 开发步骤:
* 1.使用RedissonClient客户端对象 创建锁对象
* 2.调用获取锁方法
* 3.执行业务逻辑
* 4.将锁释放
*
*/
public void testLock() {
//1 创建锁对象
RLock lock = redissonClient.getLock("lock1");
//2 尝试加锁 三种选其一
//2.1 lock() 阻塞等待一直到获取锁,默认锁有效期30s
lock.lock();
//2.2 lock() 阻塞等待一直到获取锁,默认锁有效期10s
lock.lock(10,TimeUnit.SECONDS);
//2.3 tryLock() 等待30s获取锁,超过时间自动放弃,且默认锁有效期10s
lock.tryLock(30,10,TimeUnit.SECONDS);
//3 业务代码。。。
//4 将锁释放
lock.unlock();
}
基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。
大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
看门狗原理:
只要线程一加锁成功,就会启动一个watch dog
看门狗,它是一个后台线程,会每隔10
秒检查一下,如果线程一还持有锁,那么就会不断的延长锁key
的生存时间。因此,Redisson
就是使用Redisson
解决了锁过期释放,业务没执行完问题。
1、如果我们指定了锁的超时时间,就发送给Redis执行脚本,进行占锁,默认超时就是我们制定的时间,不会自动续期;
2、如果我们未指定锁的超时时间,就使用lockWatchdogTimeout = 30 * 1000
【看门狗默认时间】
2.3.3 基于 ZooKeeper 做分布式锁
基于临时顺序节点实现