门店排队叫号、工单编号、订单号这类业务,看似只是"生成一个递增数字",真到了多台收银设备、多个门店同时取号的高峰期,重号、跳号、依赖单点的问题就会一起冒出来。用数据库自增主键会把所有取号压力压到一个点上,用时间戳拼接又会在时钟回拨、同毫秒并发时撞号。这篇拆解一套可落地的分布式发号方案:为什么会重号、号段模式怎么把发号压力摊开、Redis 出问题时如何降级到门店本地号段,文末附方案对照表和排查清单。
数据库自增:实现最简单,但每次取号都是一次写操作,高并发下自增锁成为瓶颈,且强依赖主库,主库故障就无法取号,跨门店、跨地域部署时尤其被动。
UUID / 随机串:全局唯一没问题,但它无序、超长,不能当给顾客看的排队号,作为聚簇索引主键还会导致索引频繁分裂。
时间戳拼接:用"时间 + 机器号 + 序列号"能做出趋势递增的号,但依赖时钟准确,服务器发生 NTP 时钟回拨时可能生成比之前更小甚至重复的号,同毫秒内的并发还要靠序列号兜住,实现并不简单。
叫号场景的真实诉求其实是:号在"同一个门店、同一业务日"内不重且大体递增即可,允许全局不连续,也不要求跨门店绝对连续——这个前提一明确,号段模式就成了更稳妥、综合代价更低的选择。
核心思路是不让应用每次都去问"下一个号是几",而是一次性从发号中心申领一整段号码(比如 1000 个),保存在应用内存里慢慢发,发完再申领下一段。
数据库里只需要一张号段分配表,记录当前发到的最大值和步长:
CREATE TABLE seq_segment (
biz_tag VARCHAR(64) PRIMARY KEY COMMENT '业务标识,如门店+日期',
max_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前已分配到的最大值',
step INT NOT NULL DEFAULT 1000 COMMENT '每次申领的步长',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
update_time DATETIME
);申领下一段时,用一条更新把 max_id 向前推进一个步长,推进成功就拿到了"旧 max_id+1 到新 max_id"这段区间的所有权:
UPDATE seq_segment
SET max_id = max_id + step, version = version + 1, update_time = NOW()
WHERE biz_tag = #{bizTag} AND version = #{version};应用侧的发号器维护当前号段的起始值、结束值和一个原子计数器:
public class SegmentIssuer {
private final AtomicLong current = new AtomicLong(0);
private volatile long max;
private static final long STEP = 1000;
public synchronized long nextId() {
long v = current.incrementAndGet();
if (v > max) {
// 当前号段用完,申领下一段
Segment seg = loadNextSegment(STEP);
current.set(seg.getStart());
max = seg.getEnd();
v = current.get();
}
return v;
}
}这样一来,真正访问发号中心的频率从"每号一次"降到"每千号一次",数据库压力骤降,而内存里的 AtomicLong 发号是纳秒级的,完全扛得住高峰。
上面的写法有个小瑕疵:当号段恰好耗尽、同步去申领下一段时,那一次取号会因为等待数据库而变慢。双 buffer(双缓冲)的做法是准备两个号段,当正在使用的号段消耗到一定阈值(比如用了 10%)时,就异步去把下一个号段预热好,等当前段用完直接无缝切换,用户完全感知不到申领过程:
状态 | 当前段 | 备用段 | 动作 |
|---|---|---|---|
初始 | 1,1000 | 空 | 消耗到 10% 时异步加载备用段 |
消耗过半 | 使用中 | 1001,2000 已就绪 | 继续用当前段 |
当前段耗尽 | 切到备用段 | 变当前段 | 再异步加载新的备用段 |
双 buffer 把"换段"从一次可能阻塞用户的同步操作,变成了提前完成的后台动作,是号段模式在生产环境稳定运行的关键细节。
门店叫号最怕的是中心发号服务或网络抖动时,前台连号都取不出来、顾客干等。解决办法是给每个门店预先分配"门店专属号段区间",让它在断网时也能在本地继续发号。
一种可落地的编码方式是把号设计成"门店号 + 日期 + 段内序号"的结构,并为每台设备/每个门店预留互不重叠的号段区间。正常时走中心号段,一旦连续多次申领失败,就切换到本地预留段发号,并把这些号标记为"离线发放",网络恢复后再回传对账。
public long nextIdWithFallback() {
try {
return segmentIssuer.nextId(); // 优先走中心号段
} catch (Exception e) {
return localReservedIssuer.nextId(); // 降级到本地预留段
}
}预留段的设计要点是区间必须按门店/设备提前切干净、互不重叠,从根上保证即使离线发放也不会和别的门店重号;恢复连接后做一次对账,确认离线号没有和中心号冲突。这样发号能力不再被单点绑死。
方案 | 趋势递增 | 性能 | 单点依赖 | 适用场景 |
|---|---|---|---|---|
数据库自增 | 是 | 低,每号一写 | 强依赖主库 | 低并发、单库小系统 |
UUID | 否 | 高 | 无 | 内部主键,不适合展示编号 |
雪花类时间戳算法 | 是 | 高 | 弱(需处理时钟) | 大容量全局 ID,运维要求高 |
号段模式(单buffer) | 是 | 高,每段一写 | 短暂依赖,可重试 | 大多数业务发号 |
号段模式(双buffer) | 是 | 很高,换段无抖动 | 短暂依赖 | 高峰、对延迟敏感 |
号段+本地预留降级 | 是 | 极高 | 断网仍可发 | 门店、弱网、边缘场景 |
落地路径建议分三步:先用号段表加乐观锁跑通"整段申领、内存发放"的基本盘;再引入双 buffer 做异步预热,消除换段抖动;最后为门店等弱网节点规划互不重叠的预留号段与断网降级、恢复对账。步长按业务峰值取号速率设置,让一次申领至少能撑几分钟到十几分钟,兼顾压力与号码浪费。发号器要对外暴露当前段用量,方便监控号段消耗速度和申领失败率。以上为通用工程实践,具体实现请结合自身数据库与部署架构为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。