问题描述
如下图所示的业务场景,用户向服务器提交购买优惠券的请求,服务器现在按顺序串行同步执行业务流程,这样业务虽然可以严谨执行,但是由于整个串行执行的时间耗费很长,特别是“减库存”和“创建订单”还是向MySQL中写的操作,这样一个线程就不得不按流程走完这个用户提交的请求才能去响应其它用户的请求。
这里我们就可以想到多线程结合速度更快的Redis的思路,那把哪些业务交给Redis的线程呢?我们可以给每个业务都独立开新线程吗?显然也是不行的,如果开这么多线程一旦用户请求多起来,线程池中的线程很快就用完了。
优化方案:我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点:
- 第一个难点是我们怎么在redis中去快速校验一人一单,还有库存判断
- 第二个难点是由于我们校验和tomct下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。
优化方案
我们现在来看看整体思路:当用户下单之后,判断库存是否充足只需要导redis中去根据key(stock:vid)找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作
当以上判断逻辑走完之后,我们可以判断当前redis中返回的结果是否是0 ,如果是0,则表示可以下单,则将之前说的信息存入到到queue中去,然后返回,然后再来个线程异步的下单,前端可以通过返回的订单id来判断是否下单成功。
基于JDK的阻塞队列BlockingQueue实现
需求:
-
新增秒杀优惠券的同时,将优惠券信息保存到Redis中
@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中
//SECKILL_STOCK_KEY 这个变量定义在RedisConstans中
//private static final String SECKILL_STOCK_KEY ="seckill:stock:"
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
-
基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
Lua脚本
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
---- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
--redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
/**
* 声明RedisScript,DefaultRedisScript是其实现类
*/
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>(); // 实例化
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); // 读取脚本文件
SECKILL_SCRIPT.setResultType(Long.class); // 指定返回值的类型
}
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // lua脚本中没有用到KEYS,这里传入空List
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//TODO 保存阻塞队列
// 3.返回订单id
return Result.ok(orderId);
}
-
如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
-
开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
tips:
1、BlockingQueue阻塞队列
阻塞队列特点:当一个线程尝试从这个队列中获取元素时,如果这个队列中没有元素,那么这个线程就会被阻塞,直到队列中有元素时这个线程才会被唤醒并获取元素
//阻塞队列,泛型是订单 private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
2、异步处理线程池
//异步处理线程池 private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
3、如何开启线程任务
思考:既然当用户下单后,阻塞队列中就会有元素压入,那应该什么时候开启线程关注这个阻塞队列呢?
应该当项目一启动,类一初始化完毕就开启子线程了
spring提供的@PostConstruct注解,表示被注解的方法在类一初始化就执行
内部类实现Runnable接口,用于处理阻塞队列
//spring提供的注解,表示当前类初始化后就执行注解的方法 //在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的 @PostConstruct private void init() { //类初始化后就直接开子线程执行VoucherOrderHandler业务 SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); }// 用于线程池处理的任务 // 当初始化完毕后,就会去从对列中去拿信息 private class VoucherOrderHandler implements Runnable { @Override public void run() {/*业务代码*/} }
4、代理对象proxy
下面是单线程下待优化的代码,值得注意的是,单线程的“判断用户是否有下单资格”和“数据插入数据库”的操作是一个线程执行的,而使用多线程优化代码后,“数据插入数据库”将由新线程执行,由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效。
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", voucher.getStock()) //where id = ? and stock > 0,这里不是判断版本号相等,而另外做了优化,判断相等会使得高并发情况下失败率大大增加
.update();
if (!success) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//单体服务的悲观锁实现
/* synchronized (userId.toString().intern()) { // <--- !!!在这里上锁!!!
//获取和事务有关的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}*/
/* 分布式锁 */
// 创建锁对象
// SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient1.getLock("lock:order:" + userId);
// 获取锁
boolean isLock = lock.tryLock();
// 判断是否获取锁成功
if (!isLock) {
// 获取锁失败,直接返回错误信息
return Result.fail("不允许重复下单!");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
@Transactional // <---spring提供的事务
public Result createVoucherOrder(Long voucherId) { // <-- 事务开始
// 一人一单
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("每人限购一次!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}// <--- 事务结束,spring只有当事务结束后才会执行数据库更新操作
所以我们要将声明代理对象的动作“IVoucherOrderService proxy”提权,使它成为类的成员变量,然后再在“判断用户是否有下单资格”的时候实例化proxy,在“数据插入数据库”的时候使用成员变量proxy。
最终实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
/**
* 声明RedisScript,DefaultRedisScript是其实现类
*/
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>(); // 实例化
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); // 读取脚本文件
SECKILL_SCRIPT.setResultType(Long.class); // 指定返回值的类型
}
//异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//阻塞队列,泛型是订单
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
private IVoucherOrderService proxy;
//spring提供的注解,表示当前类初始化后就执行注解的方法
//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() { //类初始化后就直接开子线程执行VoucherOrderHandler业务
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务
// 当初始化完毕后,就会去从对列中去拿信息
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) { // 不用担心死循环会占用资源,JDK提供的是阻塞队列,当队列没有内容时会阻塞这个子线程,使其继续消耗
try {
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.获取用户
Long userId = voucherOrder.getUserId();
// 2.创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 3.尝试获取锁
boolean isLock = redisLock.tryLock();
// 4.判断是否获得锁成功
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try {
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // lua脚本中没有KEYS,这里传入空的List
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
//3.获取代理对象
proxy = (IVoucherOrderService)AopContext.currentProxy();
//4.返回订单id
return Result.ok(orderId);
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("用户已经购买过了");
return ;
}
// 6.扣减库存
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
log.error("库存不足");
return ;
}
save(voucherOrder);
}
}
待优化的思考
这里是使用了java原生的阻塞队列BlockingQueue,但是这样存在“内存限制”和“数据安全”的问题,因为BlockingQueue的内容全部存在内存中,极端条件下可能导致内存溢出,并且如果服务器突然宕机了,那存在阻塞队列中的数据会随着内存丢失。