当前位置: 首页 > news >正文

黑马点评 秒杀优惠券单体下一人一单超卖问题

今天在学习黑马程序员的黑马点评实战项目中关于优惠券秒杀一人一单的时候碰到了一些问题,现在总结下来:

核心代码:VoucherOrderServiceImpl.java

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    /**
     * 抢购秒杀优惠券
     *
     * @param voucherId
     * @return
     */
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券信息
        SeckillVoucher voucher = seckillVoucherService.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("库存不足");
        }

        //获取用户ID
        Long userId = UserHolder.getUser().getId();

        //synchronized : 基于这个字符串对象加锁,同一用户的并发请求会串行执行。
        synchronized (userId.toString().intern()) {

            //直接调用类内部的createVoucherOrder方法,会导致事务注解@Transactional失效,因为Spring的事务是通过代理对象来管理的
            //return this.createVoucherOrder(voucherId);

            //获取当前的代理对象,使用代理对象调用第三方事务方法,防止事务失效
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();   //获取当前类的代理对象
            return proxy.createVoucherOrder(voucherId);
        }
    }

    /**
     * 通过数据库查询确保“一人一单”
     * @param voucherId
     * @return
     */
    @Transactional   // 事务注解:保证订单创建和库存扣减的原子性,并且只有事务提交后,其他请求才能看到新订单和库存变化
    public Result createVoucherOrder(Long voucherId) {
        //5.一人一单
        Long userId = UserHolder.getUser().getId();
        //5.1查询数据库中是否已经存在该用户抢购该优惠券的订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //5.2判断是否存在
        if (count > 0) {
            //用户已经购买过了,返回失败信息
            return Result.fail("用户已购买!");
        }

        //6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")   //set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0)   //where id = ? and stock > 0 数据库层面的乐观锁,避免超卖
                .update();

        if (!success) {
            //库存扣减失败
            return Result.fail("库存不足");
        }

        //7.创建订单(在订单表tb_voucher_order插入一条数据)
        VoucherOrder voucherOrder = new VoucherOrder();
        //7.1 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //7.2 用户id
        voucherOrder.setUserId(userId);
        //7.3 代金券id
        voucherOrder.setVoucherId(voucherId);

        //插入到订单信息表
        save(voucherOrder);

        //8.返回订单id(生成唯一订单id并保存)
        return Result.ok(orderId);
    }
}

这段代码的主要实现了一个优惠券秒杀的功能,重点处理了并发情况下的线程安全和事务处理。

1. 整体流程

  1. 查询优惠券信息:检查秒杀活动的开始和结束时间,以及库存是否充足。

  2. 用户加锁:对用户ID加锁,防止同一用户并发请求。

  3. 创建订单:在事务中检查用户是否已购买、扣减库存并生成订单。

2. 关键代码解析

2.1 锁的实现:synchronized (userId.toString().intern())
  • 目的:确保同一用户的请求在同一时间只能有一个线程处理,防止重复下单。

  • 原理

    • userId.toString().intern() 将用户ID转换为字符串,并调用 intern() 方法,确保所有相同用户ID的字符串指向常量池中的同一个对象。

    • synchronized 基于这个字符串对象加锁,同一用户的并发请求会串行执行。

2.2 代理对象:IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy()
  • 目的:确保 @Transactional 注解生效。

  • 原因

    • Spring的事务管理基于AOP代理,直接调用内部方法(如 this.createVoucherOrder())会绕过代理,导致事务失效。

    • 通过 AopContext.currentProxy() 获取当前类的代理对象,再调用方法,事务才能正确应用。

  • 依赖:需在启动类添加 @EnableAspectJAutoProxy(exposeProxy = true) 暴露代理对象。

2.3 事务方法:createVoucherOrder
  • 检查用户订单:通过数据库查询确保“一人一单”。

  • 扣减库存

    • 使用条件更新:eq("voucher_id", voucherId).gt("stock", 0)

    • 数据库层面的乐观锁,避免超卖。

  • 创建订单:生成唯一订单ID并保存。

3. 并发安全设计

  1. 用户维度锁

    • 防止同一用户并发请求导致多次下单。

    • 锁的粒度较细(用户级别),不同用户的请求可并行处理。

  2. 数据库乐观锁

    • 扣减库存时检查 stock > 0,确保库存不为负。

  3. 事务隔离

    • @Transactional 保证订单创建和库存扣减的原子性。

    • 事务提交后,其他请求才能看到新订单和库存变化。

4. 代码总结

步骤操作并发安全措施
查询优惠券信息检查时间、库存
用户加锁synchronized 锁防止同一用户重复下单
创建订单(事务)检查用户订单、扣库存、插入订单数据库乐观锁、事务隔离

5. 关键问答

Q1: 为什么要用 userId.toString().intern() 作为锁?

确保同一用户ID的字符串在不同请求中是同一个对象,锁生效。

Q2: 为什么需要代理对象调用 createVoucherOrder

Spring事务通过动态代理实现,直接调用内部方法会绕过代理,事务失效

Q3: 如何防止超卖?

数据库更新时检查stock > 0,结合应用层锁和数据库乐观锁。

6. 事务的核心特性

在秒杀场景中,事务确保以下关键操作要么全部成功,要么全部回滚:

  1. 检查用户是否已下单SELECT)。

  2. 扣减库存UPDATE stock SET stock = stock - 1)。

  3. 创建订单INSERT INTO voucher_order)。

若缺少事务,可能出现:

  • 库存扣减成功,但订单创建失败 → 数据不一致(用户付钱没拿到券)。

  • 用户重复下单检查通过,但下单时库存不足 → 业务逻辑错误。

在上面的代码中,事务实现如下:

@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 数据库操作:检查订单、扣库存、创建订单
}
  • 作用:Spring通过AOP代理为该方法添加事务管理。

  • 默认行为

    • 传播机制REQUIRED(如果当前有事务则加入,否则新建)。

    • 隔离级别ISOLATION_DEFAULT(依赖数据库默认,通常为可重复读)。

    • 回滚规则:遇到运行时异常(如RuntimeException)自动回滚。

6.1 为什么必须使用代理对象调用方法?
// 错误写法:直接调用this.createVoucherOrder(),事务失效!
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId); // 通过代理对象调用
  • 根本原因Spring的事务基于动态代理(JDK或CGLib),直接调用内部方法会绕过代理,导致@Transactional失效。

  • 解决方案:通过AopContext.currentProxy()获取当前类的代理对象。

7. 事务与锁的协作流程

步骤操作事务与锁的作用
1. 用户请求进入执行seckillVoucher方法
2. 获取用户锁synchronized(userKey)防止同一用户并发操作
3.调用代理方法proxy.createVoucherOrder()触发事务管理(AOP代理)
4. 事务开始自动开启事务开启数据库事务
5. 检查用户订单SELECT COUNT(...)事务内读操作(可重复读保证一致性)
6. 扣减库存UPDATE stock ... WHERE stock>0事务内写操作(数据库乐观锁)
7. 创建订单INSERT INTO voucher_order事务内写操作
8. 事务提交自动提交所有操作原子生效
9. 释放用户锁退出synchronized允许其他线程处理该用户请求

7.1 关键协作点
  • 锁在事务外:先加锁,再开启事务。

    • 原因:若锁在事务内,事务提交前释放锁,其他线程可能读到未提交的数据(如库存未扣减)。

    • 当前代码正确性:锁包裹事务,确保事务提交后才释放锁。

  • 事务隔离级别

    • 默认隔离级别(可重复读)防止脏读、不可重复读,确保事务内多次读取数据一致。

7.2 事务在秒杀场景的小总结
操作无事务的风险有事务的保障
检查用户订单可能通过检查,但下单时冲突可重复读确保一致性
扣减库存超卖(库存扣减到负数)数据库乐观锁(stock > 0)阻止超卖
创建订单订单丢失或重复唯一键约束+原子提交保证数据完整

7.3 关键问答

Q1: 如果事务方法中抛出非RuntimeException,事务会回滚吗?

默认不会,需通过@Transactional(rollbackFor = Exception.class)配置。

Q2: 事务方法内调用其他事务方法,事务如何传递?

默认使用REQUIRED传播机制,合并到同一事务中。

8. 串行执行 VS 并发执行

8.1串行执行

定义:任务按顺序依次执行,前一个任务完成后,后一个任务才能开始。
特点

  • 简单、安全,不会出现资源冲突。

  • 效率低,无法充分利用多核CPU资源。
    场景:单线程程序、需要严格顺序的操作(如转账步骤)。

示例:

// 单线程串行执行任务
public static void main(String[] args) {
    task1();  // 任务1执行完毕
    task2();  // 任务2开始执行
    task3();  // 任务3开始执行
}

8.2 并发执行

定义:任务看似同时执行,实际通过时间片轮转或并行处理实现。
特点

  • 提高资源利用率,适合多核CPU。

  • 可能引发线程安全问题(如数据竞争)。
    场景:Web服务器处理多请求、批量数据处理。

示例:

// 多线程并发执行任务
public static void main(String[] args) {
    new Thread(() -> task1()).start();  // 任务1开始执行
    new Thread(() -> task2()).start();  // 任务2开始执行
    new Thread(() -> task3()).start();  // 任务3开始执行
    // 三个任务可能交替执行
}

8.3 代码中秒杀系统中的订单创建
synchronized (userId.toString().intern()) {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
}
  • 锁的作用
    强制同一用户的订单创建操作串行执行(即使有多个并发请求)。
    例如:用户A的3个请求会依次处理,但用户B的请求可以与用户A并发执行。

  • 并发与串行的平衡

    • 并发:不同用户的请求可以并行处理(提高吞吐量)。

    • 串行:同一用户的请求串行处理(避免重复下单)。

8.4 并发执行的风险和解决方案
8.4.1 超卖(库存扣减为负)

并发场景

  • 线程1和线程2同时查询库存为1。

  • 两者都认为可以扣减,最终库存变为-1。

解决方案:

// 使用数据库乐观锁
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0)  // 保证库存 > 0 时才扣减
        .update();

8.4.2 重复下单

并发场景

  • 同一用户的多个请求同时通过“一人一单”检查。

  • 最终插入多条订单。

解决方案:

// 对用户ID加锁
synchronized (userId.toString().intern()) {
    // 检查订单 + 创建订单(事务内)
}

9. Spring事务管理基于AOP的底层原理详解

9.1 AOP的核心概念

AOP(Aspect-Oriented Programming,面向切面编程)用于将横切关注点(如日志、事务、安全)与业务逻辑解耦

关键术语:

术语说明事务管理中的对应示例
切面(Aspect)横切多个类的模块化功能(如事务管理)@Transactional注解标记的方法
通知(Advice)切面在特定连接点执行的动作(如“在方法前后加事务”)事务的开启、提交、回滚操作
连接点(Join Point)程序执行的点(如方法调用)createVoucherOrder()方法的执行
切点(Pointcut)匹配连接点的表达式(定义哪些方法需要增强)通过@Transactional注解匹配需要事务的方法
目标对象(Target)被代理的原始对象VoucherOrderServiceImpl类实例
代理(Proxy)对目标对象增强后的对象(由AOP框架生成)Spring生成的IVoucherOrderService代理类

9.2 Spring事务管理的实现流程
9.2.1 事务管理本质是AOP的环绕通知

Spring通过AOP动态代理,在目标方法前后添加事务逻辑:

// 伪代码:事务的AOP环绕通知实现
public class TransactionAspect {
    @Around("@annotation(transactional)")
    public Object around(ProceedingJoinPoint pjp, Transactional transactional) {
        Connection conn = DataSourceUtils.getConnection();
        try {
            conn.setAutoCommit(false); // 1.开启事务
            Object result = pjp.proceed(); // 2.执行目标方法(如createVoucherOrder)
            conn.commit(); // 3.提交事务
            return result;
        } catch (Exception e) {
            conn.rollback(); // 4.回滚事务
            throw e;
        } finally {
            DataSourceUtils.releaseConnection(conn);
        }
    }
}

9.2.2 动态代理的两种实现方式

Spring根据目标类型选择代理方式:

代理类型条件示例
JDK动态代理目标类实现了接口IVoucherOrderService接口实现类
CGLib动态代理目标类未实现接口无接口的普通类

代码中为什么比用代理对象?

直接调用 this.createVoucherOrder() 会绕过代理,导致事务失效。

9.2.3 结合代码的完整AOP事务流程

seckillVoucher方法调用为例:

  1. Spring容器启动

    • 扫描到VoucherOrderServiceImpl类,发现它实现了IVoucherOrderService接口。

    • 检测到createVoucherOrder方法有@Transactional注解,为此类生成JDK动态代理对象。

  2. 方法调用过程

    // 用户调用IVoucherOrderService.seckillVoucher()
    IVoucherOrderService proxy = Spring容器中的代理对象;
    proxy.seckillVoucher(voucherId);
    
    // 代理对象内部执行:
    public Result seckillVoucher(Long voucherId) {
        // 1. 执行AOP前置逻辑(无事务)
        synchronized (userId.toString().intern()) {
            // 2. 调用原始对象的seckillVoucher方法
            IVoucherOrderService target = new VoucherOrderServiceImpl();
            target.seckillVoucher(voucherId); // 此时this指向原始对象
            // 3. 通过AopContext获取当前代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            // 4. 通过代理调用createVoucherOrder,触发事务
            return proxy.createVoucherOrder(voucherId);
        }
    }
  3. 事务方法触发

    • 代理对象拦截createVoucherOrder方法的调用。

    • 执行事务环绕通知:开启事务 → 执行方法 → 提交/回滚事务。

9.3 AOP与事务管理的关系
步骤AOP的作用事务管理的体现
代理对象生成通过JDK/CGLib创建目标类的代理@Transactional类生成代理
方法拦截在目标方法执行前后插入通知环绕通知中管理事务(开启、提交、回滚)
异常处理捕获方法抛出的异常根据异常类型决定回滚或提交
自调用问题解决通过暴露代理对象访问增强方法使用AopContext.currentProxy()

9.4 关键问答

Q1: 为什么事务注解要加在public方法上?

  • Spring的AOP默认基于代理,只能拦截public方法。

  • 若注解加在非public方法(如private),代理无法增强该方法,事务失效。

Q2: Spring AOP和AspectJ有什么区别?

  • Spring AOP基于动态代理,仅支持方法级别的切面;AspectJ是完整的AOP框架,支持字段、构造器级别的切面。

Q3: 如何强制Spring使用CGLib代理?

  • @EnableAspectJAutoProxy中设置proxyTargetClass=true

Q4: 事务方法中调用另一个事务方法,事务如何传递?

  • @Transactional(propagation=...)决定,默认REQUIRED(合并到同一事务)。

Q5: 事务失效的常见场景有哪些?

  • 自调用、非public方法、异常被捕获未抛出、数据库引擎不支持事务等。

相关文章:

  • spring cloud gateway 转发 ws 流量
  • 【3GPP】【5G】精讲5G系统的策略和计费控制框架
  • 【MySQL从入门到精通】之基础概念详解
  • 多版本go冲突问题
  • 数据结构-哈希表
  • 免费送源码:Java+ssm+MySQL 酒店预订管理系统的设计与实现 计算机毕业设计原创定制
  • 社交电商引流策略中的让利行为及其影响——基于开源AI智能名片、链动2+1模式与S2B2C商城小程序的分析
  • Spring Boot 热部署详解,包含详细的配置项说明
  • 行业标准 | IT服务技术与标准研讨会在京召开
  • Qt文件读写
  • AMGCL库的Backends及使用示例
  • Java基础:Stream流操作
  • 【软考系统架构设计师】信息安全技术基础知识点
  • 25级总分413数学一142专业124东南大学820考研经验电子信息通信工程,真题,大纲,参考书。
  • 开源模型应用落地-qwen模型小试-Qwen2.5-7B-Instruct-tool usage入门-集成心知天气(二)
  • 深入理解 HTML5 语义元素:提升网页结构与可访问性
  • 【C++】中memcpy的使用
  • 校园AI体育:科技赋能教育,运动点亮未来
  • 【集成电路版图设计学习笔记】1. Introduction to Layout Design
  • k8s蓝绿发布
  • 一个人做网站需要多久/东莞seo网络优化
  • 网站建设公司的出路/郑州网站优化公司
  • 安徽网站制作/网络营销的定义是什么
  • 襄阳做网站/seo关键词排名优化联系方式
  • 响应式地方网站/搜索引擎关键词的工具
  • 微信搜一搜怎么做推广/网站推广seo