搞定 Java 高并发秒杀,掌握这 7 个核心设计原则就够了

前言

你有没有经历过这样的场景?

  • 凌晨12点,iPhone新品发售,你守在手机前,手指紧贴屏幕;
  • 倒计时结束,点击"立即抢购",页面卡住、按钮无响应、提示"库存不足";
  • 刷新一看,商品已经售罄------但你根本没成功下单。

这就是典型的高并发秒杀场景。它不仅仅是"卖东西",更是对系统架构的极限挑战。

什么是秒杀?

秒杀,是指在极短时间内(通常几秒到几分钟),大量用户集中访问并尝试购买限量商品的行为。其核心特征是:

特征 描述
瞬时高并发 10万+用户同时发起请求
资源有限 商品库存可能只有100件
强一致性要求 不能超卖、不能重复下单
用户体验敏感 页面卡顿、失败提示都会影响口碑

为什么普通系统扛不住?

想象一下:10万人在同一毫秒点击"抢购",如果系统不做任何优化,所有请求直接打到数据库,会发生什么?

  • 数据库连接池瞬间耗尽
  • MySQL锁竞争激烈,事务排队
  • CPU飙升,响应延迟从几毫秒变成几秒甚至超时
  • 最终结果:系统崩溃、库存超卖、订单错乱

这就像双十一零点的超市收银台------只有一个收银员,却有上万人排队结账。


一、前端优化:第一道防线

虽然前端不能完全阻止恶意行为,但它是用户体验的第一道屏障,能有效减少无效请求进入后端。

1. 按钮防重复点击(防误触)

这是最基本也是最重要的防护措施。用户手滑连点,会生成大量无意义请求。

Vue 实现示例

html 复制代码
<template>
  <button 
    :disabled="isDisabled || countdown > 0" 
    @click="handleClick"
    class="seckill-btn"
    :class="{ 'disabled': isDisabled || countdown > 0 }"
  >
    {{ buttonText }}
  </button>
</template>

<script>
export default {
  data() {
    return {
      isDisabled: false,
      countdown: 0, // 倒计时功能
      maxCountdown: 5 // 最大禁用时间(秒)
    }
  },
  computed: {
    buttonText() {
      if (this.countdown > 0) {
        return `请等待${this.countdown}秒`;
      }
      return this.isDisabled ? '抢购中...' : '立即抢购';
    }
  },
  methods: {
    async handleClick() {
      if (this.isDisabled || this.countdown > 0) return;
      
      this.isDisabled = true;
      try {
        const result = await this.$api.createOrder();
        if (result.success) {
          this.$message.success('抢购成功');
        } else {
          this.startCountdown();
          this.$message.error(result.message || '抢购失败');
        }
      } catch (error) {
        this.startCountdown();
        this.$message.error('请求失败,请重试');
      } finally {
        this.isDisabled = false;
      }
    },
    startCountdown() {
      this.countdown = this.maxCountdown;
      const timer = setInterval(() => {
        this.countdown--;
        if (this.countdown <= 0) {
          clearInterval(timer);
        }
      }, 1000);
    }
  }
}
</script>

说明 : 使用 :disabled 控制按钮状态 提供友好的用户反馈(Toast提示) 请求失败后禁用按钮防止重复提交


2. 请求频率限制(节流)

即使有人使用脚本刷请求,前端也应尽可能做初步限制。

节流函数实现(Throttle)

js 复制代码
/**
 * 函数节流:在指定时间内最多执行一次
 * @param {Function} fn - 要节流的函数
 * @param {Number} delay - 时间间隔(毫秒)
 */
function throttle(fn, delay) {
  let lastCall = 0
  return function (...args) {
    const now = Date.now()
    if (now - lastCall < delay) {
      console.warn('请求过于频繁,已被节流')
      return
    }
    lastCall = now
    return fn.apply(this, args)
  }
}

// 使用方式
const throttledCreateOrder = throttle(createOrder, 500) // 500ms内只能请求一次

防抖 vs 节流

类型 适用场景 行为
防抖 debounce 搜索框输入 停止输入后才触发
节流 throttle 秒杀按钮 固定频率执行

注意:节流只能防"正常用户误操作",无法防御恶意脚本攻击。但它是多层防御的第一环。


二、后端核心方案

1. 流量削峰:使用 Redis Streams 实现请求缓冲

设计目标

  • 防止瞬时百万级请求打爆数据库
  • 实现"削峰填谷",平滑消费请求
  • 支持失败重试与消息确认机制

架构角色

角色 职责
生产者 用户点击抢购 → 写入 Redis Streams
消费者组 多个服务实例组成消费者组,竞争消费
ACK机制 成功处理后确认,防止消息丢失
死信队列(DLQ) 处理失败消息,支持人工干预或延迟重试

完整实现流程

java 复制代码
@Component
@Slf4j
public class SeckillQueueService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final String STREAM_KEY = "seckill:requests";
    private static final String GROUP_NAME = "seckill_group";
    private static final String DLQ_KEY = "seckill:dlq"; // 死信队列

    /**
     * 初始化消费者组(仅需执行一次)
     */
    @PostConstruct
    public void initConsumerGroup() {
        try {
            redisTemplate.opsForStream().createGroup(STREAM_KEY, ReadOffset.from("0-0"), GROUP_NAME);
        } catch (Exception e) {
            log.warn("消费者组已存在,无需重复创建");
        }
    }

    /**
     * 生产者:接收秒杀请求并入队
     */
    public boolean enqueueRequest(String userId, String productId) {
        // 1. 检查是否已参与(防重复提交)
        String participatedKey = "seckill:participated:" + productId;
        Boolean hasParticipated = redisTemplate.opsForSet().isMember(participatedKey, userId);
        if (Boolean.TRUE.equals(hasParticipated)) {
            return false;
        }

        // 2. 构造消息体
        Map<String, Object> message = Map.of(
            "userId", userId,
            "productId", productId,
            "timestamp", System.currentTimeMillis(),
            "requestId", UUID.randomUUID().toString()
        );

        // 3. 写入 Stream
        try {
            redisTemplate.opsForStream().add(STREAM_KEY, message);
            redisTemplate.opsForSet().add(participatedKey, userId);
            redisTemplate.expire(participatedKey, 10, TimeUnit.MINUTES);
            return true;
        } catch (Exception e) {
            log.error("消息入队失败", e);
            return false;
        }
    }

    /**
     * 消费者:异步处理秒杀请求
     */
    @Async("seckillTaskExecutor")
    public void startConsuming() {
        StreamReadOptions options = StreamReadOptions.empty()
            .count(1)
            .block(Duration.ofSeconds(5));

        while (true) {
            try {
                Map<String, StreamMessage> messages = redisTemplate.opsForStream().read(
                    Consumer.from(GROUP_NAME, "consumer_" + Thread.currentThread().getId()),
                    options,
                    StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())
                );

                if (messages == null || messages.isEmpty()) continue;

                for (Map.Entry<String, StreamMessage> entry : messages.entrySet()) {
                    Map<Object, Object> body = entry.getValue().getValue();
                    String requestId = (String) body.get("requestId");
                    String userId = (String) body.get("userId");
                    String productId = (String) body.get("productId");

                    try {
                        boolean success = processSeckillRequest(userId, productId);
                        if (success) {
                            // ACK:确认消费成功
                            redisTemplate.opsForStream().acknowledge(STREAM_KEY, GROUP_NAME, entry.getKey());
                            log.info("秒杀成功,requestId={}", requestId);
                        } else {
                            // 失败:进入死信队列
                            redisTemplate.opsForList().leftPush(DLQ_KEY, body);
                            log.warn("秒杀失败,进入DLQ,requestId={}", requestId);
                        }
                    } catch (Exception e) {
                        log.error("处理消息异常,requestId={}", requestId, e);
                        redisTemplate.opsForList().leftPush(DLQ_KEY, body); // 进入DLQ
                    }
                }
            } catch (Exception e) {
                log.error("消费流异常", e);
                try {
                    Thread.sleep(1000); // 避免空轮询
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }

    private boolean processSeckillRequest(String userId, String productId) {
        // 扣减库存 → 创建订单 → 发送通知
        if (reduceStock(productId, 1)) {
            createOrder(userId, productId);
            return true;
        }
        return false;
    }
}

优势总结 : 支持多消费者组负载均衡 ACK 机制保障消息不丢失 可通过XPENDING查看待确认消息 支持从任意位置重新消费


2. 库存扣减:Redis + Lua 原子操作

设计目标

  • 防止超卖(一人多单)
  • 高并发下保证原子性
  • 与数据库最终一致

执行流程如下:

1.在活动开始前5~10分钟,把商品的库存从MySQL复制到Redis

java 复制代码
/**
  * 预热:把MySQL库存加载到Redis
  */
public void preloadStock(String productId) {
    Product product = productMapper.selectById(productId);
    Integer stock = product.getStock(); // 从MySQL读取库存

    String redisKey = "product:stock:" + productId;
    redisTemplate.opsForValue().set(redisKey, stock); // 写入Redis
}

2.用户下单时用Lua脚本在Redis扣减库存

它像一个"保险柜",把"检查库存 + 扣减"打包成一个动作,其他人不能插队。

Lua 复制代码
-- Lua脚本:原子扣减库存
local stock = redis.call('get', KEYS[1])        -- 读库存
if not stock or tonumber(stock) < 1 then        -- 如果库存不足
    return 0                                    -- 返回失败
else
    redis.call('decrby', KEYS[1], 1)            -- 否则扣1
    return 1                                    -- 返回成功
end

3.秒杀结束后批量同步mysql,避免频繁写数据库。

java 复制代码
/**
 * 活动结束后,把Redis库存写回MySQL
 */
public void syncStockToMySQL(String productId) {
    String redisKey = "product:stock:" + productId;
    Integer finalStock = (Integer) redisTemplate.opsForValue().get(redisKey);

    Product product = new Product();
    product.setId(productId);
    product.setStock(finalStock); // 更新MySQL库存
    productMapper.updateById(product);
}

完整示例代码

java 复制代码
@Service
@Slf4j
public class StockService {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
      * 预热:把MySQL库存加载到Redis
      */
    public void preloadStock(String productId) {
        Product product = productMapper.selectById(productId);
        Integer stock = product.getStock(); // 从MySQL读取库存

        String redisKey = "product:stock:" + productId;
        redisTemplate.opsForValue().set(redisKey, stock); // 写入Redis
    }

    /**
     * 扣减库存(原子操作)
     * @return true: 扣减成功, false: 库存不足
     */
    public boolean reduceStock(String productId, int quantity) {
        String stockKey = "product:stock:" + productId;

        String luaScript = 
            "local stock = redis.call('get', KEYS[1])\n" +
            "if not stock then\n" +
            "   return 0\n" +
            "elseif tonumber(stock) < tonumber(ARGV[1]) then\n" +
            "   return 0\n" +
            "else\n" +
            "   redis.call('decrby', KEYS[1], ARGV[1])\n" +
            "   return 1\n" +
            "end";

        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(luaScript);
        script.setResultType(Long.class);

        try {
            Long result = redisTemplate.execute(
                script,
                Collections.singletonList(stockKey),
                String.valueOf(quantity)
            );
            return result != null && result == 1L;
        } catch (Exception e) {
            log.error("Lua脚本执行失败", e);
            return false;
        }
    }

    /**
     * 增加库存(用于回滚)
     */
    public void increaseStock(String productId, int quantity) {
        String stockKey = "product:stock:" + productId;
        redisTemplate.opsForValue().increment(stockKey, quantity);
    }

    /**
     * 活动结束后,把Redis库存写回MySQL
     */
    public void syncStockToMySQL(String productId) {
        String redisKey = "product:stock:" + productId;
        Integer finalStock = (Integer) redisTemplate.opsForValue().get(redisKey);

        Product product = new Product();
        product.setId(productId);
        product.setStock(finalStock); // 更新MySQL库存
        productMapper.updateById(product);
    }
}

总结流程

复制代码
MySQL库存 → 预热 → Redis库存 → 秒杀中扣减 → 秒杀结束 → 写回MySQL

3. 防重复下单:幂等性 + 分布式锁

为什么需要防重复?

  • 用户手滑连点
  • 网络超时导致前端重试
  • 脚本恶意刷单

方案选择:业务幂等校验为主,分布式锁为辅

方案 适用场景 性能 推荐度
Redis 是否已参与 第一道防线 ⭐⭐⭐⭐
数据库唯一索引 最终防护 ⭐⭐⭐⭐
Token 校验机制 前端防刷 ⭐⭐⭐⭐
Redis 分布式锁 高并发抢购 ⭐⭐⭐

方式一:Redis 记录"已参与"(最快)

在 Redis 中记录"用户A已参与iPhone秒杀",用完即焚,活动结束后自动过期

java 复制代码
String participatedKey = "seckill:participated:" + productId;
Boolean hasParticipated = redisTemplate.opsForSet().isMember(participatedKey, userId);
if (hasParticipated) {
    throw new BusinessException("您已参与本次秒杀,请勿重复提交");
}
// 首次参与,记录
redisTemplate.opsForSet().add(participatedKey, userId);
redisTemplate.expire(participatedKey, 30, TimeUnit.MINUTES); // 30分钟后过期

快,不依赖数据库,高并发防重第一道防线

方案二:数据库唯一索引(最终一致性保障)

sql 复制代码
CREATE TABLE `seckill_order` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `user_id` VARCHAR(32) NOT NULL,
  `product_id` VARCHAR(32) NOT NULL,
  `activity_id` VARCHAR(32) NOT NULL, -- 秒杀活动ID
  `create_time` DATETIME DEFAULT NOW(),
  UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
) ENGINE=InnoDB;

同一个用户在同一个活动里只能下一单,但可以参加不同活动,或活动结束后正常购买

方案三:Token 机制(防止脚本刷单)

每次抢购前必须先获取一个"令牌",用完即失效,防止机器人无限刷

流程图

markdown 复制代码
用户 → 请求Token → Redis存Token → 用户抢购 → 后端验证 → 成功则删Token → 下单
                                      ↓
                                   失败/重复 → Token不存在 → 拒绝

完整示例代码

java 复制代码
@RestController
public class TokenController {

    @GetMapping("/token")
    public ResponseEntity<String> getToken(@RequestParam String productId) {
        String token = UUID.randomUUID().toString();
        String key = "seckill:token:" + token;
        redisTemplate.opsForValue().set(key, productId, 5, TimeUnit.MINUTES);
        return ResponseEntity.ok(token);
    }

    @PostMapping("/order")
    public ResponseEntity<?> createOrder(@RequestParam String token, @RequestParam String productId) {
        String key = "seckill:token:" + token;
        String expectedProductId = (String) redisTemplate.opsForValue().get(key);
        
        if (!productId.equals(expectedProductId)) {
            return ResponseEntity.badRequest().body("非法请求");
        }

        // 删除Token,防止重复使用
        redisTemplate.delete(key);
        // 继续下单逻辑...
        return ResponseEntity.ok("success");
    }
}

防止脚本无限刷接口,即使网络超时,前端重试也会因 Token 失效而失败

方案四:Redisson 分布式锁(防止并发创建)

java 复制代码
@Autowired
private RedissonClient redissonClient;

public boolean createOrderWithLock(String userId, String productId) {
    String lockKey = "order:create:" + userId + ":" + productId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        boolean isLocked = lock.tryLock(2, 10, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new BusinessException("操作过于频繁");
        }

        // 再次检查是否已下单(双重检查)
        if (orderService.exists(userId, productId)) {
            return false;
        }

        // 创建订单
        orderService.create(userId, productId);
        return true;
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return false;
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

4. 限流与降级:Sentinel 全链路防护

使用 Alibaba Sentinel 实现多维度限流

java 复制代码
@PostConstruct
public void initAllRules() {
    // 1. QPS 流控规则
    List<FlowRule> flowRules = new ArrayList<>();
    FlowRule flowRule = new FlowRule();
    flowRule.setResource("createOrder");
    flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);  // QPS模式
    flowRule.setCount(1000);                         // 每秒1000次
    flowRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);
    flowRules.add(flowRule);
    FlowRuleManager.loadRules(flowRules);

    // 2. 热点参数限流规则
    List<ParamFlowRule> paramRules = new ArrayList<>();
    ParamFlowRule paramRule = new ParamFlowRule();
    paramRule.setResource("createOrder");
    paramRule.setParamIdx(1);                        // 方法参数索引:productId
    paramRule.setCount(100);                         // 单商品每秒最多100次
    paramRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
    paramRules.add(paramRule);
    ParamFlowRuleManager.loadRules(paramRules);

    // 3. 降级规则(异常比例熔断)
    List<DegradeRule> degradeRules = new ArrayList<>();
    DegradeRule degradeRule = new DegradeRule();
    degradeRule.setResource("createOrder");
    degradeRule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO);
    degradeRule.setCount(0.5);                       // 异常比例 > 50%
    degradeRule.setTimeWindow(60);                   // 熔断60秒
    degradeRules.add(degradeRule);
    DegradeRuleManager.loadRules(degradeRules);

    log.info("✅ Sentinel 所有规则加载完成:流控、热点、降级");
}

Sentinel支持从外部加载规则,我们可以把所有规则写在一个sentinel-rules.json文件中

json 复制代码
{
  "flowRules": [
    {
      "resource": "createOrder",
      "grade": 1,
      "count": 1000,
      "controlBehavior": 0,
      "clusterMode": false
    }
  ],
  "paramFlowRules": [
    {
      "resource": "createOrder",
      "paramIdx": 1,
      "grade": 1,
      "count": 100,
      "durationSec": 1
    }
  ],
  "degradeRules": [
    {
      "resource": "createOrder",
      "grade": 2,
      "count": 0.5,
      "timeWindow": 60
    }
  ]
}

配置好后再到Java代码读取并加载


5. 库存回滚:预扣 + 超时释放

场景

用户抢到库存但未支付,需在一定时间后释放库存。

方案一:Redis TTL 自动过期

java 复制代码
// 抢购成功后标记预扣
String reservedKey = "seckill:reserved:" + userId + ":" + productId;
redisTemplate.opsForValue().set(reservedKey, "1", 15, TimeUnit.MINUTES);

// 定时任务扫描并释放
@Scheduled(cron = "0 0/5 * * * ?")
public void releaseExpiredReservations() {
    Set<String> keys = redisTemplate.keys("seckill:reserved:*");
    if (keys != null) {
        for (String key : keys) {
            Boolean exists = redisTemplate.hasKey(key);
            if (!exists) continue;

            // 模拟检查订单状态
            boolean paid = orderService.isPaid(key); // 伪代码
            if (!paid) {
                // 解析 productId
                String[] parts = key.split(":");
                String productId = parts[3];
                stockService.increaseStock(productId, 1);
                redisTemplate.delete(key);
            }
        }
    }
}

方案二:RocketMQ 延时消息(推荐)

java 复制代码
// 抢购成功后发送15分钟延时消息
Message msg = new Message("SECKILL_TOPIC", "RELEASE_TAG", requestId, body);
msg.setDelayTimeLevel(3); // 15分钟
producer.send(msg);

消费者收到消息后判断订单状态,决定是否回滚库存。


总结

高并发秒杀系统的本质是:用空间换时间,用异步换同步,用缓存换数据库

核心原则

原则 实现方式
前端防刷 按钮禁用、节流、验证码、行为分析
流量削峰 Redis Streams 队列、异步处理
数据一致 Redis+Lua 原子操作、分段库存
系统保护 Sentinel 限流、降级、热点参数控制
防重复下单 业务幂等校验 + 分布式锁(按需)
库存安全 预扣机制 + 超时回滚
可观测性 日志、监控、告警三位一体

如果你觉得这篇文章有帮助,欢迎点赞、收藏、转发!也欢迎在评论区交流你的秒杀设计经验。

若有不对的地方也欢迎提出指正。

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

相关推荐
AAA修煤气灶刘哥3 小时前
对象存储封神指南:OSS 分片上传 + 重复校验 + 防毒,代码直接抄!
java·后端·面试
风象南3 小时前
SpringBoot中如何实现对静态资源的访问权限控制
spring boot·后端
用户8356290780513 小时前
告别手动限制:用Python自动化Excel单元格数据验证
后端·python
努力的小郑3 小时前
Spring Boot自动装配实战:多数据源SDK解决Dubbo性能瓶颈
java·spring boot·微服务
四七伵3 小时前
为什么不推荐在 Java 项目中使用 java.util.Date?
java·后端
Json____3 小时前
使用springboot开发一个宿舍管理系统练习项目
java·spring boot·后端
爱读源码的大都督3 小时前
Java知名开源项目,5行代码,竟然有4个“bug”
java·后端·程序员
Funcy3 小时前
XxlJob 源码分析07:任务执行流程(二)之触发器揭秘
后端
先做个垃圾出来………3 小时前
Pydantic库应用
java·数据库·python