黑马点评优惠券秒杀一:订单 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");
就不会再觉得它是凭空冒出来的工具类了。