首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >高峰期排队取号也会重号:分布式发号的号段模式与降级方案

高峰期排队取号也会重号:分布式发号的号段模式与降级方案

原创
作者头像
用户5598620
发布2026-09-14 09:26:56
发布2026-09-14 09:26:56
20
举报

门店排队叫号、工单编号、订单号这类业务,看似只是"生成一个递增数字",真到了多台收银设备、多个门店同时取号的高峰期,重号、跳号、依赖单点的问题就会一起冒出来。用数据库自增主键会把所有取号压力压到一个点上,用时间戳拼接又会在时钟回拨、同毫秒并发时撞号。这篇拆解一套可落地的分布式发号方案:为什么会重号、号段模式怎么把发号压力摊开、Redis 出问题时如何降级到门店本地号段,文末附方案对照表和排查清单。

一、先看清几种朴素做法为什么会出问题

数据库自增:实现最简单,但每次取号都是一次写操作,高并发下自增锁成为瓶颈,且强依赖主库,主库故障就无法取号,跨门店、跨地域部署时尤其被动。

UUID / 随机串:全局唯一没问题,但它无序、超长,不能当给顾客看的排队号,作为聚簇索引主键还会导致索引频繁分裂。

时间戳拼接:用"时间 + 机器号 + 序列号"能做出趋势递增的号,但依赖时钟准确,服务器发生 NTP 时钟回拨时可能生成比之前更小甚至重复的号,同毫秒内的并发还要靠序列号兜住,实现并不简单。

叫号场景的真实诉求其实是:号在"同一个门店、同一业务日"内不重且大体递增即可,允许全局不连续,也不要求跨门店绝对连续——这个前提一明确,号段模式就成了更稳妥、综合代价更低的选择。

二、号段模式:一次取一段,慢慢发

核心思路是不让应用每次都去问"下一个号是几",而是一次性从发号中心申领一整段号码(比如 1000 个),保存在应用内存里慢慢发,发完再申领下一段。

数据库里只需要一张号段分配表,记录当前发到的最大值和步长:

代码语言:sql
复制
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"这段区间的所有权:

代码语言:sql
复制
UPDATE seq_segment
SET max_id = max_id + step, version = version + 1, update_time = NOW()
WHERE biz_tag = #{bizTag} AND version = #{version};

应用侧的发号器维护当前号段的起始值、结束值和一个原子计数器:

代码语言:java
复制
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:把"申领下一段"的抖动藏起来

上面的写法有个小瑕疵:当号段恰好耗尽、同步去申领下一段时,那一次取号会因为等待数据库而变慢。双 buffer(双缓冲)的做法是准备两个号段,当正在使用的号段消耗到一定阈值(比如用了 10%)时,就异步去把下一个号段预热好,等当前段用完直接无缝切换,用户完全感知不到申领过程:

状态

当前段

备用段

动作

初始

1,1000

消耗到 10% 时异步加载备用段

消耗过半

使用中

1001,2000 已就绪

继续用当前段

当前段耗尽

切到备用段

变当前段

再异步加载新的备用段

双 buffer 把"换段"从一次可能阻塞用户的同步操作,变成了提前完成的后台动作,是号段模式在生产环境稳定运行的关键细节。

四、门店场景的降级:本地预留号段

门店叫号最怕的是中心发号服务或网络抖动时,前台连号都取不出来、顾客干等。解决办法是给每个门店预先分配"门店专属号段区间",让它在断网时也能在本地继续发号。

一种可落地的编码方式是把号设计成"门店号 + 日期 + 段内序号"的结构,并为每台设备/每个门店预留互不重叠的号段区间。正常时走中心号段,一旦连续多次申领失败,就切换到本地预留段发号,并把这些号标记为"离线发放",网络恢复后再回传对账。

代码语言:java
复制
public long nextIdWithFallback() {
    try {
        return segmentIssuer.nextId();          // 优先走中心号段
    } catch (Exception e) {
        return localReservedIssuer.nextId();    // 降级到本地预留段
    }
}

预留段的设计要点是区间必须按门店/设备提前切干净、互不重叠,从根上保证即使离线发放也不会和别的门店重号;恢复连接后做一次对账,确认离线号没有和中心号冲突。这样发号能力不再被单点绑死。

五、可直接抄走的方案对照表

方案

趋势递增

性能

单点依赖

适用场景

数据库自增

低,每号一写

强依赖主库

低并发、单库小系统

UUID

内部主键,不适合展示编号

雪花类时间戳算法

弱(需处理时钟)

大容量全局 ID,运维要求高

号段模式(单buffer)

高,每段一写

短暂依赖,可重试

大多数业务发号

号段模式(双buffer)

很高,换段无抖动

短暂依赖

高峰、对延迟敏感

号段+本地预留降级

极高

断网仍可发

门店、弱网、边缘场景

六、踩坑清单

  • 号段步长设太小,高峰期频繁申领,等于退化回每号一查。
  • 只做单 buffer,号段耗尽那一刻同步申领,造成偶发取号卡顿。
  • 推进 max_id 不加版本校验,并发申领导致同一段被两个节点领走、直接重号。
  • 用时间戳方案却不处理时钟回拨,回拨时生成重复号。
  • 本地预留号段没有按门店切干净,离线发号和其他门店区间重叠。
  • 离线发放的号恢复后不对账,冲突问题被掩盖到对账时才爆。
  • 把发号器做成多实例各自维护内存号段却不区分归属,多副本之间重号。

七、工程落地建议

落地路径建议分三步:先用号段表加乐观锁跑通"整段申领、内存发放"的基本盘;再引入双 buffer 做异步预热,消除换段抖动;最后为门店等弱网节点规划互不重叠的预留号段与断网降级、恢复对账。步长按业务峰值取号速率设置,让一次申领至少能撑几分钟到十几分钟,兼顾压力与号码浪费。发号器要对外暴露当前段用量,方便监控号段消耗速度和申领失败率。以上为通用工程实践,具体实现请结合自身数据库与部署架构为准。

复盘清单

  • 发号是否做到"一次取一段"而不是每号一次请求。
  • 号段推进是否有版本校验,杜绝重复领取。
  • 是否用双 buffer 消除了换段瞬间的阻塞。
  • 弱网/中心故障时是否有不重号的本地降级段。
  • 离线号恢复后是否有对账机制。
  • 步长是否与峰值取号速率匹配。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、先看清几种朴素做法为什么会出问题
  • 二、号段模式:一次取一段,慢慢发
  • 三、双 buffer:把"申领下一段"的抖动藏起来
  • 四、门店场景的降级:本地预留号段
  • 五、可直接抄走的方案对照表
  • 六、踩坑清单
  • 七、工程落地建议
  • 复盘清单
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档