【黑马点评——优惠券秒杀模块】
2026/8/6 3:00:43 网站建设 项目流程

本文整理自黑马点评课程关于「优惠券秒杀」的课堂笔记,把零散的 PPT 截图梳理成一条完整的工程化思路:为什么需要全局唯一 ID → 用 Redis 怎么造 ID → 秒杀超卖怎么解决 → 一人一单怎么兜底。适合正在做黑马点评项目的同学,以及任何在做「高并发下单」业务的同学。


一、为什么要造一个「全局唯一 ID」?

在黑马点评中,每家店铺都可以发布优惠券。用户点击「抢购」之后,系统会生成一条订单,写入tb_voucher_order表。

看起来挺简单,对吧?但订单表如果直接用MySQL 数据库自增 ID,会暴露两个问题:

  1. ID 规律性太明显:连续自增的 ID 容易被竞争对手爬取,估算订单量、商业数据,存在安全隐患;
  1. 受单表数据量的限制:单表数据量上去之后,无论是分库分表还是迁移,都会因为 ID 递增的特性出现麻烦。

所以在秒杀场景下,我们更倾向于把「生成 ID」这一步从数据库里抽出来,由独立的组件负责,同时让 ID全局唯一、趋势递增、信息安全


二、常见的全局唯一 ID 方案对比

常见的方案有这几类:

方案

优点

缺点

UUID

简单、本地生成无网络开销

字符串无序、存储空间大、无法做范围查询

数据库自增

实现简单、单调递增

单点风险、性能差、暴露业务量

Redis 自增

性能高、趋势递增、可拼接业务信息

依赖 Redis 高可用

snowflake(雪花算法)

性能高、本地生成、bit 位可自定义

依赖时钟、机器 ID 分配麻烦

黑马点评最终选择了Redis 自增 + 自定义 bit 位拼接,原因是:

  • Redis 本身就在秒杀链路里,不必引入新组件;
  • 自增 ID 的「规律性」可以通过拼接时间戳 + 序列号来打散;
  • 一天一个 key,业务方做数据统计非常方便。

三、Redis 自增 ID 的位段设计

为了增加 ID 的安全性,我们不直接暴露 Redis 自增的数值,而是把一个long(64 bit)拆成三段:

每一段的含义:

  • 符号位(1 bit):永远为 0,保证生成的 ID 是正数;
  • 时间戳(31 bit):以秒为单位,从某个起始时间开始算,可以支撑69 年不用考虑溢出;
  • 序列号(32 bit):当前这一秒内的计数器,理论上每秒最多生成 2^32 ≈ 42 亿个不重复的 ID,秒杀绰绰有余。

既保留了「时间上有序」的特性(方便按时间排序、范围查询),又因为加入了 bit 位拼接,外界无法直接猜到下一个 ID。


四、Redis 自增 ID 的工程实现

4.1 工具类

```java package com.hmdp.utils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; /** * 全局唯一 ID 生成器 * bit 结构:1bit 符号位 + 31bit 时间戳 + 32bit 序列号 */ @Component public class RedisIdWorker { /** 起始时间戳:2024-01-01 00:00:00 */ private static final long BEGIN_TIMESTAMP = 1704038400L; /** 序列号位数 */ private static final long COUNT_BITS = 32L; @Autowired private StringRedisTemplate stringRedisTemplate; /** * 生成分布式 ID * @param keyPrefix 业务前缀(用于每天一个 key 做统计) * @return 64 bit 的 long 类型 ID */ public long nextId(String keyPrefix) { // 1. 生成时间戳(秒级) LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; // 2. 生成序列号:从 Redis 自增 // key 设计为 "icr:order:20260208",方便按天统计 String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")); long count = stringRedisTemplate.opsForValue() .increment("icr:" + keyPrefix + ":" + date); // 3. 拼接:时间戳左移 32 位,然后 OR 上序列号 return timestamp << COUNT_BITS | count; } } ```

4.2 在 Service 层使用

```java @Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private RedisIdWorker redisIdWorker; @Override public Result seckillVoucher(Long voucherId) { // ... long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); // ... return Result.ok(orderId); } } ```

4.3 小技巧

  • 每天一个 keyicr:order:yyyyMMdd,这样incr的值天然就是「当天的订单数」,做运营统计时一行命令就能拿到。
  • 起始时间戳BEGIN_TIMESTAMP别忘了初始化,常用2024-01-01 00:00:00 UTC,对应1704038400。提前算好,别在 2038 年的时候才发现已经溢出。
  • 如果 Redis 是多实例:因为我们只用increment,它是原子操作,所以即使有 N 个应用节点并发请求也是安全的。

五、超卖问题:为什么会少卖?多卖?

进入秒杀环节,我们面临的第一个经典问题是超卖

假设某个优惠券stock = 100,正常情况下最多卖 100 单。但在高并发下,如果多个线程同时「查库存 → 减库存 → 写库」会变成:两个线程都看到了stock = 1,都执行了一遍减库存,最终卖出去 200 单——超卖

超卖是典型的多线程安全问题,解决方案很明确:加锁。但「悲观锁」和「乐观锁」的取舍很不一样。

5.1 悲观锁:先上锁再干活

思想:线程安全问题一定会发生,所以操作数据之前先把锁拿到,确保串行执行。

代表实现:synchronizedLock

优点:安全、思路简单;缺点:性能差,所有请求排队通过,并发能力几乎为零。

在秒杀这种「万级 QPS」的场景下,纯悲观锁基本等于系统宕机。

5.2 乐观锁:先干活再校验

思想:线程安全问题不一定会发生,只在更新数据时判断有没有被别人改过。

  • 没人改过 → 直接更新;
  • 被人改过 → 重试或返回失败。

代表实现:CAS、版本号机制、UPDATE ... WHERE stock = oldStock

优点:性能高,适合读多写少;缺点:高并发下重试率高,可能出现「饥饿」。

5.3 黑马点评怎么选?

秒杀超卖场景下,乐观锁远远优于悲观锁:

  • 库存只有一条数据,并发争抢的就是这一行,悲观锁会让所有请求排队,性能不可接受;
  • 乐观锁让 99% 的请求 CAS 一次就成功,性能几乎无损。

最常见的乐观锁 SQL 是这样:

```sql UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE voucher_id = #{voucherId} AND stock > 0; ```

只要条件成立就扣减成功,失败就是affected_rows = 0,业务层根据这个判断「卖完了」即可。

注意:早期的乐观锁写法是WHERE stock = #{oldStock},但这是严格相等——秒杀中常常出现多个线程都拿到相同的oldStock,结果只有一个线程能成功,剩下全部失败,重试成本太高。所以实战中只要stock > 0就放行,扣减结果由数据库本身的stock - 1决定,这样能保证「不超卖」即可,不在乎「哪个线程减的」。


六、一人一单:怎么防止「一用户抢多次」?

解决了超卖,你以为就完了吗?NO。在「一人一单」的业务规则下,还要防止同一个用户用脚本反复抢。

直觉上很简单:

```java // 伪代码 if (已经存在该 userId + voucherId 的订单) { return "你已经抢过了"; } 下单(); ```

但高并发下,多个线程的「查询订单」和「插入订单」之间存在竞态:

``` 线程A:查询订单 -> 不存在 线程B:查询订单 -> 不存在 <-- 同一时刻 线程A:插入新订单 线程B:插入新订单 <-- 同一个 user 下单成功两次 ```

这就是一人一单的并发安全问题。本质上和超卖是一回事:多线程对共享状态的非原子访问

黑马点评给出的解法很优雅——,但要锁对对象、锁对范围。

6.1 单 JVM 内:用synchronized

如果你的秒杀服务暂时是单节点部署(黑马点评初始方案),可以直接用 Java 原生锁:

@Override public Result createVoucherOrder(Long voucherId) { Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { // 锁当前用户,颗粒度更细 // 1. 查询订单是否已存在 int count = query().eq("user_id", userId) .eq("voucher_id", voucherId) .count(); if (count > 0) { return Result.fail("你已经抢过该优惠券了"); } // 2. 扣减库存(乐观锁) boolean success = seckillVoucherService .update().setSql("stock = stock - 1") .eq("voucher_id", voucherId) .gt("stock", 0) .update(); if (!success) { return Result.fail("库存不足"); } // 3. 创建订单 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setId(redisIdWorker.nextId("order")); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(voucherOrder.getId()); } }

几个关键点:

  1. 锁对象用userId.toString().intern(),而不是this,颗粒度更细——不同用户之间不会互相阻塞;
  1. intern()确保字符串从常量池拿锁,避免每次 new 一个新 String 导致锁失效;
  1. synchronized 的范围:包裹住「查订单 + 插入订单」,保证两步是原子的;
  1. 库存扣减在锁内做:让「查库存 → 查订单 → 扣库存 → 写订单」在同一段代码里串行化,超卖、重复单一起解决。

6.2 多 JVM 部署:升级到分布式锁

单 JVM 的synchronized在集群下就失效了——JVM1 的锁和 JVM2 的锁不是同一把锁。

JVM1:线程1 拿 userId=1001 的锁 → 查订单不存在 JVM1:线程2 拿不到锁 (等待中) JVM2:线程3 也拿 userId=1001 的锁(同一个 userId,但不同 JVM)→ 查订单也不存在 JVM2:线程3 插入订单成功 JVM1:线程1 也插入订单成功 ← 又重复了

解决方法是分布式锁,黑马点评通常用Redis 的SETNXRedisson做兜底:

// 简化版:用 Redis SETNX public boolean tryLock(String key, long timeoutSec) { String value = UUID.randomUUID().toString(); Boolean ok = stringRedisTemplate.opsForValue() .setIfAbsent("lock:order:" + key, value, timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(ok); } public void unlock(String key, String value) { // 必须用 Lua 脚本保证「比较 + 删除」的原子性,避免误删别人的锁 String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) else return 0 end"; stringRedisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), List.of("lock:order:" + key), value); }

实战中直接用 Redisson更省心:

@Resource private RedissonClient redissonClient; @Override public Result createVoucherOrder(Long voucherId) { Long userId = UserHolder.getUser().getId(); String lockKey = "lock:order:" + userId; RLock lock = redissonClient.getLock(lockKey); boolean isLock = lock.tryLock(); // 尝试拿锁 if (!isLock) { return Result.fail("不允许重复下单"); } try { // === 业务逻辑(与上面 synchronized 版本一致)=== int count = query().eq("user_id", userId) .eq("voucher_id", voucherId).count(); if (count > 0) return Result.fail("你已经抢过该优惠券"); // ... 扣库存 + 写订单 ... return Result.ok(orderId); } finally { lock.unlock(); // 千万别忘 } }

七、完整演进路线

把这一节的内容串起来,你会发现黑马点评「秒杀」模块其实只用了三招就把所有坑踩平了:

┌───────────────────────────────────┐ │ 一、生成全局唯一 ID │ │ Redis Incr + 时间戳 + 序列号 │ └───────────────────────────────────┘ │ ▼ ┌───────────────────────────────────┐ │ 二、防止超卖 │ │ 乐观锁(CAS 扣库存) │ │ WHERE stock > 0 条件更新 │ └───────────────────────────────────┘ │ ▼ ┌───────────────────────────────────┐ │ 三、防止重单 │ │ 单 JVM:synchronized(userId) │ │ 多 JVM:Redisson 分布式锁 │ └───────────────────────────────────┘

三段式:ID 安全 → 数量安全 → 身份安全


八、踩坑清单(强烈建议收藏)

现象

原因

解决

抢到的订单 ID 连号太明显

直接暴露了 Redis 自增值

拼接时间戳 bit 位

并发下卖出去超过库存数

多线程同时「查-改-写」

乐观锁 CAS /WHERE stock > 0

同一用户抢到多张券

多个线程各自通过「查订单」

把「查 + 写」原子化(synchronized / 分布式锁)

集群部署单 JVM 锁失效

JVM 之间锁不互通

升级为 Redisson 分布式锁

分布式锁误删别人的锁

比较前没锁住,删时已经被换主

Lua 脚本「比较 + 删除」原子化


九、写在最后

整个优惠券秒杀模块看似只是「抢一个优惠」,但里面涉及的知识点密度很大:

  • 全局唯一 ID:Redis Incr + bit 位拼接;
  • 超卖:乐观锁、WHERE stock > 0
  • 一人一单:单 JVMsynchronized(userId)→ 多 JVMRedisson分布式锁;
  • 可观测性:每天一个 key 做订单量统计,方便后续压测对比。

把这套模板记住,再去做「限时抢购、票务系统、秒杀活动」会非常顺手——本质上都是「唯一 ID + 库存安全 + 身份安全」三件套。

最后,如果本文对你有帮助,欢迎点赞、收藏、评论区交流

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询