前言:
秒杀是该项目中非常重要的一个模块,涵盖的知识点以及代码质量非常之高,里面有许多细节值得反复学习观看,能帮助我们获得非常有用的知识。这篇文章除了对该秒杀功能进行了总计,还包括许多细节的分析,如:如何加锁,为什么加这个锁,加在哪里,以及涉及了动态代理等知识,对这个模块有疑问的小伙伴可以来学习,大家一起进步学习。
目录
一.添加优惠券
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
// 关联普通券id
seckillVoucher.setVoucherId(voucher.getId());
// 设置库存
seckillVoucher.setStock(voucher.getStock());
// 设置开始时间
seckillVoucher.setBeginTime(voucher.getBeginTime());
// 设置结束时间
seckillVoucher.setEndTime(voucher.getEndTime());
// 保存信息到秒杀券表中
seckillVoucherService.save(seckillVoucher);
}
二.实现秒杀下单
创建订单 扣减库存 两步
代码实现
@Transactional//涉及到了两张表
public Result seckillVoucher(Long voucherid){
//1.查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherid);
//2.判断秒杀是否开始
//3.未开始,返回错误
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
//4.已结束,返回错误
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束");
}
//5.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
//6.充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherid).update();
if(!success){
return Result.fail("库存不足")
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
//用户id
Long userId = UserHolder.getUser().getId();
//代金券id
voucherOrder.setVoucherId(voucherid);
save(voucherOrder);
//8.返回订单id
return Result.ok(voucherid);
}
三.超卖问题
1.出现的问题
在优惠券秒杀场景下,我们并不可能指望两个线程顺序执行,大概率是穿插执行的,这样就有可能引发并发安全问题,大家都发现库存充足,且都去扣减库存,这样就有可能发生超卖的问题,那这种问题我们该如何去解决呢?其实第一反应就是给它加锁,但加什么样的锁呢?下面来看看
2.解决方案
-
悲观锁
悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行,例如Synchronized、Lock等,都是悲观锁,悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
-
乐观锁(更新时候)
乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常,如果没有修改,则认为自己是安全的,自己才可以更新数据。
那问题又来了,乐观锁怎么去判断有没有其他的线程去数据进行修改呢?
CAS方法
其实说白了,就是看看查询到的库存,和真实的库存值比较,如果相等就执行扣减库存的操作,如果不相等就不执行。
修改一下代码:
代码实现
在sql语句中多加了一个比对库存
@Transactional//涉及到了两张表
public Result seckillVoucher(Long voucherid){
//1.查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherid);
//2.判断秒杀是否开始
//3.未开始,返回错误
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
//4.已结束,返回错误
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束");
}
//5.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
//6.充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherid).eq("stock",voucher.getstock())
.update();
if(!success){
return Result.fail("库存不足")
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
//用户id
Long userId = UserHolder.getUser().getId();
//代金券id
voucherOrder.setVoucherId(voucherid);
save(voucherOrder);
//8.返回订单id
return Result.ok(voucherid);
结果发现还是不对,虽然没有实现超卖,但是还是有很多失败的情况,还有很多库存没有被抢到,来分析一下这又是为什么?
在后来进来的一个线程发现,都改成99了,不等于100,后面的线程也就全部失败了,其实完全没有这个必要非要查到的库存值相等,我只要还有库存,就执行扣减库存的操作就可以了,我们在把那句sql语句修改一下
@Transactional//涉及到了两张表
public Result seckillVoucher(Long voucherid){
//1.查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherid);
//2.判断秒杀是否开始
//3.未开始,返回错误
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
//4.已结束,返回错误
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束");
}
//5.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
//6.充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherid).gt("stock",0)
.update();
if(!success){
return Result.fail("库存不足")
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
//用户id
Long userId = UserHolder.getUser().getId();
//代金券id
voucherOrder.setVoucherId(voucherid);
save(voucherOrder);
//8.返回订单id
return Result.ok(voucherid);
}
就成功咯!
四.一人一单
- 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
- 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
- 如果已存在,则不能下单,返回错误信息
- 如果不存在,则继续下单,获取优惠券
@Transactional//涉及到了两张表
public Result seckillVoucher(Long voucherid){
//1.查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherid);
//2.判断秒杀是否开始
//3.未开始,返回错误
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
//4.已结束,返回错误
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束");
}
//5.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
+ // 一人一单逻辑
+ Long userId = UserHolder.getUser().getId();
+ int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
+ if (count > 0){
+ return Result.fail("你已经抢过优惠券了哦");
+ }
//6.充足,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherid).gt("stock",0)
.update();
if(!success){
return Result.fail("库存不足")
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
//用户id
Long userId = UserHolder.getUser().getId();
//代金券id
voucherOrder.setVoucherId(voucherid);
save(voucherOrder);
//8.返回订单id
return Result.ok(voucherid);
}
但这段代码还是和以前一样存在多线程的问题,假设一个用户故意开多线程抢优惠券,那么在执行我们刚刚加号处的代码时,会发现count都为0,那就都进行扣减库存的操作,那就又出现了问题,那么我们如何解决这个问题呢?还是加锁,那么我们把这个锁加在哪里呢?值得思考
一人一单前面的逻辑只是在执行查询优惠券,不用管,我们把一人一单后的逻辑提取到一个方法中,然后给这个方法加锁
private "加锁" Result createVoucherOrder(Long voucherId) {
// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
//5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
//6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//6.1 设置订单id
long orderId = redisIdWorker.nextId("order");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
大家想一下,加在整个方法上合适吗?如果加在整个方法上,它实际上是对调用这个方法的对象进行加锁。这意味着在同一时刻,只有一个线程可以执行这个对象的该同步方法。锁的细粒度太粗,那么每一个对象在调用这个方法时,都被锁住了,那其他想要调用这个方法时就只能等待,那相当于串行执行了,效率太低。我们想想这个并发问题的本质是什么,是想解决一人一单的问题,也就是userid的位置,那直接给userid那一块加锁就好了。我们看看
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
//5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
//6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//6.1 设置订单id
long orderId = redisIdWorker.nextId("order");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
//执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
}
synchronized (userId.toString().intern()) 这一句代码的原因是什么呢?
- 由于toString的源码是new String,所以如果我们只用
userId.toString()
拿到的也不是同一个用户,需要使用intern()
,如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。 -
public static String toString(long i) { if (i == Long.MIN_VALUE) return "-9223372036854775808"; int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i); char[] buf = new char[size]; getChars(i, size, buf); return new String(buf, true); }
但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题
@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) //锁的对象是userid{
return createVoucherOrder(voucherId);
}
}
那么现在是不是就完全可以了呢?还有一点小细节,大家注意加锁那一块的代码,你在调用createVoucherOrder方法时,实际上是this.createVoucherOrder,相当于调的是原始对象而不是代理对象哦(这里不懂的可以看我AOP那篇文章,讲述了动态代理的思想),所以事务并不会生效,所以我们这里不应该用this去调用,而应该用原始对象(目标对象)的代理对象去调用
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
五.集群环境下的并发问题
1.问题
我们通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
我们将服务启动两份,端口分别为8081和8082
然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
具体操作,我们使用
POSTMAN
发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥。
这其实就是在集群环境下,syn失效的原因,因为每一个jvm都对应着它自己的锁监视器,互相看不到对方的。这种情况下就需要通过分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)。
2.分布式锁
3.基于redis实现分布式锁的初级版本
核心思路:
我们利用redis的SETNX
方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试
3.1 锁的基本接口
public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期自动释放
* @return true表示获取锁成功,false表示获取锁失败
*/
boolean tryLock(long timeoutSec)//EX;
/**
* 释放锁
*/
void unlock();
}
3.2 创建实现它的类
public class SimpleRedisLock implements ILock {
//锁的前缀
private static final String KEY_PREFIX = "lock:";
//具体业务名称,将前缀和业务名拼接之后当做Key
private String name;
//这里不是@Autowired注入,采用的是构造器注入,在创建SimpleRedisLock时,将RedisTemplate作为参数传入
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
long threadId = Thread.currentThread().getId();
//获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
//自动拆箱可能会出现null,这样写更稳妥
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//通过DEL来删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
3.3 代码实现(修改我们之前实现一人一单的逻辑代码)
@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁对象
boolean isLock = redisLock.tryLock(120);
// 加锁失败,说明当前用户开了多个线程抢优惠券,但是由于key是SETNX的,所以不能创建key,得等key的TTL到期或释放锁(删除key)
if (!isLock) {
return Result.fail("不允许抢多张优惠券");
}
try {
// 获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
redisLock.unlock();
}
}
4.分布式锁误删情况
4.1 原因分析
由于线程1阻塞,超过了锁的释放时间,意思是线程1获取的锁已经释放了,这个时候线程2就可以来获取锁去执行它的业务了,但是过了一会儿线程1醒了,完成了它的业务后它就要执行释放锁的操作,然后二话不说直接把线程2的锁给释放了(因为它自己的锁已经超时释放了),这时已经没有锁了,线程3就可以进来了获取锁后执行它自己的业务了,这时就又出现了并行执行的情况。
4.2 解决方案
我们分析一下,为什么会出现上述的问题,就是在线程1醒来后,它执行完它的业务二话不说直接就去执行释放锁的操作了,它也不管是不是自己的锁。这就是原因所在,既然知道了原因,我们也就好去解决问题了,去判断一下是不是自己的不就好了吗
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 获取当前线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标识是否一致
if (threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
4.3.分布式锁原子性问题
由于判断锁标识和释放锁是两个动作,中间有间隔,才导致的这种情况。那我们只要确保两个动作的原子性就可以了。
解决方案(Lua脚本)
java代码执行lua脚本
分析lua脚本
-- 线程标识
local threadId = "UUID-31"
-- 锁的key
local key = "lock:order:userId"
-- 获取锁中线程标识
local id = redis.call('get', key)
-- 比较线程标识与锁的标识是否一致
if (threadId == id) then
-- 一致则释放锁 del key
return redis.call('del', key)
end
return 0
改写
-- 线程标识
local threadId = "UUID-31"
-- 锁的key
local key = "lock:order:userId"
-- 获取锁中线程标识
local id = redis.call('get', key)
-- 比较线程标识与锁的标识是否一致
if (threadId == id) then
-- 一致则释放锁 del key
return redis.call('del', key)
end
return 0
java代码改写
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}