首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >订单号、发货单号怎么生成才不会重复?四种业务单号方案的实现与取舍

订单号、发货单号怎么生成才不会重复?四种业务单号方案的实现与取舍

原创
作者头像
用户7093268
发布2026-08-21 17:04:32
发布2026-08-21 17:04:32
360
举报

业务系统里几乎每个单据都要一个单号:订单号、发货单号、入库单号。要求听起来简单——全局唯一、不能重复。但真做起来会发现还有一堆隐性需求:要按天递增方便对账、要带上业务前缀、分库分表后不能撞号、高并发下不能成为瓶颈。这篇文章用 Go 实现四种主流方案:数据库自增、UUID、雪花算法、号段模式,把各自的代价讲清楚。

先明确一个常被忽视的前提:单号的唯一性约束最终必须由数据库唯一索引兜底,生成器只负责"尽量不重",数据库负责"绝对不重复"。所有方案讨论都建立在这句话之上。

一、方案一:数据库自增 ID

代码语言:sql
复制
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
);

最简单可靠的方案,插入时由数据库分配,天然唯一、天然递增。但它有两个业务层面的硬伤:

暴露业务量。 竞对下两单就能看出你今天接了多少单;客服报出"第100086号订单"也不专业。

分库分表后失效。 多张表各自自增,单号就全局不唯一了。当然可以用起始值加步长来绕,但本质上已经是在手动维护一个蹩脚的号段方案了。

结论:单表、内部系统,自增ID就是正确答案。但它产出的是纯数字ID,不是业务语义的单号——业务单号往往需要 SO2026082100123 这种带前缀带日期的格式,那是下面方案要解决的问题。

二、方案二:UUID / 随机串

代码语言:go
复制
import "github.com/google/uuid"

func NewOrderNo() string {
    return "SO" + strings.ReplaceAll(uuid.NewString(), "-", "")
}

优点是实现零成本、无中心节点、任意多实例同时生成都不撞。缺点在业务系统里相当致命:

  • 无序。 UUID作为主键插入时,B+树索引不停在随机位置分裂,写入性能和页利用率都明显下降,数据量大后能差出数倍。
  • 不可读。 一长串十六进制字符,客服在电话里没法念,财务对账没法排,日志里没法按时间排序。
  • 太长。 作为索引键占用空间大,连带所有二级索引膨胀。

UUID的甜区是"对外不展示、只当内部关联键"的场景(分布式追踪ID、消息ID)。拿它当业务单号,是拿错了工具。

三、方案三:雪花算法(Snowflake)

思路:64位整数拼成"时间戳 + 机器ID + 序列号",趋势递增、本地生成、无网络调用:

代码语言:go
复制
// 41位毫秒时间戳 | 10位机器ID | 12位序列号(每毫秒4096个)
func (n *SnowflakeNode) Next() int64 {
    n.mu.Lock()
    defer n.mu.Unlock()
    now := time.Now().UnixMilli()
    if now == n.lastMs {
        n.seq = (n.seq + 1) & 0xFFF
        if n.seq == 0 { // 序列号耗尽,自旋到下一毫秒
            for now <= n.lastMs {
                now = time.Now().UnixMilli()
            }
        }
    } else {
        n.seq = 0
    }
    n.lastMs = now
    return (now-epoch)<<22 | int64(n.machineID)<<12 | int64(n.seq)
}

雪花算法是分布式ID的标准答案,但用在业务单号上要看清三个坑:

时钟回拨。 机器时钟被NTP校准往回拨,同一毫秒就可能重复发号。工程上的对策是启动时校验、运行时发现回拨则拒绝服务并告警,而不是默默继续——默默继续就是把重复风险藏起来。

机器ID分配。 10位机器ID谁负责分配?写死配置容易在容器扩容时撞车,常见做法是从Redis或ZooKeeper租用一个worker ID。这是雪花方案真正的运维成本所在。

输出是18位的长数字。 趋势递增但不连续,且不带日期前缀。财务要"按天看单号连不连"时,雪花满足不了;它适合当主键ID,当"人读的单号"还需要再加工。

四、方案四:号段模式(Leaf-segment)

思路:不逐条取号,而是一次从数据库领一批(比如1000个号)到内存里慢慢用,用完再领下一段:

代码语言:sql
复制
CREATE TABLE id_segment (
    biz_tag VARCHAR(64) PRIMARY KEY, -- 如 'order_20260821'
    max_id  BIGINT NOT NULL,
    step    INT NOT NULL DEFAULT 1000
);
代码语言:go
复制
func (s *SegmentBuffer) fetchNextSegment() error {
    // 同事务内完成"读max_id + 更新max_id",原子不丢不重
    tx, _ := s.db.Begin()
    defer tx.Rollback()
    var maxID int64
    err := tx.QueryRow(
        `SELECT max_id FROM id_segment WHERE biz_tag = ? FOR UPDATE`,
        s.bizTag).Scan(&maxID)
    if err != nil {
        return err
    }
    if _, err := tx.Exec(
        `UPDATE id_segment SET max_id = max_id + ? WHERE biz_tag = ?`,
        s.step, s.bizTag); err != nil {
        return err
    }
    s.cur, s.max = maxID, maxID+int64(s.step)
    return tx.Commit()
}

关键工程点有两个。一是 FOR UPDATE 行锁保证领号段原子——和库存扣减一个道理,把"读+改"合成一个临界区。二是 biz_tag 可以按"业务+日期"设计(order_20260821),这样单号天然就是"前缀+日期+当日连续序号",财务按天对账、客服报号、排查断号全部顺手。

进一步优化是双buffer预取:当前号段用到80%就异步领下一段,避免号段耗尽瞬间的取号阻塞。数据库每1000个号才被访问一次,压力可以忽略。

代价也要说清楚:服务重启会浪费当前号段剩余的部分(号不连续但不重复,业务上单号"跳号"通常可接受;真要严格连续的场景比如发票号,本身就不该用预取方案);多实例部署时每个实例各领各的段,互不冲突。

五、四种方案怎么选

数据库自增:单表内部系统的主键,直接用,不要过度设计。

UUID:不对外展示、不需要排序的内部关联键(trace ID、消息ID)。别拿来做业务单号。

雪花算法:分库分表下的分布式主键ID,机器多、写并发高时它是标准答案;但要处理好时钟回拨和worker ID分配。

号段模式:业务可读单号(订单号、发货单号)的最佳匹配——带日期前缀、当日连续、数据库压力小、多实例安全。中小企业业务系统要一个"万能单号方案",就是它。

实际项目里常见的组合是两者并用:主键用雪花ID保证分布式唯一和写入性能,对外单号用号段模式保证可读可对账,各干各擅长的。

写在最后

单号生成的需求分层很清楚:唯一性是底线(由唯一索引兜底),可读性和连续性决定了财务和客服的日常效率,性能只在高并发下才成为问题。多数系统的正确答案并不复杂——主键自增或雪花,业务单号用按日期分段的号段模式。把方案选复杂通常不是因为需求复杂,而是没分清"给人看的单号"和"给机器用的ID"是两回事。

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

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

问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档