Skip to content

优惠券下单模块面经文档

项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · MySQL · Kafka · Redisson · 线程池


1. 模块概览

本模块将优惠券拆分为三类,针对不同并发场景采用不同的下单策略:

类型业务特点核心技术核心解决的问题
普通优惠券无购买数量限制,并发量不大乐观锁防止库存超卖
限购优惠券每人限购 N 张,有一定并发Redisson 分布式锁 + 乐观锁限购功能 + 防库存超卖
秒杀优惠券限购 + 时间窗口,高并发抢购Redis 预热 + Lua + Kafka 异步高并发下的高吞吐抢单

2. 整体架构图

                          用户下单请求

              ┌────────────────┼────────────────┐
              │                │                │
         普通优惠券          限购优惠券         秒杀优惠券
              │                │                │
         乐观锁扣减库存    分布式锁+乐观锁     Redis 预热判断资格
         直接写DB          直接写DB           │
                                            Kafka 消息队列

                                          消费者异步写DB

3. 普通优惠券:乐观锁防库存超卖

3.1 超卖问题的根源

并发场景下,两个线程同时查询库存都发现库存充足,然后先后执行扣减,导致实际扣减了两次,但库存只减少了一次,产生超卖。

线程A: 查库存=1 → 判断充足 →              → 扣减库存(stock=0)
线程B:             查库存=1 → 判断充足 → 扣减库存(stock=-1)❌ 超卖!

3.2 乐观锁解决方案

核心思路:扣减库存时,在 SQL 的 WHERE 条件中加入库存校验,让数据库保证原子性。

sql
-- 普通版本(用版本号)
UPDATE tb_voucher SET stock = stock - #{buyNumber}, version = version + 1
WHERE voucher_id = #{id} AND version = #{version}

-- 本项目优化版(用库存本身替代版本号)
UPDATE tb_voucher SET stock = stock - #{buyNumber}
WHERE voucher_id = #{id} AND stock >= #{buyNumber}

为什么用 stock >= buyNumber 而不是 stock = 查到的库存

假设同一时刻 10 个并发请求下单:

  • stock = 查到的库存:10 个请求同时查到 stock=100,只有 1 个更新成功,其余 9 个失败后需要重试,再次查询数据库,变成 O(n²) 的查询次数
  • stock >= buyNumber:只要库存充足,10 个并发都能成功,请求数据库次数仅 10 次查询 + 10 次更新,效率大幅提升

为什么优化掉版本号字段?

加版本号字段意味着修改表结构,侵入性强。直接复用库存字段作为"版本号"语义,无需改表,后续切换业务逻辑也不影响数据库设计。

3.3 乐观锁在分布式环境下的限制

乐观锁可以用于分布式环境(多台 Tomcat 服务器),不会导致库存超卖,原因是所有服务器看到的是同一个 MySQL 实例,SQL 的 WHERE stock >= buyNumber 由数据库保证原子执行。

但乐观锁无法实现限购:多台服务器上的多个线程同时查询同一用户的购买记录,若记录还没写入 DB,都会认为未超限而通过校验,导致限购失效。这正是限购优惠券需要分布式锁的原因。

3.4 核心代码

java
@Override
public Result commonVoucher(Long voucherId, int buyNumber) {
    CommonVoucher voucher = commonVoucherService.getById(voucherId);

    if (voucher.getStock() < buyNumber) {
        return Result.fail("库存不足!");
    }

    // 乐观锁扣减库存:WHERE voucher_id=? AND stock >= buyNumber
    boolean success = commonVoucherService.update()
            .setSql("stock = stock - " + buyNumber)
            .eq("voucher_id", voucherId)
            .ge("stock", buyNumber)
            .update();

    if (!success) {
        return Result.fail("库存不足!");
    }

    // 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(redisIdWorker.nextId("order"));
    voucherOrder.setUserId(UserHolder.getUser().getId());
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(voucherOrder.getId());
}

4. 限购优惠券:Redisson 分布式锁

4.1 为什么本地锁不能用于限购?

服务器1: 用户A请求1 → 查订单=0 → 通过限购校验 → 写入订单
服务器2: 用户A请求2 → 查订单=0 → 通过限购校验 → 写入订单  ❌ 限购失效!
(两次请求被 Nginx 负载均衡到不同服务器,本地锁无法跨服务器生效)

本地 synchronized 只在单个 JVM 进程内有效,分布式场景下必须用分布式锁。

4.2 两种分布式锁方案对比

方案一:锁用户ID(限购功能,保留乐观锁防超卖)

锁的 key = "lock:voucher:{voucherId}:{userId}"
含义:同一用户同一时间只能有一个下单请求在执行

流程:获取分布式锁 → 查库存 → 查限购 → 乐观锁扣减库存 → 创建订单 → 释放锁

  • ✅ 性能好:不同用户的请求可以并发执行
  • ❌ 并发高时 DB 压力大:大量用户同时下单仍会打到 DB(乐观锁本质是 DB 并发)

方案二:锁商品ID(同时解决限购和防超卖)

锁的 key = "lock:order:{voucherId}"
含义:同一时间只有一个请求能操作该商品的库存

流程:获取分布式锁 → 查库存 → 查限购 → 直接扣减库存 → 创建订单 → 释放锁

  • ✅ DB 并发低:对同一商品串行化,极大降低数据库并发压力
  • ❌ 性能差:所有用户购买同一商品时全部排队,吞吐量低

4.3 手写分布式锁 vs Redisson

手写 Redis 分布式锁(简单版)

java
// 加锁:SETNX + EX,原子操作
Boolean flag = stringRedisTemplate.opsForValue()
    .setIfAbsent(key, threadId, 10, TimeUnit.SECONDS);

// 释放锁:校验锁归属 + 删除,必须用 Lua 脚本保证原子性
// 若分开执行:校验通过后,锁恰好过期被其他线程获取,再执行删除就误删了别人的锁
String script =
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('del', KEYS[1]) " +
    "else return 0 end";
stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
    Collections.singletonList(key), threadId);

手写锁的缺陷:

  • 不可重入:同一线程重复获取同一把锁时会死锁
  • 无自动续期:业务执行超过锁 TTL 时锁自动过期,其他线程会乘虚而入

Redisson 分布式锁(本项目使用)

java
// 获取锁对象
RLock redisLock = redissonClient.getLock("lock:voucher:" + voucherId + userId);
try {
    // 尝试获取锁,最多等待10s
    boolean isLock = redisLock.tryLock(10, TimeUnit.SECONDS);
    if (!isLock) {
        return Result.fail("同一时间下单人数过多,请稍后重试");
    }
    // ... 业务逻辑 ...
} catch (InterruptedException e) {
    throw new RuntimeException(e);
} finally {
    redisLock.unlock(); // 确保释放
}

Redisson 解决了哪些问题:

可重入锁:底层用 Hash 结构存储,field = 线程标识,value = 重入次数。同一线程再次加锁时,判断 field 是否为当前线程,是则 value+1,释放锁时 value-1,减到 0 才真正删除。

Hash key:   "lock:voucher:123:456"
Hash field: "threadId:线程唯一标识"
Hash value: 重入次数(每次 lock +1,每次 unlock -1)

WatchDog 自动续期:加锁成功后,Redisson 在后台启动一个定时任务(每 10 秒检查一次),若业务还未完成则自动将 TTL 续期到 30 秒,防止锁提前过期。只有调用 unlock() 后 WatchDog 才停止。


5. 表结构设计

商品信息表(voucher)
├── voucher_id   PK
├── shop_id
├── title
├── price
├── type         (1=普通, 2=限购, 3=秒杀)
└── status

普通商品库存表(common_voucher)
├── voucher_id   FK
└── stock

限购商品库存表(limit_voucher)
├── voucher_id   FK
├── stock
└── limit_count  每人限购数量

秒杀商品库存表(seckill_voucher)
├── voucher_id   FK
├── stock
├── limit_count
├── begin_time   秒杀开始时间
└── end_time     秒杀结束时间

订单表(voucher_order)
├── order_id     雪花算法生成
├── user_id
├── voucher_id
├── buy_number
├── create_time
└── status

订单 ID 为什么用雪花算法而不用数据库自增?

数据库自增 ID 在分库分表后会重复;雪花算法保证全局唯一、趋势递增(有利于 B+ 树插入性能)、不依赖数据库,适合分布式场景。


6. 方案横向对比

6.1 三种下单方案全对比

维度普通(乐观锁)限购方案一(锁用户)限购方案二(锁商品)
防超卖
限购
DB 并发压力中(多用户并发)低(串行化)
吞吐量
实现复杂度
适用场景普通购物限购+高并发限购+保护 DB

6.2 分布式锁实现方案对比

维度手写 Redis 锁Redisson
可重入✅(Hash 计数)
自动续期✅(WatchDog)
释放锁原子性需手写 Lua✅(内置)
实现复杂度低(开箱即用)
适用场景理解原理生产环境推荐

7. 技术原理深挖

7.1 乐观锁 vs 悲观锁

维度乐观锁悲观锁
假设冲突少,不加锁,提交时校验冲突多,先加锁再操作
实现版本号/CASsynchronized / 数据库行锁
性能高(无锁等待)低(有锁竞争)
适用场景读多写少、冲突概率低写多、冲突概率高
超卖场景✅ 适用(普通商品并发低)也可用,但不必要

7.2 Redis SETNX 的原子性保证

Redis 单线程模型决定了所有命令都是串行执行的。SET key value NX EX seconds 是一条原子命令,等价于「只有 key 不存在时才设置,并设置过期时间」,在高并发场景下只有一个线程能成功。

早期实现中,SETNXEXPIRE 是两条独立命令,不具原子性:若 SETNX 成功后服务崩溃,EXPIRE 未执行,锁永远不会过期(死锁)。SET ... NX EX 用单命令解决了这个问题。

7.3 为什么释放锁必须用 Lua 脚本?

释放锁包含两步:① 判断 value 是否等于当前线程 ID;② 若相等则删除 key。

若拆成两条命令执行,存在以下风险:

线程A: 判断 value == 自己 ✅
                              (此时锁 TTL 到期,被线程B获取)
线程B: 获取锁成功,设置新的 value
线程A: 执行 DEL key          ❌ 误删了线程B的锁!

Lua 脚本在 Redis 中是原子执行的,两步操作不会被打断,彻底避免了误删问题。

7.4 雪花算法 ID 结构

┌─────┬──────────────────────────┬──────────────┬──────────────┐
│  0  │       时间戳(41位)      │ 机器标识(10位)│ 序列号(12位)│
└─────┴──────────────────────────┴──────────────┴──────────────┘
  符号位    毫秒级时间戳(约69年)  最多1024个节点  每ms最多4096个ID

特点:全局唯一、趋势递增(前41位是时间戳,天然有序)、不依赖数据库、高性能(纯内存计算)。

潜在问题:时钟回拨——若系统时钟被调小,可能生成重复 ID。解决方案:检测到时钟回拨时,等待时钟追上,或使用序列号补偿。


8. 极端场景与容灾

场景一:分布式锁过期,但业务还没执行完(锁提前释放)

手写锁无法解决,Redisson 的 WatchDog 机制每 10 秒续期一次,只要业务在运行,锁就不会过期。建议同时通过优化业务逻辑控制执行时间,避免业务过于耗时。

场景二:Redisson 加锁后服务宕机,WatchDog 没有续期,锁到期后其他线程进入

这是正常的降级行为,设置合理的初始 TTL(如 30 秒)可以给大多数业务足够的执行时间。宕机后锁自动过期,其他线程能正常接管,不会永久死锁。

场景三:乐观锁高并发下大量失败重试,DB 压力过大

乐观锁在高冲突场景(如秒杀)下 CAS 失败率高,会引发大量重试,产生 O(n²) 级别的数据库查询。这正是为什么高并发场景(秒杀)要换成 Redis 预扣库存的方案,将数据库写操作异步化。

场景四:Redisson 依赖的 Redis 宕机,锁全部失效

所有正在持有分布式锁的业务都会因为锁失效而导致并发问题。应对方案:Redis 高可用部署(哨兵/集群);使用 Redisson 的 RedLock 算法(在多个独立 Redis 节点上同时加锁,半数以上成功才算加锁成功),提高可靠性,但实现更复杂。

场景五:同一用户并发提交两次相同订单,但两次请求打到不同服务器

方案一(锁用户 ID)能解决这个问题:lock:voucher:{voucherId}:{userId} 包含用户 ID,两台服务器的两个请求会竞争同一把 Redis 锁,只有一个能成功。


9. 面试题清单

9.1 基础实现层

题目考察点
你的项目里有几种优惠券,分别怎么实现的?整体设计思路,三类业务区分
什么是库存超卖?你是怎么解决的?乐观锁原理与实现
乐观锁的 SQL 为什么用 stock >= buyNumber 而不是 stock = 查询到的库存性能分析,O(n²) vs O(n)
为什么限购优惠券不能用本地锁,要用分布式锁?分布式锁必要性
你用 Redisson 实现分布式锁,它和手写 Redis 锁有什么区别?Redisson 核心特性

9.2 方案对比层

题目考察点
乐观锁和悲观锁有什么区别,各适用什么场景?锁机制对比
限购分布式锁的 key 用用户 ID 和商品 ID 有什么区别?锁粒度与性能取舍
Redisson 的 WatchDog 机制是什么?解决了什么问题?自动续期原理
为什么释放锁要用 Lua 脚本,不用行吗?原子性,误删问题

9.3 原理深挖层

题目考察点
Redisson 可重入锁的底层数据结构是什么?怎么实现可重入的?Hash 结构,重入计数
雪花算法是什么结构?为什么趋势递增?分布式 ID 生成
Redis SETNX 为什么是原子操作?早期分两步写有什么问题?Redis 单线程模型
乐观锁在分布式环境下能防止库存超卖吗?能实现限购吗?为什么?分布式锁与乐观锁的适用边界

9.4 极端场景层

题目考察点
分布式锁 TTL 到期,但业务还没完成,怎么办?WatchDog / 合理设置 TTL
Redis 宕机了,分布式锁全失效了,怎么办?RedLock,Redis 高可用
乐观锁在高并发秒杀场景下会有什么问题?大量重试,DB 压力,引出秒杀方案
雪花算法遇到时钟回拨怎么处理?分布式 ID 容灾

10. 一句话总结(面试开场白模板)

下单模块将优惠券分为三类:普通优惠券用乐观锁防超卖,扣减库存的 SQL 中加 stock >= buyNumber 条件,利用数据库原子性保证并发安全;限购优惠券用 Redisson 分布式锁解决跨服务器的限购问题,锁的粒度有两种选择——锁用户ID性能高但 DB 并发大,锁商品ID减少 DB 并发但吞吐量低;Redisson 相比手写锁增加了可重入和 WatchDog 自动续期的能力,更适合生产环境。为什么选 Redisson 而不是手写锁,核心是手写锁缺少续期机制,业务执行超时会导致锁提前释放,引发并发问题。