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

一、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;
    }
}
相关推荐
用户8307196840821 小时前
Spring注入原型Bean,为啥”新“对象“不翼而飞”?
java
初听于你1 小时前
Thymeleaf 模板引擎讲解
java·服务器·windows·spring boot·spring·eclipse
刘 大 望1 小时前
JVM(Java虚拟机)
java·开发语言·jvm·数据结构·后端·java-ee
超级种码1 小时前
JVM 字节码指令活用手册(基于 Java 17 SE 规范)
java·jvm·python
元亓亓亓1 小时前
LeetCode热题100--155. 最小栈--中等
java·算法·leetcode
SadSunset1 小时前
(3)第一个spring程序
java·后端·spring
高山上有一只小老虎1 小时前
小红的双生串
java·算法
TDengine (老段)1 小时前
人力减 60%:时序数据库 TDengine 助力桂冠电力实现 AI 智能巡检
java·大数据·数据库·人工智能·时序数据库·tdengine·涛思数据
yaoxin5211232 小时前
263. Java 集合 - 遍历 List 时选用哪种方式?ArrayList vs LinkedList
java·开发语言·list