【Redis实战】秒杀优惠券优化(Lua脚本,阻塞队列,异步进程)

原代码实现

@Override
public Result seckillVoucher(Long voucherId) throws InterruptedException {
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    if (seckillVoucher == null) {
        return Result.fail("优惠卷不存在");
    }
    // 判断秒杀时间
    LocalDateTime now = LocalDateTime.now();
    if(seckillVoucher.getBeginTime().isAfter(now)){
        return Result.fail("秒杀尚未开始");
    }
    if(seckillVoucher.getEndTime().isBefore(now)){
        return Result.fail("秒杀已经结束");
    }
    // 查询库存
    if(seckillVoucher.getStock()<0){
        return Result.fail("库存不足");
    }
    // 提取一人一单,扣减库存,创建订单的代码加锁
    Long userId = UserHolder.getUser().getId();
    RLock lock = redissonClient.getLock("shop:" + userId.toString());
    boolean isLock = lock.tryLock(5, 10 , TimeUnit.SECONDS);
    if (!isLock) {
        return Result.fail("不允许重复下单");
    }
    try {
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId, userId);
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        lock.unlock();
    }
}

@Transactional
public Result createVoucherOrder(Long voucherId, Long userId) {
    Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
        return Result.fail("已经购买过了");
    }
    // 扣减库存
    boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
        .eq("voucher_id", voucherId).gt("stock", 0).update();
    if (!isSuccess) {
        return Result.fail("库存不足");
    }
    // 创建订单
    long id = idWorker.nextId("seckillVoucherOrder");
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(id);
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setUserId(userId);
    voucherOrder.setCreateTime(LocalDateTime.now());
    save(voucherOrder);
    return Result.ok(id);
}

痛点

  • 数据库压力:频繁查询库存和订单导致数据库负载过高。
  • 响应延迟:同步操作(如事务提交)阻塞用户请求。

需求分析

在这里插入图片描述

减少数据库访问:通过设置缓存,将优惠卷数量,下单用户存入缓存。

性能提升:开启异步消费者线程处理添加订单逻辑提高吞吐量。

实现过程

在这里插入图片描述

一、新增秒杀优惠卷的同时将优惠卷信息保存在Redis中

@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, voucher.getStock().toString());
}

二、基于Lua脚本,判断秒杀库存,一人一单,决定用户是否抢购成功

Lua脚本实现代码seckill.lua
--1.参数列表
--1.1优惠圈id
local voucherId = ARGV[1]
--1.2订单id
local userId = ARGV[2]
--2.key
--2.1库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2订单key
local orderKey = 'seckill:order:' .. voucherId
--3.脚本业务
--3.1判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
    --库存不足,直接返回1
    return 1
end
--3.2判断用户是否下单
if (redis.call('sismember', orderKey, userId) == 1) then
    --订单已存在,重复下单,直接返回2
    return 2
end
--3.3扣库存
redis.call('incrby', stockKey, '-1')
--3.4下单
redis.call('sadd', orderKey, userId)
Spring Data Redis定义Lua脚本
public static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("./lua/seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}

三、如果抢购成功,将优惠卷id和用户id封装后存入阻塞队列

创建阻塞队列
public BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
抢购成功加入阻塞队列
@Override
public Result seckillVoucher(Long voucherId) {
    // 1.执行Lua脚本
    long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(),
            UserHolder.getUser().getId().toString()
    );
    // 2.结果为1,库存不足
    if(result == 1){
        return Result.fail("库存不足");
    }
    // 3.结果为2,重复下单
    if(result == 2){
        return Result.fail("不允许重复下单");
    }
    // 4.库存为0,将订单信息保存到阻塞队列
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setUserId(UserHolder.getUser().getId());
    long id = idWorker.nextId("seckillVoucherOrder");
    voucherOrder.setId(id);

    orderTasks.add(voucherOrder);
    // 5.返回订单id
    return Result.ok(id);
}

四、开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

public BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
public static final ExecutorService SECKILL_ORDER_EXECUTER = Executors.newSingleThreadExecutor();

@PostConstruct
private void init(){
    SECKILL_ORDER_EXECUTER.submit(new voucherOrderHandler());
}

private class voucherOrderHandler implements Runnable {

    @Override
    public void run() {
        while(true){
            try {
                VoucherOrder order = orderTasks.take();
                handleVoucherOrder(order);
            } catch (Exception e) {
                log.error("处理订单异常", e);
            }
        }
    }
}

private void handleVoucherOrder(VoucherOrder order) throws InterruptedException {
    // 提取一人一单,扣减库存,创建订单的代码加锁
    Long userId = order.getUserId();
    // 创建锁对象
    RLock lock = redissonClient.getLock("shop:" + userId.toString());
    // 尝试获取锁
    boolean isLock = lock.tryLock(5, 10, TimeUnit.SECONDS);
    if(!isLock){
        return;
    }
    try {
        proxy.createVoucherOrder(order);
    } catch (IllegalStateException e) {
        throw new RuntimeException(e);
    } finally {
        lock.unlock();
    }
}
异步线程中代理对象获取的关键问题分析

异步线程(voucherOrderHandler)尝试通过 AopContext.currentProxy() 获取代理对象 proxy,但由于 线程上下文隔离AOP 代理机制限制,该操作在异步线程中 无法成功。以下是具体原因和解决方案:


一、问题根源
  1. AopContext 的 ThreadLocal 特性

    • AopContext.currentProxy() 底层依赖 ThreadLocal 存储当前线程的代理对象。
    • 主线程(HTTP 请求线程)中调用VoucherOrderService,Spring Aop拦截器将代理对象写入ThreadLocal,然后执行原方法,在原方法中就可以通过调用 AopContext.currentProxy()就可以获取当前类的代理对象。
  2. 异步线程中 ThreadLocal 为空的原因

当异步线程处理订单时,调用 handleVoucherOrder 方法:

private void handleVoucherOrder(VoucherOrder order) {
    // 尝试获取代理对象(失败!)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
}
  • 线程隔离性:异步线程的 ThreadLocal 是独立存储空间,主线程的 ThreadLocal 数据不会自动传递。
  • 未触发代理机制:异步线程未通过代理对象调用方法,因此 Spring 未将代理对象存入其 ThreadLocal

二、解决方案
方案 1:传递代理对象到异步任务(推荐)

在提交订单到队列时,将当前代理对象与订单对象绑定:

// 修改订单队列类型
public BlockingQueue<OrderTask> orderTasks = new ArrayBlockingQueue<>(1024*1024);

// 定义订单任务包装类
public class OrderTask {
    private VoucherOrder order;
    private IVoucherOrderService proxy;

    public OrderTask(VoucherOrder order, IVoucherOrderService proxy) {
        this.order = order;
        this.proxy = proxy;
    }
    // Getter 省略
}
// 修改秒杀方法中的任务提交逻辑
@Override
public Result seckillVoucher(Long voucherId) {
    // ... 省略其他逻辑 ...
    
    // 获取当前代理对象(主线程中)
    IVoucherOrderService currentProxy = (IVoucherOrderService) AopContext.currentProxy();
    // 提交任务时绑定代理对象
    orderTasks.add(new OrderTask(voucherOrder, currentProxy));
    
    return Result.ok(id);
}
// 修改异步任务处理逻辑
private class voucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                OrderTask task = orderTasks.take();
                handleVoucherOrder(task.getOrder(), task.getProxy());
            } catch (Exception e) {
                log.error("处理订单异常", e);
            }
        }
    }
}

private void handleVoucherOrder(VoucherOrder order, IVoucherOrderService proxy) {
    RLock lock = redissonClient.getLock("shop:" + order.getUserId());
    try {
        if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            proxy.createVoucherOrder(order); // 使用传递的代理对象
        }
    } finally {
        lock.unlock();
    }
}
方案 2:通过自注入获取代理对象

让 Spring 自动注入代理对象,避免依赖 AopContext

@Service
public class VoucherOrderServiceImpl implements IVoucherOrderService {
    @Autowired
    private IVoucherOrderService selfProxy; // Spring 会自动注入代理对象

    private void handleVoucherOrder(VoucherOrder order) {
        RLock lock = redissonClient.getLock("shop:" + order.getUserId());
        try {
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
                selfProxy.createVoucherOrder(order); // 直接使用注入的代理对象
            }
        } finally {
            lock.unlock();
        }
    }
}
方案 3:显式从 Spring 容器获取代理

通过 ApplicationContextAware 接口获取代理对象:

@Service
public class VoucherOrderServiceImpl implements IVoucherOrderService, ApplicationContextAware {
    private ApplicationContext applicationContext;
    private IVoucherOrderService proxy;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
        this.proxy = applicationContext.getBean(IVoucherOrderService.class);
    }

    private void handleVoucherOrder(VoucherOrder order) {
        RLock lock = redissonClient.getLock("shop:" + order.getUserId());
        try {
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
                proxy.createVoucherOrder(order); // 使用容器中的代理对象
            }
        } finally {
            lock.unlock();
        }
    }
}

存在的问题

  • 内存限制问题

    阻塞队列的容量是有限的,如果秒杀请求量过大,队列可能被填满,后续请求无法加入队列,导致部分用户请求失败。

  • 数据安全问题

    如果消费者进程在异步处理的过程中宕机,阻塞队列中的其他订单将会丢失,导致剩余订单无法处理。

现存问题与局限性

问题影响临时解决方案
阻塞队列容量限制高并发时队列溢出导致请求丢失增大队列容量
数据可靠性不足进程宕机导致未处理订单丢失定期持久化队列数据到数据库
扩展性受限单线程消费无法应对突发流量增加消费者线程数

改进方向

我们可以使用消息队列处理以上问题。

消息队列Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

消息队列:存储和管理消息,也被称为消息代理(Message Broker)

生产者:发送消息到消息队列

消费者:从消息队列获取消息并处理消息

下篇文章我们使用Redis完成消息队列。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MonKingWD

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值