一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

秒杀系统设计只会用update?从架构到代码:一篇文章讲透

时间:2026-07-17 08:43:50 编辑:袖梨 来源:一聚教程网

一、秒杀到底难在哪?(先认清问题)

假设你开了一家线上商店,今天做活动:1块钱抢最新款手机,只有100台。消息一出,瞬间来了10000个人。

秒杀系统设计只会用update?从架构到代码,一篇文章讲透

这时候会遇到三个致命问题:

问题具体表现后果
瞬时高并发平时QPS=500,秒杀瞬间飙到5万+服务器直接宕机,所有人都买不了
库存超卖100个并发同时扣库存,数据库可能扣成负数卖了120台,平台亏钱,用户投诉
恶意刷单用脚本疯狂点击,一个人抢走好几台普通用户抢不到,活动失去公平性

所以我们的设计目标就三条:抗住高并发、保证不超卖、防刷防重复


二、核心设计思想:漏斗模型(层层过滤)

不要把所有请求都直接冲到数据库,而是像漏斗一样,一层一层过滤掉无效流量,最后真正落到数据库的只有少量有效请求。

整体架构图(四层防御)

 复制代码用户请求
    ↓
【前端层】按钮防抖、验证码 → 拦截手抖党和脚本
    ↓
【网关层】Nginx限流 → 每IP每秒只放行10个请求
    ↓
【业务层】Redis原子扣库存 + 分布式锁防重复 → 核心战场
    ↓
【消息队列】异步下单 → 削峰填谷,保护数据库
    ↓
【数据库】最终落库 → 压力已削减99%

每一层都在"拦截",让无效请求尽早返回"已售罄"或"太火爆",绝不往下一层传递。


三、分层详解 + 代码实战

1. 前端层:把"手速党"和"脚本党"拦住

做法:点击秒杀按钮后立即变灰,5秒内不能重复点;加上简单验证码(滑块或算术题)。

 复制代码// 按钮防重复点击
document.getElementById('buyBtn').onclick = function() {
    this.disabled = true;
    this.innerText = '正在抢购...';
    // 实际发起请求
    doSeckillRequest();
    // 5秒后恢复(即使请求失败也不让狂点)
    setTimeout(() => {
        this.disabled = false;
        this.innerText = '立即秒杀';
    }, 5000);
};

2. 网关层(Nginx):限流,只放行一部分

做法:用Nginx的limit_req模块,限制每个IP每秒最多10个请求,突发20个,超过的直接返回503。

 复制代码# nginx.conf
http {
    # 定义限流区域,按IP,10MB内存,每秒10个请求
    limit_req_zone $binary_remote_addr zone=seckill_limit:10m rate=10r/s;
    
    server {
        location /seckill {
            # 允许突发20个,超出部分直接拒绝
            limit_req zone=seckill_limit burst=20 nodelay;
            limit_req_status 503;
            proxy_pass ;
        }
    }
}

3. 业务层:Redis原子扣库存(最核心)

核心逻辑:库存提前预热到Redis中,用Lua脚本保证"查库存→判足够→扣库存"三步是原子操作,绝不出错。

Lua脚本(保存为 deduct.lua)

 复制代码-- KEYS[1] : 库存Key,例如 seckill:stock:1001
-- ARGV[1] : 要扣减的数量,通常为1
local stock = redis.call('get', KEYS[1])
if not stock then
    return -1  -- 库存key不存在(活动未开始)
end
if tonumber(stock) < tonumber(ARGV[1]) then
    return 0   -- 库存不足
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1       -- 扣减成功

Java调用(Spring Boot + RedisTemplate)

 复制代码@Component
public class SeckillService {    @Autowired
    private StringRedisTemplate redisTemplate;    // 扣库存,返回true表示成功
    public boolean deductStock(String productId, int quantity) {
        String script = 
            "local stock = redis.call('get', KEYS[1]) " +
            "if not stock then return -1 end " +
            "if tonumber(stock) < tonumber(ARGV[1]) then return 0 end " +
            "redis.call('decrby', KEYS[1], ARGV[1]) " +
            "return 1";
        
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(script);
        redisScript.setResultType(Long.class);
        
        Long result = redisTemplate.execute(
            redisScript,
            Collections.singletonList("seckill:stock:" + productId),
            String.valueOf(quantity)
        );
        return result != null && result == 1L;
    }
}

预热库存:秒杀开始前,将数据库库存加载到Redis。

 复制代码public void preheatStock(String productId, int stock) {
    redisTemplate.opsForValue().set("seckill:stock:" + productId, String.valueOf(stock));
}

4. 防重复下单(分布式锁)

问题:同一个用户可能因为网络延迟,同时发来两个请求,结果抢到两次(明明限制每人只能买一个)。

方案:用Redis的setIfAbsent加一个短暂锁,key包含用户ID+商品ID。

 复制代码public boolean tryAcquireLock(Long userId, String productId) {
    String lockKey = "seckill:lock:" + userId + ":" + productId;
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
    return Boolean.TRUE.equals(locked);
}

完整抢购流程

 复制代码public String handleSeckill(Long userId, String productId) {
    // 1. 防重复
    if (!tryAcquireLock(userId, productId)) {
        return "您已参与过,请勿重复抢购";
    }
    // 2. 扣Redis库存
    if (!deductStock(productId, 1)) {
        return "库存不足,已售罄";
    }
    // 3. 发送消息到MQ(异步下单)
    sendOrderMessage(userId, productId);
    return "恭喜,抢购成功!正在生成订单";
}

5. 消息队列削峰(异步下单)

扣库存成功后,不立即写数据库,而是发消息给RocketMQ或RabbitMQ,让后台消费者慢慢创建订单。

发送消息(在业务层)

 复制代码@Autowired
private RocketMQTemplate rocketMQTemplate;public void sendOrderMessage(Long userId, String productId) {
    SeckillMessage msg = new SeckillMessage(userId, productId);
    rocketMQTemplate.syncSend("seckill_order_topic", msg);
}

消费者(后台服务)

 复制代码@RocketMQMessageListener(topic = "seckill_order_topic", consumerGroup = "seckill_group")
@Component
public class SeckillConsumer implements RocketMQListener<SeckillMessage> {    @Autowired
    private OrderService orderService;    @Override
    public void onMessage(SeckillMessage message) {
        // 实际落库:插入订单,并更新数据库库存(使用乐观锁)
        orderService.createSeckillOrder(message.getUserId(), message.getProductId());
    }
}

数据库更新库存使用乐观锁

 复制代码UPDATE product SET stock = stock - 1 
WHERE id = #{productId} AND stock > 0;

由于MQ已经削峰,同时写库的并发数很低,这条语句基本不会冲突。


四、用户取消订单,怎么安全释放库存?

用户抢到了但不想要,点"取消订单";或者超时未支付,系统自动关闭。核心原则:先改数据库(以它为准),再改Redis(释放名额),并且保证幂等(无论点多少次取消,库存只加一次)。

1. 用户主动取消(带状态校验)

 复制代码@Service
@Transactional(rollbackFor = Exception.class)
public class SeckillCancelService {    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private StringRedisTemplate redisTemplate;    public boolean cancelOrderAndReleaseStock(Long userId, String orderId) {
        // 1. 查询订单(顺便做权限校验)
        Order order = orderMapper.selectById(orderId);
        if (order == null || !order.getUserId().equals(userId)) {
            return false;
        }
        // 2. 如果不是"待支付"状态,直接返回(幂等性保证)
        if (!"PENDING".equals(order.getStatus())) {
            return false; // 已取消或已支付,什么都不做
        }        // 3. 更新订单状态为已取消(利用数据库行锁和状态判断)
        int updateOrder = orderMapper.updateStatus(
            orderId, 
            "CANCELLED",  // 新状态
            "PENDING"     // 旧状态(乐观锁条件)
        );
        
        // 如果更新影响行数为0,说明刚才被其他线程抢先修改了,直接返回
        if (updateOrder < 1) {
            return false;
        }        // 4. 恢复数据库中的库存(此时订单已是取消状态)
        productMapper.increaseStock(order.getProductId(), order.getQuantity());        // 5. 释放Redis库存(原子增加)
        releaseRedisStock(order.getProductId(), order.getQuantity());        return true;
    }    private void releaseRedisStock(String productId, int quantity) {
        String stockKey = "seckill:stock:" + productId;
        // 直接用 increment 原子增加,不涉及判断
        redisTemplate.opsForValue().increment(stockKey, quantity);
    }
}

对应的SQL

 复制代码-- 只有状态为PENDING时才能改为CANCELLED
UPDATE orders 
SET status = 'CANCELLED', cancel_time = NOW() 
WHERE order_id = #{orderId} 
  AND status = 'PENDING';

2. 支付超时自动取消(用延迟消息,别用定时扫全表)

触发方式:用户抢到库存创建订单后,立刻发送一条延迟消息(15分钟后才被消费)。

 复制代码// RocketMQ 发送延迟消息(订单创建时调用)
public void sendDelayCloseMessage(Order order) {
    Message msg = new Message(
        "order_close_topic", 
        JSON.toJSONBytes(order.getOrderId())
    );
    // 延迟级别16 ≈ 15分钟(RocketMQ 4.x 默认)
    msg.setDelayTimeLevel(16); 
    rocketMQProducer.send(msg);
}

消费者收到消息后,处理超时取消

 复制代码@Component
public class OrderTimeoutConsumer implements RocketMQListener<String> {    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private StringRedisTemplate redisTemplate;    @Override
    public void onMessage(String orderId) {
        // 1. 尝试把订单从【待支付】改为【已取消】
        // 必须同时满足:status='PENDING' 且 创建超过15分钟
        int updateResult = orderMapper.updateStatusByTimeout(
            orderId, 
            "CANCELLED",
            "PENDING",
            LocalDateTime.now().minusMinutes(15)
        );        // 2. 如果更新失败,说明订单已被支付或其他线程处理,直接放弃
        if (updateResult == 0) {
            log.info("订单 {} 状态已变更,超时取消处理放弃", orderId);
            return;
        }        // 3. 超时取消成功,立刻恢复库存
        Order order = orderMapper.selectById(orderId);
        productMapper.increaseStock(order.getProductId(), order.getQuantity());
        redisTemplate.opsForValue().increment(
            "seckill:stock:" + order.getProductId(), 
            order.getQuantity()
        );
        log.info("订单 {} 超时取消成功,库存已归还", orderId);
    }
}

3. 最关键:支付回调 vs 超时取消的"并发争夺战"

用户在第14分59秒点击支付,而超时消息恰好在第15分00秒被消费。两个线程同时在改这条订单。谁先改数据库状态,谁赢。

支付回调的代码

 复制代码public void handlePaySuccess(String orderId, String transactionId) {
    // 尝试把订单从【待支付】改为【已支付】
    int updateResult = orderMapper.updateStatus(
        orderId, 
        "PAID",      // 新状态
        "PENDING"    // 旧状态(乐观锁条件)
    );    // 如果更新失败,说明订单已经不是 PENDING 状态
    if (updateResult == 0) {
        Order order = orderMapper.selectById(orderId);
        if ("CANCELLED".equals(order.getStatus())) {
            // 【关键!】用户付钱时,订单已经被超时取消了!
            // 必须自动退款
            refundService.refundToUser(order.getUserId(), order.getPayAmount());
            log.warn("订单 {} 已超时取消,触发退款", orderId);
        }
        return;
    }    // 支付成功,正常发货
    log.info("订单 {} 支付成功", orderId);
    orderService.afterPaid(orderId, transactionId);
}

核心SQL

 复制代码-- 超时取消(带时间条件,双重保险)
UPDATE orders 
SET status = 'CANCELLED', close_time = NOW() 
WHERE order_id = #{orderId} 
  AND status = 'PENDING'
  AND create_time < #{timeoutTime};-- 支付成功(只靠状态)
UPDATE orders 
SET status = 'PAID', pay_time = NOW() 
WHERE order_id = #{orderId} 
  AND status = 'PENDING';

五、完整流程串一遍(从点击到结束)

 复制代码用户点击秒杀
    ↓
前端防抖 → 按钮变灰5秒
    ↓
Nginx限流 → 超出的返回"太火爆"
    ↓
业务层:分布式锁(防重复)→ Redis Lua扣库存
    ↓
扣成功 → 发MQ消息,返回"抢购成功"
    ↓
MQ消费者 → 插入订单(状态PENDING),发延迟消息
    ↓
用户15分钟内付款
    ├── 付款成功 → 订单状态 PAID → 发货
    └── 超时未付 → 延迟消息触发
              ↓
       乐观锁更新订单 CANCELLED
              ↓
       恢复数据库+Redis库存
              ↓
       如果恰好用户也在付款 → 支付回调检测到状态CANCELLED → 自动退款

六、避坑指南(实战经验)

坑点解决方案
Redis库存扣了,但MQ消息丢失使用RocketMQ的事务消息或手动确认机制,保证消息必达。
Redis挂了怎么办?部署Redis集群(主从+哨兵),并设置降级:Redis不可用时直接返回"活动太火爆",不查数据库。
数据库库存和Redis不一致库存扣减优先用Redis,数据库作为最终落地。消费消息时如果更新数据库失败(如乐观锁冲突),可重试+告警人工对账。
消息队列积压严重增加消费者实例,动态扩容,同时前端显示排队进度。
用户使用脚本绕过前端验证后端必须做所有校验(限流、防重复、库存),前端只是锦上添花。
超时取消成功但Redis加库存失败记录库存补偿日志,后台定时任务扫描重试。

七、技术选型推荐(轻量级方案)

组件推荐备注
网关Nginx免费、高效、配置简单
缓存Redis(单机或Cluster)必须支持Lua脚本
消息队列RocketMQ / RabbitMQ前者吞吐更高,后者更轻量
应用框架Spring Boot无状态,便于水平扩展
数据库MySQL + 乐观锁分库分表视数据量而定
限流降级Sentinel阿里开源,集成简单

八、总结(背下来)

设计口诀

取消和超时口诀

一句话核心把大部分请求挡在Redis层,用Lua脚本原子扣库存;用消息队列异步下单;取消和超时靠数据库乐观锁裁决,谁先改状态谁说了算。

这套方案足够应对百万级别的秒杀场景,所有代码都可以直接复制到项目中改造。如有问题,欢迎随时交流~

热门栏目