秒杀系统设计

一次 618 的惨烈教训

2022年618大促,我们团队满怀信心地部署了秒杀系统。结果,开抢的瞬间,数据库被打爆了 3 次,服务器重启了 5 次,故障持续了整整 2 小时。

复盘发现,问题出在一行代码上:

// 订单服务:超卖事故
public void createOrder(Long userId, Long goodsId, int quantity) {
    // ❌ 没有加锁,竞态条件导致超卖
    Goods goods = goodsDao.selectById(goodsId);
    if (goods.getStock() >= quantity) {
        goods.setStock(goods.getStock() - quantity);
        goodsDao.update(goods);
        orderDao.create(userId, goodsId, quantity);
    }
}

10 个并发请求同时读取到 stock=1,都通过了校验,导致超卖了 9 件。

秒杀系统的本质是:如何在极端流量下,保证数据一致性和系统可用性。


二、秒杀系统的核心挑战🔴

2.1 流量特征

特征描述
瞬时峰值平时 100 QPS,秒杀时 100万 QPS
读多写少99% 是查询库存,1% 是下单
库存有限商品数量少(100件),但想买的人多(100万)
地域集中抢购时间窗口集中(几秒内)
攻击风险恶意刷单、黄牛

2.2 核心矛盾

核心矛盾:库存有限 vs 流量巨大

100件商品
100万人抢购
=
99.99% 的请求必然失败
=
如何优雅地让 99.99% 的请求快速失败?
=
如何保证 0.01% 的成功请求数据正确?

三、系统架构设计🔴

3.1 分层流量控制

用户请求

CDN(静态资源)+ WAF(防刷)

API Gateway(限流、熔断)

┌─────────────────────────────────────┐
│          秒杀服务集群                 │
│  ┌─────────┐    ┌─────────┐        │
│  │ Service │    │ Service │  ...   │
│  └────┬────┘    └────┬────┘        │
└────────┼─────────────┼──────────────┘
         ↓              ↓
┌────────┴────────┬────┴────────────┐
│                 │                  │
↓                 ↓                  ↓
Redis           MQ                 MySQL
(库存缓存)      (异步下单)         (持久化)

3.2 多层拦截策略

L0: CDN 拦截 - 静态资源、已过期活动
L1: API Gateway - 限流、IP黑名单、用户黑名单
L2: 秒杀服务 - 验证码、风控检查
L3: Redis 库存 - 快速扣减、用户重复购买拦截
L4: MQ 消息队列 - 异步下单、流量削峰
L5: 数据库 - 最终库存扣减

每一层拦截掉大部分无效流量,减轻下一层的压力。


四、库存扣减设计🔴

4.1 Redis 原子扣减

@Service
class SeckillService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * Redis Lua 脚本保证原子性
     */
    public Long tryDeductStock(Long goodsId) {
        String key = "seckill:stock:" + goodsId;

        // Lua 脚本:检查库存并扣减
        String script = """
            local stock = redis.call('GET', KEYS[1])
            if not stock or tonumber(stock) <= 0 then
                return -1
            end
            redis.call('DECR', KEYS[1])
            return redis.call('GET', KEYS[1])
        """;

        return redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(key)
        );
    }

    /**
     * 预热库存到 Redis
     */
    public void preloadStock(Long goodsId, int stock) {
        String key = "seckill:stock:" + goodsId;
        redisTemplate.opsForValue().set(key, String.valueOf(stock));
    }
}

4.2 Lua 脚本的必要性

// ❌ 错误:普通命令不能保证原子性
String stock = redisTemplate.opsForValue().get(key);
if (Integer.parseInt(stock) > 0) {
    redisTemplate.opsForValue().decrement(key); // 并发时出问题
}

// ✅ 正确:Lua 脚本保证检查+扣减的原子性
// 同一 Lua 脚本中,检查和扣减是不可分割的

4.3 超卖问题解决方案

// 方案一:乐观锁
Goods goods = goodsDao.selectById(goodsId);
if (goods.getStock() >= quantity) {
    int affected = goodsDao.updateStockOptimistic(goodsId, goods.getVersion());
    if (affected == 0) {
        throw new OptimisticLockException("库存更新失败");
    }
}

// 方案二:分布式锁(悲观锁)
RLock lock = redissonClient.getLock("seckill:lock:" + goodsId);
try {
    if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
        Goods goods = goodsDao.selectById(goodsId);
        if (goods.getStock() >= quantity) {
            goodsDao.decreaseStock(goodsId, quantity);
        }
    }
} finally {
    lock.unlock();
}

// 方案三:Redis Lua 脚本(最佳方案)
// 参见 4.1
方案性能一致性适用场景
乐观锁强一致并发不高
分布式锁强一致中等并发
Redis Lua最高最终一致高并发

五、流量削峰设计🟡

5.1 消息队列异步下单

@Service
class SeckillService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public Result<String> seckill(Long userId, Long goodsId) {
        // 1. 检查用户是否已购买
        String boughtKey = "seckill:bought:" + goodsId + ":" + userId;
        if (Boolean.TRUE.equals(redisTemplate.hasKey(boughtKey))) {
            return Result.error("已购买过");
        }

        // 2. Redis 原子扣减库存
        Long remaining = tryDeductStock(goodsId);
        if (remaining < 0) {
            return Result.error("库存不足");
        }

        // 3. 发送下单消息到 MQ
        OrderMessage message = new OrderMessage(userId, goodsId, remaining);
        rocketMQTemplate.asyncSend("seckill:order:topic", message, new SendCallback() {
            @Override
            public void onSuccess(SendResult result) {
                // 发送成功
            }

            @Override
            public void onException(Throwable e) {
                // 发送失败,回补库存
                restoreStock(goodsId);
            }
        });

        // 4. 返回抢购成功
        return Result.success("抢购成功");
    }
}

5.2 消费者处理下单

@Component
@RocketMQMessageListener(
    topic = "seckill:order:topic",
    consumerGroup = "seckill-order-consumer",
    concurrency = "20"
)
class OrderConsumer {
    @Autowired
    private OrderService orderService;

    public void onMessage(OrderMessage message) {
        try {
            // 1. 创建订单
            orderService.createOrder(message.getUserId(),
                                   message.getGoodsId());

            // 2. 标记用户已购买
            markUserBought(message.getGoodsId(), message.getUserId());

        } catch (Exception e) {
            // 失败重试,最终死信队列处理
            throw new RuntimeException(e);
        }
    }
}

5.3 限流策略

@Configuration
class RateLimitConfig {
    @Bean
    public FilterRegistrationBean<RateLimitFilter> rateLimitFilter() {
        FilterRegistrationBean<RateLimitFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new RateLimitFilter());
        bean.addUrlPatterns("/seckill/*");
        return bean;
    }
}

class RateLimitFilter implements Filter {
    private final RedissonClient redisson = RedissonClientSingleton.get();

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                        FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        String userId = req.getHeader("X-User-Id");

        // 基于用户 ID 的令牌桶限流
        String key = "seckill:ratelimit:user:" + userId;
        RRateLimiter limiter = redisson.getRateLimiter(key);
        limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.MINUTES);

        if (!limiter.tryAcquire()) {
            ((HttpServletResponse) response).setStatus(429);
            response.getWriter().write("请求太频繁");
            return;
        }

        chain.doFilter(request, response);
    }
}

六、库存回补机制🟡

6.1 下单超时回补

@Service
class StockRestoreService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 定时扫描超时未支付的订单,回补库存
     */
    @Scheduled(fixedDelay = 5000)
    public void restoreTimeoutOrders() {
        // 查询超时订单(10分钟未支付)
        List<Order> timeoutOrders = orderDao.findTimeoutOrders(
            Instant.now().minus(10, ChronoUnit.MINUTES)
        );

        for (Order order : timeoutOrders) {
            // 回补 Redis 库存
            String stockKey = "seckill:stock:" + order.getGoodsId();
            redisTemplate.opsForValue().increment(stockKey);

            // 标记订单取消
            orderService.cancelOrder(order.getId());

            // 清除用户购买标记
            String boughtKey = "seckill:bought:" + order.getGoodsId()
                             + ":" + order.getUserId();
            redisTemplate.delete(boughtKey);
        }
    }
}

6.2 MQ 发送失败回补

// 发送失败时回补库存
rocketMQTemplate.asyncSend("seckill:order:topic", message, new SendCallback() {
    @Override
    public void onSuccess(SendResult result) {
        // 发送成功
    }

    @Override
    public void onException(Throwable e) {
        log.error("MQ 发送失败,需要回补库存", e);
        // 回补 Redis 库存
        restoreStock(message.getGoodsId());
    }
});

七、热点数据处理🟡

7.1 多级缓存

L1: 本地缓存(Caffeine)
    - 容量:1000 条
    - TTL:1 秒
    - 命中率:90%

L2: Redis 集群
    - 容量:全量库存
    - 命中率:99.9%

L3: MySQL
    - 最终数据源
@Service
class GoodsService {
    private final Cache<Integer, GoodsStock> localCache;

    public GoodsStock getStock(Long goodsId) {
        // L1: 本地缓存
        GoodsStock cached = localCache.getIfPresent(goodsId);
        if (cached != null) {
            return cached;
        }

        // L2: Redis
        String redisStock = redisTemplate.opsForValue().get("seckill:stock:" + goodsId);
        if (redisStock != null) {
            GoodsStock stock = new GoodsStock(goodsId, Integer.parseInt(redisStock));
            localCache.put(goodsId, stock);
            return stock;
        }

        // L3: MySQL
        return dao.getStock(goodsId);
    }
}

7.2 数据预加载

@Configuration
class DataPreloadRunner implements ApplicationRunner {
    @Autowired
    private List<SeckillGoods> seckillGoods;

    @Override
    public void run(ApplicationArguments args) {
        // 秒杀开始前,提前将库存加载到 Redis
        for (SeckillGoods goods : seckillGoods) {
            redisTemplate.opsForValue().set(
                "seckill:stock:" + goods.getGoodsId(),
                String.valueOf(goods.getStock())
            );
        }
    }
}

八、生产避坑清单🟡

8.1 超卖问题

// ❌ 致命错误:先查后改
Goods goods = goodsDao.selectById(goodsId);
if (goods.getStock() >= quantity) {
    goods.setStock(goods.getStock() - quantity);
    goodsDao.update(goods); // 并发时超卖
}

// ✅ 正确:直接扣减,数据库保证原子性
// MySQL UPDATE WHERE 条件中包含库存检查
int affected = goodsDao.updateStock(goodsId, quantity);
if (affected == 0) {
    throw new StockException("库存不足");
}

// UPDATE goods SET stock = stock - #{quantity}
// WHERE id = #{goodsId} AND stock >= #{quantity}

8.2 重复购买

// ❌ 没有拦截重复购买
public Result seckill(Long userId, Long goodsId) {
    // 任何用户都可以无限购买
    deductStock(goodsId);
}

// ✅ 用 Redis Set 拦截
public Result seckill(Long userId, Long goodsId) {
    String key = "seckill:bought:" + goodsId;
    Long added = redisTemplate.opsForSet().add(key, userId.toString());
    if (added == 0) {
        return Result.error("已购买过");
    }
    // 设置过期时间(防止内存泄漏)
    redisTemplate.expire(key, Duration.ofHours(24));
}

8.3 资源预热

// ❌ 活动开始了才开始加载数据
public void handleRequest(Long goodsId) {
    Goods goods = dao.selectById(goodsId); // 活动开始时才查数据库,晚了
}

// ✅ 提前预热
@PostConstruct
public void init() {
    // 活动开始前 10 分钟预热
    // 1. 加载库存到 Redis
    // 2. 预热热点数据到本地缓存
    // 3. 预编译 Lua 脚本
}

【架构权衡】 秒杀系统的核心矛盾是"库存有限 vs 流量巨大"。所有优化都围绕这个矛盾展开:多级拦截减少无效流量、Redis 原子操作保证一致性、MQ 削峰保证可用性。过度设计(引入太多组件)反而增加复杂度,降低可靠性。


九、面试总结

9.1 核心追问

  1. "如何解决超卖问题?" —— Redis Lua、乐观锁、分布式锁
  2. "如何防止刷单?" —— 限流、验证码、用户购买标记
  3. "Redis 挂了怎么办?" —— 多级缓存降级
  4. "MQ 发送失败怎么处理?" —— 回补库存、重试机制

9.2 级别差异

级别期望回答
P5能说出基本的秒杀流程和 Redis 库存扣减
P6能说出 Lua 脚本、MQ 削峰、多级限流
P7能设计完整的秒杀方案,包括容灾、降级、监控