秒杀能遇到的问题无非就是两种问题,一种是超买,另一种是一人买多单。而在实际解决问题时,会不断地冒出新的问题。所以我写这一份汇总,方便以后的复习。
超卖
在高并发多线程的秒杀时,如果不做干预,可能会遇到多个线程同时对数据库进行访问,得到库存还剩1的情况,这是多个线程同时对库存进行-1操作,就会使得库存变为负数,这就是超卖问题。
超卖问题的常见解决方案有两种
乐观锁
认为线程安全问题不一定发生,因此不加锁,只会在更新数据库的时候去判断有没有其它线程对数据进行修改,如果没有修改则认为是安全的,直接更新数据库中的数据即可,如果修改了则说明不安全,直接抛异常或者等待重试。常见的实现方式有:版本号法、CAS操作。
悲观锁
认为线程安全问题一定会发生,因此操作数据库之前都需要先获取锁,确保线程串行执行。悲观锁中又可以再细分为公平锁、非公平锁、可重入锁等等。常见的悲观锁有:synchronized、lock。
对比这两种方法,各有利弊。
- 悲观锁和乐观锁的解决共享变量冲突方式不同:悲观锁在冲突发生时直接阻塞其他线程;乐观锁则是在提交阶段检查冲突并进行重试。
- 悲观锁比乐观锁的性能低:悲观锁需要先加锁再操作,限制了并发性能;而乐观锁不需要加锁,所以乐观锁通常具有更好的性能。
- 应用场景:两者都是互斥锁,悲观锁适合写入操作较多、冲突频繁的场景;乐观锁适合读取操作较多、冲突较少的场景。
在解决高并发时,显然不可能对每一次请求都做锁,这对于资源消耗是巨大的。所以往往会采用乐观锁的方式来解决超卖问题。
版本号法
版本号法就是在数据库对库存加入一个版本号,查询库存余量时,同时查询版本号,在修改余量之前再查一下版本号,如果版本号一致就执行修改操作,如果不同则对版本号同时进行-1。这样可以避免一个库存被多次修改的情况。
CAS法
cas是对版本号进行的改进,不需要单独维护一个版本号,而是直接对数据本身进行检测,如果查询数据和修改数据前的数据不一致,则认为有其他线程进行过了操作。
但是这两种方法会导致任意时刻都会有很多人无法购买商品,有没有更好的方法呢?
最终选择了最朴素的检查库存余量的方法。也就是在每次即将执行修改操作前,查看一下库存的余量是否大于0,再做操作。极端情况虽然也会超卖,但是超卖概率大幅下降了。
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stoke = stoke - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
// 或者使用LambdaUpdateWrapper更新,防止字段写错
boolean success = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
// set stoke = stoke - 1
.setSql("stock = stock -1")
// where id = ? and stock > 0
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0));
一人买多单
单体服务
对于秒杀的商品来说一般都是一人限量一单的,但是一个用户同一时刻多次下单则会造成一人买多单的问题。
这就要用到悲观锁了,通过对每一个进程进行判断,每个userid只可以获取一个锁,如果锁被获取过了,则无法成功获取锁。
这里用到的是synchronized,对用userId为关键词进行锁的操作。
由于toString的源码底层是new String(),每次toString都是new了一个新字符串对象在堆中,所以如果我们只用userId.toString()拿到的也不是同一个用户,需要使用intern()方法,用intern()方法可以让同一个值的字符串对象不重复(放到了字符串常量池中)。如果字符串常量池中已经包含了一个等于值的String字符串对象,那么将返回池中的字符串地址引用;否则,将此String对象添加到池中,并返回对此String对象的引用。
@Transactional
public Result createSecKillVoucherOrder(Long voucherId) {
// 判断是否是一人一单
Long userId = ((UserVo) BaseContext.get()).getId();
// intern()方法才能保证每个用户的锁对象唯一
synchronized(userId.toString().intern()) {
// 根据用户id和优惠券id查询订单是否存在
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
// 该用户已经购买过了,不允许下多单
return Result.fail("该秒杀券用户已经购买过一次了!");
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stoke = stoke - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
throw new RuntimeException("扣减失败,秒杀券扣减失败(库存不足)!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisIDWorker.nextId(SECKILL_VOUCHER_ORDER)); // 订单id
voucherOrder.setUserId(userId); // 用户id
voucherOrder.setVoucherId(voucherId); // 代金券id
success = this.save(voucherOrder);
if (!success) {
// 创建秒杀券订单失败
throw new RuntimeException("创建秒杀券订单失败!");
}
// 返回订单id
return Result.ok(voucherOrder.getId());
}
}
事务失效
由于@Transactional是加在createSecKillVoucherOrder()上,而不是加在seckillVoucher()上。这里使用this.createSecKillVoucherOrder()调用,this是当前的VoucherOrderServiceImpl对象(目标对象),而不是它的代理对象。我们知道事务要想生效,其实是Spring对当前的VoucherOrderServiceImpl对象做了动态代理(Spring默认使用JDK动态代理,即对接口做代理,所以代理对象为IVoucherOrderService接口),拿到代理对象后去做事务处理。而当前的this非代理对象,而是目标对象,不具有事务功能。这个场景就是Spring事务失效的几种可能性之一。
// 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 使用带有事务功能的代理对象去调用
return proxy.createSecKillVoucherOrder(userId, voucherId);
为了避免这个问题要进行一些配置。
- pom中引入aspectj依赖。
- 在启动类中添加@EnableAspectJAutoProxy(exposeProxy = true)注解,允许暴露代理对象,默认是关闭的
集群服务
在集群服务下,因为用了多个JVM,而synchronized只能在单独的JVM内进行线程监视,这就导致如果服务器是轮询的,那么一个用户可以在多个服务器同时通过购买检验,使得一个用户可以买多单。
synchronized是本地锁,只能保证单个JVM内部多个线程之间的互斥。由于现在我们部署了多个tomcat节点,每个tomcat都有一个属于自己的JVM,每个JVM都有自己的堆、栈、方法区和常量池。每个JVM都有一把synchronized锁,在JVM内部是一个锁监视器,多个JVM就有多个锁监视器,导致每一个锁都可以有一个线程获取,于是从原来的本地互斥锁变成了并行执行,就会发送并发安全问题。这就是在集群环境或分布式系统下,synchronized锁失效的原因,在这种情况下,我们就需要使用分布式锁(跨JVM锁、跨进程锁)来解决这个问题,让多个JVM只能使用同一把锁。
分布式锁
分布式锁就是在整个系统中建立一个唯一的线程监视器,来保证即使在集群服务下也能做到线程唯一。
-
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
-
分布式锁的特点:
- 多线程可见性:多个线程都能看到相同的结果,多个进程之间都能感知到变化
- 互斥:互斥是分布式锁的最基本的条件,分布式锁必须能够确保在任何时刻只有一个节点能够获得锁,其他节点需要等待。
- 高可用:分布式锁应该具备高可用性,即使在网络分区或节点故障的情况下,程序不易崩溃,仍然能够正常工作。(容错性)当持有锁的节点发生故障或宕机时,系统需要能够自动释放该锁,以确保其他节点能够继续获取锁。
- 高性能:由于加锁本身就让性能降低,对于分布式锁需要较高的加锁性能和释放锁性能,尽可能减少对共享资源的访问等待时间,以及减少锁竞争带来的开销。
- 安全性:(可重入性)如果一个节点已经获得了锁,那么它可以继续请求获取该锁而不会造成死锁。(锁超时机制)为了避免某个节点因故障或其他原因无限期持有锁而影响系统正常运行,分布式锁通常应该设置超时机制,确保锁的自动释放。
这里只说Redis的方法
在redis中有setnx和expire两种指令,通过对Id进行处理后得到一个唯一的key,在setnx时如果返回的是1则代表获取锁成功,如果返回0则代表获取锁失败。
而如果一个线程获取锁后未响应了,则代表这个线程挂了,此时锁就会形成死锁,所以要设置一个自动过期时间ttl
获取锁失败后,重试获取锁有两种机制,阻塞式获取和非阻塞式获取:
- 阻塞锁:没有获取到锁,则继续等待获取锁。浪费CPU,线程等待时间较长,实现较麻烦。
- 非阻塞锁:尝试一次,没有获取到锁后,不继续等待,直接返回锁失败。(本次采用非阻塞机制)
以下是创建分布式锁的代码和对前面代码的微调。
/**
* 锁接口
*/
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
/**
* Redis分布式锁(版本一)
*/
public class SimpleRedisLock implements ILock {
// 锁key的业务名称
private String name;
private StringRedisTemplate stringRedisTemplate;
// 锁统一前缀
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
@Override
public boolean tryLock(long timeoutSec) {
String threadId = String.valueOf(Thread.currentThread().getId()); // 获取线程id作为value
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 秒杀下单优惠券
* @param voucherId 优惠券id
* @return 下单id
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 查询秒杀优惠券信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀活动尚未开始
return Result.fail("秒杀尚未开始!");
}
// 判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀活动已经结束
return Result.fail("秒杀已经结束!");
}
// 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
// 秒杀券库存不足
return Result.fail("秒杀券已抢空!");
}
// 判断是否是一人一单,如果是再去下订单
Long userId = ((UserVo) BaseContext.get()).getId();
// 创建锁对象
SimpleRedisLock lock = new SimpleRedisLock(SECKILL_VOUCHER_ORDER + userId, stringRedisTemplate);
// 尝试获取锁
boolean isLock = lock.tryLock(10L);
// 获取锁失败
if (!isLock) {
// 获取锁失败,返回错误信息或重试
return Result.fail("不允许重复下单,一个人只允许下一单");
}
try {
// 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 使用带有事务功能的代理对象去调用
return proxy.createSecKillVoucherOrder(userId, voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
这样就解决了集群一人买多单的问题了。
误删问题
问题仍然存在,假设一个线程超过ttl还未执行完毕,系统自动释放了锁,另一个线程重新获取了锁,那么此时,第一个线程执行完毕了,就会执行释放锁的指令,此时第二个线程还在执行中,由于锁又被释放了,第三个线程就会获得锁,重复循环造成并发问题。
解决方案
- 在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则进行锁的删除;如果不属于自己,则不进行释放锁逻辑。我们之前是使用当前获取锁的线程id作为锁的标识,但是在多个JVM内部,因为线程id是递增分配的,可能会出现线程id重复的情况。因此我们在线程id前面添加一个UUID,用于区分不同的JVM,而线程id用于区分同一个JVM内部的不同请求。这样就保证了分布式锁的标识唯一。
改进代码:
/**
* Redis分布式锁
*/
public class SimpleRedisLock implements ILock {
// 锁key的业务名称
private String name;
private StringRedisTemplate stringRedisTemplate;
// 锁key的统一前缀
private static final String KEY_PREFIX = "lock:";
// 锁value = "UUID-ThreadId",ID_PREFIX用于区分不同JVM,线程唯一标识用于区分不同服务
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
@Override
public boolean tryLock(long timeoutSec) {
// 获取当前JVM内部的当前线程id
String threadId = ID_PREFIX + Thread.currentThread().getId(); // 锁value = "UUID-ThreadId"
// 尝试获取锁
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);
}
}
}
原子性
当线程1获取到锁并且执行完业务后,判断完当前锁是自己的锁正准备释放锁时,由于JVM的垃圾回收机制导致短暂的阻塞发生了阻塞,恰好在阻塞期间锁被超时释放了。线程2获得锁执行业务,但就在此时线程1阻塞完成,由于已经判断过锁标识,已经确定锁是自己的锁了,于是直接删除了锁。而这时删的是线程2的锁,没有了锁的互斥,线程3再来了之后就会发生超卖问题。
所以为了解决这个问题,必须要保证判断锁标识和释放锁这两个动作是一个原子性操作。因此我们需要使用Lua脚本。
用lua脚本可以避免redis指令在程序中受到阻塞,保证了操作的原子性。
编写以下lua脚本
-- 锁的key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]
-- 获取锁中的线程标识
local id = redis.call('get', key)
-- 比较当前线程标识是否和锁中的线程标识一致
if (id == threadId) then
-- 一致,释放锁
return redis.call('del', key)
end
-- 不一致,返回0,表示释放锁失败
return 0
那么再次微调程序
- SimpleRedisLock
/**
* Redis分布式锁
*/
public class SimpleRedisLock implements ILock {
// 锁key的业务名称
private String name;
private StringRedisTemplate stringRedisTemplate;
// 锁key的统一前缀
private static final String KEY_PREFIX = "lock:";
// 锁value = "UUID-ThreadId",ID_PREFIX用于区分不同JVM,线程唯一标识用于区分不同服务
// 因为这里锁是final的静态常量,仅在项目启动时随着该类加载,而去初始化该变量,UUID只会被初始化一次,所以当前服务器JVM拿到的ID_PREFIX都一样
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
// RedisScript接口实现类
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 静态代码块初始化加载脚本
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("script/unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
@Override
public boolean tryLock(long timeoutSec) {
// 获取当前JVM内部的当前线程id
String threadId = ID_PREFIX + Thread.currentThread().getId(); // 锁value = "UUID-ThreadId"
// 尝试获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 拆箱判断
return Boolean.TRUE.equals(success);
}
/**
* 调用lua脚本释放锁
*/
@Override
public void unlock() {
// Redis调用Lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
}
Redisson
此时手搓的分布式代码就已经差不多了,但是还能再次精进,那就是用Redisson,别人提前做好的分布式锁。
redisson的优点:
在使用redisson之前要进行一些配置
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.8.100:6379").setPassword("123321");
// 创建RedissonClient客户端对象
return Redisson.create(config);
}
}
基于redisson再次微调代码
@Resource
private RedissonClient redissonClient;
/**
* 秒杀下单优惠券
* @param voucherId 优惠券id
* @return 下单id
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 业务校验省略...
// 判断是否是一人一单,如果是再去下订单
Long userId = ((UserVo) BaseContext.get()).getId();
// 创建锁对象(可重入),指定锁的名称
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + SECKILL_VOUCHER_ORDER + userId);
// 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
// 空参表示默认参数,获取锁的最大等待时间waitTime为-1,表示获取失败不等待不重试,直接返回结果;锁自动释放时间leaseTime为30秒,表示超过30秒还没有释放的话会自动释放锁
boolean isLock = lock.tryLock(); // 空参默认失败不等待
// 获取锁失败
if (!isLock) {
// 获取锁失败,返回错误信息或重试(该业务是一人一单,直接返回失败信息,不重试)
return Result.fail("不允许重复下单,一个人只允许下一单");
}
try {
// 使用AopContext.currentProxy()方法拿到当前目标对象的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 使用带有事务功能的代理对象去调用
return proxy.createSecKillVoucherOrder(userId, voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
Redisson底层原理
-
可重入锁:又称之为递归锁,也就是一个线程可以反复获取锁多次,一个线程获取到锁之后,内部如果还需要获取锁,可以直接再获取锁,前提是同一个对象或者class。可重入锁的最重要作用就是避免死锁的情况。
-
不可重入锁:又称之为自旋锁,底层是一个循环加上unsafe和cas机制,就是一直循环直到抢到锁,这个过程通过cas进行限制,如果一个线程获取到锁,cas会返回1,其它线程包括自己就不能再持有锁,需要等线程释放锁。
-
可冲入锁的原理
ReentrantLock和synchronized都是可重入锁。在ReentrantLock锁中,底层借助于一个voaltile的state变量来记录重入状态的次数的,所以允许一个线程多次获取资源锁。第一次调用lock时,计数设置为1,再次获取资源锁时加1,调用unlock解锁,计数减1,直到减为0,释放锁资源。在synchronized锁中,它在c语言代码中会有一个count,原理和state类似,也是重入一次就+1,释放一次就-1 ,直到减少成 0 时,表示当前这把锁没有被持有。
我们之前的自定义的分布式锁不具有可重入性的原因,是因为:重入锁的设计必须要求既记录线程标识,又要记录重入次数,而我们String数据类型的锁已经不够用了。因此,需要一个key里同时记录两个字段的情况,可以使用hash数据结构
而Redisson底层也是以 hash 数据结构的形式将锁存储在Redis中,并且Redisson分布式锁也具有可重入性,每次获取锁,都将 value 的值+1,每次释放锁,都将 value 的值-1,只有锁的 value 值归0时才会真正的释放锁,从而确保锁的可重入性
具体实现原理参考源码(我还没看。。。)
Redis异步优化
由于一开始要查id是否出现过,以及商品是否售罄, 是否超出时间,这些请求都是直接打到SQL的,性能很差,可以吧这一步单独封装起来,基于redis进行。
符合条件的用户放入消息队列中,交给另一个专门执行操作数据的方法来执行。
这样不同步操作的方法,叫做异步优化。
我们需要把优惠券库存信息和订单被哪些用户购买过的信息缓存在Redis中,我们应该选择什么样数据结构来保存这两个信息呢?
- 对于优惠券库存信息,使用String类型,key为优惠券库存key前缀+优惠券id,value为库存。
- 对于订单购买信息,使用Set类型(方便去重判断一人一单),key为优惠券订单key前缀+优惠券id,value为购买过该优惠券的用户id集合
业务流程如下
- 使用Lua脚本保证以下操作的原子性:判断用户秒杀资格,根据不同情况返回不同的标识(0:满足条件已下单、1:库存不足、2:该优惠券此用户已下过单),如果满足秒杀条件,预先扣减Redis中的库存(数据库的库存先不扣减,后面异步扣减),将用户id存入当前优惠券的Set集合中,作为下次判断一人一单的依据。
- 在Tomcat中,首先执行Lua脚本,判断返回的结果是否为0,如果没有购买资格返回提示信息,如果有购买资格,将优惠卷id。用户id和订单id存入阻塞队列,方便异步线程去读取信息,完成异步下单,最后返回订单id,至此基于Redis的秒杀业务已经结束,用户已经可以拿到订单id去完成支付操作了。
- 开启异步线程去读取阻塞队列中的信息,完成数据库下单和扣减库存的动作,这一步对时效性要求就不是那么高了。
改进秒杀业务,提高并发性能
需求:
4. 新增秒杀优惠券的同时,将优惠券库存信息保存到Redis中
5. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
6. 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
7. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
- 新增秒杀优惠券的同时,将优惠券库存信息保存到Redis中。
/**
* 优惠券Service的实现类
*/
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
@Transactional
public void addSecKillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 新增时将秒杀券库存保存到Redis中,不设置过期时间,秒杀到期后需要手动删除缓存
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY_PREFIX + voucher.getId(), voucher.getStock().toString());
}
}
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功。
-- 参数列表
local voucherId = ARGV[1] -- 优惠券id(用于判断库存是否充足)
local userId = ARGV[2] -- 用户id(用于判断用户是否下过单)
-- 构造缓存数据key
local stockKey = 'hmdp:seckill:stock:' .. voucherId -- 库存key
local orderKey = 'hmdp:seckill:order:' .. voucherId -- 订单key
-- 脚本业务
-- 判断库存是否充足
if tonumber(redis.call('get', stockKey)) <= 0 then
-- 库存不足,返回1
return 1
end
-- 判断用户是否下过单 SISMEMBER orderKey userId,SISMEMBER:判断Set集合中是否存在某个元素,存在返回1,不存在放回0
if redis.call('sismember', orderKey, userId) == 1 then
-- 存在,说明用户已经下单,返回2
return 2
end
-- 缓存中预先扣减库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 有下单资格,允许下单,返回0
return 0
- IVoucherOrderService
/**
* 优惠券订单Service
*/
public interface IVoucherOrderService extends IService<VoucherOrder> {
/**
* 秒杀下单优惠券
* @param voucherId 优惠券id
* @return 下单id
*/
Result seckillVoucher(Long voucherId);
/**
* 判断是否是一人一单,如果是再去创建秒杀券订单
* @param userId 用户id
* @param voucherId 订单id
* @return 订单id
*/
Result createSecKillVoucherOrder(Long userId, Long voucherId);
/**
* 将创建的秒杀券订单异步写入数据库
* @param voucherOrder 订单信息
*/
void createSecKillVoucherOrder(VoucherOrder voucherOrder);
}
- 改造VoucherOrderServiceImpl
/**
* 优惠券订单Service实现类
*/
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIDWorker redisIDWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
// RedisScript接口实现类
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 静态代码块初始化加载脚本
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("script/seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 阻塞队列特点:当一个线程尝试从队列中获取元素,没有元素,线程就会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct // 在类初始化时执行该方法
private void init() {
// 启动线程池,执行任务
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 线程任务内部类
private class VoucherOrderHandler implements Runnable {
// 线程任务: 不断从阻塞队列中获取订单信息
@Override
public void run() {
while (true) {
try {
// take()方法:从阻塞队列中获取元素,如果队列为空,线程会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
VoucherOrder voucherOrder = orderTasks.take();
// 创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 从订单信息里获取用户id(从线程池中取出的是一个全新线程,不是主线程,所以不能从BaseContext中获取用户信息)
Long userId = voucherOrder.getUserId();
// 创建锁对象(可重入),指定锁的名称
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + SECKILL_VOUCHER_ORDER + userId);
// 尝试获取锁,空参默认失败不等待,失败直接返回
boolean isLock = lock.tryLock();
// 获取锁失败,返回错误或重试(这里理论上不需要再做加锁和判断,因为抢单环节的lua脚本已经保证了业务执行的原子性,不允许重复下单)
if (!isLock) {
log.error("不允许重复下单,一个人只允许下一单!");
return;
}
try {
// 将创建的秒杀券订单异步写入数据库
proxy.createSecKillVoucherOrder(voucherOrder);
} finally {
// 释放锁
lock.unlock();
}
}
// 事务代理对象
private IVoucherOrderService proxy;
/**
* 秒杀下单优惠券(Redis分布式锁+异步秒杀优化)
* @param voucherId 优惠券id
* @return 下单id
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户id
Long userId = ((UserVo) BaseContext.get()).getId();
// 执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
// 判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 不为0,表示没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 为0,表示有购买资格,把下单信息保存到阻塞队列
long orderId = redisIDWorker.nextId(SECKILL_VOUCHER_ORDER);
// 创建订单(包括订单id,用户id,秒杀券id)
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId); // 订单id
voucherOrder.setUserId(userId); // 用户id
voucherOrder.setVoucherId(voucherId); // 秒杀券id
// 获取当前目标对象的事务代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 把订单信息保存到阻塞队列
orderTasks.add(voucherOrder);
// 返回订单id
return Result.ok(orderId);
}
/**
* 将创建的秒杀券订单异步写入数据库
* @param voucherOrder 订单信息
*/
@Transactional
public void createSecKillVoucherOrder(VoucherOrder voucherOrder) {
// 根据用户id和优惠券id查询订单是否存在
int count = query().eq("user_id", voucherOrder.getUserId()).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 一人一单判断
if (count > 0) {
// 该用户已经购买过了,不允许下多单
log.error("该秒杀券用户已经购买过一次了!");
return;
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stoke = stoke - 1
// where id = ? and stock > 0
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock", 0)
//.eq("stock", seckillVoucher.getStock()) // CAS乐观锁(成功卖出概率太低、需要用 stock > 0 来判断)
.update();
if (!success) {
// 扣减失败
log.error("扣减失败,秒杀券扣减失败(库存不足)!");
return;
}
// 将订单信息写入数据库
success = this.save(voucherOrder);
if (!success) {
// 创建秒杀券订单失败
throw new RuntimeException("创建秒杀券订单失败!");
}
}
}
- 注意:AopContext.currentProxy()底层也是利用ThreadLocal获取的,所以异步线程中也无法使用。解决方案就是提升代理对象的作用域,放到成员变量位置,在主线程中初始化,或者在主线程中创建后作为方法参数一起传递给阻塞队列。
阻塞队列
这里只探讨redis的阻塞队列
通过redis可以通过上面三种形式实现阻塞队列,各有利弊,但是Stream是结合了两家之所长的最新产物,所以秒杀基于stream来进行一波优化
先说思路,每次判断有资格后,将订单id和用户id和优惠券id加入到消息队列中,然后消息队列异步执行判断,当实现后再pending队列中更新对应的状态。如果主队列出错,则进入pending队列中直到pending队列为空。
以下是代码实现
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有异常消息,结束循环
break;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}