黑马点评秒杀优化和场景补充
目录
一、对于课程中的队列优化
1.1优化后核心代码
二、当前秒杀业务问题
2.1场景 1:订单超时未支付
2.1.1配置延迟队列(处理 30 分钟未支付订单)
2.1.2修改秒杀部分的逻辑
2.1.3编写库存恢复 Lua 脚本(保证 Redis 原子性)
2.1.4实现库存恢复方法(数据库 + Redis 双写)
2.1.5新增消费者监听超时队列,调用库存恢复方法:
2.1.6支付成功接口:更新订单状态
2.2场景2:用户主动取消订单
三、最后
一、对于课程中的队列优化
这里使用RabbitMQ来取代课程中的队列,将添加订单数据至mysql改成异步的,并且使用令牌桶算法来进行一定程度上的限流。
1.1优化后核心代码
下单代码如下
@Resource
private RedisWork redisWork;@Resource
private ISeckillVoucherService seckillVoucherService;@Resource
private StringRedisTemplate stringRedisTemplate;@Resource
private RedissonClient redissonClient;private RateLimiter rateLimiter= RateLimiter.create(10);@Resource
private MQSender mqSender;private static final DefaultRedisScript<Long> SECKILL_SCRIPT;private IVoucherOrderService iVoucherOrderService;static {SECKILL_SCRIPT = new DefaultRedisScript<>();SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));SECKILL_SCRIPT.setResultType(Long.class);
}/*** 秒杀优惠券* @param voucherId* @return*/
@Override
public Result seckillVoucher(Long voucherId) {RLock lock = redissonClient.getLock("seckill:lock");lock.tryLock();//令牌桶算法 限流if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)){return Result.fail("目前网络正忙,请重试");}long orderId = redisWork.nextId("order");Long userId = UserHolder.getUser().getId();// 1 执行lua脚本int result = stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(), userId.toString(),String.valueOf(orderId)).intValue();if (result != 0) {return Result.fail(result == 1 ? "库存不足" : "不能重复下单");}// 2 判断是否为0// 返回订单idVoucherOrder voucherOrder = new VoucherOrder();voucherOrder.setId(orderId);voucherOrder.setVoucherId(voucherId);voucherOrder.setUserId(userId);//发送消息通知向数据库中添加订单数据mqSender.sendSeckillMessage(JSON.toJSONString(voucherOrder));return Result.ok(orderId);
}
RabbitMQ的发送者(生产者)
package com.hmdp.rabbitmq;import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;import javax.annotation.Resource;@Slf4j
@Component
public class MQSender {@Resourceprivate RabbitTemplate rabbitTemplate;/*** 发送秒杀信息* @param message*/public void sendSeckillMessage(String message){log.info("发送消息:{}", message);rabbitTemplate.convertAndSend("seckill.direct", "seckill.success", message);}
}
RabbitMQ的消费者
package com.hmdp.rabbitmq;import com.hmdp.entity.VoucherOrder;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import lombok.extern.slf4j.Slf4j;
import com.alibaba.fastjson.JSON;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;import javax.annotation.Resource;@Component
@Slf4j
public class MQReceiver {@ResourceIVoucherOrderService voucherOrderService;@ResourceISeckillVoucherService seckillVoucherService;/*** 监听秒杀消息并下单* @param message*/@RabbitListener(bindings = @QueueBinding(value = @Queue(name = "seckill.queue", durable = "true"),exchange = @Exchange(name = "seckill.direct"),key = "seckill.success"))@Transactionalpublic void receiveSeckillVoucher(String message){log.info("收到秒杀消息:{}", message);VoucherOrder voucherOrder = JSON.parseObject(message, VoucherOrder.class);Long voucherId = voucherOrder.getVoucherId();//5.一人一单Long userId = voucherOrder.getUserId();//5.1查询订单int count = voucherOrderService.query().eq("user_id",userId).eq("voucher_id", voucherId).count();//5.2判断是否存在if(count>0){//用户已经购买过了log.error("该用户已购买过");return ;}log.info("扣减库存");//6.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock-1").eq("voucher_id", voucherId).gt("stock",0)//cas乐观锁.update();if(!success){log.error("库存不足");return;}//直接保存订单try {voucherOrderService.save(voucherOrder);} catch (Exception e) {log.error("订单异常信息:{}", e.getMessage());}}
}
lua脚本:
--1 参数列表
--1.1 优惠卷id
local voucherId = ARGV[1]
--1.2 用户id
local userId = ARGV[2]
--1.3 订单id
local orderId = ARGV[3]local stockKey = 'seckill:stock:' .. voucherIdlocal orderKey = 'seckill:order:' .. voucherIdif(tonumber(redis.call('get',stockKey)) <= 0) thenreturn 1endif(redis.call('sismember',orderKey,userId) == 1) thenreturn 2
endredis.call("incrby",stockKey,-1)redis.call("sadd",orderKey,userId)
-- 发消息
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
二、当前秒杀业务问题
业务比较单薄,说到底只有一个秒杀部分,没有秒杀之后会发生什么,比如秒杀后用户未及时付款导致订单超时,或者用户如果生成订单后取消订单又该如何处理,这些都是需要考虑的,现在我来大概梳理一下这部分补充之后的业务流程
大概可以分为这几步:
秒杀下单、订单创建、支付处理、超时处理、主动退单
这里我们只考虑秒杀下单、订单创建、超时处理、主动退单这四步,支付处理这部分可以参照一下黑马商场的那个模块,这里我放张图片快速过一下
秒杀下单和订单创建的逻辑其实和上面基本一致,我们来看一下超时处理和主动退单
2.1场景 1:订单超时未支付
秒杀订单创建后,用户通常有 10-30 分钟支付时间,超时后需自动恢复库存。一般设计到定时的问题都会用到xxl-job等定时任务组件,但是高并发下不推荐定时任务轮询(压力大、延迟高),优先用「消息中间件延迟队列」,这里我们使用RabbitMQ。
说到消息队列的延迟,首先想到的肯定是死信队列,这里给出代码:
2.1.1配置延迟队列(处理 30 分钟未支付订单)
新增 RabbitMQ 延迟队列配置,用于监听订单超时:
@Configuration
public class SeckillDelayMQConfig {// 延迟交换机@Beanpublic DirectExchange seckillDelayExchange() {return ExchangeBuilder.directExchange("seckill.delay.exchange").durable(true).build();}// 延迟队列(30分钟超时)@Beanpublic Queue seckillDelayQueue() {return QueueBuilder.durable("seckill.delay.queue").withArgument("x-message-ttl", 30 * 60 * 1000) // 30分钟TTL.withArgument("x-dead-letter-exchange", "seckill.dlx.exchange") // 死信交换机.withArgument("x-dead-letter-routing-key", "seckill.timeout") // 死信路由键.build();}// 绑定延迟交换机和延迟队列@Beanpublic Binding delayBinding() {return BindingBuilder.bind(seckillDelayQueue()).to(seckillDelayExchange()).with("seckill.delay");}// 死信交换机(接收超时消息)@Beanpublic DirectExchange seckillDlxExchange() {return ExchangeBuilder.directExchange("seckill.dlx.exchange").durable(true).build();}// 超时处理队列(最终消费超时订单)@Beanpublic Queue seckillTimeoutQueue() {return QueueBuilder.durable("seckill.timeout.queue").build();}// 绑定死信交换机和超时处理队列@Beanpublic Binding dlxBinding() {return BindingBuilder.bind(seckillTimeoutQueue()).to(seckillDlxExchange()).with("seckill.timeout");}
}
2.1.2修改秒杀部分的逻辑
在发送创建订单消息前面先将订单放入延迟队列
@Override
public Result seckillVoucher(Long voucherId) {// 现有逻辑:限流、Lua校验、创建订单...(省略)// 新增:发送延迟消息(30分钟后检查是否支付)// 消息体包含订单ID、优惠券ID、用户IDMap<String, Object> delayMsg = new HashMap<>();delayMsg.put("orderId", orderId);delayMsg.put("voucherId", voucherId);delayMsg.put("userId", userId);mqSender.sendSeckillDelayMessage(delayMsg); // 新增延迟消息发送方法// 原有:发送创建订单消息mqSender.sendSeckillMessage(JSON.toJSONString(voucherOrder));return Result.ok(orderId);
}// 在MQSender中新增延迟消息发送方法
public void sendSeckillDelayMessage(Map<String, Object> message) {rabbitTemplate.convertAndSend("seckill.delay.exchange", "seckill.delay", message);
}
2.1.3编写库存恢复 Lua 脚本(保证 Redis 原子性)
创建recover_stock.lua
,防止并发下重复恢复库存:
-- 参数:订单ID、优惠券ID、用户ID
local orderId = ARGV[1]
local voucherId = ARGV[2]
local userId = ARGV[3]-- Redis键:库存键、用户下单记录键、已恢复订单记录(防重复)
local stockKey = "seckill:stock:" .. voucherId
local orderKey = "seckill:order:" .. voucherId -- 原一人一单的Set集合
local recoveredKey = "seckill:recovered:" .. voucherId -- 记录已恢复的订单-- 1. 判断订单是否已恢复过(防重复消费)
if redis.call("sismember", recoveredKey, orderId) == 1 thenreturn 0 -- 已恢复,直接返回
end-- 2. 恢复库存(stock + 1)
redis.call("incrby", stockKey, 1)-- 3. 从用户下单记录中移除(允许用户重新秒杀)
redis.call("srem", orderKey, userId)-- 4. 记录已恢复的订单ID
redis.call("sadd", recoveredKey, orderId)return 1 -- 恢复成功
2.1.4实现库存恢复方法(数据库 + Redis 双写)
@Service
public class VoucherOrderServiceImpl implements IVoucherOrderService {// 初始化Lua脚本private static final DefaultRedisScript<Long> RECOVER_SCRIPT;static {RECOVER_SCRIPT = new DefaultRedisScript<>();RECOVER_SCRIPT.setLocation(new ClassPathResource("recover_stock.lua"));RECOVER_SCRIPT.setResultType(Long.class);}/*** 库存恢复核心方法* @param orderId 订单ID* @param voucherId 优惠券ID* @param userId 用户ID*/public void recoverStock(Long orderId, Long voucherId, Long userId) {// 1. 数据库层面:更新订单状态为“已取消”+恢复库存(乐观锁保证原子性)// 1.1 先更新订单状态(仅未支付订单可取消)int updateOrder = lambdaUpdate().set(VoucherOrder::getStatus, 2) // 2-已取消.eq(VoucherOrder::getId, orderId).eq(VoucherOrder::getStatus, 0) // 仅未支付订单.update();if (updateOrder == 0) {log.info("订单{}无需恢复(已支付或已取消)", orderId);return;}// 1.2 恢复数据库库存boolean recoverDb = seckillVoucherService.lambdaUpdate().set(SeckillVoucher::getStock, SeckillVoucher::getStock + 1).eq(SeckillVoucher::getVoucherId, voucherId).update();if (!recoverDb) {log.error("订单{}数据库库存恢复失败", orderId);return;}// 2. Redis层面:执行Lua脚本恢复库存Long redisResult = stringRedisTemplate.execute(RECOVER_SCRIPT,Collections.emptyList(),orderId.toString(),voucherId.toString(),userId.toString());if (redisResult == 1) {log.info("订单{}库存恢复成功(DB+Redis)", orderId);} else {log.warn("订单{}Redis库存已恢复,无需重复操作", orderId);}}
}
2.1.5新增消费者监听超时队列,调用库存恢复方法:
@Component
public class MQRecoveryReceiver {@Resourceprivate IVoucherOrderService voucherOrderService;// 处理超时未支付订单(核心优化:添加状态校验)@RabbitListener(queues = "seckill.timeout.queue")public void handleTimeoutOrder(Map<String, Object> msg, Channel channel, Message message) throws IOException {long deliveryTag = message.getMessageProperties().getDeliveryTag();try {Long orderId = Long.parseLong(msg.get("orderId").toString());Long voucherId = Long.parseLong(msg.get("voucherId").toString());Long userId = Long.parseLong(msg.get("userId").toString());// 关键步骤:查询订单当前状态VoucherOrder order = voucherOrderService.getById(orderId);if (order == null) {// 订单不存在,直接确认消息channel.basicAck(deliveryTag, false);return;}// 若订单已支付(状态1)或已取消(状态2),则不执行恢复if (order.getStatus() != 0) {log.info("订单{}已支付或取消,无需处理超时", orderId);channel.basicAck(deliveryTag, false);return;}// 仅未支付订单(状态0)执行库存恢复voucherOrderService.recoverStock(orderId, voucherId, userId);channel.basicAck(deliveryTag, false);} catch (Exception e) {log.error("超时订单处理失败", e);channel.basicNack(deliveryTag, false, false); // 拒绝消息,不重回队列}}
}
2.1.6支付成功接口:更新订单状态
在支付成功的业务逻辑中,添加订单状态更新操作(关键是将状态改为 “已支付”):
@Service
public class PayService {@Autowiredprivate IVoucherOrderService voucherOrderService;/*** 用户支付成功后调用* @param orderId 订单ID*/public Result paySuccess(Long orderId) {// 1. 更新订单状态为“已支付”boolean success = voucherOrderService.lambdaUpdate().set(VoucherOrder::getStatus, 1) // 1-已支付.eq(VoucherOrder::getId, orderId).eq(VoucherOrder::getStatus, 0) // 仅未支付订单可更新.update();if (!success) {return Result.fail("订单状态异常,支付失败");}// 2. 其他支付后续逻辑(如生成支付凭证等)return Result.ok("支付成功");}
}
2.2场景2:用户主动取消订单
就是直接取消订单,然后恢复库存即可
@Service
public class VoucherOrderServiceImpl implements IVoucherOrderService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate SeckillVoucherServiceImpl seckillVoucherService;@Autowiredprivate RedissonClient redissonClient;// 初始化库存恢复Lua脚本(复用之前的)private static final DefaultRedisScript<Long> RECOVER_STOCK_SCRIPT;static {RECOVER_STOCK_SCRIPT = new DefaultRedisScript<>();RECOVER_STOCK_SCRIPT.setLocation(new ClassPathResource("recover_stock.lua"));RECOVER_STOCK_SCRIPT.setResultType(Long.class);}/*** 同步取消订单接口(用户主动取消)* @param orderId 订单ID* @return 取消结果*/@Override@Transactional // 同步操作,用事务保证DB一致性public Result cancelOrderSync(Long orderId) {// 1. 获取当前用户ID(需确保登录态,从UserHolder获取)Long userId = UserHolder.getUser().getId();if (userId == null) {return Result.fail("请先登录");}// 2. 分布式锁:防止同一订单被并发取消(避免重复恢复库存)RLock lock = redissonClient.getLock("lock:cancel:order:" + orderId);boolean isLock = lock.tryLock(5, 10, TimeUnit.SECONDS); // 5秒等待,10秒自动释放if (!isLock) {return Result.fail("取消请求处理中,请稍后再试");}try {// 3. 核心校验:订单存在+归属正确+状态为“未支付”VoucherOrder order = getById(orderId);if (order == null) {return Result.fail("订单不存在");}if (!order.getUserId().equals(userId)) {return Result.fail("无权限取消此订单");}if (order.getStatus() != 0) { // 0=未支付,只有未支付订单可取消return Result.fail("订单已支付/已取消,无需重复操作");}// 4. 同步执行库存恢复(DB+Redis)// 4.1 更新订单状态为“已取消”(事务内执行,失败回滚)boolean updateOrder = lambdaUpdate().set(VoucherOrder::getStatus, 2) // 2=已取消.eq(VoucherOrder::getId, orderId).eq(VoucherOrder::getStatus, 0).update();if (!updateOrder) {return Result.fail("取消失败,请重试");}// 4.2 恢复数据库库存(乐观锁,避免超恢复)boolean recoverDbStock = seckillVoucherService.lambdaUpdate().set(SeckillVoucher::getStock, SeckillVoucher::getStock + 1).eq(SeckillVoucher::getVoucherId, order.getVoucherId()).update();if (!recoverDbStock) {throw new RuntimeException("库存恢复失败,取消回滚"); // 抛异常触发事务回滚}// 4.3 恢复Redis库存(Lua脚本原子执行)Long redisResult = stringRedisTemplate.execute(RECOVER_STOCK_SCRIPT,Collections.emptyList(),orderId.toString(),order.getVoucherId().toString(),userId.toString());if (redisResult != 1) {log.warn("订单{}Redis库存恢复重复,已忽略", orderId);}return Result.ok("订单取消成功,库存已恢复");} catch (Exception e) {log.error("同步取消订单失败", e);return Result.fail("取消失败,请稍后重试");} finally {// 释放分布式锁if (lock.isHeldByCurrentThread()) {lock.unlock();}}}
}
三、最后
感兴趣的宝子可以关注一波,后续会更新更多有用的知识!!!