秒杀功能完整实现笔记
模块:mall-order(主体)、mall-product(真实库存)、mall-api(Feign 契约)、mall-gateway(路由)、ui-app(前端页面)
技术:Redis + Lua、RabbitMQ、OpenFeign、MyBatis-Plus 乐观锁、雪花 ID、JWT
目标:高并发抢购下不超卖、不少卖、不压垮数据库,并保证最终一致
目录
- 一、要解决的问题
- 二、整体架构与流量漏斗
- 三、核心设计决策
- 四、Redis Key 设计
- 五、数据库表
- 六、后端完整代码
- 七、前端代码
- 八、启动与验证步骤
- 九、异常场景与补偿
- 十、已知边界与生产增强
- 十一、面试高频问答
一、要解决的问题
| 问题 | 含义 | 本项目对策 |
|---|---|---|
| 超卖 | 100 件卖给 105 人 | Redis Lua 原子预减 + DB 乐观锁双保险 |
| 压垮 DB | 所有请求直接打 MySQL | 内存预减挡掉绝大部分流量 + MQ 削峰 |
| 一人多单 | 黄牛囤货 | Lua 内 SISMEMBER/SADD 一人一单 |
| 少卖 | 扣了库存但下单失败 | 失败补偿:回补 Redis + DB 库存、解除一人一单 |
| 重复下单 | MQ 至少一次投递 | 消费者用雪花 orderNo 幂等判重 |
项目原本没有秒杀:Product.stock 只用于展示,下单/加购的库存校验还是 TODO。本实现新增一套完整异步秒杀链路。
二、整体架构与流量漏斗
海量抢购请求
│
┌───────────────▼────────────────┐
│ 网关 Gateway:/seckill/** 路由 │ JWT 鉴权(必须登录)
│ → lb://order-service │
└───────────────┬────────────────┘
▼
POST /seckill/{id}/execute(抢购入口,只做内存操作,毫秒返回)
1. 校验活动时间窗(读 Redis 活动信息)
2. 执行 Lua(原子):
SISMEMBER 一人一单 → GET 判库存 → DECR 预减 → SADD 标记
返回 1成功 / 0售罄 / 2重复
3. 成功:生成雪花 orderNo,结果置"排队中",发 MQ
│
▼
┌───────────────────────────┐
│ RabbitMQ seckill.order.queue │ ← 削峰缓冲
└───────────────────────────┘
│ 消费者按 DB 能力平稳消费
▼
SeckillOrderListener:
① orderNo 幂等:订单已存在则跳过
② ProductClient.deductStock(乐观锁 where stock>=1)
③ @Transactional 建 Order + OrderItem(秒杀价、未支付)
④ 成功 → 结果置"成功";失败 → 补偿回补,结果置"失败"
│
▼
GET /seckill/result/{orderNo} → 0排队中 / 1成功 / 2失败(前端每秒轮询)
核心思想:请求在到达数据库之前,就被「鉴权 → 时间窗 → 一人一单 → Redis 库存」一层层挡掉,只有”抢到配额”的那么多请求(等于秒杀库存数)才会进入 MQ、最终落库。
三、核心设计决策
- 为什么用 Lua:「判断库存 + 扣减 + 一人一单」必须是原子操作。Redis 单线程执行 Lua 脚本,期间不会被其他命令打断,天然避免并发超卖。
- 两个库存 key 用 hash tag
{id}:seckill:{id}:stock与seckill:{id}:users落在同一 slot,未来上 Redis Cluster 也能在同一脚本里执行。 - 为什么还要 DB 乐观锁:Redis 预减只是”第一道闸”,真实库存以
product.stock为准。消费者用update ... where stock >= n兜底,即使 Redis 与 DB 初始配额有偏差也绝不超卖。 - 为什么异步:抢购入口不直接写库,只发消息立即返回”排队中”,把瞬时尖刺拉平成数据库能承受的平缓写入(削峰填谷)。
- 为什么前端轮询而不是同步等待:下单在 MQ 消费者里异步完成,HTTP 线程不能阻塞等待,用一个独立结果 key 让前端轮询状态。
- 雪花 orderNo 在入口提前生成:它既是订单主键,又是结果 key 和消费幂等依据。
四、Redis Key 设计
| Key | 类型 | 含义 | TTL |
|---|---|---|---|
seckill:{活动id}:stock |
String | 剩余可抢数量,Lua 原子 DECR | 到活动结束 |
seckill:{活动id}:users |
Set | 已抢购用户 id(一人一单) | 与库存同生命周期 |
seckill:goods:{活动id} |
String | 活动信息 JSON(时间窗校验用) | 到活动结束 |
seckill:result:{orderNo} |
String | 下单结果 0/1/2 | 排队 key 10 分钟,终态 1 小时 |
五、数据库表
脚本:mall-order/src/main/resources/sql/seckill.sql(在 new-mall 库手动执行)
drop table if exists seckill_goods;
create table seckill_goods (
id bigint primary key comment '秒杀活动id',
product_id bigint not null comment '关联商品id',
seckill_price decimal(10,2) not null comment '秒杀价',
seckill_stock int not null comment '秒杀总库存(预热到Redis的配额,应≤商品库存)',
start_time datetime not null comment '活动开始时间',
end_time datetime not null comment '活动结束时间',
status tinyint default 1 comment '1开启 0关闭',
create_time datetime default current_timestamp comment '创建时间',
update_time datetime default current_timestamp on update current_timestamp comment '更新时间'
) comment '秒杀活动表';
-- 示例活动:自动挑选一个在售商品,5 件、5 折、当前开始、2 小时后结束
insert into seckill_goods (id, product_id, seckill_price, seckill_stock, start_time, end_time, status)
select 1001, id, round(price * 0.5, 2), 5, now(), date_add(now(), interval 2 hour), 1
from product
where deleted = 0 and status = 1
order by id
limit 1;
六、后端完整代码
6.1 mall-order:秒杀活动实体
pojo/entity/SeckillGoods.java
package com.situ.mall.pojo.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("seckill_goods")
@EqualsAndHashCode(callSuper = false)
public class SeckillGoods implements Serializable {
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
@TableField("product_id")
private Long productId;
@TableField("seckill_price")
private BigDecimal seckillPrice;
@TableField("seckill_stock")
private Integer seckillStock;
@TableField("start_time")
private Date startTime;
@TableField("end_time")
private Date endTime;
/** 1开启 0关闭 */
private Integer status;
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
6.2 视图对象 VO
pojo/vo/SeckillGoodsVO.java
package com.situ.mall.pojo.vo;
import com.situ.mall.api.pojo.entity.Product;
import com.situ.mall.pojo.entity.SeckillGoods;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class SeckillGoodsVO extends SeckillGoods {
private Product product;
/** Redis 中实时剩余可抢数量(预热前为 null) */
private Integer remaining;
}
6.3 Mapper
mapper/SeckillGoodsMapper.java
package com.situ.mall.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.situ.mall.pojo.entity.SeckillGoods;
public interface SeckillGoodsMapper extends BaseMapper<SeckillGoods> {
}
6.4 常量类
constant/SeckillConstant.java
package com.situ.mall.constant;
public interface SeckillConstant {
String STOCK_KEY_PREFIX = "seckill:{%d}:stock"; // hash tag 保证与 users 同 slot
String USERS_KEY_PREFIX = "seckill:{%d}:users";
String GOODS_KEY_PREFIX = "seckill:goods:";
String RESULT_KEY_PREFIX = "seckill:result:";
int RESULT_QUEUING = 0;
int RESULT_SUCCESS = 1;
int RESULT_FAIL = 2;
static String stockKey(Long id) { return String.format(STOCK_KEY_PREFIX, id); }
static String usersKey(Long id) { return String.format(USERS_KEY_PREFIX, id); }
static String goodsKey(Long id) { return GOODS_KEY_PREFIX + id; }
static String resultKey(Long orderNo) { return RESULT_KEY_PREFIX + orderNo; }
}
6.5 Lua 原子脚本
resources/lua/seckill.lua
-- KEYS[1] = seckill:{活动id}:stock
-- KEYS[2] = seckill:{活动id}:users
-- ARGV[1] = userId
-- 返回:1=成功 0=售罄 2=重复抢购
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
return 2
end
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil or stock <= 0 then
return 0
end
-- 预减;极端并发减成负数则回滚并判定售罄
if redis.call('decr', KEYS[1]) < 0 then
redis.call('incr', KEYS[1])
return 0
end
redis.call('sadd', KEYS[2], ARGV[1])
return 1
6.6 装载 Lua 脚本
config/SeckillRedisScriptConfig.java
package com.situ.mall.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
@Configuration
public class SeckillRedisScriptConfig {
@Bean
public DefaultRedisScript<Long> seckillScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/seckill.lua")));
script.setResultType(Long.class);
return script;
}
}
6.7 MQ 常量与消息体
在 constant/MqConstant.java 追加:
/** 秒杀异步下单(普通 topic 交换机,非延时) */
String SECKILL_EXCHANGE = "seckill.topic";
String SECKILL_ORDER_QUEUE = "seckill.order.queue";
String SECKILL_ORDER_ROUTING_KEY = "seckill.order";
util/SeckillMessage.java(放在 util 包,匹配 RabbitMQConfig 的反序列化信任包)
package com.situ.mall.util;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillMessage implements Serializable {
private Long orderNo; // 提前生成的雪花订单号
private Long seckillGoodsId; // 秒杀活动id
private Long userId; // 抢购用户id
}
6.8 Service 接口
service/ISeckillService.java
package com.situ.mall.service;
import com.situ.mall.pojo.entity.SeckillGoods;
import com.situ.mall.pojo.vo.SeckillGoodsVO;
import com.situ.mall.util.SeckillMessage;
import java.util.List;
public interface ISeckillService {
List<SeckillGoodsVO> listGoods();
SeckillGoods getGoods(Long id);
void createGoods(SeckillGoods goods);
void preheat(Long id);
Long execute(Long id); // 返回雪花订单号
Integer result(Long orderNo); // 0排队 1成功 2失败
void createSeckillOrder(SeckillMessage message, SeckillGoods goods);
void restoreStock(Long seckillGoodsId, Long userId);
}
6.9 Service 实现(核心)
service/impl/SeckillServiceImpl.java
package com.situ.mall.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.situ.mall.api.pojo.entity.Product;
import com.situ.mall.api.product.ProductClient;
import com.situ.mall.constant.MqConstant;
import com.situ.mall.constant.SeckillConstant;
import com.situ.mall.enums.OrderStatus;
import com.situ.mall.exception.ServiceException;
import com.situ.mall.mapper.OrderItemMapper;
import com.situ.mall.mapper.OrderMapper;
import com.situ.mall.mapper.SeckillGoodsMapper;
import com.situ.mall.pojo.entity.Order;
import com.situ.mall.pojo.entity.OrderItem;
import com.situ.mall.pojo.entity.SeckillGoods;
import com.situ.mall.pojo.vo.SeckillGoodsVO;
import com.situ.mall.service.ISeckillService;
import com.situ.mall.util.LoginContext;
import com.situ.mall.util.Result;
import com.situ.mall.util.SeckillMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Slf4j
@Service
public class SeckillServiceImpl implements ISeckillService {
@Autowired private SeckillGoodsMapper seckillGoodsMapper;
@Autowired private OrderMapper orderMapper;
@Autowired private OrderItemMapper orderItemMapper;
@Autowired private ProductClient productClient;
@Autowired private StringRedisTemplate stringRedisTemplate;
@Autowired private RabbitTemplate rabbitTemplate;
@Autowired private RedisScript<Long> seckillScript;
@Autowired private ObjectMapper objectMapper;
@Override
public List<SeckillGoodsVO> listGoods() {
List<SeckillGoods> goodsList = seckillGoodsMapper.selectList(
new LambdaQueryWrapper<SeckillGoods>()
.eq(SeckillGoods::getStatus, 1)
.orderByDesc(SeckillGoods::getStartTime));
// 批量回填商品,避免循环内逐条 Feign(N+1)
List<Long> productIds = goodsList.stream()
.map(SeckillGoods::getProductId).filter(Objects::nonNull).distinct().toList();
Map<Long, Product> productMap = Collections.emptyMap();
if (!productIds.isEmpty()) {
Result<List<Product>> r = productClient.listByIds(productIds);
if (r != null && r.getCode() == Result.OK && r.getData() != null) {
productMap = r.getData().stream()
.collect(Collectors.toMap(Product::getId, p -> p, (a, b) -> a));
}
}
Map<Long, Product> finalMap = productMap;
return goodsList.stream().map(g -> {
SeckillGoodsVO vo = new SeckillGoodsVO();
BeanUtils.copyProperties(g, vo);
vo.setProduct(finalMap.get(g.getProductId()));
String remaining = stringRedisTemplate.opsForValue().get(SeckillConstant.stockKey(g.getId()));
vo.setRemaining(remaining == null ? g.getSeckillStock() : Integer.valueOf(remaining));
return vo;
}).toList();
}
@Override
public SeckillGoods getGoods(Long id) {
return seckillGoodsMapper.selectById(id);
}
@Override
public void createGoods(SeckillGoods goods) {
seckillGoodsMapper.insert(goods);
}
@Override
public void preheat(Long id) {
SeckillGoods goods = seckillGoodsMapper.selectById(id);
if (goods == null) {
throw new ServiceException("秒杀活动不存在");
}
stringRedisTemplate.opsForValue().set(SeckillConstant.stockKey(id),
String.valueOf(goods.getSeckillStock()));
// 重新预热清空一人一单集合,避免旧数据影响
stringRedisTemplate.delete(SeckillConstant.usersKey(id));
try {
stringRedisTemplate.opsForValue().set(SeckillConstant.goodsKey(id),
objectMapper.writeValueAsString(goods));
} catch (Exception e) {
throw new RuntimeException("活动信息序列化失败", e);
}
long ttl = Duration.ofMillis(goods.getEndTime().getTime() - System.currentTimeMillis()).getSeconds();
if (ttl > 0) {
stringRedisTemplate.expire(SeckillConstant.stockKey(id), ttl, TimeUnit.SECONDS);
stringRedisTemplate.expire(SeckillConstant.goodsKey(id), ttl, TimeUnit.SECONDS);
}
log.info("秒杀活动预热完成 id={}, stock={}", id, goods.getSeckillStock());
}
@Override
public Long execute(Long id) {
Long userId = (Long) LoginContext.getLoginInfo().get("id");
// 1.校验活动与时间窗(优先读预热缓存,减少 DB 压力)
SeckillGoods goods = loadGoods(id);
if (goods.getStatus() == null || goods.getStatus() != 1) {
throw new ServiceException("活动未开启");
}
Date now = new Date();
if (now.before(goods.getStartTime())) {
throw new ServiceException("活动尚未开始");
}
if (now.after(goods.getEndTime())) {
throw new ServiceException("活动已结束");
}
// 2.Lua 原子:一人一单 + 预减库存
Long ret = stringRedisTemplate.execute(seckillScript,
List.of(SeckillConstant.stockKey(id), SeckillConstant.usersKey(id)),
String.valueOf(userId));
long code = ret == null ? 0L : ret;
if (code == 2) {
throw new ServiceException("您已经抢购过该商品,每人限购一件");
}
if (code == 0) {
throw new ServiceException("手慢了,商品已售罄");
}
// 3.预减成功:生成雪花订单号,置"排队中",发 MQ 立即返回(削峰)
long orderNo = IdWorker.getId();
stringRedisTemplate.opsForValue().set(SeckillConstant.resultKey(orderNo),
String.valueOf(SeckillConstant.RESULT_QUEUING), 10, TimeUnit.MINUTES);
try {
rabbitTemplate.convertAndSend(MqConstant.SECKILL_EXCHANGE,
MqConstant.SECKILL_ORDER_ROUTING_KEY,
new SeckillMessage(orderNo, id, userId));
} catch (Exception e) {
// 发送失败:回补 Redis 库存与一人一单,避免"扣了库存却没下单"
log.error("秒杀消息发送失败,回补库存 orderNo={}", orderNo, e);
restoreStock(id, userId);
stringRedisTemplate.delete(SeckillConstant.resultKey(orderNo));
throw new ServiceException("系统繁忙,请稍后再试");
}
return orderNo;
}
@Override
public Integer result(Long orderNo) {
String v = stringRedisTemplate.opsForValue().get(SeckillConstant.resultKey(orderNo));
return v == null ? SeckillConstant.RESULT_QUEUING : Integer.valueOf(v);
}
@Transactional(propagation = Propagation.REQUIRED)
@Override
public void createSeckillOrder(SeckillMessage message, SeckillGoods goods) {
Result<Product> pr = productClient.selectById(goods.getProductId());
Product product = (pr != null && pr.getCode() == Result.OK) ? pr.getData() : null;
if (product == null) {
throw new ServiceException("商品不存在或已下架");
}
Order order = new Order();
order.setOrderNo(message.getOrderNo());
order.setUserId(message.getUserId());
order.setPayment(goods.getSeckillPrice());
order.setPaymentType(1);
order.setPostage(0);
order.setStatus(OrderStatus.UNPAID.getCode());
orderMapper.insert(order);
OrderItem item = new OrderItem();
item.setOrderNo(message.getOrderNo());
item.setUserId(message.getUserId());
item.setProductId(goods.getProductId());
item.setProductName(product.getName());
item.setProductImage(product.getMainImage());
item.setCurrentUnitPrice(goods.getSeckillPrice());
item.setQuantity(1);
item.setTotalPrice(goods.getSeckillPrice());
item.setStatus(1);
orderItemMapper.insert(item);
}
@Override
public void restoreStock(Long seckillGoodsId, Long userId) {
stringRedisTemplate.opsForValue().increment(SeckillConstant.stockKey(seckillGoodsId));
stringRedisTemplate.opsForSet().remove(SeckillConstant.usersKey(seckillGoodsId),
String.valueOf(userId));
}
private SeckillGoods loadGoods(Long id) {
String json = stringRedisTemplate.opsForValue().get(SeckillConstant.goodsKey(id));
if (json != null) {
try {
return objectMapper.readValue(json, SeckillGoods.class);
} catch (Exception e) {
log.warn("秒杀活动缓存反序列化失败 id={}", id, e);
}
}
SeckillGoods goods = seckillGoodsMapper.selectById(id);
if (goods == null) {
throw new ServiceException("秒杀活动不存在");
}
return goods;
}
}
6.10 Controller
controller/SeckillController.java
package com.situ.mall.controller;
import com.situ.mall.pojo.entity.SeckillGoods;
import com.situ.mall.pojo.vo.SeckillGoodsVO;
import com.situ.mall.service.ISeckillService;
import com.situ.mall.util.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private ISeckillService seckillService;
@GetMapping("/goods")
public Result<List<SeckillGoodsVO>> listGoods() {
return Result.ok(seckillService.listGoods());
}
@GetMapping("/goods/{id}")
public Result<SeckillGoods> getGoods(@PathVariable Long id) {
return Result.ok(seckillService.getGoods(id));
}
@PostMapping("/goods")
public Result createGoods(@RequestBody SeckillGoods goods) {
seckillService.createGoods(goods);
return Result.ok("创建成功");
}
@PostMapping("/goods/{id}/preheat")
public Result preheat(@PathVariable Long id) {
seckillService.preheat(id);
return Result.ok("预热成功");
}
@PostMapping("/{id}/execute")
public Result<Long> execute(@PathVariable Long id) {
return Result.ok("", seckillService.execute(id));
}
@GetMapping("/result/{orderNo}")
public Result<Integer> result(@PathVariable Long orderNo) {
return Result.ok(seckillService.result(orderNo));
}
}
业务异常(售罄/重复/未开始)抛
ServiceException,由 mall-common 的GlobalExceptionHandler统一转成Result.error(msg),前端据此弹提示。
6.11 MQ 消费者
listener/SeckillOrderListener.java
package com.situ.mall.listener;
import com.situ.mall.api.product.ProductClient;
import com.situ.mall.constant.MqConstant;
import com.situ.mall.constant.SeckillConstant;
import com.situ.mall.mapper.OrderMapper;
import com.situ.mall.mapper.SeckillGoodsMapper;
import com.situ.mall.pojo.entity.SeckillGoods;
import com.situ.mall.service.ISeckillService;
import com.situ.mall.util.Result;
import com.situ.mall.util.SeckillMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class SeckillOrderListener {
@Autowired private ISeckillService seckillService;
@Autowired private SeckillGoodsMapper seckillGoodsMapper;
@Autowired private OrderMapper orderMapper;
@Autowired private ProductClient productClient;
@Autowired private StringRedisTemplate stringRedisTemplate;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(MqConstant.SECKILL_ORDER_QUEUE),
exchange = @Exchange(name = MqConstant.SECKILL_EXCHANGE, type = "topic"),
key = MqConstant.SECKILL_ORDER_ROUTING_KEY
))
public void onSeckillOrder(SeckillMessage msg) {
Long orderNo = msg.getOrderNo();
log.info("收到秒杀下单消息 orderNo={}", orderNo);
// 1.幂等:订单已存在(消息重复投递)直接忽略
if (orderMapper.selectById(orderNo) != null) {
return;
}
SeckillGoods goods = seckillGoodsMapper.selectById(msg.getSeckillGoodsId());
if (goods == null) {
markFail(msg);
return;
}
// 2.商品服务乐观锁扣减真实库存(最终防超卖)
Result<Boolean> r = productClient.deductStock(goods.getProductId(), 1);
boolean deducted = r != null && r.getCode() == Result.OK && Boolean.TRUE.equals(r.getData());
if (!deducted) {
log.warn("库存不足,秒杀失败 orderNo={}", orderNo);
markFail(msg);
return;
}
// 3.事务建单(订单+明细)
try {
seckillService.createSeckillOrder(msg, goods);
} catch (Exception e) {
log.error("秒杀建单失败,回补库存 orderNo={}", orderNo, e);
productClient.addStock(goods.getProductId(), 1); // 回补 DB 库存
markFail(msg);
return;
}
// 4.标记成功
stringRedisTemplate.opsForValue().set(SeckillConstant.resultKey(orderNo),
String.valueOf(SeckillConstant.RESULT_SUCCESS), 1, TimeUnit.HOURS);
}
/** 标记失败并补偿:回补 Redis 库存 + 解除一人一单 */
private void markFail(SeckillMessage msg) {
seckillService.restoreStock(msg.getSeckillGoodsId(), msg.getUserId());
stringRedisTemplate.opsForValue().set(SeckillConstant.resultKey(msg.getOrderNo()),
String.valueOf(SeckillConstant.RESULT_FAIL), 1, TimeUnit.HOURS);
}
}
6.12 mall-product:乐观锁扣减/回补库存
mapper/ProductMapper.java
@Update("update product set stock = stock - #{count} " +
"where id = #{id} and stock >= #{count} and deleted = 0")
int deductStock(@Param("id") Long id, @Param("count") Integer count);
@Update("update product set stock = stock + #{count} where id = #{id} and deleted = 0")
int addStock(@Param("id") Long id, @Param("count") Integer count);
IProductService / ProductServiceImpl 增加:
boolean deductStock(Long id, Integer count);
boolean addStock(Long id, Integer count);
@CacheEvict(value = "productCache", key = "#id")
@Override
public boolean deductStock(Long id, Integer count) {
return productMapper.deductStock(id, count) > 0;
}
@CacheEvict(value = "productCache", key = "#id")
@Override
public boolean addStock(Long id, Integer count) {
return productMapper.addStock(id, count) > 0;
}
ProductController 增加内部接口:
@PutMapping("/{id}/stock/deduct")
public Result<Boolean> deductStock(@PathVariable Long id, @RequestParam Integer count) {
return Result.ok("", productService.deductStock(id, count));
}
@PutMapping("/{id}/stock/add")
public Result<Boolean> addStock(@PathVariable Long id, @RequestParam Integer count) {
return Result.ok("", productService.addStock(id, count));
}
6.13 mall-api:Feign 契约
ProductClient 增加:
@PutMapping("/product/{id}/stock/deduct")
Result<Boolean> deductStock(@PathVariable("id") Long id, @RequestParam("count") Integer count);
@PutMapping("/product/{id}/stock/add")
Result<Boolean> addStock(@PathVariable("id") Long id, @RequestParam("count") Integer count);
6.14 网关路由
mall-gateway/src/main/resources/application.yml 增加:
- id: seckill
uri: lb://order-service
predicates:
- Path=/seckill/**
/seckill/** 默认经过 AuthGlobalFilter 的 JWT 校验,未登录返回 401。
七、前端代码
7.1 API 封装
ui/ui-app/src/api/seckill/seckill.js
import request from "@/utils/request.js";
const seckillApi = {
listGoods() {
return request.get("/seckill/goods")
},
execute(id) {
return request.post("/seckill/" + id + "/execute")
},
getResult(orderNo) {
return request.get("/seckill/result/" + orderNo)
},
preheat(id) {
return request.post("/seckill/goods/" + id + "/preheat")
}
}
export default seckillApi
7.2 秒杀页面
ui/ui-app/src/views/seckill/SeckillList.vue
<template>
<div class="seckill-page">
<PageHeader title="限时秒杀"/>
<div class="seckill-tip" v-if="now">⚡ 商品数量有限,抢到即赚到</div>
<div v-loading="loading" class="seckill-list">
<div v-for="item in list" :key="item.id" class="seckill-card">
<img class="seckill-img" :src="formatImage(item.product?.mainImage)" :alt="item.product?.name"/>
<div class="seckill-info">
<div class="seckill-name ellipsis-2">{{ item.product?.name || '商品已失效' }}</div>
<div class="seckill-price-row">
<span class="seckill-price">{{ formatPrice(item.seckillPrice) }}</span>
<span class="origin-price">{{ formatPrice(item.product?.price) }}</span>
</div>
<div class="seckill-meta">
<span class="stock">剩余 {{ item.remaining ?? item.seckillStock }} 件</span>
<span class="countdown" :class="phase(item)">{{ phaseText(item) }}</span>
</div>
</div>
<el-button class="seckill-btn" type="danger" round
:disabled="phase(item) !== 'running' || (item.remaining ?? 1) <= 0"
:loading="grabbingId === item.id" @click="grab(item)">
{{ grabText(item) }}
</el-button>
</div>
<el-empty v-if="!loading && list.length === 0" description="暂无秒杀活动"/>
</div>
</div>
</template>
<script setup>
import {ref, onMounted, onBeforeUnmount} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import seckillApi from '@/api/seckill/seckill.js'
import PageHeader from '@/components/PageHeader.vue'
import {formatImage, formatPrice} from '@/utils/format.js'
const router = useRouter()
const list = ref([])
const loading = ref(true)
const now = ref(Date.now())
const grabbingId = ref(null)
let clockTimer = null
let pollTimer = null
const parse = (s) => s ? new Date(String(s).replace(' ', 'T')).getTime() : 0
const phase = (item) => {
const t = now.value
if (t < parse(item.startTime)) return 'waiting'
if (t > parse(item.endTime)) return 'ended'
return 'running'
}
const pad = (n) => String(n).padStart(2, '0')
const countdownText = (target) => {
let s = Math.max(0, Math.floor((target - now.value) / 1000))
return `${pad(Math.floor(s / 3600))}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`
}
const phaseText = (item) => {
const p = phase(item)
if (p === 'waiting') return '距开始 ' + countdownText(parse(item.startTime))
if (p === 'ended') return '活动已结束'
return '距结束 ' + countdownText(parse(item.endTime))
}
const grabText = (item) => {
if (phase(item) === 'waiting') return '即将开始'
if (phase(item) === 'ended') return '已结束'
if ((item.remaining ?? 1) <= 0) return '已售罄'
return '立即抢购'
}
const loadData = () => {
loading.value = true
seckillApi.listGoods().then(res => {
if (res.code === 1) list.value = res.data || []
}).finally(() => loading.value = false)
}
const grab = (item) => {
grabbingId.value = item.id
seckillApi.execute(item.id).then(res => {
if (res.code !== 1) {
ElMessage.error(res.msg || '抢购失败')
return
}
ElMessage.success('抢购请求已提交,正在下单…')
startPolling(res.data)
}).finally(() => grabbingId.value = null)
}
const startPolling = (orderNo) => {
clearInterval(pollTimer)
let times = 0
pollTimer = setInterval(() => {
times++
seckillApi.getResult(orderNo).then(res => {
if (res.code !== 1) return
if (res.data === 1) {
stopPolling(); ElMessage.success('恭喜,秒杀成功!'); loadData()
setTimeout(() => router.push('/order'), 800)
} else if (res.data === 2) {
stopPolling(); ElMessage.error('下单失败,可能库存不足'); loadData()
}
})
if (times >= 30) { // 最多轮询 30s
stopPolling()
ElMessage.warning('下单结果查询超时,请稍后到订单列表查看')
}
}, 1000)
}
const stopPolling = () => { clearInterval(pollTimer); pollTimer = null }
onMounted(() => {
loadData()
clockTimer = setInterval(() => { now.value = Date.now() }, 1000)
})
onBeforeUnmount(() => { clearInterval(clockTimer); stopPolling() })
</script>
<style scoped>
.seckill-page { padding-bottom: 20px; }
.seckill-tip { padding: 10px 14px; color: var(--el-color-danger); font-size: 13px; font-weight: 600; }
.seckill-list { padding: 0 12px; }
.seckill-card {
display: flex; align-items: center; gap: 12px; background: #fff;
border: 1px solid var(--app-line); border-radius: var(--app-card-radius);
box-shadow: 0 1px 2px rgba(31,34,41,.04); padding: 10px; margin-bottom: 12px;
}
.seckill-img { width: 90px; height: 90px; object-fit: contain; border-radius: 8px; }
.seckill-info { flex: 1; min-width: 0; }
.seckill-name { font-size: 14px; color: var(--app-ink); line-height: 1.4; min-height: 39px; }
.seckill-price-row { display: flex; align-items: baseline; gap: 8px; margin-top: 4px; }
.seckill-price { color: var(--el-color-danger); font-size: 18px; font-weight: 700; }
.origin-price { color: var(--app-ink-3); font-size: 12px; text-decoration: line-through; }
.seckill-meta { display: flex; justify-content: space-between; margin-top: 4px; font-size: 12px; color: var(--app-ink-3); }
.countdown.waiting { color: var(--el-color-warning); }
.countdown.running { color: var(--el-color-danger); }
.seckill-btn { flex-shrink: 0; }
</style>
7.3 路由与入口
src/router/index.js:import 后在 Layout children 中注册 {path: '/seckill', component: SeckillList}(登录守卫已覆盖)。 src/views/Home.vue:增加一张「⚡ 限时秒杀」横幅,点击 router.push('/seckill')。
八、启动与验证步骤
- 执行
seckill.sql,确保示例商品库存 ≥ 秒杀配额(5)。 - 重新安装公共模块:
mvn clean install -pl mall-common,mall-api -am。 - 启动 Nacos、Redis、RabbitMQ、product / order / gateway 等服务和前端。
- 预热:带 token 调
POST /seckill/goods/1001/preheat,确认 Redis 出现seckill:{1001}:stock=5。 - 前端登录 → 首页进「限时秒杀」→ 抢购,看到”排队中→成功”并跳转订单页。
- 压测(JMeter):对
POST /seckill/1001/execute起多线程并发(携带不同/相同用户 token):
- 成功订单数 恰好 = 5,不超卖;
- 同一用户第二次 → “每人限购一件”;
- 库存抢空后 → “已售罄”;
- 校验
product.stock减少 5、Redis 库存为 0。
九、异常场景与补偿
| 场景 | 处理 |
|---|---|
| Redis 预减成功,但发 MQ 失败 | 入口 catch:INCR 回补库存 + SREM + 删结果 key,提示重试 |
| 消息重复投递 | 消费者先 selectById(orderNo) 幂等判重 |
| DB 库存不足(乐观锁返回 0) | markFail:回补 Redis 库存、解除一人一单、结果置失败 |
| DB 已扣库存但建单抛异常 | catch:productClient.addStock 回补 DB + markFail 回补 Redis |
| 前端轮询超时 | 30 次后停止,提示去订单列表查看(结果 key 终态保留 1 小时) |
十、已知边界与生产增强
当前为教学版,与项目整体现状一致,生产还应补:
- MQ 可靠性:开启
publisher-confirm-type: correlated、消费端手动 ack、死信队列 DLX/DLQ。 - 接口限流:网关 Sentinel/令牌桶,按用户/IP 限频,防恶意刷量;活动资格校验前置。
- Redis 高可用:主从/集群 + AOF 持久化(Lua 已用 hash tag 兼容集群)。
- 库存对账:定时任务比对 Redis 已扣、MQ 在途、DB 已下单,修复极端不一致。
- 服务隔离:秒杀流量大,生产可把秒杀下单拆成独立服务/独立线程池与数据库,避免拖垮普通下单。
十一、面试高频问答
Q:为什么能防超卖? A:两道闸——Redis Lua 把”判库存+扣减+一人一单”做成原子操作挡住洪峰;消费者落库前再用 update product set stock=stock-1 where id=? and stock>=1 乐观锁兜底,影响行数为 0 就判定失败并补偿,所以真实库存绝不会为负。
Q:为什么用 MQ? A:削峰。抢购入口只做内存操作就返回,数据库按消费者能力平稳写入,避免瞬时高并发压垮 MySQL。
Q:一人一单怎么做? A:Lua 里先 SISMEMBER 判断用户是否在已购集合,不在且库存够才 DECR 并 SADD,全程原子;下单失败用 SREM 回滚。
Q:消息重复/丢失怎么办? A:重复用 orderNo 幂等;丢失当前靠入口发送失败同步补偿,生产再加 publisher confirm + 手动 ack + 死信队列和对账任务。
Q:Redis 挂了怎么办? A:最终扣减以 DB 乐观锁为准,不会超卖;但会影响抢购入口可用性,生产用 Redis 集群 + AOF,必要时降级为”限流后直接走 DB 乐观锁扣减”。
Q:整条链路的数据流? A:登录用户请求 → 网关 JWT 鉴权路由到 order-service → Lua 预减 → 发 MQ 返回 orderNo/排队中 → 消费者 Feign 调 product-service 乐观锁扣库存 → 事务建订单/明细 → 结果写 Redis → 前端轮询跳转。