秒杀功能完整实现笔记

秒杀功能完整实现笔记

模块:mall-order(主体)、mall-product(真实库存)、mall-api(Feign 契约)、mall-gateway(路由)、ui-app(前端页面)
技术:Redis + Lua、RabbitMQ、OpenFeign、MyBatis-Plus 乐观锁、雪花 ID、JWT
目标:高并发抢购下不超卖、不少卖、不压垮数据库,并保证最终一致


目录


一、要解决的问题

问题 含义 本项目对策
超卖 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、最终落库。


三、核心设计决策

  1. 为什么用 Lua:「判断库存 + 扣减 + 一人一单」必须是原子操作。Redis 单线程执行 Lua 脚本,期间不会被其他命令打断,天然避免并发超卖。
  2. 两个库存 key 用 hash tag {id}:seckill:{id}:stock 与 seckill:{id}:users 落在同一 slot,未来上 Redis Cluster 也能在同一脚本里执行。
  3. 为什么还要 DB 乐观锁:Redis 预减只是”第一道闸”,真实库存以 product.stock 为准。消费者用 update ... where stock >= n 兜底,即使 Redis 与 DB 初始配额有偏差也绝不超卖。
  4. 为什么异步:抢购入口不直接写库,只发消息立即返回”排队中”,把瞬时尖刺拉平成数据库能承受的平缓写入(削峰填谷)。
  5. 为什么前端轮询而不是同步等待:下单在 MQ 消费者里异步完成,HTTP 线程不能阻塞等待,用一个独立结果 key 让前端轮询状态。
  6. 雪花 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')。


八、启动与验证步骤

  1. 执行 seckill.sql,确保示例商品库存 ≥ 秒杀配额(5)。
  2. 重新安装公共模块:mvn clean install -pl mall-common,mall-api -am。
  3. 启动 Nacos、Redis、RabbitMQ、product / order / gateway 等服务和前端。
  4. 预热:带 token 调 POST /seckill/goods/1001/preheat,确认 Redis 出现 seckill:{1001}:stock=5。
  5. 前端登录 → 首页进「限时秒杀」→ 抢购,看到”排队中→成功”并跳转订单页。
  6. 压测(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 小时)

十、已知边界与生产增强

当前为教学版,与项目整体现状一致,生产还应补:

  1. MQ 可靠性:开启 publisher-confirm-type: correlated、消费端手动 ack、死信队列 DLX/DLQ。
  2. 接口限流:网关 Sentinel/令牌桶,按用户/IP 限频,防恶意刷量;活动资格校验前置。
  3. Redis 高可用:主从/集群 + AOF 持久化(Lua 已用 hash tag 兼容集群)。
  4. 库存对账:定时任务比对 Redis 已扣、MQ 在途、DB 已下单,修复极端不一致。
  5. 服务隔离:秒杀流量大,生产可把秒杀下单拆成独立服务/独立线程池与数据库,避免拖垮普通下单。

十一、面试高频问答

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 → 前端轮询跳转。

上一篇
下一篇