详解Java商城秒杀活动的实现方案

147 阅读5分钟

方案概览:

Java单体架构项目,实现限时抢购:前端要做到动静分离,降低刷新秒杀页面对服务器的压力。防止脚本抢购,用户点击抢购按钮,前端先携带抢购商品id向后端请求验证码图片,抢购商品id不同于商品id,它所属于用于抢购的表,后端生成6位随机验证码,将6位码存Redis的String,key是前缀加用户id加抢购商品id,value是6位码,过期时间60秒,然后根据这个6位码使用Kaptcha生成扭曲图片并返回给前端图片二进制流。

用户输入验证码后,前端才携带用户输入的6位码和抢购商品id调用后端的抢购接口,后端先查询验证码是否存在,因为不区分大小写,所以验证码对比时都取小写,如果不存在,则返回验证码错误,前端就再次调用请求验证码的接口;如果存在,为防止超卖,就用Redis的String预减库存,key是前缀加抢购商品id,value是剩余数量,同时还要设置一种Set,key是前缀加用户id,value是抢购商品id,用于检测当前用户是否已经抢购过这个商品。

首先判断用户是否抢购过这个商品,有的话直接返回已经抢购过了,没的话判断该商品是否还有货,没货直接返回已经售空,有货的话令String记录的剩余数量减1并在Set记录用户已抢了这个商品,这个过程使用lua脚本保证原子性。然后使用RocketMQ创建普通消息,执行剩余业务逻辑,如下单等,实现流量的削峰填谷。使用Sentinel进行限流操作,因为是单机部署,所以创建验证码qps限流1000,抢购qps限流2500。限时抢购活动结束时清理用于预减库存和重复抢购的key。

以下是实现该方案的具体操作:

1. 前端动静分离

  • 静态资源:将HTML/CSS/JS部署到Nginx/CDN
  • 动态接口:通过Ajax调用后端API
  • 秒杀页面示例
    // 点击抢购按钮时请求验证码
    document.getElementById('seckill-btn').onclick = function() {
        const secKillId = 123; // 抢购商品ID
        fetch(`/captcha?secKillId=${secKillId}`)
            .then(response => response.blob())
            .then(blob => {
                document.getElementById('captcha-img').src = URL.createObjectURL(blob);
            });
    };
    
    // 提交抢购请求
    function submitSeckill() {
        const secKillId = 123;
        const captcha = document.getElementById('captcha-input').value;
        fetch('/seckill', {
            method: 'POST',
            body: JSON.stringify({ secKillId, captcha }),
            headers: {'Content-Type': 'application/json'}
        }).then(handleResponse);
    }
    

2. 验证码生成接口

@GetMapping("/captcha")
public void generateCaptcha(
        @RequestParam("secKillId") Long secKillId,
        HttpServletResponse response) {

    // 1. 生成6位随机验证码(转小写)
    String captcha = RandomStringUtils.randomAlphanumeric(6).toLowerCase();
    
    // 2. 存储Redis:key= captcha:{userId}:{secKillId}
    String redisKey = "captcha:" + getCurrentUserId() + ":" + secKillId;
    redisTemplate.opsForValue().set(redisKey, captcha, 60, TimeUnit.SECONDS);
    
    // 3. 生成图片并返回
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    kaptchaProducer.createImage(captcha).writeTo(os);
    response.getOutputStream().write(os.toByteArray());
}

3. 抢购接口实现(核心逻辑)

@PostMapping("/seckill")
public SeckillResponse executeSeckill(@RequestBody SeckillRequest request) {
    // 1. 验证码校验(忽略大小写)
    String captchaKey = "captcha:" + getCurrentUserId() + ":" + request.getSecKillId();
    String storedCaptcha = redisTemplate.opsForValue().get(captchaKey);
    if (storedCaptcha == null || !storedCaptcha.equals(request.getCaptcha().toLowerCase())) {
        return SeckillResponse.fail("验证码错误");
    }

    // 2. 执行Lua脚本保证原子性
    Long result = executeLuaScript(request.getSecKillId(), getCurrentUserId());
    
    // 3. 处理脚本结果
    if (result == 1) {
        return SeckillResponse.fail("库存不足");
    } else if (result == 2) {
        return SeckillResponse.fail("请勿重复抢购");
    }

    // 4. 发送MQ消息异步处理订单
    rocketMQTemplate.send("seckill_order_topic", 
        MessageBuilder.withPayload(buildOrderMessage(request)).build());
    
    return SeckillResponse.success("抢购成功");
}

// Lua脚本(resources/seckill.lua)
private final DefaultRedisScript<Long> SECKILL_SCRIPT = new DefaultRedisScript<>(
    "local stockKey = KEYS[1] " +
    "local userKey = KEYS[2] " +
    "local secKillId = ARGV[1] " +
    "local userId = ARGV[2] " +
    -- 检查是否已抢购
    "if redis.call('SISMEMBER', userKey, secKillId) == 1 then " +
        "return 2 " +
    "end " +
    -- 检查库存
    "local stock = tonumber(redis.call('GET', stockKey)) " +
    "if stock <= 0 then " +
        "return 1 " +
    "end " +
    -- 扣减库存并记录用户
    "redis.call('DECR', stockKey) " +
    "redis.call('SADD', userKey, secKillId) " +
    "return 0",
    Long.class
);

private Long executeLuaScript(Long secKillId, Long userId) {
    List<String> keys = Arrays.asList(
        "stock:" + secKillId,       // 库存Key
        "user_seckill:" + userId    // 用户抢购记录Key
    );
    return redisTemplate.execute(
        SECKILL_SCRIPT,
        keys,
        secKillId.toString(),
        userId.toString()
    );
}

4. RocketMQ消费者处理订单

@RocketMQMessageListener(topic = "seckill_order_topic", consumerGroup = "seckill_group")
public class OrderConsumer implements RocketMQListener<OrderMessage> {
    @Override
    public void onMessage(OrderMessage message) {
        // 1. 数据库减库存(幂等操作)
        seckillService.reduceStock(message.getSecKillId());
        
        // 2. 创建订单
        orderService.createOrder(message.getUserId(), message.getSecKillId());
    }
}

5. Sentinel限流配置

// 验证码接口限流1000QPS
@PostConstruct
public void initFlowRules() {
    List<FlowRule> rules = new ArrayList<>();
    
    // 验证码限流
    FlowRule captchaRule = new FlowRule();
    captchaRule.setResource("captchaApi");
    captchaRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
    captchaRule.setCount(1000);
    rules.add(captchaRule);
    
    // 抢购接口限流2500QPS
    FlowRule seckillRule = new FlowRule();
    seckillRule.setResource("seckillApi");
    seckillRule.setCount(2500);
    rules.add(seckillRule);
    
    FlowRuleManager.loadRules(rules);
}

// 接口资源注解
@SentinelResource(value = "captchaApi", blockHandler = "handleBlock")
@GetMapping("/captcha") 
public void generateCaptcha(...) { ... }

@SentinelResource("seckillApi")
@PostMapping("/seckill") 
public SeckillResponse executeSeckill(...) { ... }

6. 活动结束清理Redis

public void clearSeckillKeys(Long secKillId) {
    // 1. 删除所有库存Key
    redisTemplate.delete("stock:" + secKillId);
    
    // 2. 扫描删除用户抢购记录(使用SCAN避免阻塞)
    String pattern = "user_seckill:*";
    RedisConnection conn = redisTemplate.getConnectionFactory().getConnection();
    try (Cursor<byte[]> cursor = conn.scan(ScanOptions.scanOptions().match(pattern).build())) {
        while (cursor.hasNext()) {
            String key = new String(cursor.next());
            conn.del(key.getBytes());
        }
    }
}

关键点说明

  1. 原子性保障

    • 使用Lua脚本确保库存扣减和用户记录添加的原子操作
  2. 防超卖设计

    • Redis预减库存 + 数据库最终扣减(MQ消费)
  3. 防脚本机制

    • 验证码流程强制人机交互
    • 验证码绑定用户与商品
  4. 流量削峰

    • RocketMQ异步化订单处理
    • Sentinel限流保护系统
  5. 数据清理

    • 活动结束后批量删除Redis相关Key(SCAN遍历大Key)

部署注意事项

  1. Redis需配置持久化防止宕机数据丢失
  2. RocketMQ开启持久化+主从复制
  3. Nginx配置静态资源缓存
  4. 使用spring-boot-starter-data-redisrocketmq-spring-boot-starter简化集成

通过以上实现,系统可支撑高并发抢购场景,同时有效防止脚本作弊和超卖问题。