黑马点评中 VoucherOrderServiceImpl 实现类中的一人一单实现解析(单机部署)

📚 目录(点击跳转对应章节)

一、前言:为什么秒杀系统这么难?
二、秒杀整体流程总览
[三、秒杀入口设计:为什么要"用户锁 + 代理调用"?](#三、秒杀入口设计:为什么要"用户锁 + 代理调用"?)
[四、为什么要使用 synchronized(userId)?](#四、为什么要使用 synchronized(userId)?)
[五、Spring 事务为什么会失效?如何解决?](#五、Spring 事务为什么会失效?如何解决?)
六、一人一单:真正的幂等性设计
[七、库存扣减设计:为什么 CAS 是秒杀最优解?](#七、库存扣减设计:为什么 CAS 是秒杀最优解?)
[八、订单 ID 设计:为什么使用 Redis 生成?](#八、订单 ID 设计:为什么使用 Redis 生成?)
九、一人一单秒杀完整链路回顾
十、本文提及的问题和解决方案总结

之前课程中关于缓存问题的解决博客详细解析:Redis 缓存穿透与缓存击穿解决(依据黑马点评项目CacheClient工具类代码解析)

一、前言:为什么秒杀系统这么难?

秒杀系统的难点,并不在于接口是否能跑通,而在于 极端并发场景下的一致性与正确性保障

在真实业务中,秒杀通常会同时面临以下问题:

  1. 库存超卖
  2. 同一用户重复下单
  3. Spring 事务在高并发下失效
  4. 数据库在瞬时并发下承压严重

本文基于黑马点评项目中的 VoucherOrderServiceImpl 实际代码,从业务流程、并发控制、事务机制、数据一致性四个维度,系统解析秒杀场景下一人一单的实现方案。


二、秒杀整体流程总览

在展开具体代码前,先给出本实现的整体执行链路,后文所有设计点都围绕这条流程展开。

秒杀下单完整流程

  1. 校验秒杀时间(是否开始 / 是否结束)
  2. 基于用户维度加锁,防止同一用户并发下单
  3. 通过 AOP 代理进入事务方法
  4. 校验用户是否已经下过单
  5. 使用 CAS 方式扣减库存,防止超卖
  6. 创建订单并持久化到数据库
  7. 返回订单 ID

三、秒杀入口设计:为什么要"用户锁 + 代理调用"?

3.1 秒杀统一入口:seckillVoucher

java 复制代码
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1. 查询秒杀券
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

    // 2. 校验秒杀时间
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.fail("秒杀尚未开始");
    }
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.fail("秒杀已经结束");
    }
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("秒杀券已经抢空");
    }

    // 3. 创建订单
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        IVoucherOrderService proxy =
                (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(userId, voucherId);
    }
}

3.2 入口层承担的职责

秒杀入口方法的设计原则是:只做快速校验与并发控制,不做核心业务操作,主要包括:

  • 秒杀时间合法性校验
  • 避免无意义的数据库访问
  • 对同一用户的并发请求进行串行化
  • 确保事务方法通过 Spring 代理对象调用

四、为什么要使用 synchronized(userId)

4.1 锁的粒度设计

java 复制代码
synchronized (userId.toString().intern())

该锁的核心设计思想是:

  • 锁住的是用户,而不是库存
  • 同一用户的多个并发请求只能串行执行
  • 不会影响其他用户的正常并发下单

这是一个典型的用户维度防重锁设计。


4.2 为什么一定要调用 intern()

如果只使用:

java 复制代码
synchronized (userId.toString())

在高并发场景下会出现严重问题:

  • toString() 每次可能生成新的对象
  • 不同线程锁的不是同一个对象
  • synchronized 实际上失效

intern() 的作用是将字符串放入 JVM 常量池,确保:

相同 userId 在 JVM 中只对应一把锁


4.3 这种方案的适用边界

该方案有明确的使用前提:

  • 仅适用于单机部署
  • 集群环境下锁无法共享
  • 大量用户可能对字符串常量池产生压力

五、Spring 事务为什么会失效?如何解决?

5.1 问题本质

Spring 的 @Transactional 是基于 AOP 动态代理 实现的,因此存在一个经典限制:

  • 同一个类中的方法互相调用
  • 不经过代理对象
  • 事务不会生效

5.2 错误示例

java 复制代码
this.createVoucherOrder(userId, voucherId);

上述写法会绕过代理对象,导致事务失效。


5.3 正确做法:通过 AopContext 获取代理对象

java 复制代码
IVoucherOrderService proxy =
    (IVoucherOrderService) AopContext.currentProxy();
proxy.createVoucherOrder(userId, voucherId);

需要额外开启配置:

java 复制代码
@EnableAspectJAutoProxy(exposeProxy = true)

这样可以确保:

  • 事务正常生效
  • 异常能够正确回滚
  • 数据一致性得到保障

六、一人一单:真正的幂等性设计

幂等性的定义:同一个请求,不管执行一次还是执行多次,最终结果都一致。

6.1 synchronized 不是最终兜底

synchronized 的作用只是减少并发冲突,并不能完全保证一人一单。

真正的幂等性校验必须放在事务内部:

java 复制代码
Long count = query()
    .eq("user_id", userId)
    .eq("voucher_id", voucherId)
    .count();

如果已经存在订单,直接返回失败。


6.2 为什么必须在事务中完成?

原因在于并发场景下:

  • 多个线程可能同时通过校验
  • 只有事务才能保证校验、扣库存、创建订单的原子性

七、库存扣减设计:为什么 CAS 是秒杀最优解?

7.1 CAS 扣库存实现

java 复制代码
boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    .gt("stock", 0)
    .update();

对应 SQL:

sql 复制代码
UPDATE seckill_voucher
SET stock = stock - 1
WHERE voucher_id = ?
AND stock > 0;

如果更新失败,说明库存不足或已被抢完。


7.2 为什么不使用悲观锁?

第四大点中方案的实现就是用的悲观锁原理

sql 复制代码
SELECT ... FOR UPDATE

在高并发秒杀场景下:

  • 行锁持有时间长
  • 并发能力极差
  • 极易拖垮数据库

因此并不推荐。


7.3 CAS 与版本号方案对比

  • 版本号法:
  • CAS:
方案 特点
版本号机制 通用性强,需要额外字段
CAS(stock > 0) 实现简单,性能高,秒杀场景首选

八、订单 ID 设计:为什么使用 Redis 生成?

java 复制代码
long orderId = redisIdWorker.nextId(
    RedisConstants.SECKILL_VOUCHER_ORDER
);

其中redisIdWorker工具类的作用是:基于 Redis 的分布式 ID 生成器,用于生成全局唯一的雪花算法风格的 ID。它通过结合时间戳和序列号来确保 ID 的唯一性和有序性。

使用 Redis 生成全局 ID 的优势:

  • 高并发下无锁
  • 分布式环境不冲突
  • 避免数据库自增 ID 成为性能瓶颈

九、一人一单秒杀完整链路回顾

最终完整执行顺序如下:

  1. 校验秒杀时间
  2. 基于用户维度加锁
  3. 通过代理对象调用事务方法
  4. 校验是否已下单
  5. CAS 扣减库存
  6. 创建订单并落库
  7. 返回订单 ID

十、本文提及的问题和解决方案总结

问题 解决方案
库存超卖 数据库 CAS 更新
一人多单 用户锁 + 事务校验
事务失效 AOP 代理调用
高并发 乐观锁
ID 冲突 Redis 全局 ID
相关推荐
CaffeinePro9 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax10 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH10 小时前
Koa和Express的区别
后端
MariaH10 小时前
Koa框架的使用
后端
luckdewei11 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某12 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy13 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom13 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github
用户31693538118314 小时前
Java连接Redis
redis
倔强的石头_17 小时前
《Kingbase护城河》——数据库存储空间全景探测与精细化瘦身实战
数据库