号段模式这个词,最近在技术社区里讨论度很高,尤其聊到分布式ID生成的时候,它几乎总跟雪花算法、UUID放在一起对比。我第一次正经用上它,是做订单中台那阵子:单量从一天几十万涨到几百万,数据库自增扛不住,UUID生成的订单号又长又乱,最后切到号段模式加双Buffer预加载,才算把发号这件事彻底做稳了。这篇文章不绕弯子,直接把号段模式的核心代码实现拆开讲:数据库表怎么设计、取号接口怎么写、内存发号器怎么实现、双Buffer预加载的完整逻辑,以及我在生产环境里踩过的几个坑。无论你是正要搭发号系统,还是准备把现有ID方案换成号段模式,都可以照着一份实现走。
1. 号段模式解决的痛点:为什么数据库自增和UUID都撑不住
先说清楚一个前提:不是所有系统都需要专门的发号器。如果单量几百一天,数据库自增绰绰有余。真正需要上号段模式的,是那些对ID有硬性要求、又扛不住常规方案缺点的业务。
1.1 业务对主键ID的四个要求
我做了十几年后端,总结下来,业务侧对ID的需求通常就四条:
- 全局唯一:跨库、跨实例、分库分表之后也不能撞车。
- 趋势递增:不要求严格连续,但大体上要往上走。这样InnoDB聚簇索引不会频繁页分裂,查询和写入性能都稳。
- 高吞吐:下单、支付、创建会话这类接口,QPS动不动上千上万,发号器本身不能成为瓶颈。
- 长度可控、可读性尚可:用户看到的订单号、流水号最好是一串不太长的数字,而不是一坨35位的乱码。
1.2 常规方案的短板
数据库自增主键的问题,分库分表之后最先暴露:多个分片各自自增,主键必然冲突。你当然可以用"Redis INCR + 步长"或者"设置不同分片不同起始值"来绕,但本质还是在跟单点死磕。更麻烦的是,每次插入都要先拿一个ID,一次一条SQL,数量一上来,主库压力全花在发号上了。
UUID的问题正好反过来:它不需要任何中心协调,生成性能也高,但完全不有序。拿UUID当主键,InnoDB聚簇索引要不停做随机插入,页分裂频繁,写入性能明显下滑;而且UUID存成字符串,占的空间大,索引也大,订单号给用户看更是灾难。
雪花算法是目前最常见的折中方案:64位,纯数字,带时间戳,趋势递增。但它的痛点也很直接——依赖机器时钟,发生时钟回拨可能导致重复ID;还需要单独管理workerId的分配。另外雪花生成的ID虽然递增,但不是连续数字,用户看到的订单号是一串带随机位的大整数,观感上不如"第10086号"这种连续号舒服。
提示:如果你做的是订单、单据、流水这类业务,用户关注的是"号连着发、看起来正常",那么号段模式生成的连续数字ID,天然比雪花ID更贴合场景。
2. 号段模式的取号模型:一张表、一条UPDATE、一个步长
号段模式的核心思想,一句话就能说清:不要一条条地向数据库要ID,而是一次性拿一整段ID回内存,慢慢发。
2.1 用"取号机"理解号段模式
想象一下政务大厅里的自助取号机,普通人是按一下出一个号;号段模式相当于有个人直接抱走一沓票,放在自己口袋里慢慢撕。他手里的票发完了,再去抱一沓。数据库就是那个放票的箱子,箱子只记录一件事:票已经发到多少号了。
放到真实系统里,这个"箱子"就是一张表,每行表示一类业务。表里最关键的三列是:
max_id:这个业务已经发放到的最大ID。step:每次从数据库取多少ID,也就是号段长度。version:乐观锁版本号,用来防止并发取号冲突。
2.2 表结构与初始化SQL
我的生产环境里用的是这么一张表:
CREATE TABLE `segment_generator` ( `biz_tag` varchar(64) NOT NULL COMMENT '业务标识,如 order / user / coupon', `max_id` bigint NOT NULL DEFAULT 0 COMMENT '当前已分配的最大ID', `step` int NOT NULL DEFAULT 1000 COMMENT '每次取号的号段长度', `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新+1', `description` varchar(255) DEFAULT NULL COMMENT '业务备注', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`biz_tag`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号段发号器配置表';注意主键是biz_tag,不是自增ID。因为每个业务一行,直接用业务标识定位。初始化一条订单号段数据:
INSERT INTO segment_generator(biz_tag, max_id, step, version, description) VALUES ('order', 0, 1000, 0, '订单ID');这行的含义是:订单号还没发过,每次取1000个,当前版本0。发号器的所有状态,本质上就是这一行记录,非常轻量。
2.3 取号的过程与边界语义
取号的过程分三步:
- 按照
biz_tag查出当前那行记录,拿到max_id和version。 - 计算出这个号段的范围:最小值是
max_id + 1,最大值是max_id + step,闭区间,整个号段长度正好是step。 - 执行一条带乐观锁条件的UPDATE,把
max_id推到新位置,version加1。如果影响行数为1,说明没人和你抢,号段归你了;如果影响行数为0,说明有其他实例先更新了,重新读一遍再试。
这里有个细节值得多说一句:为什么不用SELECT ... FOR UPDATE行锁,而要用乐观锁CAS?因为SELECT ... FOR UPDATE需要在数据库连接上持有锁,直到整个事务提交,期间如果业务要处理号段、做缓存、做异步加载,事务时间会被拉得很长,数据库锁等待的几率直线上升。CAS的方式把锁的持有时间压缩到一条UPDATE语句,做完立刻释放,并发友好得多。
3. 核心代码实现:从Mapper到内存发号器
下面这段实现,是我在项目里实际跑过的版本,去掉了和业务无关的部分,保留了核心链路。技术栈是Java + MyBatis,但思路迁到Go、PHP、C#都完全一样。
3.1 数据访问层:实体和Mapper
先定义实体类,对应表结构:
public class SegmentRow { private String bizTag; private long maxId; private int step; private int version; // getter / setter 略 }Mapper接口,用注解SQL演示,逻辑一目了然:
@Mapper public interface SegmentGeneratorMapper { @Select("SELECT biz_tag, max_id, step, version " + "FROM segment_generator WHERE biz_tag = #{bizTag}") SegmentRow selectByBizTag(@Param("bizTag") String bizTag); @Update("UPDATE segment_generator " + "SET max_id = #{newMaxId}, version = version + 1 " + "WHERE biz_tag = #{bizTag} AND version = #{oldVersion}") int compareAndSetMaxId(@Param("bizTag") String bizTag, @Param("newMaxId") long newMaxId, @Param("oldVersion") int oldVersion); @Insert("INSERT INTO segment_generator(biz_tag, max_id, step, version, description) " + "VALUES(#{bizTag}, 0, #{step}, 0, #{description})") int insertTag(@Param("bizTag") String bizTag, @Param("step") int step, @Param("description") String description); }三个方法都设计得很克制:查、CAS更新、初始化插入。查询走主键,更新走主键+版本号条件,插入只在首次使用时执行一次。
3.2 取号服务:CAS + 重试 + 初始化兜底
核心服务类是发号器的"数据库游标",职责只有一个:从数据库拿一个区间。
@Service public class SegmentStoreService { @Autowired private SegmentGeneratorMapper mapper; private static final int MAX_RETRY = 5; private static final int DEFAULT_STEP = 1000; /** * 从数据库取一个号段,返回闭区间 [min, max] */ public SegmentRange fetchNextRange(String bizTag) { for (int attempt = 0; attempt < MAX_RETRY; attempt++) { SegmentRow row = mapper.selectByBizTag(bizTag); if (row == null) { // 首次使用,初始化一行 try { mapper.insertTag(bizTag, DEFAULT_STEP, bizTag); } catch (DuplicateKeyException e) { // 被并发实例抢先初始化了,继续循环读取 } continue; } long min = row.getMaxId() + 1; long max = row.getMaxId() + row.getStep(); int affected = mapper.compareAndSetMaxId(bizTag, max, row.getVersion()); if (affected == 1) { return new SegmentRange(min, max, row.getStep()); } // CAS 失败,说明有人抢先领走了这个区间,自旋重试 } throw new IllegalStateException("号段获取失败, bizTag=" + bizTag); } }SegmentRange就是一个简单的不可变对象:
public class SegmentRange { private final long min; private final long max; private final int step; public SegmentRange(long min, long max, int step) { this.min = min; this.max = max; this.step = step; } // getter 略 }这里有两个设计细节,新手容易忽略。
第一个是初始化竞争的处理。多个实例同时启动,可能同时对同一个biz_tag执行INSERT,主键冲突是必然事件。我直接捕获DuplicateKeyException当作"别人已经建好了",然后回到循环开头重新查询。不要试图在这个位置加分布式锁,没必要,主键冲突本身已经是最好的并发控制。
第二个是重试上限。理论上CAS失败之后重新查、重新更新,肯定能成功,但数据库万一持续抖动,无限重试会拖垮调用线程。设一个MAX_RETRY = 5,超过就抛异常,宁可接口报错也不能死循环。
3.3 内存号段对象
数据库取到区间之后,剩下的活全靠内存对象干。Segment代表一个号段:
public class Segment { private final long min; // 号段最小值(包含) private final long max; // 号段最大值(包含) private final int step; private final AtomicLong cursor = new AtomicLong(); public Segment(long min, long max, int step) { this.min = min; this.max = max; this.step = step; this.cursor.set(min - 1); } /** * 返回下一个ID;号段耗尽返回 -1 */ public long next() { long current = cursor.incrementAndGet(); return current <= max ? current : -1; } /** * 已消耗比例,范围 0.0 ~ 1.0 以上 */ public double usage() { long used = cursor.get() - (min - 1); long total = max - (min - 1); return (double) used / total; } }注意cursor的初值是min - 1。比如数据库返回区间[1001, 2000],那cursor从1000起步,第一次incrementAndGet()拿到1001,第1000次拿到2000。凡是超过max,就返回-1作为"号段耗尽"的信号。
usage()方法看起来不起眼,但它是双Buffer预加载的触发依据,后面第4节会用到。
3.4 内存双Buffer:号段的切换和预加载
接下来是号段模式真正的精华部分:SegmentBuffer。它同时持有两个Segment,一个正在发号,一个负责预加载,两个交替使用。
public class SegmentBuffer { private final String bizTag; private final SegmentStoreService storeService; private final Segment[] segments = new Segment[2]; private volatile int currentIndex = 0; // 当前发号位置 private volatile boolean nextReady = false; // 备用号段是否已就绪 private final Object switchLock = new Object(); private volatile boolean loading = false; // 防止重复触发加载 public SegmentBuffer(String bizTag, SegmentStoreService storeService) { this.bizTag = bizTag; this.storeService = storeService; } /** * 初始化:同步加载两个号段 */ public synchronized void initialize() { if (segments[0] != null) { return; } SegmentRange first = storeService.fetchNextRange(bizTag); segments[0] = new Segment(first.getMin(), first.getMax(), first.getStep()); SegmentRange second = storeService.fetchNextRange(bizTag); segments[1] = new Segment(second.getMin(), second.getMax(), second.getStep()); currentIndex = 0; nextReady = true; } /** * 获取下一个ID */ public long nextId() { if (shouldTriggerPreload()) { asyncLoadNext(); } long id = segments[currentIndex].next(); if (id >= 0) { return id; } return switchAndNext(); } private boolean shouldTriggerPreload() { Segment current = segments[currentIndex]; return current != null && current.usage() >= 0.8 && !loading && !nextReady; } private void asyncLoadNext() { SegmentLoadExecutor.submit(() -> { synchronized (switchLock) { if (loading || nextReady) { return; } loading = true; try { SegmentRange range = storeService.fetchNextRange(bizTag); segments[1 - currentIndex] = new Segment(range.getMin(), range.getMax(), range.getStep()); nextReady = true; } finally { loading = false; } } }); } private long switchAndNext() { synchronized (switchLock) { if (!nextReady) { // 异步加载失败或来不及,降级为同步加载 SegmentRange range = storeService.fetchNextRange(bizTag); segments[1 - currentIndex] = new Segment(range.getMin(), range.getMax(), range.getStep()); nextReady = true; } currentIndex = 1 - currentIndex; nextReady = false; return segments[currentIndex].next(); } } }初始化时一次性同步加载两个号段,保证服务刚启动就有满额缓冲。正常运行时,当前号段用到80%就触发了异步预加载,把备用号段准备好。等到当前号段真正耗尽,代码走switchAndNext(),把currentIndex翻个面,整个过程几乎感觉不到数据库的存在。
这里asyncLoadNext()用synchronized (switchLock)和loading双保险,是为了防止高并发下多个线程同时发现"该预加载了",一股脑往线程池里塞重复任务。loading标志位让后续触发者直接放弃,只有第一个触发者真正干活。
3.5 门面类与线程池
对外暴露一个简单的门面,按bizTag管理多个SegmentBuffer:
@Component public class SegmentIdGenerator { private final Map<String, SegmentBuffer> bufferMap = new ConcurrentHashMap<>(); @Autowired private SegmentStoreService storeService; public long nextId(String bizTag) { SegmentBuffer buffer = bufferMap.computeIfAbsent(bizTag, key -> { SegmentBuffer buf = new SegmentBuffer(key, storeService); buf.initialize(); return buf; }); return buffer.nextId(); } }预加载用的小线程池,配置如下:
public class SegmentLoadExecutor { private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("segment-load-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() ); public static void submit(Runnable task) { EXECUTOR.submit(task); } }线程数不用多,因为预加载的频率很低,每个业务号段生命周期里最多触发几次。CallerRunsPolicy很关键:线程池满了就让调用线程自己跑,宁可牺牲一点当前性能,也不能把预加载请求丢掉。
调用方式也极其简单,业务方一行代码搞定:
@Autowired private SegmentIdGenerator idGenerator; public Order createOrder() { Order order = new Order(); order.setOrderId(idGenerator.nextId("order")); // ... }4. 双Buffer预加载机制:让数据库从请求链路里"隐形"
很多博客把双Buffer说得很玄乎,其实它解决的就是一个问题:不要在请求正忙的时候让发号器去查数据库。
4.1 没有双Buffer会发生什么
假设只有一个号段,且没有预加载。当前号段发完,下一个请求必须同步去数据库取新号段。一次查询加一次更新,正常耗时10~20毫秒。听起来不长,但在5000 QPS的接口里,20毫秒的阻塞足以让调用线程堆积、超时、乃至拖垮整个服务。
双Buffer的思路是:快用完的时候,在后台偷偷把下一段取好。等当前段真正耗尽,切换是纯粹的CPU内存操作,数据库全程不出现在请求的同步路径上。
4.2 预加载触发阈值的量化计算
预加载阈值不是拍脑袋定的,它取决于三个变量:号段长度step、当前QPS、数据库单次取号的耗时latency。核心不等式是:
(1 - 阈值) × step ≥ QPS × latency左边的含义是:从触发预加载到号段耗尽的这段时间里,还能扛住多少个请求;右边是异步取号需要的时间窗口内会来多少个请求。左边必须大于右边,预加载才不会在路上被耗尽。
举个例子:QPS=5000,数据库取号latency=20ms,阈值取0.8。那么:
(1 - 0.8) × step ≥ 5000 × 0.02 0.2 × step ≥ 100 step ≥ 500也就是说step只要不小于500就稳。但实际生产里我通常会把step调到远大于这个下限,为了扛数据库抖动和GC导致的延时毛刺。一个比较省心的经验值:让一个号段至少撑1分钟,即step = 60 × QPS。还是5000 QPS的话,step直接给30万,数据库取号的频率降到每分钟一次,压力微乎其微。
4.3 触发点放到80%而不是100%
代码里阈值定在0.8,是因为预留出来的20%空间就是刚才公式里的"安全余量"。等到100%再触发,留给异步加载的时间就是零,必然走同步降级,体验会差。80%是常规选择,如果你对数据库延迟特别敏感,可以提到85%,余量更大,但相应地号段空洞也更大。这个取舍没有绝对标准,按业务容忍度来。
4.4 步长自适应调整:应对流量突增
固定step有一个尴尬场景:平时QPS只有几百,某天活动流量突然十倍上涨,固定步长导致取号频率飙升,数据库压力变大。可以加一个简单的自适应逻辑:每次从数据库取回号段时,统计一下这个号段消耗的速率,如果消耗过快,就把表里的step调大。
// 简化版:动态调整步长 if (consumeSpeed > step / 30) { // 30秒内就会消耗完 int newStep = (int) Math.min((long) consumeSpeed * 120, MAX_STEP); mapper.updateStep(bizTag, newStep); }对应SQL:
UPDATE segment_generator SET step = #{newStep} WHERE biz_tag = #{bizTag};这里有个坑:**步长只能影响下一次取号,不能影响已经取出来的号段。**所以动态调步长要赶在当前号段还剩不少余量的时候提前做,否则等你发现流量大了再调,下一次取号还要等一个号段周期。我踩过这个坑,后来把步长调整的逻辑也放进了异步预加载里,预加载触发的同时检查消耗速度,一起处理。
提示:步长不要无上限地翻倍。号段越大,服务重启或异常崩溃时丢掉的未使用ID就越多。我一般设置上限10万,防止出现"一次拿10万个号,回头重启全部作废"的情况。
5. 生产环境里真正会踩的坑:并发竞争、惊群效应和号段空洞
双Buffer只是解决了"数据库不在同步路径里",但生产环境远比代码逻辑复杂。下面这几个坑,是我在线上真刀真枪踩过的,每一个都值得单独拿出来说。
5.1 号段耗尽瞬间的"同步降级惊群"
理论上80%就触发预加载,同步降级几乎不会出现。但数据库抖动、网络延迟、线程池队列满,这些小概率事件叠加起来,同步降级就是真实存在的兜底路径。
惊群的场景是这样的:当前号段已经耗尽,一瞬间几十个线程都进了switchAndNext()。好在方法里有synchronized (switchLock),只会有一个线程进去做同步加载,其余线程在锁外面排队。等锁释放,它们挨个进来发现nextReady=true,直接切换发号,不会重复打数据库。相比之前见过的一些无锁实现,这里必须加锁,否则几十个线程同时发现"备用号段没就绪",一起向数据库发起取号,数据库会被瞬间打满。
但有一个衍生问题:如果同步加载遇到了数据库超时,switchAndNext()会抛出异常,排队的几十个线程会同时拿到异常,接口直接雪崩。应对办法是在门面层做异常兜底:
public long nextIdWithFallback(String bizTag) { try { return nextId(bizTag); } catch (Exception e) { // 降级方案:临时用时间戳+随机数生成不冲突的ID // 或者在本地生成一个临时号段,等DB恢复后再切换回来 return System.currentTimeMillis() * 1000 + ThreadLocalRandom.current().nextInt(1000); } }这个兜底ID虽然不那么规整,但能保证接口不挂。恢复之后号段模式的ID会继续沿用数据库里的max_id,两边不会冲突,因为异常时并没有成功从数据库取号,max_id没有前进。
5.2 多实例部署时,每个实例都有一份号段,重启必然丢号段
双Buffer模式下,每个服务实例都从数据库取过号段。假设三个实例各取了一个步长1万的号段,手里一共攥着3万个未发完的ID。这时候一个实例重启,它手里那最多1万个ID就永远消失了,数据库里的max_id已经推进到更高的位置,不会再回头补。
所以号段模式天然会产生空洞,空洞大小上限是"实例数 × 步长"。订单号10086后面直接跳成20001,这是常态,不是bug。如果业务要求严格无空洞,比如发票号码、票据流水号,号段模式就不合适。
我见过团队在这个问题上纠结很久,最后用了个折中:把步长调小到几百,控制空洞上限,但代价是数据库取号频率变高。还是那句话,取舍要按业务来,大部分订单号、流水号场景是能接受空洞的。
5.3 初始化时的主键竞争,属于"看起来危险、其实很安全"
前文提到初始化用DuplicateKeyException兜底。这里把完整链路再说透一点:两个实例同时初始化user这个biz_tag,一个INSERT成功,另一个必然主键冲突。冲突的实例捕获异常后回到循环重新SELECT,此时能看到对方插入的行,继续正常取号。整个过程不需要额外加锁,也不需要INSERT ... ON DUPLICATE KEY,因为我们要的不是"覆盖",而是"唯一初始化"。
5.4 数据库热点行与连接池
号段模式的UPDATE永远落在同一个biz_tag的行上,从数据库的角度看,这是一个热点行。正常情况下问题不大,因为取号频率低,行锁持有时间只有一条UPDATE那么短。但有两个情况下热点会放大:
一是步长太小。曾见过有团队把步长设为100,结果QPS一上来,每秒钟就要更新十几次这一行,行锁竞争直接拉高数据库CPU。
二是事务里混入多余操作。有人会把"查号段+更新号段"放进同一个事务,中间还夹了业务逻辑,事务时间拉长,行锁释放变慢。正确的做法是让取号操作处于一个独立、短小、自动提交的事务里,业务逻辑一律放到锁外面。
5.5 可观测性:这几个指标必须盯
发号器平时安静,出问题往往是一瞬间的事,所以监控尤其重要。我在生产环境里维护的指标有:
- 当前号段已用比例:超过90%且备用号段未就绪,立即告警,说明预加载链路出了问题。
- 同步降级次数:理论上应该为0,一旦大于0,说明预加载没有及时完成。
- 数据库取号耗时:超过100ms持续告警,多半是数据库连接池撑不住或行锁争抢。
- 号段空洞监控:通过
max_id和实际发出的最大ID差值,能反推有多少号段在内存里没有发出去,差值异常偏大说明某些实例长期挂着没干活,或者是历史遗留的大步长没收敛。
这些指标用Micrometer或者Prometheus客户端暴露出来,十几行代码的事,但关键时刻能救命。
6. 号段模式、雪花算法与UUID的选型账本
写到这里,把号段模式放在整个ID生成方案里做个横向比较,方便你判断到底该选谁。
| 方案 | 全局唯一 | 趋势递增 | 生成性能 | 外部依赖 | 时钟敏感 | 空洞 | 适用场景 |
|---|---|---|---|---|---|---|---|
| 数据库自增 | 单库内唯一 | 严格递增 | 低,每次打DB | 数据库 | 无 | 无 | 单库低并发,主键 |
| UUID | 几乎不重复 | 无序 | 极高 | 无 | 无 | 无 | 日志ID、文件ID、对有序无要求的字段 |
| 雪花算法 | 依赖workerId | 趋势递增 | 极高 | 机器ID管理 | 高 | 有 | 需要从ID里解析时间戳的场景 |
| 号段模式 | 依赖DB协调 | 连续递增 | 极高(内存发号) | 数据库(低频) | 无 | 有 | 订单号、流水号、对连续数字有偏好的业务 |
我的选型建议很直接:
- 对日志traceId、对象key、消息ID这类只看唯一性、不关心顺序的,直接用UUID,或者压缩版UUID,别给自己找麻烦。
- 需要从ID里还原时间戳做分析、排序的,用雪花算法,但要做好workerId管理和时钟回拨监控。
- 业务是订单号、账单号、会员号,用户和运营都希望号是整齐连续数字的,选号段模式最合适。它的生成性能在内存里,数据库只做低频协调,是所有方案里把"高吞吐"和"有序"平衡得最好的。
- 也可以按业务混用:我在项目里就是订单号走号段模式,内部日志和异步消息走雪花,文件的路径key用UUID。发号方案不是只能选一个,按场景切分反而最省心。
最后说一个我个人的实操建议:刚开始接入号段模式,别一上来就上双Buffer和异步预加载。先把表建好,把SegmentStoreService和单Segment的代码跑通,压测确认基本逻辑无误;再加双Buffer预加载,观察同步降级次数归零;最后再上动态步长和监控。一步一步来,每一步都验证充分,这套方案就能稳稳地托管你所有的业务ID。