秒杀业务流程回顾

  • 核心功能:接收优惠券ID,完成两个核心操作:扣减库存和创建订单
  • 业务限制:
    • 库存不能超卖,必须判断库存是否充足
    • 实现一人一单功能,限制每个用户只能购买一次

分析

image-20.webp

  • 原始流程问题:传统秒杀业务采用串行执行方式,包含查询优惠券、库存判断、查询订单、扣减库存、创建订单五个步骤,其中四个步骤涉及数据库操作,且包含写操作和分布式锁,导致业务耗时过长、并发能力弱。
  • 类比案例:以饭店经营为例,原始模式如同一个服务员负责接待、点餐、做饭全流程,效率低下;优化方案是将耗时短的接待点餐与耗时长的做饭分离,由不同人员并行处理。
  • 优化方向:将秒杀业务拆分为资格判断(快速)和下单操作(耗时)两部分,分别由不同线程处理,并引入Redis缓存提升资格判断效率。

image-2.webp

流程概览

1)判断库存是否充足

  • 数据结构选择:使用Redis的String结构存储库存,key格式为”stock:vid:7”,value为库存数量(如100)。
  • 判断逻辑:检查value是否大于0,若充足则预减库存(原子性减1),防止超卖。
  • 关键设计:预减库存操作需在Redis中完成,避免直接操作数据库,通过Lua脚本保证原子性。

2)判断一人一单

  • 数据结构选择:使用Redis的Set集合存储用户ID,key格式为”order:vid:7”,利用Set的自动去重特性保证唯一性。
  • 判断逻辑:检查用户ID是否已存在于集合中,存在则拒绝重复下单。
  • 数据同步:通过将用户ID存入Set集合,为后续请求提供判断依据。

3)使用Lua脚本

  • 必要性:由于库存判断、一人一单校验、预减库存、记录用户ID等多个操作需要保证原子性。
  • 执行流程:
    • 判断库存不足返回1
    • 判断重复下单返回2
    • 预减库存
    • 记录用户ID
    • 成功返回0
  • 异步处理:资格校验通过后,将优惠券ID、用户ID、订单ID存入消息队列,由独立线程异步完成数据库写操作。
  • 性能优势:主流程仅包含Redis操作,耗时从200ms级降至10ms级,吞吐量提升20倍以上(从约100QPS提升至2000+QPS)。

image-3.webp

需求罗列

  • 需求1: 新增秒杀优惠券时,将优惠券信息保存到Redis中
  • 需求2: 基于Lua脚本判断秒杀库存和一人一单,决定用户抢购资格
  • 需求3: 抢购成功后,将优惠券id和用户id存入阻塞队列
  • 需求4: 开启线程任务从阻塞队列获取信息,实现异步下单

代码实现

VoucherServiceImpl中,添加秒杀优惠券时向Redis中保存库存信息

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

src/main/resources/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  
    -- 库存不足  
    return 1  
end  
-- 3.2. 判断用户是否重复下单  
if (redis.call("sismember", orderKey, userId) == 1) then  
    -- 用户重复下单  
    return 2  
end  
-- 3.3. 扣减库存  
redis.call("incrby", stockKey, -1)  
-- 3.4. 下单  
redis.call("sadd", orderKey, userId)
return 0

调整VoucherOrderServiceImpl秒杀业务代码

@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;  
    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 BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);  
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();  
  
    private IVoucherOrderService proxy;  
  
    @PostConstruct  
    private void init() {  
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());  
    }  
  
    private class VoucherOrderHandler implements Runnable {  
        @Override  
        public void run() {  
            while (true) {  
                try {  
                    // 获取队列中的订单信息  
                    VoucherOrder voucherOrder = orderTasks.take();  
                    // 创建订单  
                    handleVoucherOrder(voucherOrder);  
                } catch (Exception e) {  
                    log.error("处理订单异常", e);  
                }  
            }  
        }  
    }  
  
    private void handleVoucherOrder(VoucherOrder voucherOrder) {  
        // 获取用户  
        Long userId = voucherOrder.getUserId();  
        // 创建锁对象  
        RLock lock = redissonClient.getLock("lock:order:" + userId);  
        // 获取锁  
        boolean isLock = lock.tryLock();  
        // 判断获取锁成功与否  
        if (!isLock) {  
            // 获取锁失败,返回错误或者重试  
            log.error("不允许重复下单");  
            return;  
        }  
        try {  
            proxy.createVoucherOrder(voucherOrder);  
        } catch (Exception e) {  
            throw new RuntimeException(e);  
        }  
    }  
  
  
    /**  
     * 秒杀优惠券  
     *  
     * @param voucherId  
     * @return  
     */    public Result seckillVoucher(Long voucherId) {  
        // 执行lua脚本  
        Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,  
                Collections.emptyList(),  
                voucherId.toString(),  
                UserHolder.getUser().getId().toString());  
        // 判断结果是否为0  
        int r = result.intValue();  
        if (r != 0) {  
            // 不为0,代表没有购买资格  
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");  
        }  
        // 为0,有购买资格,把下单信息保存到阻塞队列  
        Long orderId = redisIdWorker.nextId("order");  
        VoucherOrder voucherOrder = new VoucherOrder();  
        voucherOrder.setId(orderId);  
        voucherOrder.setUserId(UserHolder.getUser().getId());  
        voucherOrder.setVoucherId(voucherId);  
        orderTasks.add(voucherOrder);  
        // 获取代理对象  
        proxy = (IVoucherOrderService) AopContext.currentProxy();  
        return Result.ok(orderId);  
    }  
  
    @Transactional  
    public void createVoucherOrder(VoucherOrder voucherOrder) {  
        // 一人一单  
        // 根据用户id和秒杀券id查询订单  
        Long userId = voucherOrder.getUserId();  
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();  
        if (count > 0) {  
            log.error("用户已经购买过一次了");  
            return;  
        }  
  
        // 扣减库存  
        boolean success = seckillVoucherService.update()  
                .setSql("stock=stock-1")  
                .eq("voucher_id", voucherOrder.getVoucherId())  
                .gt("stock", 0) // 乐观锁  
                .update();  
        if (!success) {  
            log.error("库存不足");  
            return;  
        }  
        // 保存订单  
        save(voucherOrder);  
    }  
}

代码主要调整内容

1. 架构优化:引入Lua脚本和消息队列

  • 新增了seckill.lua脚本,将库存检查和下单验证的逻辑移至Redis中执行,提高并发性能
  • 使用阻塞队列]和单线程执行器处理订单,将秒杀请求与实际订单处理解耦

2. 业务逻辑重构

  • seckillVoucher方法从直接处理订单转变为调用Lua脚本验证后将订单放入队列
  • createVoucherOrder方法从返回Result改为无返回值的void类型,专注于订单创建本身

3. 数据一致性增强

  • 在创建秒杀券时,将库存同步到Redis中,供Lua脚本使用
  • 使用Redisson分布式锁保证同一用户不会重复下单

当前业务逻辑分析

秒杀整体流程:

  1. 前端请求秒杀

  2. Redis中预检查(通过Lua脚本):

    • 检查库存是否充足
    • 检查用户是否已下单
    • 如果通过检查,则扣减库存并记录用户下单状态
  3. 订单异步处理

    • 将订单信息放入阻塞队列
    • 后台线程从队列中取出订单信息进行处理
  4. 数据库订单创建

    • 使用Redisson分布式锁防止同一用户重复下单
    • 查询数据库确认用户未购买过该优惠券
    • 使用乐观锁更新数据库库存
    • 保存订单信息

优势分析

  1. 高性能:将库存检查和重复下单验证放在Redis中执行,避免了数据库压力
  2. 原子性:使用Lua脚本保证检查和扣减库存的原子性操作
  3. 异步处理:通过消息队列解耦请求和处理,提高系统吞吐量
  4. 数据一致性:通过Redis和数据库的双重保障,确保数据一致性
  5. 防重复下单:使用Redis集合和分布式锁防止用户重复下单

这种设计大大提高了秒杀系统的并发处理能力,同时保证了数据的一致性和系统的稳定性。

注意事项

为什么要在主线程中获取代理对象(proxy)?

  1. ThreadLocal的线程隔离特性

    • UserHolder 使用 ThreadLocal 存储用户信息,而 ThreadLocal 是线程隔离的,只能在当前线程内访问。
    • 当请求到达时,主线程(处理HTTP请求的线程)会通过拦截器将用户信息保存到 ThreadLocal 中。
    • 但是在异步处理订单的子线程中,ThreadLocal 中没有用户信息,因为子线程无法访问主线程的 ThreadLocal 变量。
  2. AOP代理对象的获取限制

    • AopContext.currentProxy() 只能在AOP代理的上下文中调用才能成功返回代理对象。
    • 当在子线程中调用 AopContext.currentProxy() 时,由于不在原始的AOP上下文中,会抛出异常或返回null。
  3. 事务管理需求

    • createVoucherOrder 方法使用了 @Transactional 注解,需要通过代理对象调用才能使事务生效。
    • 如果直接调用 createVoucherOrder 方法而不是通过代理对象调用,事务将不会生效。

能否在子线程中通过ThreadLocal获取当前线程的userId?

不能直接在子线程中通过ThreadLocal获取userId,因为:

  1. 子线程有自己独立的ThreadLocalMap,无法访问主线程的ThreadLocal变量
  2. 用户信息只在主线程的拦截器中被设置到ThreadLocal中

解决方案

当前代码的实现是合理的:

  1. 在主线程中获取代理对象并保存到实例变量中
  2. 将订单信息封装后放入阻塞队列
  3. 子线程从队列中取出订单信息进行处理
  4. 子线程中通过订单信息获取用户ID,而不是通过ThreadLocal

这种设计避免了ThreadLocal跨线程访问的问题,同时保证了事务的正确性。如果想在子线程中获取用户信息,需要手动将用户信息从主线程传递到子线程,但这会增加代码复杂度,并且当前的设计已经很好地解决了这个问题。