第三章:优惠券秒杀模块
章节概览
本章围绕电商秒杀场景,讲解 Redis 在秒杀业务中的核心应用,涵盖: 1. 全局唯一 ID 生成 2. 秒杀下单功能实现 3. 库存超卖问题与乐观锁 4. 一人一单与分布式锁 5. 秒杀优化 — 异步秒杀方案 6. 基于 Stream 消息队列的异步秒杀
一、全局唯一 ID 生成
1.1 为什么需要全局唯一 ID?
订单表不使用 MySQL 自增 ID 的原因: - 规律性太明显:自增 ID 会暴露业务信息(如日订单量),存在安全隐患 - 数据量限制:订单量巨大时需要分表,多张表各自自增会导致 ID 冲突
1.2 全局唯一 ID 需满足的特性
| 特性 | 说明 |
|---|---|
| 唯一性 | 全局业务内唯一,不冲突 |
| 高可用 | 任何时候都能生成 ID |
| 高性能 | 生成速度足够快,不能拖慢业务 |
| 递增性 | 整体单调递增,有利于数据库索引 |
| 安全性 | 规律不明显,不易被猜测 |
1.3 基于 Redis 的 ID 生成方案
核心思路:利用 Redis 的 INCR 命令实现自增
ID 结构设计(64 位 long 类型):
| 1 bit 符号位 | 31 bit 时间戳 | 32 bit 序列号 |
| 永远为0 | 秒级时间差 | 当日自增计数 |- 符号位:1 位,永远为 0,表示正数
- 时间戳:31 位,当前时间与基准时间(如 2022-01-01)的秒数差,可用约 69 年
- 序列号:32 位,Redis
INCR的自增值,每秒理论支持 2³² 个不同 ID
1.4 代码实现
@Component
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 基准时间:2022-01-01 00:00:00 UTC
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 序列号位数
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix) {
// 1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2. 生成序列号(精确到天,每天一个 key)
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
// 3. 位运算拼接:时间戳左移 32 位,再与序列号做或运算
return timestamp << COUNT_BITS | count;
}
}1.5 Key 设计要点
icr:order:2022:09:10 → 当天订单的自增计数每天一个 Key 的优势: 1. 避免单个 Key 自增值超过 32 位上限 2. 方便统计每天/每月/每年的订单量(利用 Redis key 分层结构)
1.6 其他全局 ID 方案对比
| 方案 | 特点 | 缺点 |
|---|---|---|
| UUID | JDK 自带,简单 | 非递增、字符串长度大、不利于索引 |
| Redis 自增 | 满足所有特性,性能好 | 依赖 Redis |
| Snowflake 雪花算法 | 不依赖 Redis,性能更高 | 时钟依赖强,需维护机器 ID |
| 数据库自增 | 单独一张表做自增 | 性能不如 Redis |
二、秒杀下单功能实现
2.1 两种优惠券类型
| 类型 | 表 | 特点 |
|---|---|---|
| 普通券 | tb_voucher |
平价券,可大量发放,随意购买 |
| 秒杀券(特价券) | seckill_voucher |
折扣大,有库存限制、时间限制,需要抢购 |
秒杀券额外字段: - stock:库存上限 - begin_time / end_time:秒杀生效时间段
2.2 下单业务流程
请求(voucher_id)
│
├─ 查询优惠券信息
│
├─ 判断秒杀是否开始 → 未开始 → 返回异常
│
├─ 判断秒杀是否结束 → 已结束 → 返回异常
│
├─ 判断库存是否充足 → 不足 → 返回异常
│
├─ 扣减库存(stock - 1)
│
├─ 创建订单(写入 tb_voucher_order)
│
└─ 返回订单 ID2.3 核心代码
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 4. 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
if (!success) {
return Result.fail("库存不足");
}
// 6. 创建订单
VoucherOrder order = new VoucherOrder();
order.setId(redisIdWorker.nextId("order"));
order.setUserId(UserHolder.getUser().getId());
order.setVoucherId(voucherId);
voucherOrderService.save(order);
return Result.ok(order.getId());
}三、库存超卖问题
3.1 问题分析
高并发场景下,多个线程交叉执行导致超卖:
线程1: 查询库存=1 → 判断>0 ✓
线程2: 查询库存=1 → 判断>0 ✓ (在线程1扣减前查询)
线程1: 扣减 → 库存=0
线程2: 扣减 → 库存=-1 ❌ 超卖!3.2 悲观锁 vs 乐观锁
| 悲观锁 | 乐观锁 | |
|---|---|---|
| 理念 | 认为安全问题一定会发生 | 认为安全问题不一定发生 |
| 方式 | 加锁,串行执行 | 不加锁,更新时判断数据是否被修改 |
| 性能 | 较差 | 较好 |
| 实现 | synchronized、数据库 for update | CAS(版本号法 / 条件判断) |
3.3 版本号法
-- 查询时获取版本号
SELECT stock, version FROM seckill_voucher WHERE voucher_id = 10;
-- 更新时带上版本号判断
UPDATE seckill_voucher
SET stock = stock - 1, version = version + 1
WHERE voucher_id = 10 AND version = 1;3.4 CAS 法(推荐用于库存场景)
直接用库存值代替版本号判断:
UPDATE seckill_voucher
SET stock = stock - 1
WHERE voucher_id = 10 AND stock = 1; -- stock 等于查询时的值改进方案(解决成功率低的问题):
-- 不再判断 stock 是否等于查询值,而是判断 stock > 0
UPDATE seckill_voucher
SET stock = stock - 1
WHERE voucher_id = 10 AND stock > 0;关键点:库存场景下,只要
stock > 0就可以安全扣减,无需完全匹配。
四、一人一单
4.1 需求说明
同一用户对同一优惠券只能下单一次,防止黄牛刷单。
4.2 实现思路
下单前先查询:WHERE user_id = ? AND voucher_id = ? - 存在记录 → 已购买过,拒绝 - 无记录 → 允许下单
4.3 并发安全问题
多人同时首次下单时,查询都返回 0,导致重复插入 → 需要悲观锁(串行化查询+插入)。
4.4 代码实现
// 外层获取锁,确保事务提交后才释放
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// intern() 确保相同值的字符串指向同一对象(锁对象一致)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单判断
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("该用户已经购买过");
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
// 创建订单
VoucherOrder order = new VoucherOrder();
order.setId(redisIdWorker.nextId("order"));
order.setUserId(userId);
order.setVoucherId(voucherId);
save(order);
return Result.ok(order.getId());
}4.5 关键注意事项
- 锁的范围:synchronized 应加在事务方法外部,确保事务提交后才释放锁
- 锁对象:使用
userId.toString().intern()确保相同用户使用同一把锁 - 事务生效:内部方法调用需通过代理对象(
AopContext.currentProxy()),否则事务不生效 - 需要添加依赖
spring-boot-starter-aop并开启@EnableAspectJAutoProxy(exposeProxy = true)
五、集群下的并发安全问题
5.1 问题本质
synchronized 锁是 JVM 级别的: - 单机:同一 JVM 内的锁监视器可以实现互斥 - 集群:每个 JVM 有自己的锁监视器 → 多台机器各有一个线程能获取锁 → 并发安全问题再次出现
5.2 解决方案
需要分布式锁 — 跨 JVM 进程的锁(详见分布式锁章节)。
六、秒杀优化 — 异步秒杀
6.1 性能瓶颈分析
原有同步流程中,查询优惠券、查询订单、扣减库存、创建订单都涉及数据库操作,耗时长。
6.2 优化思路
将业务分为两部分: 1. 秒杀资格判断(快速)→ 在 Redis 中完成 2. 下单操作(耗时)→ 异步执行
6.3 Redis 中的数据结构
# 库存(String 类型)
seckill:stock:{voucherId} → "100"
# 已购买用户集合(Set 类型)
seckill:order:{voucherId} → {userId1, userId2, ...}6.4 Lua 脚本实现秒杀资格判断
-- 参数: 1-优惠券ID, 2-用户ID, 3-订单ID
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
-- Key
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
-- 1. 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 库存不足
end
-- 2. 判断用户是否已下单
if (redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 重复下单
end
-- 3. 扣减库存
redis.call('incrby', stockKey, -1)
-- 4. 保存用户到已购集合
redis.call('sadd', orderKey, userId)
-- 5. 发送消息到 Stream 消息队列
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0 -- 有购买资格6.5 业务代码
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
Long orderId = redisIdWorker.nextId("order");
// 1. 执行 Lua 脚本判断秒杀资格
Long result = stringRedisTemplate.execute(
seckillScript,
Collections.emptyList(),
voucherId.toString(), userId.toString(), orderId.toString()
);
// 2. 判断结果
if (result != 0) {
return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
}
// 3. 有资格,返回订单 ID(实际下单由异步线程处理)
return Result.ok(orderId);
}七、基于 Stream 消息队列的异步秒杀
7.1 为什么用 Stream 替代阻塞队列?
| JDK 阻塞队列 | Redis Stream | |
|---|---|---|
| 存储 | JVM 内存 | Redis(持久化) |
| 内存限制 | 队列满则无法写入 | 无此问题 |
| 数据安全 | 服务宕机则数据丢失 | 持久化,数据不丢 |
| 消费确认 | 无 | 支持 ACK 确认 |
| 异常处理 | 任务取出后异常则丢失 | 未确认消息进入 pending list 可重试 |
7.2 创建 Stream 消息队列和消费者组
# 创建队列和消费者组(mkstream 参数自动创建不存在的队列)
XGROUP CREATE stream.orders g1 0 MKSTREAM7.3 异步消费线程
@PostConstruct
public void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1. 获取消息队列中的订单信息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(QUEUE_NAME, ReadOffset.lastConsumed())
);
// 2. 判断是否获取成功
if (list == null || list.isEmpty()) {
continue;
}
// 3. 解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder order = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4. 创建订单
handleVoucherOrder(order);
// 5. ACK 确认
stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 从 pending list 读取未确认消息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(QUEUE_NAME, ReadOffset.from("0"))
);
if (list == null || list.isEmpty()) {
break; // 没有异常消息,结束
}
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder order = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
handleVoucherOrder(order);
stringRedisTemplate.opsForStream().acknowledge(QUEUE_NAME, "g1", record.getId());
} catch (Exception e) {
log.error("处理 pending list 异常", e);
try { Thread.sleep(20); } catch (InterruptedException ex) {}
}
}
}
}7.4 异步下单处理方法
private void handleVoucherOrder(VoucherOrder order) {
Long userId = order.getUserId();
// 获取锁(兜底,Redis 已做过判断)
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
log.error("不允许重复下单");
return;
}
try {
// 获取代理对象创建订单
proxy.createVoucherOrder(order);
} finally {
lock.unlock();
}
}八、核心知识点总结
秒杀业务中的 Redis 应用全景
┌──────────────────────────────────────────────────┐
│ 秒杀优化方案 │
│ │
│ 前端请求 → Lua脚本(资格判断) → Stream消息队列 │
│ │ │ │
│ ├─ 库存判断(String) │ │
│ ├─ 一人一单判断(Set) │ │
│ └─ 扣库存 + 发消息 ↓ │
│ 异步消费线程 │
│ │ │
│ 创建订单(数据库) │
└──────────────────────────────────────────────────┘性能对比
| 方案 | 平均响应时间 | 说明 |
|---|---|---|
| 同步下单(原始方案) | ~400ms | 全部走数据库,加分布式锁 |
| 乐观锁 CAS | ~200ms | 解决超卖,但数据库压力大 |
| 异步秒杀(阻塞队列) | ~200ms | 流程缩短,但内存受限、数据不安全 |
| 异步秒杀(Stream) | ~110ms | 最优方案,持久化 + ACK 确认 |
注意事项清单
- 库存超卖:使用
stock > 0条件判断,而非stock = 查询值 - 一人一单:锁加在事务外部,确保事务提交后释放;使用
intern()确保锁对象一致 - 事务代理:内部方法调用需通过
AopContext.currentProxy()获取代理对象 - Lua 脚本原子性:多条 Redis 操作需保证原子性,使用 Lua 脚本
- 异步线程获取用户信息:子线程无法从
ThreadLocal获取用户,需从VoucherOrder对象中获取 - Stream ACK 确认:处理完消息必须 ACK,否则消息会进入 pending list
- pending list 处理:异常消息未确认,需定期重试处理
- 新增秒杀券时:同时将库存信息写入 Redis