day34 全局唯一ID与优惠券秒杀
一、全局唯一ID的需求背景
1.1 优惠券秒杀场景
优惠券秒杀场景下,用户抢购时会生成订单并保存到 tb_voucher_order 表。如果直接使用数据库自增 ID 作为订单号,存在安全性和扩展性问题,因此需要一个全局唯一 ID 生成器来生成订单号。
项目中 VoucherOrder 实体类的主键使用 @TableId(type = IdType.INPUT),表示 ID 由应用层手动赋值,不依赖数据库自增:
1 |
|
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 |
|
关键说明:
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 | public static void main(String[] args) { |
2.3 使用方式
在需要生成全局唯一 ID 的地方注入 RedisIdWorker,调用 nextId 方法:
1 | long orderId = redisIdWorker.nextId("order"); |
当前项目中 VoucherOrderController 的秒杀接口尚未实现具体逻辑,返回的是占位结果:
1 |
|
三、LocalDateTime与时区问题
3.1 问题分析
RedisIdWorker 中使用 LocalDateTime.now() 获取当前时间,再通过 toEpochSecond(ZoneOffset.UTC) 转换为秒级时间戳。由于运行环境的系统默认时区是北京时间(UTC+8),而转换时指定的是 UTC 偏移,这里存在一个需要理解的时间差。
3.2 深入分析
LocalDateTime.now()拿到的是北京时间墙上读数,不带时区信息,比真实 UTC 多 8 小时。LocalDateTime本身没有时区,toEpochSecond(offset)必须指定偏移量,含义是”把这串本地读数当作该偏移下的时间”去算时间戳。now.toEpochSecond(ZoneOffset.UTC)是把北京时间当作 UTC 时间,算出的时间戳比真实 UTC 多了 8 小时。now.toEpochSecond(ZoneOffset.ofHours(8))才是真实的当前 UTC 时间戳。- 关键结论:求”现在到起始时间”的差值时,只要起始时间和当前时间使用同一个偏移转成时间戳再相减,偏移量会抵消,所以用 UTC 或 +08:00 结果一样。
- 项目中
START_TIMESTAMP名义上是 UTC 2026 元旦的时间戳,但now也按 UTC 转换,两者相减后,实际等于北京时间 2026 元旦到现在的秒数。 date变量使用now(北京读数)格式化,所以 Redis key 按北京日期分片。
注意:起始时间与当前时间必须使用同一个
ZoneOffset来转秒数时间戳,否则差值会有偏差。
四、CountDownLatch
4.1 作用与使用场景
CountDownLatch 名为信号枪,主要作用是同步协调多线程的等待与唤醒:让一个或多个线程等待其他一组线程完成某些操作后再继续执行。
典型场景:在测试 RedisIdWorker 的并发性能时,主线程需要等待所有子线程执行完毕后再统计总耗时。如果没有 CountDownLatch,由于线程池异步执行,主线程可能在子线程之前结束。
4.2 核心方法
| 方法 | 作用 |
|---|---|
await() |
阻塞当前线程,直到计数器归零;如果已经是 0,立即返回 |
countDown() |
计数器减 1;减到 0 时唤醒所有等待线程 |
4.3 在ID性能测试中的应用
1 |
|
流程说明:
- 创建
CountDownLatch(300),计数器初始为 300 - 提交 300 个任务到线程池,每个任务生成 100 个 ID
- 每个任务执行完后调用
countDown(),计数器减 1 - 主线程调用
await()阻塞等待,直到计数器归零 - 所有子线程完成后,主线程继续执行,计算总耗时
五、其他全局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 维护问题 |