我接手这类需求不是第一回了,业务方最初的表述往往是:“我们要一套全局统一的订单号,高并发下不能重复,而且要能看出先后顺序。”听上去毫无歧义,可等真正落到架构设计阶段就会发现,“能看出先后顺序”至少有三种理解,而每一种对底层方案的要求差着数量级。SequenceGenerator 这个中间件设计的起点,就是把“有序”这个词彻底拆开,否则后面所有并发模型、存储选型、容灾方案都建立在沙子上。
这篇文章会把 SequenceGenerator 的完整架构设计思路整理出来。它面向的是对分布式主键、序列号生成、中间件设计有兴趣的后端开发和架构师;如果你正准备设计一个高并发发号器,或者想理解号段模式为什么能同时兼顾性能和一定的有序性,这篇内容应该能直接给你一张可复用的设计地图。
1. 先给“有序”下定义:三种语义决定架构走向
1.1 全局严格递增:听起来简单,代价可能远超想象
所谓全局严格递增,通常指业务上任意两次取号,后取到的数值一定大于先取到的数值,且理想情况还不允许跳号。这种语义对账务、票据、单据编号等场景有吸引力,但它隐含了一个非常苛刻的前提:必须有一个全局仲裁者对所有取号请求进行串行排序。
只要存在多台应用服务器并发发号,哪怕每个人都只发一个号,这个全局仲裁者都会成为单点。更麻烦的是,如果要求“严格连续、不跳号”,那号码就不能提前预分配,因为任何一段预留在客户端内存里的号码,都可能因为实例宕机永久丢失,从而产生空洞。想让崩溃后也不产生空洞,就必须引入事务性发放、号码回拨、故障恢复后重新分配废弃段等复杂机制,复杂度基本等同于自己做一个带事务能力的数据库。
所以架构设计的第一个结论是:全局严格递增 + 高并发 + 高可用,三者本质上是互相冲突的。业务方如果没有经过计算就说要这种语义,架构师的第一责任不是立刻拍胸脯,而是把这条约束的代价说清楚。
1.2 趋势递增:绝大多数业务真正需要的语义
真正被高频使用的语义,其实是趋势递增。它的意思是,后分配的号段在数值上整体大于先分配的号段,但在并发场景下,不同实例之间正在生成的号码,并不会有严格的先后全序。
举个直观例子:实例 A 拿到 [1, 1000] 这一段,实例 B 拿到 [1001, 2000] 这一段。A 发到 800 的时候,B 可能才发到 1010,从生成时间看 1010 晚于 800,但数值上 1010 大于 800,整体趋势仍然是递增的。即便极端情况下出现某个时间点 A 发出的 999 和 B 发出的 1001 在业务侧几乎同时落库,只要落库顺序和业务处理顺序不依赖 ID 数值本身,业务也不会受任何影响。
趋势递增在工程上的最大价值,是可以把一次性大量号段预分配给多个实例,实例内部用原子变量自行发号,实例与实例之间只有在申请下一批号段时才需要触碰中心存储。这就把高频分配变成了低频申请,是高并发发号器最核心的破局思路。
1.3 分区内有序:另一种容易被忽视的隐藏需求
还有一类系统,要求的不是全局有序,而是同一业务维度内部有序,例如同一用户的操作流水号、同一设备的事件序号、同一会话里的消息序号。这类需求对应的 ID 生成策略,甚至不需要一个独立的高吞吐中间件,只需要把 seq_key 设计成“用户ID + 场景名”,让同一维度的序列在同一行状态上串行取号即可。
这个结论在需求澄清阶段特别重要。SequenceGenerator 后来把核心发号接口设计成 nextId(seqKey) 而不是简单的 nextId(),就是因为真实业务场景里,不同 seqKey 的序列根本不需要共享同一个递增基线。把它们设计成独立隔离的行,既能避免全局热点,又能让一个组件天然支持分区内有序。
1.4 非功能指标必须先于实现定稿
除了有序语义,还有四个非功能指标必须在写代码前达成一致:并发量峰值、P99 时延、可用性目标、以及故障时是否允许跳号。这四个指标最终汇总成一张“可接受降级清单”。SequenceGenerator 的设计底线定的是:唯一性绝不能破,已分配号段绝不重复,中心存储故障时允许业务短暂取不到号或出现段空洞,但绝不能通过放宽唯一性来换取可用性。这套约束直接决定了后面的整体架构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 备选发号方案实测对比:号段模式为什么胜出
2.1 单库自增与 Redis 自增的真实瓶颈
很多系统最初都用数据库自增主键。把 last_value 存在 MySQL 单表里,每次 INSERT 后拿自增 ID,实现最直接。问题在于所有写请求都打在同一张表、甚至同一行上,事务提交、行锁竞争、binlog 同步都会成为瓶颈。单实例 MySQL 在普通配置下,这类热点行的吞吐大概只到每秒千级到万级,距离“高并发”差得很远。
另一种常见做法是 Redis INCR。Redis 单实例确实能扛住十万级每秒的 INCR,性能不错。但 Redis 的问题在于持久化语义不够稳,默认 RDB 快照可能丢数据,AOF 在断电时也可能丢最后一小段写操作。对于发号器来说,一旦出现“发的号回退”,系统里已经落库的旧数据就可能和新数据撞主键,这是非常难处理的线上事故。只要不能接受编号回退,Redis 就不能作为唯一事实源使用。
2.2 雪花类方案的问题不在性能,而在语义
雪花算法是讨论高并发 ID 时绕不开的方案,它把 64 位拆成时间戳、机器 ID、序列号三段,完全在本地生成,不依赖中心存储,性能极高。SequenceGenerator 在早期调研时也仔细评估过它。雪花类方案最大的问题是 ID 的有序性很弱,它只保证单机内序列号近似按时间递增,跨机、跨时钟域之间没有排序保证。
真正劝退的还不是性能或有序性,而是运维侧的两个麻烦。一是时钟回拨,机器如果做了 NTP 校时,ID 生成器必须每秒都判断时钟有没有倒退,否则会生成已经出现过的重复 ID 或导致乱序。二是机器 ID 的分配问题,引入 ZooKeeper、数据库或独立注册中心来管理机器 ID,本身又是一个需要高可用的组件。如果团队没有长期运营中间件的经验,雪花方案埋下的坑会在上线后慢慢显现。
2.3 号段模式:用一次低频串行换高频并行
号段模式的设计思路可以用一句话概括:让中心每次分配一小段连续号码给某个客户端实例,实例在本地消费完后再来取下一段。
中心数据库仍然保存每个序列的当前值,但中心被访问的频率被大幅降低。假设每个号段包含 1000 个号,那么业务每发出 1000 个 ID,才需要访问一次中心。号段模式的巧妙之处在于:中心只有在发放号段时才必须串行,发放完成之后,大量业务请求在客户端本地用 AtomicLong 并发分配,完全不触碰热点行。这就是用低频的中心串行换取高频的本地并行。
2.4 方案对比总表
| 方案 | 典型中心吞吐瓶颈 | 有序性 | 持久化风险 | 实现复杂度 | 是否适合作为高并发中间件核心 |
|---|---|---|---|---|---|
| MySQL 自增 | 热点行写入,约千级 TPS | 严格 | 依赖主库 | 低 | 否 |
| Redis INCR | 单实例约十万 INCR | 严格但会因故障回退 | 存在丢写可能 | 低 | 否 |
| 雪花算法 | 无中心瓶颈 | 弱趋势 | 依赖机器时钟 | 中高 | 可做辅助方案 |
| 号段模式 | DB 只需承载低频段申请 | 可配置的趋势/分区有序 | 段起点持久化后不可回退 | 中 | 是 |
结论并不复杂。SequenceGenerator 最终以号段模式为主发号链路,同时为有内部时间排序需求的用户保留一个可选的雪花风格 ID 模式,两者共用同一个 SDK 和数据模型,只是最终号码的拼接规则不同。这个决策是在理解了有序语义后自然落地的。
3. SequenceGenerator 整体架构与一次取号的完整旅程
3.1 三个角色:SDK、分配服务与状态存储
SequenceGenerator 整体由三部分组成:嵌入业务服务的客户端 SDK、独立的中心分配服务、以及后端的数据库状态存储。
客户端 SDK 的核心是 SegmentBuffer,它负责在本地缓存号段、并发分配并自动触发下一段预加载。分配服务本身是无状态的,它接收 SDK 的号段申请请求,用事务操作数据库中的 sequence_allocator 表,把一段新的连续区间返回给 SDK。数据库是唯一的事实源,存的是每个 seqKey 当前已经分配到的最大值。
业务方接入时并不需要感知分配服务内部的细节,只需要通过 SDK 调用 nextId("orderSeq") 或批量版 nextIds("orderSeq", 100)。SDK 会优先从本地缓冲返回号码,只有缓冲不足时才同步或异步请求分配服务。
3.2 一次 nextId 请求的真实路径
一次完整取号的路径大致如下:
- 业务线程调用
client.nextId(seqKey)。 - SDK 按
seqKey找到对应的SegmentBuffer对象,判断当前段的剩余量。 - 如果当前段仍有余量,则通过本地原子变量递增并返回号码,整个过程无网络 IO。
- 如果当前段余量低于预取阈值,SDK 立即唤醒后台预取线程,自身则继续使用当前剩余号码,而不是阻塞等待。
- 后台预取线程向分配服务发送申请,尝试获取下一段号码。
- 分配服务在事务内更新数据库行,获取新的区间,返回给 SDK。
- SDK 将新区间放入备用缓冲,等待当前段耗尽后切换使用。
这套流程保证了业务线程绝大多数请求只做一次内存递增,真正访问数据库的请求被挪到了异步预取路径上。
3.3 双段缓冲如何把同步申请从关键路径上移除
如果只设计一个缓冲,号码用完后必须同步等待下一段从数据库返回,在高并发下,每一次远端等待都会直接拉高 P99 甚至引发线程阻塞雪崩。SequenceGenerator 采用双段缓冲,当前段和备用段各占一个槽位,当前段剩余量一旦降到阈值,就立刻异步加载备用段;当前段完全耗尽时,直接把备用段切换为当前段,并让原当前段槽位进入加载状态。
双段缓冲的本质,是把发号链路里唯一的同步等待点从业务关键路径上剥离。即使分配服务因网络抖动慢了 50 毫秒,只要备用段还没耗尽,业务线程就毫无感知。这个设计和 Kafka 客户端批量拉取消息、连接池预创建连接,背后的思路完全一致。
4. 关键模块的精读:缓冲器、分配器与可观测性
4.1 Client 端 SegmentBuffer 的正确写法
SegmentBuffer 的设计有几个容易被忽略的细节。首先,号码分配必须用无锁原子变量,否则多线程竞争会让发号本身成为瓶颈。核心结构可以简化成下面这样:
java复制public class Segment {
private final long start;
private final long step;
private final AtomicLong index = new AtomicLong(0);
public long nextId() {
long cur = index.getAndIncrement();
if (cur < step) {
return start + cur;
}
return -1L; // 当前段耗尽
}
}
每个 Segment 持有 start 和 step,内部用 AtomicLong 从 0 递增。线程取号时通过 CAS 语义竞争,index.getAndIncrement() 能保证并发安全,只要返回值小于 step,就能得到唯一且不重复的号码。
其次,段耗尽边界附近的处理比正常路径更重要。多线程场景下,会有不少线程同时发现当前段已经耗尽,这时不能直接抛异常,也不能无限自旋。常见的处理是把这些线程统一阻塞在一个 CountDownLatch 或 Lock 条件变量上,等待备用段加载完成后再唤醒;如果备用段加载失败,再根据配置决定抛出异常还是返回一个可重试信号。
我在实际编码时建议给 SegmentBuffer 增加一个状态字段,区分 RUNNING、SWITCHING、LOADING、FAILED。这样既能精确控制并发切换,也让监控埋点有据可依。
4.2 Server 端分配器的事务与并发细节
分配服务向外提供的核心能力是“申请下一段”,它与数据库的一次交互看起来很短,但事务边界稍有偏差,就会产生重复发号或丢失更新。
sequence_allocator 表的设计非常简单:
sql复制CREATE TABLE sequence_allocator (
seq_key VARCHAR(128) PRIMARY KEY,
last_value BIGINT NOT NULL DEFAULT 0,
step INT NOT NULL DEFAULT 1000,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB;
核心发段 SQL 逻辑封装在一个事务里:
sql复制BEGIN;
SELECT last_value
FROM sequence_allocator
WHERE seq_key = 'orderSeq'
FOR UPDATE;
-- 应用层计算出 newStart = last_value + 1, newEnd = last_value + step
UPDATE sequence_allocator
SET last_value = last_value + #{step}
WHERE seq_key = 'orderSeq';
COMMIT;
SELECT ... FOR UPDATE 的意义在于把同一行上的并发申请变成串行。假设两个分配服务实例同时收到申请,二者都会尝试锁同一行,只有一个能进入事务,另一个必须等待。这样最终每个实例拿到的区间的起点都是前一个区间终点加一,从数据库事实源视角看,号码段绝不重叠。
还有一个必须处理的场景是接入方第一次使用某个新 seqKey,对应行还不在表里。需要在事务前做一次初始化:
sql复制INSERT INTO sequence_allocator (seq_key, last_value, step)
VALUES ('orderSeq', 0, 1000)
ON DUPLICATE KEY UPDATE seq_key = seq_key;
ON DUPLICATE KEY UPDATE 在这里只是为了避免主键冲突报错,真正的更新逻辑仍然由后续事务完成。这段初始化必须与后续 FOR UPDATE 分开但保持幂等。
4.3 监控埋点清单与告警阈值建议
发号器属于最容易埋雷的基础组件,它一旦故障,所有接入方业务同时被卡住。因此可观测性设计不是可选项,而是必须从第一版就写进去。
| 监控位置 | 指标 | 作用 | 建议经验阈值 |
|---|---|---|---|
| SDK | 当前段剩余量 | 判断是否可能即将阻塞 | 低于 20% 时预取线程应已启动 |
| SDK | 取号 P99 时延 | 反映本地分配是否正常 | 排除网络后应稳定低于 1ms |
| 分配服务 | 段申请 QPS | 评估中心压力 | 配合 DB 事务耗时观察 |
| 分配服务 | DB 事务失败率 | 发现锁等待或主库抖动 | 超过 0.1% 必须告警 |
| 数据库 | sequence_allocator 行锁等待 | 判断热点竞争 | 单 key 行锁等待持续超过 100ms 需要关注 |
| 数据库 | segment_log 增长速率 | 审计与对账数据量 | 按保留周期设置归档 |
我可以分享一个经验:只盯着分配服务本身的 QPS 是不够的,因为客户端本地 ID 发放量远比段申请量大。真正需要重点盯的是 SDK 上报的剩余量,以及段申请成功率。只要每个 SDK 节点的剩余量都能在段耗尽前被补足,业务侧就不会感知到中心故障。
5. 高可用目标下的防重设计
5.1 让分配服务变成无状态节点
很多中间件一谈到高可用就自然想到要做“选主”,但 SequenceGenerator 的主链路并不需要传统意义上的主从选举。核心原因是所有发号状态都持久化在数据库里,分配服务本身不持有任何不可丢失的内存状态。
分配服务节点可以水平扩展,每个节点都直接访问同一个数据库主库。谁先抢到行锁,谁就分配下一段号码;节点宕机后,其他节点会继续接到申请,不存在某个节点死了就没人能发号的情况。这种设计的运维模型很简单:分配服务节点只是无状态的数据库代理,不需要互相感知,也不需要同步心跳。
真正需要保障的是数据库本身的高可用。数据库主库故障时,需要切换到一个没有丢失已提交事务的从库,否则从库的 last_value 可能小于主库故障前已分配给客户的区间终点,导致再次分配时产生重叠号。我的建议是使用半同步复制,确保日志在返回客户端成功前至少到达一个从库。
5.2 事务持久化先于返回,是防重的最后一道防线
最容易出问题的环节是分配服务返回超时。客户端 SDK 发出段申请后没有收到响应,通常会重试,如果第一次请求其实已经在数据库提交成功但响应丢失,重试得到的新区间起点已经前移,这样虽然不会重复,但会浪费第一个区间没有被任何人使用,产生空洞。
这属于至少一次语义下无法完全避免的现象。真正绝对不能接受的,是第一次请求还没提交就把号码返回给客户端。为了避免这类问题,SequenceGenerator 严格要求一个完整段区间只有在数据库事务提交后才会随响应返回给 SDK。这条纪律需要靠代码审查和测试保障,不能指望某个线程碰巧不犯错。
5.3 故障窗口的容量计算与降级策略
高可用设计最后要回答一个现实问题:数据库故障后,业务还能撑多久?
答案取决于所有客户端当前缓存号段的总剩余量。假设有 100 个业务实例,每个实例备用段和当前段合计缓存了 2000 个号,事务平均每秒消耗 5000 个号,那么全系统在无法申请新段的情况下,大约还能支撑 100 * 2000 / 5000 = 40 秒。这个时间窗口足够触发告警并让运维介入。
因此 SequenceGenerator 的降级策略是分层的:
- 数据库主库抖动但未宕机:分配服务自动重试,SDK 不感知。
- 主库宕机但已有备用段未耗尽:SDK 持续本地发号,暂停新增段申请。
- 所有待消费号段耗尽:SDK 返回排队等待或快速失败,由接入业务决定是否降级切换到备用发号通道。
我建议接入方提前给自己的业务系统约定一个快速失败标准。发号器牺牲可用性,远比发放重复号码造成脏数据后在数据链路里反复排查要安全得多。
5.4 区间日志对账:把不可见的状态变可见
每次分配服务成功发放一个号段,都会在同一事务内写入一条 segment_log:
sql复制INSERT INTO segment_log (seq_key, start_value, end_value, assign_server, created_at)
VALUES ('orderSeq', #{start}, #{end}, #{serverIp}, NOW());
这张日志表平时看起来是冗余,却是排查问题时最可靠的现场证据。它可以回答三个问题:某个时间段内号码分配到了哪里、是否存在相邻区间重叠、某个异常区间是否来自特定分配服务节点。对账任务可以定期扫描这张表,检查 start_value 是否等于上一条记录的 end_value + 1。一旦发现不连续,说明中间存在空洞或数据损坏,告警开启人工复核。
6. 参数配置、压测执行与容量规划方法
6.1 关键配置项说明
一套默认参数跑天下是不现实的。SequenceGenerator 把最影响行为的参数都开放出来,接入方需要根据自身业务特征调整。
| 参数 | 默认值 | 说明 | 调优建议 |
|---|---|---|---|
segment-step |
1000 | 每次申请多少连续号码 | 峰值越高或实例数越多,适当调大 |
prefetch-threshold |
20% | 当前段剩余比例触发预取 | 网络不稳定时调高到 30%-40% |
switch-state-wait |
3000ms | 切换备用段等待上限 | 超过则按配置抛异常或返回空 |
max-retry |
3 | 分配服务申请段的重试次数 | 适合网络抖动频繁的机房内环境 |
enable-time-sort-id |
false | 是否启用带时间位的雪花风格 | 取决于业务是否依赖 ID 近似时间排序 |
段大小是整个系统最核心的旋钮。段太大会让客户端一次囤积大量号码,实例重启后浪费严重,同时也会让号码在不同实例间的分布颗粒度过粗;段太大会导致分配服务被频繁访问,数据库压力上升。建议按“峰值每秒消耗量 × 5 到 10 秒”来估算一个初始段大小,再在压测中调整。
6.2 一份内部压测报告怎么读
这里给出一份我们验收环境的参考口径,硬件配置是 3 台分配服务节点均为 8C16G,数据库为 SSD 上的 MySQL 8.0,客户端使用 100 个线程并发取号。模拟结果大致如下:
- 中心 DB 段申请 TPS 稳定
