📚 目录(点击跳转对应章节)
一、前言:为什么秒杀系统这么难?
二、秒杀整体流程总览
[三、秒杀入口设计:为什么要"用户锁 + 代理调用"?](#三、秒杀入口设计:为什么要"用户锁 + 代理调用"?)
[四、为什么要使用 synchronized(userId)?](#四、为什么要使用 synchronized(userId)?)
[五、Spring 事务为什么会失效?如何解决?](#五、Spring 事务为什么会失效?如何解决?)
六、一人一单:真正的幂等性设计
[七、库存扣减设计:为什么 CAS 是秒杀最优解?](#七、库存扣减设计:为什么 CAS 是秒杀最优解?)
[八、订单 ID 设计:为什么使用 Redis 生成?](#八、订单 ID 设计:为什么使用 Redis 生成?)
九、一人一单秒杀完整链路回顾
十、本文提及的问题和解决方案总结
之前课程中关于缓存问题的解决博客详细解析:Redis 缓存穿透与缓存击穿解决(依据黑马点评项目CacheClient工具类代码解析)
一、前言:为什么秒杀系统这么难?
秒杀系统的难点,并不在于接口是否能跑通,而在于 极端并发场景下的一致性与正确性保障。
在真实业务中,秒杀通常会同时面临以下问题:
- 库存超卖
- 同一用户重复下单
- Spring 事务在高并发下失效
- 数据库在瞬时并发下承压严重
本文基于黑马点评项目中的
VoucherOrderServiceImpl实际代码,从业务流程、并发控制、事务机制、数据一致性四个维度,系统解析秒杀场景下一人一单的实现方案。
二、秒杀整体流程总览
在展开具体代码前,先给出本实现的整体执行链路,后文所有设计点都围绕这条流程展开。
秒杀下单完整流程

- 校验秒杀时间(是否开始 / 是否结束)
- 基于用户维度加锁,防止同一用户并发下单
- 通过 AOP 代理进入事务方法
- 校验用户是否已经下过单
- 使用 CAS 方式扣减库存,防止超卖
- 创建订单并持久化到数据库
- 返回订单 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 成为性能瓶颈
九、一人一单秒杀完整链路回顾
最终完整执行顺序如下:
- 校验秒杀时间
- 基于用户维度加锁
- 通过代理对象调用事务方法
- 校验是否已下单
- CAS 扣减库存
- 创建订单并落库
- 返回订单 ID
十、本文提及的问题和解决方案总结
| 问题 | 解决方案 |
|---|---|
| 库存超卖 | 数据库 CAS 更新 |
| 一人多单 | 用户锁 + 事务校验 |
| 事务失效 | AOP 代理调用 |
| 高并发 | 乐观锁 |
| ID 冲突 | Redis 全局 ID |