秒杀系统中的超卖与重复下单问题

一、Bug 场景

在一个电商平台的秒杀活动中,某款限量商品(库存 100 件)开启秒杀。系统使用 Spring Boot + Redis + MySQL 实现,用户通过前端点击秒杀按钮提交请求,后端校验库存后创建订单并扣减库存。但活动结束后,发现实际下单数量远超 100 件,出现超卖;同时,部分用户反馈点击一次秒杀按钮,却收到了多个订单成功的通知,存在重复下单问题。

二、代码示例

秒杀服务实现(有缺陷)

java 复制代码
@Service
public class SeckillService {

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private StringRedisTemplate redisTemplate;

    // 商品库存缓存键
    private static final String STOCK_KEY = "seckill:stock:%d";

    /**
     * 秒杀核心逻辑
     */
    public boolean seckill(Long productId, Long userId) {
        // 1. 检查 Redis 缓存中的库存
        String stockKey = String.format(STOCK_KEY, productId);
        Integer stock = Integer.parseInt(redisTemplate.opsForValue().get(stockKey));
        if (stock == null || stock <= 0) {
            return false; // 库存不足
        }

        // 2. 检查用户是否已下单(防重复下单)
        String userOrderKey = "seckill:order:" + productId + ":" + userId;
        if (Boolean.TRUE.equals(redisTemplate.hasKey(userOrderKey))) {
            return false; // 已下单
        }

        // 3. 扣减 Redis 库存
        redisTemplate.opsForValue().decrement(stockKey);

        // 4. 创建订单并扣减数据库库存
        try {
            // 扣减数据库库存
            int rows = productMapper.decreaseStock(productId, 1);
            if (rows <= 0) {
                // 数据库扣减失败,回滚 Redis 库存
                redisTemplate.opsForValue().increment(stockKey);
                return false;
            }

            // 创建订单
            Order order = new Order();
            order.setProductId(productId);
            order.setUserId(userId);
            order.setStatus(1); // 未支付
            orderMapper.insert(order);

            // 标记用户已下单
            redisTemplate.opsForValue().set(userOrderKey, "1", 24, TimeUnit.HOURS);
            return true;
        } catch (Exception e) {
            // 异常回滚 Redis 库存
            redisTemplate.opsForValue().increment(stockKey);
            return false;
        }
    }
}

控制器

java 复制代码
@RestController
@RequestMapping("/seckill")
public class SeckillController {

    @Autowired
    private SeckillService seckillService;

    @PostMapping("/{productId}")
    public ResponseEntity<String> doSeckill(@PathVariable Long productId, @RequestParam Long userId) {
        boolean success = seckillService.seckill(productId, userId);
        return success ? ResponseEntity.ok("秒杀成功") : ResponseEntity.ok("秒杀失败");
    }
}

三、问题描述

  1. 预期行为:秒杀商品库存严格控制在 100 件以内,每个用户只能成功下单一次。

  2. 实际行为

    • 超卖问题:高并发下,多个线程同时通过 Redis 库存检查(步骤 1),都认为库存充足,导致 Redis 库存扣减后实际库存为负,最终数据库扣减也超过实际库存。例如,库存剩 1 时,10 个线程同时通过检查,Redis 扣减后变为 -9,数据库最终库存被减为 -9。
    • 重复下单问题 :用户快速点击秒杀按钮时,多个请求同时到达,都通过 "用户已下单" 检查(步骤 2),导致同一用户创建多个订单。原因是 Redis 的 hasKey 检查与 set 标记非原子操作,存在间隙。

四、解决方案

  1. 解决超卖问题

    • 使用 Redis 原子操作校验并扣减库存,替代先查后减的非原子逻辑:
    java 复制代码
    // 替换步骤 1 和 3:原子性检查并扣减库存
    Long remainStock = redisTemplate.opsForValue().decrement(stockKey);
    if (remainStock == null || remainStock < 0) {
        // 库存不足,回滚
        redisTemplate.opsForValue().increment(stockKey);
        return false;
    }
    • 数据库层面加行锁,确保扣减库存的原子性:
    sql 复制代码
    -- ProductMapper 的 decreaseStock 方法对应的 SQL
    UPDATE product 
    SET stock = stock - 1 
    WHERE id = #{productId} AND stock > 0;
  2. 解决重复下单问题

    • 使用 Redis 的 setIfAbsent 原子操作,检查并标记用户下单状态:
    java 复制代码
    // 替换步骤 2:原子性检查并标记已下单
    String userOrderKey = "seckill:order:" + productId + ":" + userId;
    Boolean isFirstOrder = redisTemplate.opsForValue().setIfAbsent(userOrderKey, "1", 24, TimeUnit.HOURS);
    if (isFirstOrder == null || !isFirstOrder) {
        // 已下单或设置失败,回滚库存
        redisTemplate.opsForValue().increment(stockKey);
        return false;
    }
  3. 最终优化后的秒杀逻辑

java 复制代码
public boolean seckill(Long productId, Long userId) {
    String stockKey = String.format(STOCK_KEY, productId);
    String userOrderKey = "seckill:order:" + productId + ":" + userId;

    // 1. 原子扣减库存,判断是否成功
    Long remainStock = redisTemplate.opsForValue().decrement(stockKey);
    if (remainStock == null || remainStock < 0) {
        redisTemplate.opsForValue().increment(stockKey); // 回滚
        return false;
    }

    // 2. 原子检查并标记用户下单
    Boolean isFirstOrder = redisTemplate.opsForValue().setIfAbsent(userOrderKey, "1", 24, TimeUnit.HOURS);
    if (isFirstOrder == null || !isFirstOrder) {
        redisTemplate.opsForValue().increment(stockKey); // 回滚库存
        return false;
    }

    // 3. 数据库操作(扣减库存 + 创建订单)
    try {
        int rows = productMapper.decreaseStock(productId, 1);
        if (rows <= 0) {
            // 数据库扣减失败,双重回滚
            redisTemplate.opsForValue().increment(stockKey);
            redisTemplate.delete(userOrderKey);
            return false;
        }

        Order order = new Order();
        order.setProductId(productId);
        order.setUserId(userId);
        order.setStatus(1);
        orderMapper.insert(order);
        return true;
    } catch (Exception e) {
        // 异常回滚
        redisTemplate.opsForValue().increment(stockKey);
        redisTemplate.delete(userOrderKey);
        return false;
    }
}
相关推荐
二哈赛车手5 分钟前
新人笔记---ApiFox的一些常见使用出错
java·笔记·spring
栗子~~44 分钟前
JAVA - 二层缓存设计(本地缓冲+redis缓冲+广播所有本地缓冲失效) demo
java·redis·缓存
YDS8291 小时前
DeepSeek RAG&MCP + Agent智能体项目 —— RAG知识库的搭建和接口实现
java·ai·springboot·agent·rag·deepseek
未若君雅裁2 小时前
MyBatis 一级缓存、二级缓存与清理机制
java·缓存·mybatis
AI人工智能+电脑小能手3 小时前
【大白话说Java面试题 第65题】【JVM篇】第25题:谈谈对 OOM 的认识
java·开发语言·jvm
阿维的博客日记3 小时前
Nacos 为什么能让配置动态生效?(涉及 @RefreshScope 注解)
java·spring
雨辰AI3 小时前
SpringBoot3 + 人大金仓读写分离 + 分库分表 + 集群高可用 全栈实战
java·数据库·mysql·政务
辰海Coding4 小时前
MiniSpring框架学习-完成的 IoC 容器
java·spring boot·学习·架构
小小编程路5 小时前
C++ 多线程与并发
java·jvm·c++
AI视觉网奇5 小时前
linux 检索库 判断库是否支持
java·linux·服务器