外观
优惠券下单模块面经文档
项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · MySQL · Kafka · Redisson · 线程池
1. 模块概览
本模块将优惠券拆分为三类,针对不同并发场景采用不同的下单策略:
| 类型 | 业务特点 | 核心技术 | 核心解决的问题 |
|---|---|---|---|
| 普通优惠券 | 无购买数量限制,并发量不大 | 乐观锁 | 防止库存超卖 |
| 限购优惠券 | 每人限购 N 张,有一定并发 | Redisson 分布式锁 + 乐观锁 | 限购功能 + 防库存超卖 |
| 秒杀优惠券 | 限购 + 时间窗口,高并发抢购 | Redis 预热 + Lua + Kafka 异步 | 高并发下的高吞吐抢单 |
2. 整体架构图
用户下单请求
│
┌────────────────┼────────────────┐
│ │ │
普通优惠券 限购优惠券 秒杀优惠券
│ │ │
乐观锁扣减库存 分布式锁+乐观锁 Redis 预热判断资格
直接写DB 直接写DB │
Kafka 消息队列
│
消费者异步写DB3. 普通优惠券:乐观锁防库存超卖
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 悲观锁
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 假设 | 冲突少,不加锁,提交时校验 | 冲突多,先加锁再操作 |
| 实现 | 版本号/CAS | synchronized / 数据库行锁 |
| 性能 | 高(无锁等待) | 低(有锁竞争) |
| 适用场景 | 读多写少、冲突概率低 | 写多、冲突概率高 |
| 超卖场景 | ✅ 适用(普通商品并发低) | 也可用,但不必要 |
7.2 Redis SETNX 的原子性保证
Redis 单线程模型决定了所有命令都是串行执行的。SET key value NX EX seconds 是一条原子命令,等价于「只有 key 不存在时才设置,并设置过期时间」,在高并发场景下只有一个线程能成功。
早期实现中,SETNX 和 EXPIRE 是两条独立命令,不具原子性:若 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 而不是手写锁,核心是手写锁缺少续期机制,业务执行超时会导致锁提前释放,引发并发问题。