// 查询指定商品的详细信息,包括秒杀价格、库存等
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断商品库存是否充足(即是否还有剩余可秒杀的数量)
if (goods.getStockCount() < 1) {
// 库存不足,返回秒杀失败,提示库存为空
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
// 判断当前用户是否已经秒杀过该商品(防止重复抢购)
// 注释掉的是原来的数据库方式判断:
// SeckillOrder seckillOrder = seckillOrderService.getOne(new
// QueryWrapper().eq("user_id", user.getId()).eq("goods_id", goodsId));
// 使用 Redis 判断是否已经下过秒杀订单
// 拼接 Redis key:order:用户id:商品id
String seckillOrderJson = (String) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
// 判断 Redis 中是否存在该 key 的值,说明该用户已经抢购过
if (!StringUtils.isEmpty(seckillOrderJson)) {
// 存在记录,说明重复抢购,返回错误提示
return RespBean.error(RespBeanEnum.REPEATE_ERROR);
}
// 正常进入下单流程,调用秒杀下单服务
Order order = orderService.seckill(user, goods);
// 如果下单成功,返回成功响应以及订单对象
if (null != order) {
return RespBean.success(order);
}
这段代码整体逻辑顺序如下:
// 从数据库中查找是否已经存在该用户对该商品的秒杀订单
SeckillOrder seckillOrder = seckillOrderService.getOne(
new QueryWrapper<SeckillOrder>()
.eq("user_id", user.getId())
.eq("goods_id", goodsId)
);
new QueryWrapper<SeckillOrder>() // 创建一个查询构造器,用于构造 SeckillOrder 表的查询条件
.eq("user_id", user.getId()) // 添加查询条件:字段 user_id 等于当前用户的 ID(即查询该用户的记录)
.eq("goods_id", goodsId) // 添加查询条件:字段 goods_id 等于当前商品的 ID(即查询该商品的记录)
这段代码等价于 SQL 中的:
SELECT * FROM seckill_order
WHERE user_id = 当前用户ID AND goods_id = 当前商品ID;
从秒杀订单表中查询 user_id 等于当前用户,且 goods_id 等于当前商品 的记录。
逻辑解释:
SeckillOrder
表是秒杀订单表,设置了唯一索引,同一个用户,对同一件商品,只能有一条秒杀订单记录。CREATE TABLE seckill_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
goods_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
-- 其他字段 ...
UNIQUE KEY uniq_user_goods (user_id, goods_id)
);
seckillOrder != null
,就说明用户已经抢购过:if (seckillOrder != null) {
return RespBean.error(RespBeanEnum.REPEATE_ERROR);
}
String seckillOrderJson = (String) redisTemplate.opsForValue()
.get("order:" + user.getId() + ":" + goodsId);
if (!StringUtils.isEmpty(seckillOrderJson)) {
return RespBean.error(RespBeanEnum.REPEATE_ERROR);
}
优点:
// 获取 Redis 中的操作对象,用于字符串类型操作
ValueOperations valueOperations = redisTemplate.opsForValue();
// -------- 判断是否重复抢购 --------
// 从 Redis 中获取该用户是否已经抢购过该商品
String seckillOrderJson = (String) valueOperations.get("order:" + user.getId() + ":" + goodsId);
// 如果已经存在该用户对该商品的订单,说明是重复抢购
if (!StringUtils.isEmpty(seckillOrderJson)) {
return RespBean.error(RespBeanEnum.REPEATE_ERROR); // 返回“重复秒杀”错误
}
// -------- 内存标记减少 Redis 访问 --------
// 如果内存中的标记已经说明该商品没有库存了,直接返回,减少对 Redis 的访问
if (EmptyStockMap.get(goodsId)) {
return RespBean.error(RespBeanEnum.EMPTY_STOCK); // 返回“库存为空”错误
}
// -------- 预减库存(Redis 预扣减) --------
// 对 Redis 中的商品库存执行递减操作
Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
// 如果库存扣减后小于 0,说明库存已被抢光
if (stock < 0) {
// 设置内存标记,后续请求就不再访问 Redis 了
EmptyStockMap.put(goodsId, true);
// 回滚 Redis 中的库存(因为刚才减了一次)
valueOperations.increment("seckillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.EMPTY_STOCK); // 返回“库存为空”错误
}
// -------- 请求入队(异步下单) --------
// 创建秒杀消息对象,封装用户和商品信息
SeckillMessage message = new SeckillMessage(user, goodsId);
// 发送消息到 RabbitMQ 队列,让后端异步去处理下单逻辑
mqSender.sendsecKillMessage(JsonUtil.object2JsonStr(message));
// 秒杀请求排队中,立即返回成功(前端可以轮询查询是否下单成功)
return RespBean.success(0);
// 实现 InitializingBean 接口的 afterPropertiesSet 方法,在 Spring 初始化 Bean 后执行
@Override
public void afterPropertiesSet() throws Exception {
// 查询所有参与秒杀的商品列表
List<GoodsVo> list = goodsService.findGoodsVo();
// 如果商品列表为空,直接返回
if (CollectionUtils.isEmpty(list)) {
return;
}
// 遍历每个商品,将库存数量加载到 Redis,同时初始化内存标记为“有库存”
list.forEach(goodsVo -> {
// Redis 中设置商品库存,key 是 seckillGoods:商品ID,value 是库存数量
redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(),
goodsVo.getStockCount());
// 内存中标记该商品有库存(false 表示“未被标记为无库存”)
EmptyStockMap.put(goodsVo.getId(), false);
});
}
阶段 | 技术 | 作用 |
---|---|---|
重复抢购校验 | Redis + 用户ID-商品ID 键 | 高效判断是否已经秒杀过 |
库存控制 | Redis decrement |
避免并发超卖 |
内存标记 | EmptyStockMap |
避免频繁访问 Redis |
异步处理 | RabbitMQ + 秒杀消息对象 | 将核心下单操作交由后端异步处理,减轻主线程压力 |
初始化 | Redis 预加载 | 提前加载秒杀商品库存,提升响应速度 |
RabbitMQConfig.java
:配置 RabbitMQ 消息队列package com.xxxxx.seckill.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置类
* 配置队列、交换机和绑定关系
* 用于秒杀系统的消息异步处理
*/
@Configuration
public class RabbitMQConfig {
// 定义队列名称常量
private static final String QUEUE = "seckillQueue";
// 定义交换机名称常量
private static final String EXCHANGE = "seckillExchange";
/**
* 定义一个名为 seckillQueue 的队列
* @return 队列对象
*/
@Bean
public Queue queue(){
return new Queue(QUEUE);
}
/**
* 将队列与交换机进行绑定,并设置路由键为 seckill.#
* 意味着所有以 seckill. 开头的消息都会被路由到 seckillQueue 队列中
*/
@Bean
public Binding binding01(){
return BindingBuilder
.bind(queue()) // 绑定队列
.to(topicExchange()) // 指定交换机
.with("seckill.#"); // 路由键匹配规则:以 seckill. 开头的所有消息
}
}
MQSender.java
:发送秒杀消息package com.xxxxx.seckill.rabbitmq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 消息发送者(将秒杀请求异步发送到 RabbitMQ)
*/
@Service
@Slf4j
public class MQSender {
// 注入 RabbitTemplate,用于操作 RabbitMQ
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 发送秒杀消息
* @param message 消息体(通常是包含用户和商品ID的 JSON 字符串)
*/
public void sendsecKillMessage(String message) {
log.info("发送消息:" + message); // 打印日志,便于调试
// 发送消息到交换机 seckillExchange,使用 routingKey 为 seckill.msg
rabbitTemplate.convertAndSend("seckillExchange", "seckill.msg", message);
}
}
MQReceiver.java
:接收秒杀消息并处理package com.xxxxx.seckill.rabbitmq;
import com.xxxxx.seckill.pojo.User;
import com.xxxxx.seckill.service.IGoodsService;
import com.xxxxx.seckill.service.IOrderService;
import com.xxxxx.seckill.util.JsonUtil;
import com.xxxxx.seckill.vo.GoodsVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
这些是基本的包导入,含业务服务类、工具类和 Redis 组件。
/**
* 消息接收者(从 RabbitMQ 获取秒杀请求并处理)
*/
@Service
@Slf4j
public class MQReceiver {
@Autowired
private IGoodsService goodsService;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private IOrderService orderService;
/**
* 消费者监听 seckillQueue 队列
* 接收到消息后开始处理秒杀逻辑
*/
@RabbitListener(queues = "seckillQueue")
public void receive(String msg) {
log.info("QUEUE接受消息:" + msg); // 打印日志
// 将 JSON 字符串反序列化成 SeckillMessage 对象
SeckillMessage message = JsonUtil.jsonStr2Object(msg, SeckillMessage.class);
// 从消息中提取商品ID和用户信息
Long goodsId = message.getGoodsId();
User user = message.getUser();
// 查询商品详情(包括秒杀库存等)
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
// ------- 判断库存是否足够 -------
if (goods.getStockCount() < 1) {
return; // 库存不足,直接返回,不再继续处理
}
// ------- 判断是否重复秒杀 -------
// 使用 Redis 判断该用户是否已抢购该商品(Redis中有记录则表示已经下单)
String seckillOrderJson = (String)
redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (!StringUtils.isEmpty(seckillOrderJson)) {
return; // 已经秒杀过了,直接返回
}
// ------- 执行秒杀下单逻辑 -------
// 调用订单服务,完成订单生成、库存扣减等
orderService.seckill(user, goods);
}
}
步骤 | 说明 |
---|---|
1. 前端点击“秒杀”按钮 | 请求发送到后台秒杀接口 |
2. 后台进行校验 | 包括是否重复抢购、库存校验、内存标记 |
3. 校验通过后发送消息 | 使用 MQSender 发送消息到 RabbitMQ |
4. 消费端监听 seckillQueue |
使用 @RabbitListener 自动接收消息 |
5. 反序列化消息 | 转换为 SeckillMessage 对象 |
6. 查询商品信息 | 获取库存 |
7. 再次校验是否重复秒杀、库存是否足够 | |
8. 执行下单逻辑 | 调用 orderService.seckill() 进行下单入库、更新 Redis 等操作 |
已经在接口层(controller/service)对库存是否足够和是否重复秒杀做了一次判断,为什么在 MQReceiver.java 里还要再判断一遍呢?
答案可以用一句话总结:
因为消息队列是异步的,接口层的判断并不能保证最终数据一致性。真正的“抢购成功”必须由消息消费方进行最终确认。
秒杀接口
中):// 1. 判断是否重复秒杀(Redis 中存在这个用户和商品的订单)
if (!StringUtils.isEmpty(seckillOrderJson)) {
return RespBean.error(RespBeanEnum.REPEATE_ERROR);
}
// 2. 判断库存是否为 0(Redis 预减库存)
Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
if (stock < 0) {
EmptyStockMap.put(goodsId,true);
valueOperations.increment("seckillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
// 3. 通过 RabbitMQ 异步下单
mqSender.sendsecKillMessage(JsonUtil.object2JsonStr(message));
这个阶段主要是为了快速响应用户请求、限流、预拦截非法操作。因为秒杀高并发,不能所有请求都进入数据库,先通过 Redis 做一轮筛选。
MQReceiver.java
中):// 1. 获取商品库存信息(查数据库)
if (goods.getStockCount() < 1) {
return;
}
// 2. 再次判断是否已经秒杀(Redis 或数据库确认)
String seckillOrderJson = (String)
redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (!StringUtils.isEmpty(seckillOrderJson)) {
return;
}
为什么还要再次判断?
数据最终一致性的保障(兜底逻辑)
防止 Redis 与数据库数据不一致
避免“多次秒杀”绕过逻辑
层级 | 处理位置 | 作用 | 缺点 | 优点 |
---|---|---|---|---|
第一次 | 接口层(Controller / Service) | 快速判断,提高性能,减轻 MQ 和数据库压力 | 数据不一定可靠 | 响应快,限流效果好 |
第二次 | MQReceiver 消费者端 | 最终判断是否成功抢购 | 响应慢(异步) | 保证数据一致性,防止超卖和重复秒杀 |
用户点击「秒杀」按钮
|
发起 /doSeckill 请求(通常是 POST)
|
秒杀服务判断幂等、库存、入队
|
秒杀消息被投递到 MQ(如RabbitMQ)
|
---异步处理开始---
|
MQReceiver 消费消息
|
判断库存是否充足、是否重复秒杀
|
创建订单 & 秒杀订单 & 写Redis标记
|
---异步处理结束---
|
客户端开始定时轮询 /result 接口(GET)
|
ISeckillOrderService.getResult(user, goodsId)
|
Redis中判断是否库存为空 or 查询订单记录
|
返回三种状态:
✔️ 订单ID:成功
❌ -1:失败(库存为空)
⏳ 0:排队中(异步线程尚未处理完)
/result
轮询接口逻辑详解你提供的 /result
Controller:
@RequestMapping(value = "/result", method = RequestMethod.GET)
@ResponseBody
public RespBean getResult(User user, Long goodsId) {
if (user == null) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
Long orderId = seckillOrderService.getResult(user, goodsId);
return RespBean.success(orderId);
}
getResult()
方法逻辑:@Override
public Long getResult(User user, Long goodsId) {
// 1. 从数据库中查询是否已经生成秒杀订单
SeckillOrder seckillOrder = seckillOrderMapper.selectOne(
new QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq("goods_id", goodsId)
);
if (seckillOrder != null) {
return seckillOrder.getId(); // 秒杀成功,返回订单ID
}
// 2. 如果Redis中标记了库存为空,说明秒杀失败
if (redisTemplate.hasKey("isStockEmpty:" + goodsId)) {
return -1L; // 秒杀失败
}
// 3. 否则仍在排队中
return 0L;
}
/result
来获得是否秒杀成功。isStockEmpty:goodsId
:快速失败标记,防止浪费时间排队。order:userId:goodsId
:避免重复秒杀,保证幂等性。上面代码实际演示会发现Redis的库存有问题,原因在于Redis没有做到原子性。
假设你在做一个“秒杀”活动,商品库存是 10
,使用 Redis 存储库存数量:
set stock 10
每当一个用户下单时,就会执行如下操作:
int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
if (stock > 0) {
redisTemplate.opsForValue().set("stock", stock - 1);
}
上面代码是三个步骤:
stock = 10
stock = 9
假设两个线程 A 和 B 几乎同时执行:
时间顺序 | 线程 A | 线程 B |
---|---|---|
T1 | 读取 stock=10 | |
T2 | 读取 stock=10 | |
T3 | 判断 >0 | 判断 >0 |
T4 | 写入 stock=9 | 写入 stock=9 |
虽然来了两个用户,正确的逻辑应该库存变为 8
,但实际却被覆盖成了 9
,相当于少扣了一次库存(出现超卖/重复卖的问题)。
Redis 单个命令是原子性的,但你把多个命令组合起来执行时(如 get
+ if
+ set
)就不是原子操作了。
也就是说:
多个命令之间线程是可以插队的,这就导致了并发安全问题。
为了解决这个并发问题,我们引入锁机制:
if (get lock成功) {
// 执行:get stock -> check -> set stock
// 释放锁
}
因为如下问题:
所以最终需要用:
Boolean isLock = valueOperations.setIfAbsent("k1", "v1");
SETNX
(set if not exists)机制实现分布式锁。true
,就认为加锁成功,进入临界区,操作完后手动 del
删除锁。Boolean isLock = valueOperations.setIfAbsent("k1", "v1", 5, TimeUnit.SECONDS);
RedisTemplate.setIfAbsent(K key, V value, long timeout, TimeUnit unit)
方法。String uuid = UUID.randomUUID().toString();
Boolean isLock = redisTemplate.opsForValue().setIfAbsent("k1", uuid, 5, TimeUnit.SECONDS);
...
// 释放锁前先判断value
String value = (String) redisTemplate.opsForValue().get("k1");
if (uuid.equals(value)) {
redisTemplate.delete("k1");
}
get
+ delete
是两个独立操作,中间可能有线程切换,仍然有并发安全问题,无法保证原子性!String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(redisScript, Collections.singletonList("k1"), uuid);
阶段 | 代码关键点 | 解决问题 | 遗留问题 |
---|---|---|---|
1️⃣ 初始锁 setIfAbsent(k, v) |
实现基本分布式锁 | 没有过期时间,可能死锁 | |
2️⃣ 加过期时间 setIfAbsent(k, v, timeout) |
防止死锁 | 业务执行慢时锁可能提前释放 | |
3️⃣ 加唯一值(UUID) + get + delete | 防止误删他人锁 | get+delete 非原子 |
|
4️⃣ Lua 脚本判断+删除 | 完整原子释放锁,最终完善版本 | 基础 Redis 实现,后续可封装成工具 |