回顾概述
(1)在一人一单优惠券秒杀中,先是解决了因线程安全导致的超卖问题,为了满足一人一单的业务需求需要给查询订单表查询库存表上锁,但是synchronized只能满足单机环境下线程安全,在分布式环境对多个JVM需要使用分布式锁,于是手写分布式锁,但是在极端情况下,例如业务超时,会出现“误删”问题,手写锁无法解决,于是引入redisson,借助看门狗机制可以有效解决这个问题。
(2)在整个过程中,涉及到了多次查询和写入数据库操作,通过Jmeter测试性能,最长响应时间达到了秒级,用户体验很差,想到借助异步秒杀(用到了Stream流),并且将两次查询数据库的操作放到redis中以Lua脚本方式(主要是为了保证原子性),将“处理订单”的操作放到消息队列当中,让其他线程接收并且处理。
我们只需要执行一个判断的逻辑,如果库存足够并且满足“一人一单”,就给用户响应下单成功的结果。
业务流程
当用户发起请求时,会请求到nginx,nginx会访问tomcat,而tomcat中的程序,会进行串行操作,分成如下几个步骤:
1、查询优惠卷
2、判断秒杀库存是否足够
3、查询订单
4、校验是否是一人一单
5、扣减库存
6、创建订单
方案思考
我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点。
如何快速判断一人一单
当用户下单之后,判断库存是否充足只需要导redis中去根据key找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作。
如何知道下单是否成功
我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。
阻塞队列
内存限制/数据安全都无法得到保证
//异步处理线程池
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 {
// 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.lock();
// 4.判断是否获得锁成功
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try {
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
//a
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
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 = seckillVoucherService.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);
}
Stream流
思路
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
实现
-- 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
public Result seckillVoucher(Long voucherId){
Long userId = UserHolder.getUser().getId();
//生成订单编号
long orderId = redisIdWorker.nextId("order");
// 执行lua脚本
// 会在lua脚本当中将订单放到stream流中
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString(),
String.valueOf(orderId)
);
int success = result.intValue();
if (success != 0) return Result.fail(success == -1 ? "库存不足" : "不能重复下单");
//将订单保存至阻塞队列
//3.返回订单id
return Result.ok(orderId);
}
//异步秒杀业务更改,执行lua脚本之后异步下单
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
//子线程
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()) continue;
//3.解析数据
MapRecord<String,Object,Object> record = list.get(0);
//使用value来填充voucherOrder的属性
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
//3.创建订单
createVoucherOrder2(voucherOrder);
//4.确认消息xack
stringRedisTemplate.opsForStream().acknowledge("s1","g1",record.getId());
}catch (Exception e){
e.printStackTrace();
handlePendingList();
}
}
}
private void handlePendingList() {
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()) continue;
//3.解析数据
//因为只读取一条记录所以list.get(0)
MapRecord<String,Object,Object> record = list.get(0);
//使用value来填充voucherOrder的属性
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
//3.创建订单
createVoucherOrder2(voucherOrder);
//4.确认消息xack
stringRedisTemplate.opsForStream().acknowledge("s1","g1",record.getId());
}catch (Exception e){
log.error("处理订单异常{}",e.getMessage());
// handlePendingList();
}
}
}
/**
* 改造成为异步下单
* 创建订单
* @param voucherOrder 订单信息
*/
private void createVoucherOrder2(VoucherOrder voucherOrder) {
//添加逻辑:该用户是否已经已经下过单了
//防止黄牛恶意刷单 但是会存在和超卖相同的问题 同一个用户但是超卖了三张票
Long userId = voucherOrder.getUserId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean tryLock = lock.tryLock();
if (!tryLock) {
log.error("不允许重复下单");
return;
}
try {
Long voucherId = voucherOrder.getVoucherId();
//3.查询订单
Integer count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
//4.校验一人一单
if (count > 0) {
log.error("无法重复下单");
return;
}
//5.库存扣减 超减问题并发量3000的情况下 超卖10张
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0).update();
if (!success) {
log.error("库存不足,下单失败");
return;
}
//6.添加订单
voucherOrderService.save(voucherOrder);
}catch (Exception e){
log.error("下单失败{}",e.getMessage());
}finally {
lock.unlock();
}
}
}