

本文介绍了在分布式环境下生成全局唯一ID的必要性及实现方案。 文章首先分析了秒杀场景中数据库自增ID的不足,提出了Redis自增+时间戳的解决方案,详细讲解了位运算合并时间戳和序列号的技术原理。 重点阐述了黑马点评项目采用的Redis自增策略,包括三种实现方式对比、核心代码示例及性能优化建议。 同时,文章还探讨了分布式环境的特点、面临的问题,以及优惠券秒杀业务的数据表设计和下单流程实现。 最后指出了当前方案存在的超卖、并发控制等缺陷,为后续优化改进奠定了基础。
在整个系统(分布式环境) 中,为每一张成功抢购到的订单、或每一次秒杀记录,生成一个绝对不会重复的唯一标识符。
秒杀业务中,数据库自增 ID 不够用:
INCR key 每天一个 key(如 order:20250421)
假设你秒杀一张优惠券成功,生成的订单 ID 不是:
712345678912345678(全局唯一、趋势递增、无规律可推测)
全局唯一 ID 主要是为了避免超卖时订单 ID 冲突,并支持后续分库分表。
目标:生成一个全局唯一、趋势递增的 64 位长整型 ID。
核心公式:
全局唯一ID = 时间戳(高位) + 序列号(低位)存储结构(常见 64 位分配):
位段 | 长度 | 说明 |
|---|---|---|
符号位 | 1 bit | 固定 0(正数) |
时间戳 | 31 bit | 秒级 / 毫秒级(可支撑数年) |
序列号 | 32 bit | 同一秒内最大 42 亿+ |
为了简化,通常采用 Redis 自增 + 日期前缀 或 分段组合 方式。
java
// 每天一个 key
String key = "order:id:" + LocalDate.now();
Long id = redisTemplate.opsForValue().increment(key);生成的 ID 示例:
缺点:ID 会超过 64 位(前面是日期数字),不适合作为数据库主键(推荐用 Long)。
结构:
31位时间戳差值(秒) + 32位自增序列步骤:
INCR 自增(可每天重置)
示例代码(简化逻辑):
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 方案 |
|---|---|---|
全局唯一 | ❌ 分库分表会冲突 | ✅ 集中生成 |
性能 | 低(每次写磁盘) | 高(内存操作) |
趋势递增 | ✅ | ✅(按时间) |
高并发 | 有瓶颈 | 单线程 + 管道可到 10w+ QPS |
依赖 | 数据库 | Redis(可集群) |
INCRBY 批量取号,减少网络开销
特性 | Redis 方案 | 雪花算法(Snowflake) |
|---|---|---|
依赖 | Redis(外部中间件) | 无(本地生成) |
时钟回拨 | 依赖 Redis 时间 | 需自行处理 |
高可用 | Redis 集群 | 无中心化 |
复杂程度 | 简单 | 中(需分配机器ID) |
优先推荐 Redis 方案,因为项目本身已集成 Redis,简单可靠。
基于 Redis 的全局唯一 ID 生成策略: 采用 时间戳差值(高位) + 自增序列(低位) 的方式,通过 Redis 的
INCR命令每天重置序列号,保证 ID 在分布式环境下唯一且趋势递增。相比数据库自增,性能更高、支持分库分表;相比雪花算法,实现更简单、无时钟回拨风险(依赖 Redis 时间)。
分布式环境,通俗来说就是:
一个任务由多台计算机(服务器)共同协作完成,这些计算机通过网络通信,对外看起来像一台超级计算机。
为了更好地理解,我们先看它的反面——单机环境。
对比项 | 单机环境 | 分布式环境 |
|---|---|---|
硬件 | 1台服务器 | 多台服务器(可能成百上千台) |
处理能力 | 受限于单机 CPU、内存 | 可横向扩展(加机器) |
故障影响 | 机器挂了 → 服务全挂 | 个别机器挂了 → 服务仍可用 |
数据存储 | 一个数据库 | 分库分表、多副本 |
典型场景 | 个人博客、小型网站 | 双11秒杀、微信、淘宝 |
举个例子:
随着用户量和数据量增长,单机遇到瓶颈:
典型例子:
分布式不是简单地加机器,会引入新问题:
问题 | 说明 | 例子 |
|---|---|---|
全局唯一ID | 每台机器独立生成ID会重复 | 订单号冲突 |
分布式锁 | 多台机器抢同一资源 | 超卖(同一商品被两台机器同时卖出) |
数据一致性 | 多副本数据可能不一致 | 刚支付的订单查不到 |
分布式事务 | 跨多个数据库的操作要原子性 | 转账(扣A账户 + 加B账户) |
服务发现 | 机器动态上下线,如何知道谁活着 | 某台服务器宕机,请求不能再发给它 |
链路追踪 | 一个请求经过十几台机器,如何排查故障 | 用户反馈下单慢,不知道卡在哪台机器 |
为什么需要全局唯一ID?
text
服务器A:订单1、订单2、订单3...
服务器B:订单1、订单2、订单3... ← 重复了!
服务器C:订单1、订单2、订单3... ← 重复了!解决方案:
这就是为什么在分布式环境下,需要特殊的“全局唯一ID生成策略”。
分布式环境 = 多台计算机通过网络协作,共同完成一个任务。 它带来了高性能、高可用、可扩展的好处,但也引入了全局ID、分布式锁、数据一致性等新挑战。

间戳和序列号是怎么通过位运算合并成一个64位整数的,这是全局唯一ID生成中最关键的一步。
我们有两个数字:
1765440000(很大,约31位)
5(很小,只需要几位)
不能直接相加,因为:
text
1765440000 + 5 = 1765440005 ← 这样会破坏结构,无法拆回原来的两个数需要用位运算,把两个数字拼接到不同位置,互不干扰。
假设我们用一个 16位的整数 来模拟(实际是64位):
text
高8位存放时间戳差值,低8位存放序列号步骤演示:
步骤 | 操作 | 二进制 | 十进制 |
|---|---|---|---|
1. 时间戳差值 = 10 | 左移8位 | 00001010 00000000 | 2560 |
2. 序列号 = 3 | 不移动 | 00000000 00000011 | 3 |
3. 按位或合并 | 00001010 00000011 | 2563 |
最终结果 2563 怎么拆回去
2563 >> 8 = 10(得到时间戳)
2563 & 0xFF = 3(得到序列号)
两个数字被"塞"进了同一个整数,互不干扰。
text
时间戳 = 10 → 二进制:00001010
序列号 = 3 → 二进制:00000011
第一步:时间戳左移8位
00001010 00000000 (时间戳占用了高8位,低8位全是0)
第二步:序列号不动
00000000 00000011 (序列号只在低8位)
第三步:按位或(合并)
00001010 00000011 ← 最终ID
高8位↑ ↑低8位
(时间戳) (序列号)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
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就是这个超大整数因为
sequence最大可能是多少?
2^32 - 1 = 4,294,967,295(约42亿)
所以:
java
// 从最终ID中拆出时间戳
long timestamp = id >> 32; // 右移32位,抛弃低32位
// 从最终ID中拆出序列号
long sequence = id & 0xFFFFFFFFL; // 按位与,保留低32位假设我们直接用当前时间戳:
java
long now = System.currentTimeMillis() / 1000; // 当前秒级时间戳
long id = (now << 32) | sequence;问题来了:
当前时间戳大约是 1,765,440,000(2025年),左移32位后:
text
1,765,440,000 × 2^32 = 7,581,000,000,000,000,000(约 7.58 × 10^18)这个数字已经接近64位整数的上限(9.22 × 10^18)。
如果系统运行几年:
1,890,000,000 → 左移后更大
位运算 | 作用 | 例子 |
|---|---|---|
<< n | 左移,空出低位 | 10 << 8 = 2560 |
| | 按位或,合并 | 2560 | 3 = 2563 |
>> n | 右移,取出高位 | 2563 >> 8 = 10 |
& 掩码 | 按位与,取出低位 | 2563 & 0xFF = 3 |
一句话理解:
左移就像把数字往"高位房间"推,留出"低位房间"给另一个数字,然后用按位或把两个房间拼在一起。
在黑马点评的优惠券秒杀场景中,基于 Redis 自增 ID 策略 是最核心、最常用的全局唯一 ID 生成方式。
我们用构造器注入redis
构造函数注入是更规范的写法,但不是必须的写法。如果你看到代码里有构造函数,那通常是作者遵循了 Spring 的最佳实践。我们当然也可以用@Autowrid(可能拼的不对)谅解
private StringRedisTemplate stringRedisTemplate;
public RedisIdWork(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}回到刚才的代码:
java
String key = "icr:" + keyPrefix + ":" + date;
Long sequence = redisTemplate.opsForValue().increment(key);这个 key 的作用:记录某一天、某种业务的当前序列号是多少。
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重新开始1. 区分不同业务
java
nextId("order") → key = "icr:order:2025-04-21"
nextId("coupon") → key = "icr:coupon:2025-04-21"订单和优惠券的序列号各自独立,互不干扰。
2. 每天重置
java
String date = LocalDate.now().toString(); // 每天变化
4月21日的 key:icr:order:2025-04-21
4月22日的 key:icr:order:2025-04-22这样每天的序列号都从 1 开始,不会无限增长。
3. 方便管理
bash
# 查看今天生成了多少个订单ID
GET icr:order:2025-04-21
# 删除昨天的key(释放内存)
DEL icr:order:2025-04-20在实际项目中,key 通常用 冒号 : 分隔,形成层级结构:
java
// 格式:业务:子业务:参数
"order:detail:1001" // 订单详情
"user:info:888" // 用户信息
"icr:order:2025-04-21" // 自增计数器
"lock:seckill:100" // 分布式锁好处:
KEYS user:* 查所有用户
调用 nextId("order")
↓
生成 key = "icr:order:2025-04-21"
↓
Redis.INCR("icr:order:2025-04-21")
↓
第一次调用:key 不存在 → 创建并设为 1
第二次调用:key 存在 → 自增为 2
第三次调用: → 自增为 3
↓
返回 sequence = 3
↓
与时间戳合并 → 最终 IDjava
// 32位最大 4,294,967,295(约42亿)
// 如果一天超过42亿次自增怎么办?解决方案:
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;
}yaml
# Redis 配置(保证 ID 不丢失)
save 900 1 # 15分钟至少1次修改则RDB
appendonly yes # 开启AOF
appendfsync everysec # 每秒刷盘对比项 | Redis自增策略 | 雪花算法 |
|---|---|---|
依赖 | Redis(外部) | 无(本地生成) |
性能 | 单次 ~0.1ms | ~0.01ms |
高可用 | Redis集群 | 无中心化 |
时钟回拨 | 不涉及 | 需处理 |
实现复杂度 | 简单 | 中等 |
适用场景 | 已用Redis的项目 | 极致性能需求 |
1. 优惠券表(tb_voucher)
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)
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 存秒杀特有信息。
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; // 结束时间
}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();
}
}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());
}
}id | title | pay_value | actual_value | type | status |
|---|---|---|---|---|---|
1001 | 限时5折 | 10000 | 5000 | 1 | 1 |
voucher_id | stock | begin_time | end_time |
|---|---|---|---|
1001 | 100 | 2025-04-21 10:00:00 | 2025-04-21 20:00:00 |
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. 保存数据...
}添加优惠券功能的核心要点:
tb_voucher(通用信息)+ tb_seckill_voucher(秒杀信息)

这里我们分层,在Service中书写具体逻辑:
@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
注意点:
作用:
我们在这里还用了工具类reidsWork来为订单生成全局唯一ID。 为什么需要事务 扣减库存和创建订单是两个数据库操作,必须保证:
如果不加事务会怎样
注意点:
RuntimeException
rollbackFor
缺陷类型 | 严重程度 | 说明 |
|---|---|---|
超卖风险 | 🔴 严重 | 扣库存没有加 stock > 0 条件 |
一人多抢 | 🔴 严重 | 同一用户可以无限次抢购 |
事务范围过大 | 🟡 中等 | 整个方法都在事务中,性能差 |
库存判断无效 | 🟡 中等 | 业务层判断在高并发下形同虚设 |
缺少空指针判断 | 🟡 中等 | voucher 可能为 null |
用户未登录处理 | 🟡 中等 | UserHolder.getUser() 可能为 null |
结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!
