day34 全局唯一ID与优惠券秒杀

day34 全局唯一ID与优惠券秒杀

一、全局唯一ID的需求背景

1.1 优惠券秒杀场景

优惠券秒杀场景下,用户抢购时会生成订单并保存到 tb_voucher_order 表。如果直接使用数据库自增 ID 作为订单号,存在安全性和扩展性问题,因此需要一个全局唯一 ID 生成器来生成订单号。

项目中 VoucherOrder 实体类的主键使用 @TableId(type = IdType.INPUT),表示 ID 由应用层手动赋值,不依赖数据库自增:

1
2
3
4
5
6
@TableName("tb_voucher_order")
public class VoucherOrder implements Serializable {
@TableId(value = "id", type = IdType.INPUT)
private Long id;
// ...
}

1.2 数据库自增ID的问题

问题 说明
规律性强 ID 连续递增,容易暴露订单量,不安全,不适合作为订单号
单表数量限制 数据库单表数据量不宜过大,分表存储时自增 ID 无法保证全局唯一

二、Redis实现全局唯一ID

2.1 ID结构设计

使用 64 位整型数值存储,Java 中对应 long 类型。ID 由三部分组成:

1
1 bit 符号位 | 31 bit 时间戳 | 32 bit 序列号
部分 位数 说明
符号位 1 bit 固定为 0,保证 ID 始终为正数
时间戳 31 bit 以秒为单位,当前秒数减去指定起始时间的秒数,可用约 69 年
序列号 32 bit 使用 Redis 自增数值实现,支持每秒产生 2^32 个不同 ID

最终 ID 的拼接方式:时间戳左移 32 位,再与序列号做按位或运算(timestamp << 32 | count)。

注意:这里使用的是按位或(|),不是按位与(&)。时间戳左移 32 位后低 32 位全为 0,只有按位或才能将序列号填入低位;如果做按位与,结果永远为 0。

2.2 项目中的实现

项目中 RedisIdWorker 类实现了上述方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
public class RedisIdWorker {

private final long START_TIMESTAMP = 1767225600;

private StringRedisTemplate stringRedisTemplate;

public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

public long nextId(String prefix) {
// 获取秒级时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - START_TIMESTAMP;

// 生成序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue().increment("icr:" + prefix + ":" + date);

// 返回 ID
return timestamp << 32 | count;
}
}

关键说明:

  • START_TIMESTAMP = 1767225600,即 LocalDateTime.of(2026,1,1,0,0,0).toEpochSecond(ZoneOffset.UTC) 的计算结果,作为起始时间戳。讲义中使用的是 1640995200(2022-01-01),项目根据自己的起始日期做了调整。
  • Redis key 格式为 icr:{prefix}:{yyyy:MM:dd},按天分片自增。例如生成订单 ID 时 prefix 为 "order",key 为 icr:order:2026:09:10。
  • 序列号按天自增,每天重新从 1 开始计数,因为 key 中包含了日期。
  • prefix 参数用于区分不同业务(如订单、用户等),避免不同业务的序列号互相影响。

计算起始时间戳的辅助方法(项目中已注释保留):

1
2
3
4
public static void main(String[] args) {
long epochSecond = LocalDateTime.of(2026, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC);
System.out.println(epochSecond); // 输出 1767225600
}

2.3 使用方式

在需要生成全局唯一 ID 的地方注入 RedisIdWorker,调用 nextId 方法:

1
long orderId = redisIdWorker.nextId("order");

当前项目中 VoucherOrderController 的秒杀接口尚未实现具体逻辑,返回的是占位结果:

1
2
3
4
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return Result.fail("功能未完成");
}

三、LocalDateTime与时区问题

3.1 问题分析

RedisIdWorker 中使用 LocalDateTime.now() 获取当前时间,再通过 toEpochSecond(ZoneOffset.UTC) 转换为秒级时间戳。由于运行环境的系统默认时区是北京时间(UTC+8),而转换时指定的是 UTC 偏移,这里存在一个需要理解的时间差。

3.2 深入分析

  1. LocalDateTime.now() 拿到的是北京时间墙上读数,不带时区信息,比真实 UTC 多 8 小时。
  2. LocalDateTime 本身没有时区,toEpochSecond(offset) 必须指定偏移量,含义是”把这串本地读数当作该偏移下的时间”去算时间戳。
  3. now.toEpochSecond(ZoneOffset.UTC) 是把北京时间当作 UTC 时间,算出的时间戳比真实 UTC 多了 8 小时。
  4. now.toEpochSecond(ZoneOffset.ofHours(8)) 才是真实的当前 UTC 时间戳。
  5. 关键结论:求”现在到起始时间”的差值时,只要起始时间和当前时间使用同一个偏移转成时间戳再相减,偏移量会抵消,所以用 UTC 或 +08:00 结果一样。
  6. 项目中 START_TIMESTAMP 名义上是 UTC 2026 元旦的时间戳,但 now 也按 UTC 转换,两者相减后,实际等于北京时间 2026 元旦到现在的秒数。
  7. date 变量使用 now(北京读数)格式化,所以 Redis key 按北京日期分片。

注意:起始时间与当前时间必须使用同一个 ZoneOffset 来转秒数时间戳,否则差值会有偏差。


四、CountDownLatch

4.1 作用与使用场景

CountDownLatch 名为信号枪,主要作用是同步协调多线程的等待与唤醒:让一个或多个线程等待其他一组线程完成某些操作后再继续执行。

典型场景:在测试 RedisIdWorker 的并发性能时,主线程需要等待所有子线程执行完毕后再统计总耗时。如果没有 CountDownLatch,由于线程池异步执行,主线程可能在子线程之前结束。

4.2 核心方法

方法 作用
await() 阻塞当前线程,直到计数器归零;如果已经是 0,立即返回
countDown() 计数器减 1;减到 0 时唤醒所有等待线程

4.3 在ID性能测试中的应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);

Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};

long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}

流程说明:

  1. 创建 CountDownLatch(300),计数器初始为 300
  2. 提交 300 个任务到线程池,每个任务生成 100 个 ID
  3. 每个任务执行完后调用 countDown(),计数器减 1
  4. 主线程调用 await() 阻塞等待,直到计数器归零
  5. 所有子线程完成后,主线程继续执行,计算总耗时

五、其他全局ID生成方案

5.1 UUID

  • 优点:本地生成,不依赖外部服务
  • 缺点:无法保持递增趋势,是字符串类型,不便于数据库索引和存储

5.2 雪花算法(Snowflake)

与 Redis 自增方案思路类似,都是时间戳 + 序列号的结构,但由本地生成。

ID 结构:

1
0 | 41 位时间戳 | 10 位机器 ID | 12 位序列号(同一毫秒的序列号)
特点 说明
趋势递增 long 数值类型,按时间递增
本地生成 不依赖 Redis,性能非常高
时钟回拨风险 序列号是毫秒级序列号,每毫秒重置且仅 4096 个(2^12),时间回拨极有可能与同一机器码生成的 ID 重复
需维护机器 ID 10 位机器 ID 需要分配和管理
不绝对递增 趋势递增但不保证绝对递增

与 Redis 方案对比:Redis 方案依赖 Redis 的 INCR 命令保证序列号自增,不需要机器 ID,也不受时钟回拨影响;雪花算法本地生成,性能更高,但需要解决机器 ID 分配和时钟回拨问题。


今日总结

模块 核心要点
全局唯一ID需求 优惠券秒杀场景下,数据库自增 ID 存在规律性强和分表无法保证唯一的缺点
Redis实现方案 1 符号位 + 31 时间戳 + 32 序列号,时间戳左移 32 位与序列号做按位或,序列号通过 Redis INCR 按天自增
时区问题 起始时间与当前时间用同一 ZoneOffset 转换后相减,偏移抵消,结果不受影响
CountDownLatch 用于多线程等待同步,await() 阻塞等待,countDown() 计数减 1,归零后唤醒
雪花算法 41 时间戳 + 10 机器 ID + 12 序列号,本地生成性能高,但有时钟回拨和机器 ID 维护问题