首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >【黑马点评日记03】秒杀场景必备:全局唯一ID生成策略及秒杀下单逻辑

【黑马点评日记03】秒杀场景必备:全局唯一ID生成策略及秒杀下单逻辑

作者头像
北极的代码
发布2026-04-22 13:50:29
发布2026-04-22 13:50:29
1170
举报

本文介绍了在分布式环境下生成全局唯一ID的必要性及实现方案。 文章首先分析了秒杀场景中数据库自增ID的不足,提出了Redis自增+时间戳的解决方案,详细讲解了位运算合并时间戳和序列号的技术原理。 重点阐述了黑马点评项目采用的Redis自增策略,包括三种实现方式对比、核心代码示例及性能优化建议。 同时,文章还探讨了分布式环境的特点、面临的问题,以及优惠券秒杀业务的数据表设计和下单流程实现。 最后指出了当前方案存在的超卖、并发控制等缺陷,为后续优化改进奠定了基础。


一 全局唯一ID

整个系统(分布式环境) 中,为每一张成功抢购到的订单、或每一次秒杀记录,生成一个绝对不会重复的唯一标识符。

为什么需要它

秒杀业务中,数据库自增 ID 不够用:

  • 多台服务器同时处理请求 → 每台数据库自增 ID 可能冲突
  • 数据量极大(千万、亿级)→ 自增 ID 容易暴露业务量
  • 分库分表后,单表自增 ID 无法保证全局不重复
核心特点
  1. 全局唯一:任何一台服务器、任何时候生成的 ID 都不会重复
  2. 高可用、高性能:秒杀场景下不能成为瓶颈
  3. 趋势递增(不一定连续):利于数据库索引,但不暴露具体订单数
  4. 长度适中:一般 64 位整数(如 18~19 位数字)
常见实现方式
  • Redis 自增INCR key 每天一个 key(如 order:20250421
  • 雪花算法(Snowflake):时间戳 + 机器 ID + 序列号,不依赖数据库
  • 数据库号段模式:一次取一批 ID 到本地缓存

例子

假设你秒杀一张优惠券成功,生成的订单 ID 不是:

  • ❌ 1、2、3(单表自增) 而是:
  • 712345678912345678(全局唯一、趋势递增、无规律可推测)

全局唯一 ID 主要是为了避免超卖时订单 ID 冲突,并支持后续分库分表

整体设计思路

目标:生成一个全局唯一、趋势递增的 64 位长整型 ID。

核心公式

代码语言:javascript
复制
全局唯一ID = 时间戳(高位) + 序列号(低位)
  • 时间戳:确保 ID 随时间趋势递增
  • 序列号:同一毫秒内不重复

存储结构(常见 64 位分配):

位段

长度

说明

符号位

1 bit

固定 0(正数)

时间戳

31 bit

秒级 / 毫秒级(可支撑数年)

序列号

32 bit

同一秒内最大 42 亿+

为了简化,通常采用 Redis 自增 + 日期前缀分段组合 方式。


二、具体实现方式

方案1:Redis 自增 + 日期前缀(最简单)
代码语言:javascript
复制
java

// 每天一个 key
String key = "order:id:" + LocalDate.now();
Long id = redisTemplate.opsForValue().increment(key);

生成的 ID 示例

  • 20250421 + 000001
  • 20250421 + 000002

缺点:ID 会超过 64 位(前面是日期数字),不适合作为数据库主键(推荐用 Long)。

方案2:分段组合

结构

代码语言:javascript
复制
31位时间戳差值(秒) + 32位自增序列

步骤

  1. 获取当前时间(秒级或毫秒级)
  2. 减去自定义起始时间(如 2023-01-01 00:00:00),得到差值
  3. 左移 32 位,预留低位空间
  4. 低位用 Redis INCR 自增(可每天重置)
  5. 按位或运算组合

示例代码(简化逻辑):

代码语言:javascript
复制
java

// 起始时间戳(秒级)
private static final long START_TIMESTAMP = 1672531200L; // 2023-01-01
private static final int COUNT_BITS = 32;

public long nextId(String keyPrefix) {
    // 1. 时间戳差值(秒)
    long now = System.currentTimeMillis() / 1000;
    long timestamp = now - START_TIMESTAMP;
    
    // 2. 序列号(每天或每小时重置,避免太长)
    String date = LocalDate.now().toString();
    String key = "icr:" + keyPrefix + ":" + date;
    Long count = redisTemplate.opsForValue().increment(key);
    
    // 3. 组合
    return (timestamp << COUNT_BITS) | count;
}
对比

场景

推荐策略

原因

小项目/后台管理

日期前缀+自增

可读性好,调试方便,并发低

高并发秒杀

分段策略

性能好,支持高并发,不暴露业务量

分库分表

分段策略

适合作为分片键

日志/导出文件命名

日期前缀+自增

人眼可读,便于管理

订单ID

分段策略

数据库主键,性能要求高

对外暴露的ID

分段策略

不暴露业务量,更安全



三、为什么用 Redis 而不是数据库自增

对比项

数据库自增

Redis 方案

全局唯一

❌ 分库分表会冲突

✅ 集中生成

性能

低(每次写磁盘)

高(内存操作)

趋势递增

✅(按时间)

高并发

有瓶颈

单线程 + 管道可到 10w+ QPS

依赖

数据库

Redis(可集群)


四、Redis 方案的注意事项

  1. 持久化:RDB / AOF 确保 ID 不丢失(AOF 建议每秒刷盘)
  2. 序列号上限:如果同一秒内超过 2^32,可增加时间精度(毫秒)或重置周期更短
  3. 时钟回拨:如果 Redis 服务器时间回拨,可能重复 → 建议 NTP 同步 + 监控
  4. 高性能优化:可使用 Redis 的 INCRBY 批量取号,减少网络开销

五、与雪花算法的对比(补充)

特性

Redis 方案

雪花算法(Snowflake)

依赖

Redis(外部中间件)

无(本地生成)

时钟回拨

依赖 Redis 时间

需自行处理

高可用

Redis 集群

无中心化

复杂程度

简单

中(需分配机器ID)

优先推荐 Redis 方案,因为项目本身已集成 Redis,简单可靠。


六、总结(面试/项目回答可用)

基于 Redis 的全局唯一 ID 生成策略: 采用 时间戳差值(高位) + 自增序列(低位) 的方式,通过 Redis 的 INCR 命令每天重置序列号,保证 ID 在分布式环境下唯一且趋势递增。相比数据库自增,性能更高、支持分库分表;相比雪花算法,实现更简单、无时钟回拨风险(依赖 Redis 时间)。

补充:

分布式环境,通俗来说就是:

一个任务由多台计算机(服务器)共同协作完成,这些计算机通过网络通信,对外看起来像一台超级计算机。

为了更好地理解,我们先看它的反面——单机环境

一、单机环境 vs 分布式环境

对比项

单机环境

分布式环境

硬件

1台服务器

多台服务器(可能成百上千台)

处理能力

受限于单机 CPU、内存

可横向扩展(加机器)

故障影响

机器挂了 → 服务全挂

个别机器挂了 → 服务仍可用

数据存储

一个数据库

分库分表、多副本

典型场景

个人博客、小型网站

双11秒杀、微信、淘宝

举个例子

  • 单机:你一个人做饭、炒菜、洗碗 → 忙不过来就崩溃
  • 分布式:一个洗菜、一个切菜、一个炒菜、一个洗碗 → 协作完成,挂了1个人还能继续
二、为什么需要分布式环境

随着用户量和数据量增长,单机遇到瓶颈:

  1. 性能瓶颈:一台服务器的 CPU、内存、磁盘 IO 有限
  2. 可用性问题:单机宕机 → 服务不可用
  3. 扩展性差:只能垂直升级(换更强的机器),成本高
  4. 地理限制:用户遍布全球,单机房延迟高

典型例子

  • 双11零点秒杀:几亿人同时抢 → 一台机器瞬间压垮
  • 微信支付:每天数十亿笔交易 → 单数据库存不下
三、分布式环境的典型问题(难点)

分布式不是简单地加机器,会引入新问题:

问题

说明

例子

全局唯一ID

每台机器独立生成ID会重复

订单号冲突

分布式锁

多台机器抢同一资源

超卖(同一商品被两台机器同时卖出)

数据一致性

多副本数据可能不一致

刚支付的订单查不到

分布式事务

跨多个数据库的操作要原子性

转账(扣A账户 + 加B账户)

服务发现

机器动态上下线,如何知道谁活着

某台服务器宕机,请求不能再发给它

链路追踪

一个请求经过十几台机器,如何排查故障

用户反馈下单慢,不知道卡在哪台机器

四、回到黑马点评的秒杀场景

为什么需要全局唯一ID?

  • 假设有 3台服务器 同时处理秒杀请求
  • 每台服务器独立生成订单ID(比如从1开始自增)
  • 结果:三台都会生成 ID=1、2、3… → 严重冲突

text

代码语言:javascript
复制
服务器A:订单1、订单2、订单3...
服务器B:订单1、订单2、订单3...  ← 重复了!
服务器C:订单1、订单2、订单3...  ← 重复了!

解决方案

  • Redis 统一生成ID(集中式)
  • 或每台机器用 雪花算法(本地生成,但通过机器ID保证不重复)

这就是为什么在分布式环境下,需要特殊的“全局唯一ID生成策略”。






五、总结一句话

分布式环境 = 多台计算机通过网络协作,共同完成一个任务。 它带来了高性能、高可用、可扩展的好处,但也引入了全局ID、分布式锁、数据一致性等新挑战。


全局唯一ID生成(具体操作):

间戳和序列号是怎么通过位运算合并成一个64位整数的,这是全局唯一ID生成中最关键的一步。


1.为什么要合并

我们有两个数字:

  • 时间戳差值:比如 1765440000(很大,约31位)
  • 序列号:比如 5(很小,只需要几位)

不能直接相加,因为:

text

代码语言:javascript
复制
1765440000 + 5 = 1765440005  ← 这样会破坏结构,无法拆回原来的两个数

需要用位运算,把两个数字拼接到不同位置,互不干扰。


2.用一个简单例子理解

假设我们用一个 16位的整数 来模拟(实际是64位):

text

代码语言:javascript
复制
高8位存放时间戳差值,低8位存放序列号

步骤演示

步骤

操作

二进制

十进制

1. 时间戳差值 = 10

左移8位

00001010 00000000

2560

2. 序列号 = 3

不移动

00000000 00000011

3

3. 按位或合并

00001010 00000011

2563

最终结果 2563 怎么拆回去

  • 取高8位:2563 >> 8 = 10(得到时间戳)
  • 取低8位:2563 & 0xFF = 3(得到序列号)

两个数字被"塞"进了同一个整数,互不干扰。


3.用图示理解(16位版)

text

代码语言:javascript
复制
时间戳 = 10  →  二进制:00001010
序列号 = 3   →  二进制:00000011

第一步:时间戳左移8位
00001010 00000000   (时间戳占用了高8位,低8位全是0)

第二步:序列号不动
00000000 00000011   (序列号只在低8位)

第三步:按位或(合并)
00001010 00000011   ← 最终ID
高8位↑     ↑低8位
(时间戳) (序列号)

4.项目中的实际代码(64位版)

代码语言:javascript
复制
java

// 配置常量
private static final long START_TIMESTAMP = 1672531200L; // 2023-01-01
private static final int COUNT_BITS = 32;  // 序列号占用32位

public long nextId(String keyPrefix) {
    // 1. 获取当前时间戳差值(秒级)
    long now = System.currentTimeMillis() / 1000;
    long timestamp = now - START_TIMESTAMP;  // 比如 = 123456789
    
    // 2. 获取序列号(Redis自增)
    String key = "icr:" + keyPrefix + ":" + LocalDate.now();
    Long sequence = redisTemplate.opsForValue().increment(key);  // 比如 = 5
    
    // 3. 位运算合并
    // timestamp 左移 32 位,空出低32位给 sequence
    long id = (timestamp << COUNT_BITS) | sequence;
    
    return id;
}

逐步计算示例

text

代码语言:javascript
复制
timestamp = 123456789
sequence = 5

1. timestamp << 32
   123456789 × 2^32 = 530,242,313,337,176,064

2. 按位或 sequence
   530,242,313,337,176,064 | 5 = 530,242,313,337,176,069
   
最终ID就是这个超大整数

5.为什么左移32位

因为 sequence 最大可能是多少?

  • Redis 自增,一天可能几十万、几百万
  • 32位的最大值是 2^32 - 1 = 4,294,967,295(约42亿)
  • 42亿的序列号足够一天使用

所以:

  • 高32位:存放时间戳差值
  • 低32位:存放序列号

6.如何拆回原来的值
代码语言:javascript
复制
java

// 从最终ID中拆出时间戳
long timestamp = id >> 32;  // 右移32位,抛弃低32位

// 从最终ID中拆出序列号
long sequence = id & 0xFFFFFFFFL;  // 按位与,保留低32位

7.为什么要用相对时间戳

假设我们直接用当前时间戳:

代码语言:javascript
复制
java

long now = System.currentTimeMillis() / 1000;  // 当前秒级时间戳
long id = (now << 32) | sequence;

问题来了

当前时间戳大约是 1,765,440,000(2025年),左移32位后:

text

代码语言:javascript
复制
1,765,440,000 × 2^32 = 7,581,000,000,000,000,000(约 7.58 × 10^18)

这个数字已经接近64位整数的上限9.22 × 10^18)。

如果系统运行几年:

  • 2030年:时间戳约 1,890,000,000 → 左移后更大
  • 很快就会超过64位上限,导致溢出!
8.核心要点总结

位运算

作用

例子

<< n

左移,空出低位

10 << 8 = 2560

|

按位或,合并

2560 | 3 = 2563

>> n

右移,取出高位

2563 >> 8 = 10

& 掩码

按位与,取出低位

2563 & 0xFF = 3

一句话理解

左移就像把数字往"高位房间"推,留出"低位房间"给另一个数字,然后用按位或把两个房间拼在一起。

黑马点评的优惠券秒杀场景中,基于 Redis 自增 ID 策略 是最核心、最常用的全局唯一 ID 生成方式。


生成序列号:

我们用构造器注入redis

构造函数注入是更规范的写法,但不是必须的写法。如果你看到代码里有构造函数,那通常是作者遵循了 Spring 的最佳实践。我们当然也可以用@Autowrid(可能拼的不对)谅解

代码语言:javascript
复制
 private StringRedisTemplate stringRedisTemplate;
    public RedisIdWork(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
在全局唯一ID生成中的 key

回到刚才的代码:

代码语言:javascript
复制
java

String key = "icr:" + keyPrefix + ":" + date;
Long sequence = redisTemplate.opsForValue().increment(key);

这个 key 的作用:记录某一天、某种业务当前序列号是多少

举个例子
代码语言:javascript
复制
bash

# 2025年4月21日,订单业务的序列号
key = "icr:order:2025-04-21"
value = 1  # 第1次调用自增 → 变成2 → 3 → 4...

# 2025年4月21日,优惠券业务的序列号  
key = "icr:coupon:2025-04-21"
value = 1  # 独立计数,互不影响

# 第二天的序列号(key不同)
key = "icr:order:2025-04-22"
value = 1  # 从1重新开始

为什么要设计这样的 key

1. 区分不同业务

代码语言:javascript
复制
java

nextId("order")   → key = "icr:order:2025-04-21"
nextId("coupon")  → key = "icr:coupon:2025-04-21"

订单和优惠券的序列号各自独立,互不干扰。

2. 每天重置

代码语言:javascript
复制
java

String date = LocalDate.now().toString(); // 每天变化

4月21日的 key:icr:order:2025-04-21

4月22日的 key:icr:order:2025-04-22

这样每天的序列号都从 1 开始,不会无限增长。

3. 方便管理

代码语言:javascript
复制
bash

# 查看今天生成了多少个订单ID
GET icr:order:2025-04-21

# 删除昨天的key(释放内存)
DEL icr:order:2025-04-20

Redis key 的设计规范(重要!)

在实际项目中,key 通常用 冒号 : 分隔,形成层级结构:

代码语言:javascript
复制
java

// 格式:业务:子业务:参数
"order:detail:1001"        // 订单详情
"user:info:888"            // 用户信息  
"icr:order:2025-04-21"     // 自增计数器
"lock:seckill:100"         // 分布式锁

好处

  • ✅ 可读性强:一眼看出用途
  • ✅ 支持模糊查询:KEYS user:* 查所有用户
  • ✅ 逻辑分组:同一业务 key 前缀相同
代码语言:javascript
复制
调用 nextId("order")
    ↓
生成 key = "icr:order:2025-04-21"
    ↓
Redis.INCR("icr:order:2025-04-21")
    ↓
第一次调用:key 不存在 → 创建并设为 1
第二次调用:key 存在   → 自增为 2
第三次调用:           → 自增为 3
    ↓
返回 sequence = 3
    ↓
与时间戳合并 → 最终 ID
  • ✅ 纯 Long 类型,数据库索引友好
  • ✅ 趋势递增(高位是时间戳)
  • ✅ 不暴露业务量(序列号被隐藏在低位)
  • ✅ 每天序列号重置,不会无限增长


Redis 自增的关键细节

1. 序列号上限问题

java

代码语言:javascript
复制
// 32位最大 4,294,967,295(约42亿)
// 如果一天超过42亿次自增怎么办?

解决方案

  • 增加位数:用 40 位(2^40 ≈ 1万亿)
  • 缩短重置周期:每小时重置一次
  • 改用毫秒级时间戳:减少单时间单位的并发量
2. 高性能优化
代码语言:javascript
复制
java

// 批量获取 ID,减少网络开销
public List<Long> batchNextId(String keyPrefix, int batchSize) {
    String key = "icr:" + keyPrefix + ":" + LocalDate.now();
    Long start = redisTemplate.opsForValue().increment(key, batchSize);
    List<Long> ids = new ArrayList<>();
    for (int i = 0; i < batchSize; i++) {
        long timestamp = getTimestamp();
        long sequence = start - batchSize + i + 1;
        ids.add((timestamp << COUNT_BITS) | sequence);
    }
    return ids;
}
3. 持久化配置

yaml

代码语言:javascript
复制
# Redis 配置(保证 ID 不丢失)
save 900 1     # 15分钟至少1次修改则RDB
appendonly yes # 开启AOF
appendfsync everysec # 每秒刷盘

与雪花算法的对比

对比项

Redis自增策略

雪花算法

依赖

Redis(外部)

无(本地生成)

性能

单次 ~0.1ms

~0.01ms

高可用

Redis集群

无中心化

时钟回拨

不涉及

需处理

实现复杂度

简单

中等

适用场景

已用Redis的项目

极致性能需求


添加优惠卷:

数据库表设计

1. 优惠券表(tb_voucher)

代码语言:javascript
复制
sql

CREATE TABLE `tb_voucher` (
  `id` bigint(20) NOT NULL COMMENT '主键',
  `shop_id` bigint(20) DEFAULT NULL COMMENT '商铺id',
  `title` varchar(255) NOT NULL COMMENT '优惠券标题',
  `sub_title` varchar(255) DEFAULT NULL COMMENT '副标题',
  `rules` varchar(1024) DEFAULT NULL COMMENT '使用规则',
  `pay_value` bigint(10) NOT NULL COMMENT '支付金额',
  `actual_value` bigint(10) NOT NULL COMMENT '抵扣金额',
  `type` tinyint(1) NOT NULL COMMENT '类型 0-普通 1-秒杀',
  `status` tinyint(1) DEFAULT '1' COMMENT '状态 1-上架 2-下架',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE,
  PRIMARY KEY (`id`)
);

2. 秒杀优惠券表(tb_seckill_voucher)

代码语言:javascript
复制
sql

CREATE TABLE `tb_seckill_voucher` (
  `voucher_id` bigint(20) NOT NULL COMMENT '优惠券id',
  `stock` int(11) NOT NULL COMMENT '库存',
  `begin_time` datetime NOT NULL COMMENT '开始时间',
  `end_time` datetime NOT NULL COMMENT '结束时间',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE,
  PRIMARY KEY (`voucher_id`)
);

关系:一对一,tb_voucher 存基本信息,tb_seckill_voucher 存秒杀特有信息。


添加优惠券的完整流程

步骤1:接收请求参数
代码语言:javascript
复制
java

@Data
public class VoucherAddDTO {
    private String title;           // 优惠券标题
    private String subTitle;        // 副标题
    private String rules;           // 使用规则
    private Integer payValue;       // 支付金额(分)
    private Integer actualValue;    // 抵扣金额(分)
    private Integer type;           // 0-普通 1-秒杀
    
    // 秒杀特有字段
    private Integer stock;          // 库存
    private LocalDateTime beginTime;// 开始时间
    private LocalDateTime endTime;  // 结束时间
}
步骤2:Controller 层
代码语言:javascript
复制
java

@RestController
@RequestMapping("/voucher")
public class VoucherController {
    
    @Autowired
    private IVoucherService voucherService;
    
    /**
     * 添加普通优惠券
     */
    @PostMapping("/add")
    public Result addVoucher(@RequestBody Voucher voucher) {
        voucherService.save(voucher);
        return Result.ok(voucher.getId());
    }
    
    /**
     * 添加秒杀优惠券(重点)
     */
    @PostMapping("/seckill/add")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok();
    }
}
步骤3:Service 层(核心逻辑)
代码语言:javascript
复制
java

@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> 
        implements IVoucherService {
    
    @Autowired
    private SeckillVoucherMapper seckillVoucherMapper;
    
    @Override
    @Transactional  // 事务保证两张表同时成功或失败
    public void addSeckillVoucher(Voucher voucher) {
        // 1. 保存普通优惠券信息
        voucher.setType(1);  // 类型:秒杀券
        boolean saved = save(voucher);
        if (!saved) {
            throw new RuntimeException("添加优惠券失败");
        }
        
        // 2. 保存秒杀特有信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());  // 关联普通券ID
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        
        int inserted = seckillVoucherMapper.insert(seckillVoucher);
        if (inserted != 1) {
            throw new RuntimeException("添加秒杀信息失败");
        }
        
        // 3. 可选:将库存同步到 Redis(为秒杀做准备)
        String key = "seckill:stock:" + voucher.getId();
        stringRedisTemplate.opsForValue().set(key, voucher.getStock().toString());
    }
}

添加后的数据示例

tb_voucher 表

id

title

pay_value

actual_value

type

status

1001

限时5折

10000

5000

1

1

tb_seckill_voucher 表

voucher_id

stock

begin_time

end_time

1001

100

2025-04-21 10:00:00

2025-04-21 20:00:00


添加时的校验逻辑
代码语言:javascript
复制
java

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 1. 参数校验
    if (voucher.getTitle() == null || voucher.getTitle().isEmpty()) {
        throw new IllegalArgumentException("优惠券标题不能为空");
    }
    
    if (voucher.getPayValue() <= 0 || voucher.getActualValue() <= 0) {
        throw new IllegalArgumentException("金额必须大于0");
    }
    
    if (voucher.getPayValue() <= voucher.getActualValue()) {
        throw new IllegalArgumentException("支付金额必须大于抵扣金额");
    }
    
    // 2. 秒杀券特有校验
    if (voucher.getType() == 1) {
        if (voucher.getStock() == null || voucher.getStock() <= 0) {
            throw new IllegalArgumentException("秒杀券库存必须大于0");
        }
        
        LocalDateTime now = LocalDateTime.now();
        if (voucher.getBeginTime().isBefore(now)) {
            throw new IllegalArgumentException("开始时间不能早于当前时间");
        }
        
        if (voucher.getEndTime().isBefore(voucher.getBeginTime())) {
            throw new IllegalArgumentException("结束时间不能早于开始时间");
        }
    }
    
    // 3. 保存数据...
}

添加优惠券功能的核心要点

  1. 两张表配合tb_voucher(通用信息)+ tb_seckill_voucher(秒杀信息)
  2. 事务保证:@Transactional 确保两张表同时成功
  3. 参数校验:金额、库存、时间范围等
  4. Redis 预热:添加时同步库存到 Redis,为秒杀做准备
  5. 全局唯一ID:优惠券 ID 也需要用之前讲的 Redis ID 生成器


实现秒杀下单:

业务流程:

这里我们分层,在Service中书写具体逻辑:

代码语言:javascript
复制
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService iSeckillVoucherService;
    @Resource
    private RedisIdWork redisIdWork;
    /**
     * 下单秒杀优惠卷
     * @param voucherId 优惠券id
     * @return
     */
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //提交优惠卷id,查询优惠卷
        SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
        //判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
            return Result.fail("秒杀尚未开始");
        }
        //秒杀是否终止
        if (voucher.getEndTime().isBefore(LocalDateTime.now())){
            return Result.fail("秒杀已经结束");
        }
        //判断库存
        if(voucher.getStock()<1){
            return Result.fail("库存不足");
        }
        //扣减库存
        Boolean success=iSeckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id",voucherId).update();
        if (!success){
            return Result.fail("库存不足");
        }
        //创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 5.1 生成全局唯一订单ID
        long orderId = redisIdWork.nextId("order");
        voucherOrder.setId(orderId);
        // 5.2 设置用户ID(从ThreadLocal获取当前登录用户)
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        // 5.3 设置优惠券ID
        voucherOrder.setVoucherId(voucherId);
        // 5.4 设置支付状态(未支付)
        voucherOrder.setStatus(0);
        // 5.5 设置创建时间
        voucherOrder.setCreateTime(LocalDateTime.now());
        // 保存订单
        save(voucherOrder);

        // ========== 6. 返回订单ID ==========
        return Result.ok(orderId);
        
    }
}

这里写的也只是简单的逻辑,这里还有一些别的问题需要解决,我们下一章节再进行分析。

注意点

  • 查询的是 tb_seckill_voucher 表,不是 tb_voucher
  • 因为秒杀券有库存、开始/结束时间等特有字段
  • 如果传入了不存在的 voucherId,直接返回失败

注意点

  • 这里只是提前判断,快速返回,避免无效的数据库操作
  • 不能依赖这个判断防止超卖,因为高并发下会有多个线程同时通过

作用

  • 库存为0时,直接拒绝,不执行后续的数据库扣减操作
  • 减轻数据库压力

我们在这里还用了工具类reidsWork来为订单生成全局唯一ID。 为什么需要事务 扣减库存和创建订单是两个数据库操作,必须保证:

  • 要么都成功
  • 要么都失败

如果不加事务会怎样

  • 扣减库存成功,但创建订单失败 → 库存少了,订单没生成(用户损失)
  • 扣减库存失败,但创建订单成功 → 库存没变,订单多了(商家损失)

注意点

  • 事务默认只回滚 RuntimeException
  • 如果需要回滚 checked 异常,需要指定 rollbackFor

核心缺陷总结(后面进行改善)

缺陷类型

严重程度

说明

超卖风险

🔴 严重

扣库存没有加 stock > 0 条件

一人多抢

🔴 严重

同一用户可以无限次抢购

事务范围过大

🟡 中等

整个方法都在事务中,性能差

库存判断无效

🟡 中等

业务层判断在高并发下形同虚设

缺少空指针判断

🟡 中等

voucher 可能为 null

用户未登录处理

🟡 中等

UserHolder.getUser() 可能为 null

结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2026-04-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一 全局唯一ID
    • 为什么需要它
    • 核心特点
    • 常见实现方式
    • 例子
    • 整体设计思路
  • 二、具体实现方式
    • 方案1:Redis 自增 + 日期前缀(最简单)
    • 方案2:分段组合
    • 对比
  • 三、为什么用 Redis 而不是数据库自增
  • 四、Redis 方案的注意事项
  • 五、与雪花算法的对比(补充)
  • 六、总结(面试/项目回答可用)
    • 补充:
      • 一、单机环境 vs 分布式环境
      • 二、为什么需要分布式环境
      • 三、分布式环境的典型问题(难点)
      • 四、回到黑马点评的秒杀场景
  • 五、总结一句话
  • 全局唯一ID生成(具体操作):
    • 1.为什么要合并
    • 2.用一个简单例子理解
    • 3.用图示理解(16位版)
  • 4.项目中的实际代码(64位版)
    • 5.为什么左移32位
    • 6.如何拆回原来的值
    • 7.为什么要用相对时间戳
    • 8.核心要点总结
  • 生成序列号:
    • 在全局唯一ID生成中的 key
    • 举个例子
    • 为什么要设计这样的 key
    • Redis key 的设计规范(重要!)
  • Redis 自增的关键细节
    • 1. 序列号上限问题
    • 2. 高性能优化
    • 3. 持久化配置
  • 与雪花算法的对比
  • 添加优惠卷:
    • 数据库表设计
  • 添加优惠券的完整流程
    • 步骤1:接收请求参数
    • 步骤2:Controller 层
    • 步骤3:Service 层(核心逻辑)
  • 添加后的数据示例
    • tb_voucher 表
    • tb_seckill_voucher 表
    • 添加时的校验逻辑
  • 实现秒杀下单:
    • 业务流程:
  • 核心缺陷总结(后面进行改善)
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档