黑马点评-优惠券秒杀-01_redis_global_id

黑马点评优惠券秒杀一:订单 ID 为什么不能直接用数据库自增?

本文整理自我学习黑马点评 Redis 实战篇第 3 章「优惠券秒杀」时的理解。

第三章一上来没有直接讲"怎么抢券",而是先讲全局唯一 ID。刚开始我也觉得有点跳:秒杀不是应该先判断库存吗,为什么先讲 ID?后来才发现,订单 ID 是秒杀下单链路里的第一块地基。如果这块没理解,后面看到 redisIdWorker.nextId("order") 会很突兀。


1. 本文解决什么问题

这篇文章只讲一件事:

text 复制代码
黑马点评为什么要用 Redis 生成全局唯一订单 ID?

围绕这个问题,会顺带讲清:

text 复制代码
1. 为什么订单 ID 不建议直接用数据库自增。
2. RedisIdWorker 的 ID 为什么分成时间戳和序列号两部分。
3. LocalDateTime、toEpochSecond、BEGIN_TIMESTAMP 分别在干什么。
4. Redis increment 本质上对应哪条 Redis 命令。
5. timestamp << 32 | count 到底是不是"位运算魔法"。

先给一句话结论:

RedisIdWorker 的本质是:用时间戳作为 ID 的高位,用 Redis 的 INCR 自增序列作为低位,拼出一个趋势递增、全局唯一、适合分布式场景的 long 型订单 ID。


2. 为什么订单 ID 不能总依赖数据库自增

普通业务里,数据库自增 ID 很方便。

比如插入订单时,数据库自动生成:

text 复制代码
1, 2, 3, 4, 5...

但秒杀场景里,它会暴露几个问题。

2.1 规律太明显

如果订单 ID 是连续自增的,外部用户可能通过订单号推测业务量。

比如今天第一单是:

text 复制代码
100000

晚上看到订单号变成:

text 复制代码
180000

别人很容易猜出今天大概产生了多少订单。

这对真实业务来说不太合适。

2.2 分库分表后容易冲突

当订单表数据量很大时,后续可能要分库分表。

比如:

text 复制代码
tb_voucher_order_0
tb_voucher_order_1
tb_voucher_order_2

如果每张表都自己从 1 开始自增,就会出现:

text 复制代码
表 0 有 id = 1
表 1 也有 id = 1
表 2 也有 id = 1

从单表看没问题,但从整个订单系统看,ID 重复了。

所以秒杀订单需要的是:

text 复制代码
全局唯一 ID

3. 全局 ID 需要满足什么特点

全局 ID 生成器通常要满足这些要求:

text 复制代码
1. 唯一性:不同订单不能生成相同 ID。
2. 高性能:秒杀高并发下生成 ID 不能太慢。
3. 递增性:最好大致递增,方便数据库索引。
4. 安全性:不要直接暴露业务规模。
5. 分布式可用:多台服务同时生成 ID 也不能冲突。

黑马点评这里用 Redis 来生成序列号,是因为 Redis 的自增命令天然是原子的,多台服务实例访问同一个 Redis 时,也能拿到不重复的递增值。


4. RedisIdWorker 的整体结构

核心代码在 RedisIdWorker

java 复制代码
public long nextId(String keyPrefix) {
    // 1.生成时间戳
    LocalDateTime now = LocalDateTime.now();
    long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
    long timestamp = nowSecond - BEGIN_TIMESTAMP;

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

    // 3.拼接并返回
    return timestamp << COUNT_BITS | count;
}

它最终生成的 ID 由两部分组成:

text 复制代码
高位:时间戳部分
低位:Redis 自增序列号部分

可以画成这样:

text 复制代码
0 | 时间戳 31 bit | 序列号 32 bit

符号位永远是 0,所以生成的是正数。


5. 时间戳部分到底在做什么

先看这三行:

java 复制代码
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;

5.1 LocalDateTime.now() 是什么

它拿到的是当前时间对象。

比如:

text 复制代码
2026-05-14 16:30:20

注意,它现在还不是数字。

订单 ID 最终要返回 long,所以必须把时间对象转换成数值。

5.2 toEpochSecond(ZoneOffset.UTC) 是什么

它的作用是:

text 复制代码
把当前时间转换成秒级时间戳

也就是从某个固定起点到当前时间,一共过去了多少秒。

比如可以粗略理解成:

text 复制代码
2026-05-14 16:30:20 -> 1778757020

这里的数字只是示例,重点是理解:

时间对象被转换成了 long 数字。

5.3 为什么要减 BEGIN_TIMESTAMP

代码中有一个常量:

java 复制代码
private static final long BEGIN_TIMESTAMP = 1640995200L;

它可以理解为业务起始时间,大致对应:

text 复制代码
2022-01-01 00:00:00

如果直接使用从 1970 年开始算的时间戳,数字比较大。

减去业务起点后:

text 复制代码
timestamp = 当前秒级时间戳 - 业务起始秒级时间戳

它表达的意思就变成:

text 复制代码
从项目设定的起点到现在,过去了多少秒

这样数值更短,也更贴合业务。


6. 序列号部分:increment 本质就是 Redis 的 INCR

再看这两行:

java 复制代码
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
        .increment("icr:" + keyPrefix + ":" + date);

假设:

text 复制代码
keyPrefix = order
date = 2026:05:14

那么 Redis key 就是:

text 复制代码
icr:order:2026:05:14

这句 Java 代码:

java 复制代码
increment("icr:order:2026:05:14")

本质上相当于执行 Redis 命令:

redis 复制代码
INCR icr:order:2026:05:14

这一下就很清楚了。

Redis 会维护这个 key 的数字值:

text 复制代码
第一次 INCR -> 1
第二次 INCR -> 2
第三次 INCR -> 3

所以 count 的含义是:

text 复制代码
今天 order 业务生成的第几个 ID

为什么 key 里要带日期?

因为这样每天都会使用一个新的计数器:

text 复制代码
icr:order:2026:05:14
icr:order:2026:05:15
icr:order:2026:05:16

这样计数不会无限增长,也方便按天统计。


7. 为什么只靠时间戳还不够

如果 ID 只用时间戳:

text 复制代码
id = timestamp

那么同一秒内的多个请求会生成相同 ID。

比如:

text 复制代码
请求 A:16:30:20 到达
请求 B:16:30:20 到达

它们的秒级时间戳完全一样。

所以必须加一个序列号来区分同一秒内的多个请求。

这就是 Redis INCR 的作用。


8. timestamp << 32 | count 到底在干什么

代码:

java 复制代码
return timestamp << COUNT_BITS | count;

其中:

java 复制代码
private static final int COUNT_BITS = 32;

这行代码的作用不是玄学,而是:

text 复制代码
把 timestamp 放到高位,把 count 放到低位。

为了好理解,先不用 32 位,用 4 位举例。

假设:

text 复制代码
timestamp = 5
count = 3
COUNT_BITS = 4

二进制表示:

text 复制代码
5 = 0101
3 = 0011

先左移 4 位:

text 复制代码
timestamp << 4

0101 -> 0101 0000

这一步就是给低位的序列号腾位置。

再执行按位或:

text 复制代码
0101 0000
0000 0011
---------
0101 0011

最终得到一个拼接后的数字。

真实代码里只是把 4 位换成了 32 位:

text 复制代码
高位放 timestamp
低 32 位放 count

9. 完整 ID 生成流程图

#mermaid-svg-xU0zrkD7lRonFSBM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xU0zrkD7lRonFSBM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xU0zrkD7lRonFSBM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xU0zrkD7lRonFSBM .error-icon{fill:#552222;}#mermaid-svg-xU0zrkD7lRonFSBM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xU0zrkD7lRonFSBM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xU0zrkD7lRonFSBM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xU0zrkD7lRonFSBM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xU0zrkD7lRonFSBM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xU0zrkD7lRonFSBM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xU0zrkD7lRonFSBM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xU0zrkD7lRonFSBM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xU0zrkD7lRonFSBM .marker.cross{stroke:#333333;}#mermaid-svg-xU0zrkD7lRonFSBM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xU0zrkD7lRonFSBM p{margin:0;}#mermaid-svg-xU0zrkD7lRonFSBM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xU0zrkD7lRonFSBM .cluster-label text{fill:#333;}#mermaid-svg-xU0zrkD7lRonFSBM .cluster-label span{color:#333;}#mermaid-svg-xU0zrkD7lRonFSBM .cluster-label span p{background-color:transparent;}#mermaid-svg-xU0zrkD7lRonFSBM .label text,#mermaid-svg-xU0zrkD7lRonFSBM span{fill:#333;color:#333;}#mermaid-svg-xU0zrkD7lRonFSBM .node rect,#mermaid-svg-xU0zrkD7lRonFSBM .node circle,#mermaid-svg-xU0zrkD7lRonFSBM .node ellipse,#mermaid-svg-xU0zrkD7lRonFSBM .node polygon,#mermaid-svg-xU0zrkD7lRonFSBM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xU0zrkD7lRonFSBM .rough-node .label text,#mermaid-svg-xU0zrkD7lRonFSBM .node .label text,#mermaid-svg-xU0zrkD7lRonFSBM .image-shape .label,#mermaid-svg-xU0zrkD7lRonFSBM .icon-shape .label{text-anchor:middle;}#mermaid-svg-xU0zrkD7lRonFSBM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xU0zrkD7lRonFSBM .rough-node .label,#mermaid-svg-xU0zrkD7lRonFSBM .node .label,#mermaid-svg-xU0zrkD7lRonFSBM .image-shape .label,#mermaid-svg-xU0zrkD7lRonFSBM .icon-shape .label{text-align:center;}#mermaid-svg-xU0zrkD7lRonFSBM .node.clickable{cursor:pointer;}#mermaid-svg-xU0zrkD7lRonFSBM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xU0zrkD7lRonFSBM .arrowheadPath{fill:#333333;}#mermaid-svg-xU0zrkD7lRonFSBM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xU0zrkD7lRonFSBM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xU0zrkD7lRonFSBM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xU0zrkD7lRonFSBM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xU0zrkD7lRonFSBM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xU0zrkD7lRonFSBM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xU0zrkD7lRonFSBM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xU0zrkD7lRonFSBM .cluster text{fill:#333;}#mermaid-svg-xU0zrkD7lRonFSBM .cluster span{color:#333;}#mermaid-svg-xU0zrkD7lRonFSBM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xU0zrkD7lRonFSBM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xU0zrkD7lRonFSBM rect.text{fill:none;stroke-width:0;}#mermaid-svg-xU0zrkD7lRonFSBM .icon-shape,#mermaid-svg-xU0zrkD7lRonFSBM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xU0zrkD7lRonFSBM .icon-shape p,#mermaid-svg-xU0zrkD7lRonFSBM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xU0zrkD7lRonFSBM .icon-shape .label rect,#mermaid-svg-xU0zrkD7lRonFSBM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xU0zrkD7lRonFSBM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xU0zrkD7lRonFSBM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xU0zrkD7lRonFSBM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调用 nextId(order)
获取当前时间 LocalDateTime.now
toEpochSecond 转成秒级时间戳
减去 BEGIN_TIMESTAMP 得到 timestamp
格式化日期 yyyy:MM:dd
Redis INCR icr:order:日期
得到当天自增序列 count
timestamp 左移 32 位
按位或拼接 count
返回 long 类型订单 ID


10. 为什么这个 ID 既快又唯一

它唯一,靠的是两层保证:

text 复制代码
1. 不同时间生成的 ID,timestamp 不同。
2. 同一时间生成的 ID,Redis INCR 返回的 count 不同。

它适合分布式,靠的是:

text 复制代码
多台服务实例访问同一个 Redis,INCR 自增序列仍然全局唯一。

它比较快,靠的是:

text 复制代码
生成 ID 不需要先插入订单表,也不依赖数据库自增。

11. 易错点

1. token 和订单 ID 不是一回事

token 是登录凭证。

订单 ID 是订单主键。

它们都可能是字符串或数字,但业务含义完全不同。

2. Redis 自增不是 Java 本地变量自增

increment() 操作的是 Redis 里的 key,本质对应:

redis 复制代码
INCR key

多台服务访问同一个 Redis 时,拿到的是同一个计数器。

3. << 32 不是加密

它只是把时间戳移动到高位,给序列号腾出低 32 位空间。

4. 减 BEGIN_TIMESTAMP 不是为了保证唯一

唯一性主要靠:

text 复制代码
timestamp + count

BEGIN_TIMESTAMP 主要是为了缩短时间戳部分,让 ID 设计更紧凑。


12. 面试怎么回答

如果面试官问:为什么不用数据库自增 ID?

可以回答:

数据库自增 ID 规律明显,容易暴露业务量;而且后续分库分表后,各表自增 ID 可能冲突。秒杀订单属于高并发写入场景,更适合使用全局 ID 生成器提前生成订单号。

如果面试官问:RedisIdWorker 怎么生成全局唯一 ID?

可以回答:

它把 ID 拆成两部分:高位是从业务起始时间到当前时间经过的秒数,低位是 Redis 按天维护的自增序列号。Redis 自增通过 INCR icr:order:日期 实现,保证同一天同业务下序列号不重复。最后通过左移和按位或把时间戳和序列号拼成一个 long。

如果面试官问:为什么 Redis INCR 能保证并发下不重复?

可以回答:

Redis 单条命令执行具有原子性。多个线程或多台服务同时执行 INCR,Redis 会按顺序处理,每次返回不同的递增值,因此可以作为分布式环境下的序列号来源。


13. 总结

这一节的重点不是背位运算,而是理解订单 ID 的设计思路。

秒杀下单前要先有订单 ID,是因为订单创建、异步下单、消息队列传递都需要一个稳定的订单号。

RedisIdWorker 的核心可以压缩成一句话:

时间戳负责让 ID 大致递增,Redis INCR 负责让同一时间内的并发请求不重复,位运算负责把两部分拼成一个 long。

理解完这一节,后面看到:

java 复制代码
long orderId = redisIdWorker.nextId("order");

就不会再觉得它是凭空冒出来的工具类了。

相关推荐
牧羊狼的狼15 小时前
MySQL 提升SQL查询性能的全套实战优化方法
数据库·sql·mysql
weixin_4896900215 小时前
企业微信 PC 端本地数据库结构中的巧妙设计
数据库·oracle·企业微信
运维行者_21 小时前
Applications Manager中的Redis监控
大数据·服务器·数据库·人工智能·网络协议
悦数图数据库1 天前
图数据库选型指南 2026:从架构、性能、AI 适配三个维度看 悦数科技
数据库·人工智能·架构
handler011 天前
【MySQL】常用命令总结(库与表增删查改)
运维·数据库·mysql·命令·总结
week@eight1 天前
Linux - Doris
linux·运维·数据库·mysql
cdbqss11 天前
VB2026 菜单生成基类 BqGetMenuStrip
数据库·经验分享·学习·oracle·vb
洛水水1 天前
Redis 分布式锁详解:实现与缺陷
数据库·redis·分布式