场景面试题:如何避免用户重复下单(多次下单未支付,占用库存)

📅 2025-12-29 21:20:52阅读时间: 16分钟

Spring Boot 如何避免用户重复下单(多次下单未支付,占用库存)?—— 面试题详解

在高并发电商系统中,一个常见的问题是:用户短时间内多次点击“下单”按钮,生成多个未支付订单,导致库存被无效占用,影响其他用户购买。这不仅浪费系统资源,还可能引发超卖或库存不足的问题。

本文将围绕 Spring Boot 技术栈,从原理、方案设计到代码实现,详细讲解如何有效防止用户重复下单,并形成一套可落地的解决方案。


一、问题场景还原(面试题背景)

面试官提问
“在我们的电商平台中,经常有用户在未支付的情况下多次点击下单按钮,造成多个待支付订单和库存锁定。请问你如何在 Spring Boot 项目中解决这个问题?”

这是一个典型的 幂等性 + 库存管理 + 分布式锁 综合问题。


二、核心思路分析

要解决重复下单问题,需从以下维度入手:

  1. 前端防重(基础但不可靠)
  2. 后端接口幂等性控制
  3. 库存预占与释放机制
  4. 分布式锁防止并发创建订单
  5. 定时任务清理未支付订单

注意:仅靠前端防重是不够的,必须在后端做兜底。


三、Spring Boot 实现方案详解

方案一:基于 Redis 的幂等令牌(Token)机制 ✅(推荐)

原理:

  • 用户进入下单页面时,后端生成一个唯一 orderToken 并存入 Redis(带过期时间,如 5 分钟)。
  • 提交订单时,携带该 orderToken
  • 后端校验 Token 是否存在且有效,若存在则消费(删除),并继续创建订单;否则拒绝请求。

优点:

  • 简单高效,天然支持幂等。
  • 可防止用户刷新页面重复提交。

代码实现:

java 复制代码
// 1. 生成下单 Token
@RestController
public class OrderController {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @GetMapping("/createOrderToken")
    public String createOrderToken(@RequestParam Long userId) {
        String token = UUID.randomUUID().toString();
        String key = "ORDER_TOKEN:" + userId;
        redisTemplate.opsForValue().set(key, token, Duration.ofMinutes(5));
        return token;
    }

    // 2. 提交订单
    @PostMapping("/submitOrder")
    public ResponseEntity<String> submitOrder(@RequestBody OrderRequest request) {
        String tokenKey = "ORDER_TOKEN:" + request.getUserId();
        String storedToken = redisTemplate.opsForValue().get(tokenKey);

        if (storedToken == null || !storedToken.equals(request.getOrderToken())) {
            return ResponseEntity.badRequest().body("重复提交或 Token 无效");
        }

        // 尝试删除 Token(原子操作)
        Boolean deleted = redisTemplate.delete(tokenKey);
        if (!deleted) {
            return ResponseEntity.badRequest().body("请勿重复提交");
        }

        // 执行创建订单逻辑(需加分布式锁,见下文)
        createRealOrder(request);
        return ResponseEntity.ok("下单成功");
    }
}

方案二:基于用户 + 商品维度的分布式锁

即使有 Token,仍需防止极端情况下的并发请求(如用户开多个 tab 同时下单)。

使用 Redisson 分布式锁:

java 复制代码
@Autowired
private RedissonClient redisson;

private void createRealOrder(OrderRequest request) {
    String lockKey = "ORDER_LOCK:" + request.getUserId() + ":" + request.getProductId();

    RLock lock = redisson.getLock(lockKey);
    try {
        boolean acquired = lock.tryLock(1, 10, TimeUnit.SECONDS);
        if (!acquired) {
            throw new RuntimeException("系统繁忙,请稍后再试");
        }

        // 再次检查是否已有未支付订单(双重校验)
        if (hasUnpaidOrder(request.getUserId(), request.getProductId())) {
            throw new RuntimeException("您有未支付订单,请先完成支付");
        }

        // 扣减预占库存 & 创建订单
        inventoryService.reserveStock(request.getProductId(), request.getQuantity());
        orderService.createOrder(request);

    } finally {
        lock.unlock();
    }
}

方案三:库存预占 + 定时释放

  • 下单时 预占库存(非真实扣减),设置过期时间(如 15 分钟)。
  • 若用户未支付,通过 定时任务延迟队列 自动释放库存。

示例(使用 Redis 过期键 + 监听):

java 复制代码
// 预占库存
public void reserveStock(Long productId, Integer quantity) {
    String stockKey = "STOCK_RESERVED:" + productId;
    // 使用 Lua 脚本保证原子性
    redisTemplate.execute(reserveStockScript, 
        Collections.singletonList(stockKey), 
        quantity.toString(), 
        Duration.ofMinutes(15).getSeconds()
    );
}

// Lua 脚本示例(伪代码)
// eval "if redis.call('GET', KEYS[1]) then ... end"

更高级方案:使用 RabbitMQ 延迟队列 或 Redis Streams + 消费者监听过期事件。


方案四:数据库层面兜底(最终防线)

在订单表中添加唯一约束或状态检查:

sql 复制代码
-- 订单表增加联合索引(用户ID + 商品ID + 状态)
CREATE UNIQUE INDEX idx_user_product_unpaid 
ON orders(user_id, product_id) 
WHERE status = 'UNPAID';

注意:MySQL 需 8.0+ 支持函数索引,否则可建普通索引 + 应用层查询判断。

Java 层校验:

java 复制代码
public boolean hasUnpaidOrder(Long userId, Long productId) {
    return orderRepository.existsByUserIdAndProductIdAndStatus(
        userId, productId, OrderStatus.UNPAID
    );
}

四、完整流程图(逻辑顺序)

复制代码
用户 → 获取 orderToken → 提交订单(带 Token)
       ↓
后端校验 Token(Redis)→ 失败?→ 返回“重复提交”
       ↓ 成功
获取分布式锁(用户+商品维度)
       ↓
检查是否存在未支付订单 → 是?→ 返回“请先支付”
       ↓ 否
预占库存(Redis + 过期)
       ↓
创建订单(DB)
       ↓
返回成功
       ↓
【后台】15分钟后未支付 → 释放库存 + 关闭订单

五、面试回答模板(精简版)

“针对用户重复下单问题,我会采用 前后端结合 + 多层防护 的策略:

  1. 前端:点击后禁用按钮,防止快速连点;
  2. 后端
    • 引入 下单 Token 机制,利用 Redis 实现接口幂等;
    • 使用 Redisson 分布式锁,按用户+商品维度加锁,防止并发创建;
    • 下单前 查询是否存在未支付订单,若有则拒绝;
    • 采用 库存预占 + 定时释放 机制,避免库存长期被占用;
  3. 兜底:数据库加唯一索引或应用层校验,确保数据一致性。

此外,配合 延迟队列定时任务 自动清理超时未支付订单,释放库存。”


六、扩展思考

  • 如何处理 Token 被恶意刷取?
    → 限流(如 Guava RateLimiter 或 Sentinel)
  • 如何保证库存预占的原子性?
    → Redis Lua 脚本 or 分布式事务(Seata,但成本高)
  • 高并发下 Redis 成为瓶颈?
    → 分片、本地缓存 + 二级缓存策略

总结

防止重复下单不是单一技术点,而是 系统性工程。在 Spring Boot 项目中,应结合 Redis 幂等令牌、分布式锁、库存预占、定时清理 等手段,构建多层次防御体系。既保障用户体验,又确保系统稳定性和数据一致性。

💡 关键点幂等性是核心,分布式锁是保障,库存释放是闭环。


希望这篇文章能帮助你在面试中从容应对“重复下单”类问题!