黑马点评中 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
相关推荐
acaad2 小时前
Redis下载与安装(Windows)
数据库·redis·缓存
SunflowerCoder2 小时前
EF Core + PostgreSQL 配置表设计踩坑记录:从 23505 到 ChangeTracker 冲突
数据库·postgresql·c#·efcore
J_liaty2 小时前
Spring Boot拦截器与过滤器深度解析
java·spring boot·后端·interceptor·filter
短剑重铸之日2 小时前
《7天学会Redis》Day2 - 深入Redis数据结构与底层实现
数据结构·数据库·redis·后端
码事漫谈3 小时前
从C++到C#的转型完全指南
后端
码事漫谈3 小时前
TCP心跳机制:看不见的“生命线”
后端
亲爱的非洲野猪3 小时前
Java锁机制八股文
java·开发语言
rgeshfgreh3 小时前
C++字符串处理:STL string终极指南
java·jvm·算法
Zoey的笔记本3 小时前
「支持ISO27001的GTD协作平台」数据生命周期管理方案与加密通信协议
java·前端·数据库