|

第三章:优惠券秒杀模块

章节概览

本章围绕电商秒杀场景,讲解 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)
  │
  └─ 返回订单 ID

2.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 关键注意事项

  1. 锁的范围:synchronized 应加在事务方法外部,确保事务提交后才释放锁
  2. 锁对象:使用 userId.toString().intern() 确保相同用户使用同一把锁
  3. 事务生效:内部方法调用需通过代理对象AopContext.currentProxy()),否则事务不生效
  4. 需要添加依赖 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 MKSTREAM

7.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 确认

注意事项清单

  1. 库存超卖:使用 stock > 0 条件判断,而非 stock = 查询值
  2. 一人一单:锁加在事务外部,确保事务提交后释放;使用 intern() 确保锁对象一致
  3. 事务代理:内部方法调用需通过 AopContext.currentProxy() 获取代理对象
  4. Lua 脚本原子性:多条 Redis 操作需保证原子性,使用 Lua 脚本
  5. 异步线程获取用户信息:子线程无法从 ThreadLocal 获取用户,需从 VoucherOrder 对象中获取
  6. Stream ACK 确认:处理完消息必须 ACK,否则消息会进入 pending list
  7. pending list 处理:异常消息未确认,需定期重试处理
  8. 新增秒杀券时:同时将库存信息写入 Redis
评论交流

文章目录