Skip to content

这篇文章是我学习秒杀系统时整理的笔记。我为什么拿秒杀当高并发入门的第一个案例?因为它把高并发里最典型的问题一次性全占了:瞬时流量、热点数据、库存一致性、异步削峰。把秒杀搞明白,分布式系统里缓存、消息队列、数据库一致性这些核心知识就全串起来了。

笔记按"先看整体架构,再逐层拆代码"的顺序写,代码按 Spring Boot 落地,能写真实代码的写真实代码,不适合写代码的用伪代码说明。


一、先看整体架构:流量是怎么一层层被削掉的

浏览器 / App
  │  ① 商品页静态化 + CDN(读不回源)
  │  ② 答题/验证码、按钮防重、一次性 token(削峰 + 挡脚本)

Nginx 层(limit_req 按 IP 限流)

网关层(Spring Cloud Gateway + Sentinel:限流、鉴权、黑名单)

秒杀应用(Spring Boot)
  │  ③ 活动时间校验 + 一人一单校验(Redis SETNX)
  │  ④ 库存预热 + Lua 原子扣减  ← 在这里拦掉 99% 的请求
  ▼ 扣减成功才继续
RabbitMQ(削峰填谷,异步下单)

消费端:DB 乐观锁扣库存(防超卖兜底)→ 创建订单
  ▼ 失败 / 支付超时
库存回滚(Redis INCRBY 还回去)+ 订单状态机

理解这张图,关键是一句话:每一层都在把请求量往下一级砍一个数量级。秒杀平时没什么流量,活动开始那一秒涌进平时几千倍的请求,但真实可售库存可能只有几百。所以架构的核心不是"让所有请求都打到数据库",而是分层拦截、逐级削峰、异步落库、最终一致——到数据库时,请求数已经和真实库存同量级了。


二、数据模型:先把三张表建出来

秒杀只需要三张表:商品(活动信息)、库存(独立成表)、订单(状态机)。库存单独拆一张表而不是塞在商品表里,是为了后面能用 UPDATE ... WHERE stock >= 1 做乐观锁扣减——这个写法只有独立库存表才好写、才好加索引。

sql
-- 秒杀商品(活动信息)
CREATE TABLE seckill_item (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  item_name   VARCHAR(64)   NOT NULL,
  price       DECIMAL(10,2) NOT NULL,
  start_time  DATETIME      NOT NULL COMMENT '开抢时间',
  end_time    DATETIME      NOT NULL COMMENT '结束时间',
  created_at  DATETIME      DEFAULT CURRENT_TIMESTAMP
);

-- 库存表(乐观锁扣减的目标)
CREATE TABLE seckill_stock (
  id       BIGINT PRIMARY KEY AUTO_INCREMENT,
  item_id  BIGINT      NOT NULL,
  stock    INT         NOT NULL COMMENT '剩余库存',
  version  INT         NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  UNIQUE KEY uk_item (item_id)
);

-- 订单表(状态机:0待支付 / 1已支付 / 2已取消)
CREATE TABLE seckill_order (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no    VARCHAR(32) NOT NULL,
  user_id     BIGINT      NOT NULL,
  item_id     BIGINT      NOT NULL,
  status      TINYINT     NOT NULL DEFAULT 0,
  create_time DATETIME    DEFAULT CURRENT_TIMESTAMP,
  expire_time DATETIME    NOT NULL COMMENT '支付超时时间',
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_user (user_id)
);

对应的 Maven 依赖就四样:spring-boot-starter-data-redisspring-boot-starter-amqpmybatis-plus-boot-starterspring-cloud-starter-alibaba-sentinel,后面每一层会用到对应的那个。


三、缓存层:库存预热 + Lua 原子扣减(最关键的一层)

3.1 为什么要把库存预热到 Redis

活动开始的瞬间,如果每个请求都去数据库"查库存、扣库存",数据库必挂。所以库存要先从 DB 搬进 Redis,让绝大多数请求在内存层就被处理掉。

预热要注意一个细节:setIfAbsent 而不是 set。因为 key 一旦存在就说明库存已经在用了,如果每次都 set,活动结束后再跑一次预热就会把已扣减的库存覆盖回去,数据就乱了:

java
@Component
@Slf4j
public class StockPreheatRunner implements ApplicationRunner {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public void run(ApplicationArguments args) {
        // 查 DB 里所有"即将开始"的活动商品
        List<SeckillItem> items = itemMapper.selectUpcomingItems();
        items.forEach(item -> {
            String key = "seckill:stock:" + item.getId();
            Boolean ok = redisTemplate.opsForValue()
                    .setIfAbsent(key, String.valueOf(item.getStock()));
            if (Boolean.TRUE.equals(ok)) {
                log.info("预热库存 itemId={} stock={}", item.getId(), item.getStock());
            }
        });
    }
}

预热时机一般交给定时任务(xxl-job / SnailJob 等)在开抢前几分钟触发,而不是跟服务启动走——防止库存提前暴露。

3.2 为什么扣减必须用 Lua 脚本(防超卖的核心)

防超卖的本质是:"判断库存够不够"和"扣减库存"这两个动作必须是原子的

初学者最容易写的版本是"先 GET 再 DECR":

java
// ❌ 错误示范:两条命令之间能插入其他请求,并发下必然超卖
int stock = Integer.parseInt(redisTemplate.opsForValue().get(key));
if (stock >= 1) {
    redisTemplate.opsForValue().decrement(key);
}

假设库存只剩 1,两个请求同时 GET 都读到 1,都满足 >= 1,于是都执行 DECR——库存变成 -1,超卖了。

正确做法是用 Lua 脚本。Redis 是单线程执行脚本的,脚本跑完之前不会执行任何其他命令,所以"判断 + 扣减"整体是原子的:

lua
-- KEYS[1] = seckill:stock:{itemId}
-- ARGV[1] = 本次扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
    return -1          -- 库存未预热
end
if stock < tonumber(ARGV[1]) then
    return 0           -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1               -- 扣减成功

Java 侧封装成 Service,返回值约定:1 扣减成功、0 库存不足、-1 未预热:

java
@Service
@Slf4j
public class StockService {

    private static final String DEDUCT_LUA =
            "local stock = tonumber(redis.call('GET', KEYS[1])) " +
            "if stock == nil then return -1 end " +
            "if stock < tonumber(ARGV[1]) then return 0 end " +
            "redis.call('DECRBY', KEYS[1], ARGV[1]) " +
            "return 1";

    private final DefaultRedisScript<Long> script =
            new DefaultRedisScript<>(DEDUCT_LUA, Long.class);

    private final StringRedisTemplate redisTemplate;

    public StockService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * @return 1 扣减成功;0 库存不足;-1 未预热
     */
    public int deduct(Long itemId, int count) {
        Long result = redisTemplate.execute(
                script,
                List.of("seckill:stock:" + itemId),
                String.valueOf(count));
        return result == null ? -1 : result.intValue();
    }

    /** 回滚(少卖时用,后面章节会用到) */
    public void rollback(Long itemId, int count) {
        redisTemplate.opsForValue().increment("seckill:stock:" + itemId, count);
    }
}

这一层拦掉的量是最夸张的:库存 500,第 501 个请求起在 Redis 就被 return 0 挡掉,根本走不到消息队列和数据库


四、接口层:一次性 token + 秒杀主流程

4.1 token 解决什么问题

token 解决的是防连点 + 防脚本刷单。用户点"立即抢购"先向后端申请一个 token(10 秒有效、一人一商品一个),下单时必须携带,用一次就删。没这个设计,脚本一秒钟能刷几十次请求,把后端请求量放大一个量级:

java
@RestController
@RequestMapping("/seckill")
@Slf4j
public class SeckillController {

    @Autowired private StringRedisTemplate redisTemplate;
    @Autowired private SeckillService seckillService;

    /** 1. 先拿 token(10 秒有效,一人一商品一个) */
    @PostMapping("/token")
    public Result<String> getToken(@RequestParam Long itemId,
                                   @RequestParam Long userId) {
        String token = UUID.randomUUID().toString().replace("-", "");
        redisTemplate.opsForValue().set(
                "seckill:token:" + userId + ":" + itemId,
                token, 10, TimeUnit.SECONDS);
        return Result.ok(token);
    }

    /** 2. 秒杀下单 */
    @PostMapping("/order")
    public Result<String> seckill(@RequestParam Long itemId,
                                  @RequestParam Long userId,
                                  @RequestParam String token) {
        String key = "seckill:token:" + userId + ":" + itemId;
        String cached = redisTemplate.opsForValue().get(key);
        if (cached == null || !cached.equals(token)) {
            return Result.fail("非法请求或已提交");
        }
        redisTemplate.delete(key);          // token 一次性
        return seckillService.seckill(itemId, userId);
    }
}

4.2 主流程:把每一步的职责拆清楚

java
@Service
@Slf4j
public class SeckillService {

    @Autowired private StockService stockService;
    @Autowired private RabbitTemplate rabbitTemplate;

    public Result<String> seckill(Long itemId, Long userId) {
        // ① 活动时间校验(活动信息放 Redis 缓存,避免查 DB)
        if (!isInSeckillWindow(itemId)) {
            return Result.fail("活动未开始或已结束");
        }

        // ② 一人一单:SETNX 成功才继续,防止同一用户重复抢
        Boolean first = redisTemplate.opsForValue()
                .setIfAbsent("seckill:user:" + userId + ":" + itemId, "1",
                             1, TimeUnit.HOURS);
        if (!Boolean.TRUE.equals(first)) {
            return Result.fail("您已参与过该秒杀");
        }

        // ③ Redis Lua 原子扣减(核心拦截点)
        int code = stockService.deduct(itemId, 1);
        if (code == -1) {
            return Result.fail("活动火爆,请稍后再试");   // 库存未预热 → 降级
        }
        if (code == 0) {
            return Result.fail("已抢光");                  // 拦截 99% 请求
        }

        // ④ 发 MQ,异步落库(立即返回"排队中")
        SeckillOrderMessage msg = SeckillOrderMessage.of(itemId, userId, orderNo());
        rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE,
                                      RabbitConfig.ROUTING_KEY, msg);
        return Result.ok("排队中,请稍后查看结果");
    }
}

注意:走到第 ④ 步之前,一次请求全程没碰过数据库——这是秒杀能扛住瞬时洪峰的根本。


五、异步层:RabbitMQ 削峰落库

5.1 为什么必须异步落库

"扣库存"和"创建订单"如果同步做,一次请求就占一次数据库连接,活动瞬间连接池直接被打满。所以要把这两个动作拆开:Redis 扣完库存立刻返回"排队中",真正落库交给消费者按自己的节奏慢慢来。消息队列在这里既是削峰填谷的缓冲,也是解耦点。

java
@Configuration
public class RabbitConfig {

    public static final String EXCHANGE = "seckill.exchange";
    public static final String QUEUE = "seckill.order.queue";
    public static final String ROUTING_KEY = "seckill.order";

    @Bean
    public Queue orderQueue() {
        return QueueBuilder.durable(QUEUE).build();   // 持久化,防重启丢消息
    }

    @Bean
    public DirectExchange exchange() {
        return new DirectExchange(EXCHANGE, true, false);
    }

    @Bean
    public Binding binding() {
        return BindingBuilder.bind(orderQueue()).to(exchange()).with(ROUTING_KEY);
    }
}

5.2 消费者:DB 乐观锁兜底 + 事务落库

java
@Component
@Slf4j
public class OrderConsumer {

    @Autowired private StockMapper stockMapper;
    @Autowired private OrderMapper orderMapper;
    @Autowired private StockService stockService;

    @RabbitListener(queues = RabbitConfig.QUEUE)
    @Transactional(rollbackFor = Exception.class)
    public void onCreateOrder(SeckillOrderMessage msg) {
        // ① DB 乐观锁扣库存(防超卖的最后一道防线)
        //    UPDATE seckill_stock SET stock=stock-1, version=version+1
        //    WHERE item_id=#{itemId} AND stock >= 1
        int rows = stockMapper.deductStock(msg.getItemId());
        if (rows == 0) {
            // ② 扣不到 → 回滚 Redis 预扣,避免"少卖"
            stockService.rollback(msg.getItemId(), 1);
            log.warn("DB 扣减失败,回滚 Redis 预扣 itemId={}", msg.getItemId());
            return;
        }

        // ③ 创建订单(status=0 待支付,15 分钟后过期)
        orderMapper.insert(SeckillOrder.builder()
                .orderNo(msg.getOrderNo())
                .userId(msg.getUserId())
                .itemId(msg.getItemId())
                .status(0)
                .expireTime(LocalDateTime.now().plusMinutes(15))
                .build());
        log.info("下单成功 orderNo={}", msg.getOrderNo());
    }
}

5.3 Mapper:乐观锁扣减(为什么 WHERE stock >= 1

xml
<update id="deductStock">
    UPDATE seckill_stock
    SET stock = stock - 1,
        version = version + 1
    WHERE item_id = #{itemId}
      AND stock >= 1
</update>

WHERE stock >= 1 保证永远扣不成负数,影响行数为 0 就是扣减失败。这行 SQL 是数据库层面的"最后一道防线"——就算 Redis 数据被误改,DB 也不会超卖。到这里,防超卖就有两道独立的闸门了:Redis Lua 预扣(乐观、高性能)+ DB 乐观锁(权威、兜底)。


六、库存回滚与超时关单

6.1 "少卖"是怎么产生的

用户抢到但 15 分钟不付款,库存不还回去,就会出现"看着有货却抢不到"的少卖问题。所以要有超时关单:定期扫出过期未支付的订单,置为已取消,并把库存加回去——DB 和 Redis 两边都要加,保持两个库存源一致:

java
@Component
@Slf4j
public class OrderCloseTask {

    @Scheduled(fixedDelay = 30_000)   // 每 30 秒扫一次
    @Transactional(rollbackFor = Exception.class)
    public void closeExpiredOrders() {
        // ① 查出所有 status=0 且 expire_time < now 的订单
        List<SeckillOrder> expired = orderMapper.selectExpired();
        for (SeckillOrder order : expired) {
            // ② 置为已取消(用状态条件防止并发重复关单)
            int rows = orderMapper.cancelIfPending(order.getOrderNo());
            if (rows > 0) {
                // ③ 回滚库存(DB 加回 + Redis 加回)
                stockMapper.increaseStock(order.getItemId(), 1);
                stockService.rollback(order.getItemId(), 1);
            }
        }
    }
}

如果想要"秒级关单",可以用 RabbitMQ 延迟队列(TTL + 死信交换机):下单时发一条延迟 15 分钟的消息,到期检查未支付就关单。效果更实时,代价是多一张延迟交换机配置。入门阶段用定时任务足够。


七、稳定性:限流 / 熔断 / 降级

7.1 Sentinel 接口限流 + 熔断

在秒杀接口上用 @SentinelResource 挂两层兜底:限流走 blockHandler,业务异常走 fallback,都是友好返回而不是把错误堆给用户:

java
@SentinelResource(
        value = "seckill-order",           // 资源名,在控制台配置 QPS 阈值
        blockHandler = "seckillBlock",      // 触发限流时执行
        fallback = "seckillFallback")       // 业务异常时执行
public Result<String> seckill(Long itemId, Long userId) {
    // ...主流程
}

// 限流兜底
public Result<String> seckillBlock(Long itemId, Long userId, BlockException e) {
    return Result.fail("活动太火爆,请稍后再试");
}

// 熔断兜底
public Result<String> seckillFallback(Long itemId, Long userId, Throwable t) {
    return Result.fail("系统繁忙,请稍后再试");
}

7.2 网关和 Nginx 再叠两层

网关按用户维度限流(Spring Cloud Gateway + RequestRateLimiter):

yaml
spring:
  cloud:
    gateway:
      routes:
        - id: seckill-route
          uri: lb://seckill-service
          predicates:
            - Path=/seckill/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100   # 每秒填充令牌
                redis-rate-limiter.burstCapacity: 200   # 桶容量
                key-resolver: "#{@userKeyResolver}"     # 按 userId 维度

Nginx 最外层再按 IP 限:limit_req_zone $binary_remote_addr zone=seckill:10m rate=5r/s;。越靠外层的限流越粗,但越能扛。

7.3 降级的三条底线

  • Redis 不可用:Lua 扣减直接降级为"活动火爆",绝不穿透到数据库扛全量——这是最容易犯的错,Redis 一挂就想着"还有 DB 兜底",结果 DB 直接被冲垮。
  • MQ 积压:不必慌,消费端限流 + 加消费者实例即可,用户轮询结果接口就行,秒杀本来就不追求实时。
  • DB 压力大:查询订单结果的接口加缓存,别让"查结果"变成新的读洪峰。

八、秒杀常见的坑(整理成对照表)

#根因解法
1超卖"判断 + 扣减"不是原子的Redis Lua 原子扣减 + DB 乐观锁双保险
2少卖扣了 Redis,DB 失败不回滚消费失败/关单统一 INCRBY 回滚
3一人多单无防重一次性 token + SETNX user:item
4热点 key单库存 key 被打满库存分桶 seckill:stock:{itemId}:{0..9},取模路由
5数据库连接打满同步落库MQ 削峰 + 消费限流
6缓存与 DB 不一致两边各写各的单向链路:Redis 预扣 → DB 权威 → 失败回滚
7消息重复消费网络重投订单表 order_no 唯一键 + 状态机(幂等)
8活动时间被绕过客户端控制服务端校验活动窗口 + 库存预热时间窗

小结

整理完这份笔记,我最大的感受是:秒杀系统没有黑科技,就是把每一层的基本功排好队。每一层用一个组件、守住一段关键代码:

组件关键代码点
最外层拦截Nginx + Gateway + Sentinel限流、鉴权、token
缓存层Redis + Lua 脚本setIfAbsent 预热、DECRBY 原子扣减
异步层RabbitMQ持久化队列 + @RabbitListener 消费
数据层MyBatis-Plus + MySQLWHERE stock >= 1 乐观锁、@Transactional
稳定性Sentinel + 定时任务@SentinelResource、超时关单回滚

而贯穿始终的就三句话:尽早拦截、异步解耦、最终一致。学的时候建议按这个顺序把每一层亲手实现一遍,比看十遍文章都管用。