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

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

流程概览
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)。

需求罗列
- 需求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分布式锁保证同一用户不会重复下单
当前业务逻辑分析
秒杀整体流程:
-
前端请求秒杀:
- 用户请求秒杀优惠券,调用seckillVoucher方法
-
Redis中预检查(通过Lua脚本):
- 检查库存是否充足
- 检查用户是否已下单
- 如果通过检查,则扣减库存并记录用户下单状态
-
订单异步处理:
- 将订单信息放入阻塞队列
- 后台线程从队列中取出订单信息进行处理
-
数据库订单创建:
- 使用Redisson分布式锁防止同一用户重复下单
- 查询数据库确认用户未购买过该优惠券
- 使用乐观锁更新数据库库存
- 保存订单信息
优势分析
- 高性能:将库存检查和重复下单验证放在Redis中执行,避免了数据库压力
- 原子性:使用Lua脚本保证检查和扣减库存的原子性操作
- 异步处理:通过消息队列解耦请求和处理,提高系统吞吐量
- 数据一致性:通过Redis和数据库的双重保障,确保数据一致性
- 防重复下单:使用Redis集合和分布式锁防止用户重复下单
这种设计大大提高了秒杀系统的并发处理能力,同时保证了数据的一致性和系统的稳定性。
注意事项
为什么要在主线程中获取代理对象(proxy)?
-
ThreadLocal的线程隔离特性:
- UserHolder 使用
ThreadLocal存储用户信息,而ThreadLocal是线程隔离的,只能在当前线程内访问。 - 当请求到达时,主线程(处理HTTP请求的线程)会通过拦截器将用户信息保存到
ThreadLocal中。 - 但是在异步处理订单的子线程中,
ThreadLocal中没有用户信息,因为子线程无法访问主线程的ThreadLocal变量。
- UserHolder 使用
-
AOP代理对象的获取限制:
AopContext.currentProxy()只能在AOP代理的上下文中调用才能成功返回代理对象。- 当在子线程中调用
AopContext.currentProxy()时,由于不在原始的AOP上下文中,会抛出异常或返回null。
-
事务管理需求:
- createVoucherOrder 方法使用了
@Transactional注解,需要通过代理对象调用才能使事务生效。 - 如果直接调用 createVoucherOrder 方法而不是通过代理对象调用,事务将不会生效。
- createVoucherOrder 方法使用了
能否在子线程中通过ThreadLocal获取当前线程的userId?
不能直接在子线程中通过ThreadLocal获取userId,因为:
- 子线程有自己独立的ThreadLocalMap,无法访问主线程的ThreadLocal变量
- 用户信息只在主线程的拦截器中被设置到ThreadLocal中
解决方案
当前代码的实现是合理的:
- 在主线程中获取代理对象并保存到实例变量中
- 将订单信息封装后放入阻塞队列
- 子线程从队列中取出订单信息进行处理
- 子线程中通过订单信息获取用户ID,而不是通过ThreadLocal
这种设计避免了ThreadLocal跨线程访问的问题,同时保证了事务的正确性。如果想在子线程中获取用户信息,需要手动将用户信息从主线程传递到子线程,但这会增加代码复杂度,并且当前的设计已经很好地解决了这个问题。