业务系统里几乎每个单据都要一个单号:订单号、发货单号、入库单号。要求听起来简单——全局唯一、不能重复。但真做起来会发现还有一堆隐性需求:要按天递增方便对账、要带上业务前缀、分库分表后不能撞号、高并发下不能成为瓶颈。这篇文章用 Go 实现四种主流方案:数据库自增、UUID、雪花算法、号段模式,把各自的代价讲清楚。
先明确一个常被忽视的前提:单号的唯一性约束最终必须由数据库唯一索引兜底,生成器只负责"尽量不重",数据库负责"绝对不重复"。所有方案讨论都建立在这句话之上。
一、方案一:数据库自增 ID
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY
);最简单可靠的方案,插入时由数据库分配,天然唯一、天然递增。但它有两个业务层面的硬伤:
暴露业务量。 竞对下两单就能看出你今天接了多少单;客服报出"第100086号订单"也不专业。
分库分表后失效。 多张表各自自增,单号就全局不唯一了。当然可以用起始值加步长来绕,但本质上已经是在手动维护一个蹩脚的号段方案了。
结论:单表、内部系统,自增ID就是正确答案。但它产出的是纯数字ID,不是业务语义的单号——业务单号往往需要 SO2026082100123 这种带前缀带日期的格式,那是下面方案要解决的问题。
二、方案二:UUID / 随机串
import "github.com/google/uuid"
func NewOrderNo() string {
return "SO" + strings.ReplaceAll(uuid.NewString(), "-", "")
}优点是实现零成本、无中心节点、任意多实例同时生成都不撞。缺点在业务系统里相当致命:
UUID的甜区是"对外不展示、只当内部关联键"的场景(分布式追踪ID、消息ID)。拿它当业务单号,是拿错了工具。
三、方案三:雪花算法(Snowflake)
思路:64位整数拼成"时间戳 + 机器ID + 序列号",趋势递增、本地生成、无网络调用:
// 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个号)到内存里慢慢用,用完再领下一段:
CREATE TABLE id_segment (
biz_tag VARCHAR(64) PRIMARY KEY, -- 如 'order_20260821'
max_id BIGINT NOT NULL,
step INT NOT NULL DEFAULT 1000
);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 删除。