翻一个大型时序指标存储项目的依赖目录时,我停在了vendor/github.com/rs/xid这个路径上。这个项目就是 Grafana Mimir,一个用 Go 写的分布式时序数据库。它把 xid 这个只有几百行代码的 ID 库整个复制进了仓库,而不是在go.mod里写一行版本号了事。这个动作本身就值得琢磨:一个生成 ID 的库,说到底就是拼几个字节、做一次编码,凭什么值得被当成源码的一部分锁死?
答案藏在 xid 的三个设计取舍里:12 字节的定长结构、字典序可排序、完全零配置。这三条单独拿出来都不稀奇,凑在一起就构成了一套在分布式存储场景里非常好用的主键方案。它不像 UUID 那样随机到把 B+ 树打散,也不像 Snowflake 那样逼着你给每台机器分配一个 worker id,更不像自增主键那样在分库分表时直接失效。这篇文章我会把 xid 的 12 个字节按位拆开,讲清楚每一段是怎么来的、为什么这么排、以及在实际落库和排查时最容易撞上的几个坑。适合正在做主键选型、或者已经在用 xid 但没细究过它边界条件的后端同学。
1. 12 字节这个数字,是怎么从几种主流方案里被挑出来的
主键生成这件事,看起来是基础设施里最没有技术含量的部分,实际上它同时牵动三件事:ID 本身占多少存储、写入时索引怎么维护、以及运维上要不要额外配置。这三件事互相拉扯,随便动一个都会牵连另外两个。我在几个项目里换过不同的方案,每次换完都要重新算一遍账,所以这里先把几个对照组的账面数据摆出来。
1.1 先看几个对照组的账面数据
最常用的几类 ID 方案,按"字节数 / 可读性 / 是否有序 / 是否需要配置"四个维度拉平看,差距非常直观。
| 方案 | 原始字节 | 文本长度 | 是否时间有序 | 是否需要额外配置 | 典型问题 |
|---|---|---|---|---|---|
| 自增整数 | 4 或 8 | 最多 20 位十进制 | 是 | 否 | 分片、多写场景下不可用 |
| UUID v4 | 16 | 36 | 否 | 否 | 随机写入,索引页分裂严重 |
| UUID v7 | 16 | 36 | 是 | 否 | 文本太长,索引体积大 |
| Snowflake | 8 | 最多 19 位十进制 | 是 | 必须分配 worker id | 配置复杂,运维成本高 |
| ULID | 16 | 26 | 是 | 否 | 比 xid 多 4 字节 |
| xid | 12 | 20 | 是 | 否 | 秒级精度,无法单进程内高频去重 |
这里最扎眼的是最后一行:xid 用 12 字节做到了 ULID 用 16 字节、UUIDv7 用 16 字节才能做到的事情,还顺手把长度压到了 20 个字符。20 个字符是什么概念?UUID 去掉横线是 32 个字符,ULID 是 26 个,xid 是 20 个。文本长度直接决定了它在日志里占多宽、在 URL 里占多长、在 JSON 响应体里多占多少字节。
别小看这 6 到 16 个字符的差值。一个每天写入一亿行记录的系统,主键每多 1 个字节,一年就是将近 36 GB 的纯主键数据量。这还没算二级索引——在 InnoDB 的聚簇索引结构里,每一条二级索引记录都要把主键完整地再存一遍,主键膨胀的代价是成倍放大的。
1.2 为什么"零配置"在分布式场景里是个硬指标
Snowflake 这类方案天生需要 worker id。听着简单,实际落地时会发现这是个运维黑洞:扩缩容要重新分配、容器重建后 id 不能重复、跨机房部署要预先划分号段、号段用完了要回收。这些逻辑都得自己写,写完还得测,测完还得担心哪天运维手工改了个配置把号段搞重了。
xid 的做法是把这一步彻底从配置里拿掉:机器标识从 hostname 的 MD5 里取 3 个字节,进程号从操作系统直接读,两个都不需要人插手。代价是它的唯一性保证依赖于"hostname 和 pid 的组合在同一时刻不冲突",这个前提在容器环境里是有条件的——后面第 4 章会专门算这笔账。但在大多数常规部署形态下,它足够用,而且省掉了一整块运维逻辑。
真正让我觉得这个取舍聪明的地方在于:它把"需要人做对的事"换成了"机器自动算出来的东西"。人做错配置是必然事件,自动计算至少是确定性的。
2. 把 12 个字节按位拆开:四段拼接的顺序决定了它的全部性质
xid 在 Go 里的类型定义就是一行:
type ID [12]byte一个定长数组,值类型,不会逃逸到堆上,New()返回时直接按值拷贝。这个细节对高频生成的场景很重要——每次生成 ID 都做一次堆分配,在每秒十万次调用的路径上就是十万次 GC 压力。xid 把这个成本压到了零。
12 个字节被切成了四段,每段的位置和长度都不能随意改。
2.1 前 4 字节:秒级时间戳,大端序
前 4 个字节装的是 Unix 时间戳的秒数,用大端序写进去。
binary.BigEndian.PutUint32(id[:4], uint32(time.Now().Unix()))为什么必须是大端序?因为大端序会把数值的高位放在低地址,而字符串比较、字节数组比较都是从低地址往高地址逐个比的。这样一来,两个 ID 从头开始比,先比到的就是时间戳的高位,时间戳大的 ID 自然排在后面。如果用大端序存,排序语义和数值语义就自动对齐了,不需要任何额外的比较函数。
4 字节的无符号整数上限是 4294967295,按秒算大约到 2106 年。这个期限对绝大多数系统来说是够的,但选型时心里要有数:它不是永久的。
秒级精度是 xid 和 ULID、UUIDv7 最大的分歧点。后两者用毫秒,xid 用秒。秒级的好处是省下 2 个字节(毫秒需要 6 字节),代价是同一秒内的多个 ID 无法靠时间戳区分——这个空缺要靠后面的计数器补上。
2.2 中 5 字节:机器指纹与进程号
第 5 到第 9 字节(索引 4 到 8)是两个字段:3 字节的机器标识和 2 字节的进程号。
机器标识的来源是 hostname 的 MD5 哈希取前 3 字节:
func readMachineID() []byte { hostname, err1 := os.Hostname() if err1 != nil { hostname = strconv.Itoa(os.Getpid()) } sum := md5.Sum([]byte(hostname)) return sum[:3] }用哈希而不是直接用主机名的原因很简单:主机名长度不定,而这里只有 3 个字节的空间。哈希把它压成定长,同时保留了一定的区分度。3 字节是 1677 万种取值,只要主机名不完全相同,撞上的概率就很低。
紧接着的 2 字节是操作系统给的进程号。注意它是uint16,也就是 0 到 65535。Linux 默认的 pid 上限正好就是这个数,所以不会截断。
把机器和进程分开存,而不是合并成一个随机数,这个设计有个副作用值得留意:当进程重启但 pid 被复用时,机器标识和进程号这一段会完全一样。这是后面讨论唯一性边界时的关键前提。
2.3 尾 3 字节:不是随机数,是递增计数器
最后 3 个字节是一个 24 位计数器,但它的行为经常被误解。
它不是每次生成都随机。进程启动时初始化一个随机值,之后每一次New()都对它做原子加一:
var counter = rand.Int31() func New() ID { var id ID binary.BigEndian.PutUint32(id[:], uint32(time.Now().Unix())) id[4], id[5], id[6] = machineID[0], machineID[1], machineID[2] id[7], id[8] = byte(pid>>8), byte(pid) c := atomic.AddUint32(&counter, 1) id[9] = byte(c >> 16) id[10] = byte(c >> 8) id[11] = byte(c) return id }这个设计带来两个好处。第一,同一进程内生成的 ID,计数器部分严格递增,绝不重复。第二,即使系统时钟往回跳,时间戳变小了,计数器的递增也不会停,所以同进程内依然不会产生重复 ID。时钟回拨在这套结构下只会导致排序错乱,不会导致主键冲突——这是个很重要的区分,很多方案在这里是直接崩掉的。
随机初值的意义在于错开不同进程的计数器起点。如果所有进程都从 0 开始计数,两台机器上 pid 相同的进程在同一秒内各生成 100 个 ID,就会产生 100 个完全相同的 ID。加了随机初值之后,两个区间重叠的概率降到可接受范围,具体的量化在第 4 章。
2.4 Base32 hex 编码为什么刚好是 20 个字符
12 字节是 96 位。Base32 每个字符承载 5 位,96 除以 5 等于 19.2,向上取整是 20 个字符。多出来的 4 位在末尾补零,因为用了NoPadding,所以不会出现=号。
var encoding = base32.HexEncoding.WithPadding(base32.NoPadding)选 Base32 hex 而不是标准 Base32,是因为 hex 变体的字符表是0-9a-v,前 10 个是数字,后 22 个是小写字母,ASCII 码顺序和数值顺序完全一致。这一点对"可排序"这个性质是决定性的:只有当字符的字典序等于它代表的数值顺序时,字符串比较才能等价于数值比较。
作为对照,标准 Base32 的字符表是A-Z2-7,字母在前数字在后,Z的 ASCII 码是 90,2是 50,字典序和数值序错位,直接拿来做字符串排序就会得到错误结果。这个坑踩过一次就再也忘不了。
选小写也是为了让 ID 在视觉上不那么刺眼,同时避免大小写不敏感系统(比如某些数据库的默认排序规则)把a和A混淆。
3. "可排序"在存储引擎里到底能兑现成什么
"可排序"这三个字经常被当成一个营销词挂在方案介绍里,但它要落到真实的存储引擎上才有意义。我在两个不同类型的库上做过压测对比,收益差距是非常具体的。
3.1 B+ 树的最右页追加 vs 随机插页
拿 MySQL 的 InnoDB 举例。聚簇索引是一棵 B+ 树,数据按主键顺序物理存放。当你插入一条新记录时:
- 如果主键是随时间递增的(自增、xid、Snowflake),新记录永远落在最右边的叶子页上。最右页满了就新开一页,写入基本是顺序的,页填充率接近 100%。
- 如果主键是随机的(UUID v4),新记录会随机落到树中间某个叶子页。这个页大概率已经写满了,于是触发页分裂:一半数据挪到新页,原来的空间被拆散。结果是页填充率掉到 50% 到 70%,索引体积膨胀,缓存命中率下降。
我做过一个粗糙的对照:同一个表结构,写入 500 万行,主键分别用 UUID v4 和 xid。UUID 版本的表文件比 xid 版本大了将近一倍,写入耗时高出约三倍。差异主要来自页分裂和随之而来的随机 IO。
xid 的时间戳在高位,所以它的字典序就是生成顺序。批量插入时,主键单调递增,走的就是最右页追加这条快路。
3.2 字符串比较与数值比较的一致性
这是一个容易被忽略但非常实用的性质:因为 Base32 hex 的字符表和 ASCII 顺序对齐,你可以直接在 SQL 里对 ID 做字符串比较,得到的顺序就是时间顺序。
SELECT * FROM orders WHERE id > 'c9k2m4v0es8q3n1p7a50' ORDER BY id LIMIT 100;这条语句能正确返回比给定 ID 更晚生成的记录,走的是普通的主键索引范围扫描,不需要任何函数转换。如果你的 ID 列是BINARY(12),比较逻辑同样成立,因为字节比较也是从前往后逐字节比。
这个性质在实现"按时间翻页"时特别好用。传统的LIMIT offset, size在深翻页时性能会崩,而用 ID 做游标就没有这个问题——上一页最后一条的 ID 就是下一页的起点,一次索引范围扫描搞定。
3.3 分页与游标:把 ID 直接当游标用
基于 ID 的游标分页在实践中比基于时间戳的分页更可靠。原因是时间戳会有重复和间隙:同一毫秒内写入的几千条记录,用WHERE created_at > ?分页时,要么漏数据要么重复数据,除非把 ID 一起带上做复合条件。
而 ID 天然唯一,用它做游标就没有这个问题:
func pageAfter(ctx context.Context, db *sql.DB, lastID string, limit int) ([]Record, error) { rows, err := db.QueryContext(ctx, `SELECT id, payload FROM items WHERE id > ? ORDER BY id LIMIT ?`, lastID, limit) // ... }把上一页最后一条的 ID 直接作为下一页的起点即可,逻辑简单,索引友好,也天然支持"断点续传"这类场景。
4. 零配置的代价:唯一性边界到底在哪
前面一直在夸,这一章讲它真正的边界。xid 的 README 里其实写得很清楚,只是很多人扫一眼就过去了:它不保证跨进程重启的唯一性,前提是"同一秒、同一机器、同一进程号"这三个条件同时成立。
4.1 冲突只可能出现在这三个条件同时成立的瞬间
拆开看,ID 的四段里,只有后 5 字节是可能重复的,因为前 4 字节是时间戳。要产生完全相同的 ID,必须满足:
- 两条记录在同一秒内生成——时间戳段相同;
- 机器标识相同——hostname 的 MD5 前 3 字节一致;
- 进程号相同——两个进程的 pid 相同;
- 计数器值相同——这需要两个进程的计数器随机初值加上各自的递增步数刚好撞上。
前三条是"环境条件",第四条是"概率问题"。前三条同时成立时(比如容器里 hostname 固定、pid 都是 1、同一秒内两个副本都在写),风险就全部压在计数器上了。
两个进程各自在同一秒内生成 K 个 ID,它们的计数器区间长度都是 K,起点随机分布在 2^24 的空间里。两个区间相交的概率近似为2K / 2^24。代入不同的写入速率:
| 单进程每秒生成 ID 数 | 两进程区间重叠概率 | 说明 |
|---|---|---|
| 100 | 约 0.0012% | 几乎可以忽略 |
| 1,000 | 约 0.012% | 常规业务量级 |
| 10,000 | 约 0.12% | 高频写入需要关注 |
| 100,000 | 约 1.2% | 必须处理 |
注意这只是"区间重叠"的概率,重叠区间里还要真的撞上具体的那一个值才算冲突,实际概率还要再除以 K。但这不代表可以放心——概率再低,只要条件具备,长期跑下去总有撞上的一天,而主键冲突在线上是硬故障。
4.2 容器化环境里的机器指纹陷阱
这是我在实际部署里遇到过的问题。容器的 hostname 有两种常见配置:
- 随机生成(默认情况下很多编排系统会给容器一个随机名字)。这种情况下每个副本的 hostname 都不同,机器标识自动区分开了,风险最低。
- 固定指定(为了日志可读性,很多人会显式设置成统一的值)。这种情况下所有副本的机器标识完全一样,区分度全部落在 pid 上——而容器里的主进程 pid 往往就是 1。
两个条件一叠加,同一秒内生成的 ID 就只剩下计数器在兜底。如果你的服务每秒生成上万个 ID,这个场景下就真的需要评估了。
我的处理办法很简单:不要在容器里固定 hostname 为同一个值,或者干脆在启动时读一个环境变量,把它混进机器标识的来源里。如果不想改库,也可以在每个实例启动时把实例编号写进 hostname,保证 3 字节机器标识有区分度。这个改动只有几行代码,但把风险从"概率问题"降回了"几乎不可能"。
4.3 时钟回拨会不会造成重复
这个问题的答案和大多数人的直觉相反:不会造成重复,但会造成排序错乱。
原因在前面提过——计数器是进程内单调递增的,从不重置。即使时间戳从3F2A回拨到3F28,计数器的值依然比上一次大,两个 ID 整体上不会相等。所以在单进程内,时钟回拨对唯一性没有影响。
但排序会出问题:回拨期间生成的 ID,时间戳比之前的还小,排在前面的反而是更新的数据。如果你的业务依赖 ID 顺序来做增量同步(比如"拉取 ID 大于 X 的所有变更"),回拨期间的数据会被漏掉。
处理思路有两种。一种是在增量同步的场景里不要只依赖 ID 比较,加一个时间窗口的容错(比如每次回溯 5 分钟重扫)。另一种是在应用层做个简单的时间戳保护:记录上一次生成 ID 用的秒数,如果当前秒数比它小,就用上一次的秒数继续生成。这会让 ID 的时间精度暂时失真,但保证了单调性。
5. 把 xid 源码 vendored 进仓库,这个动作本身在解决什么问题
回到最开始那个疑问:为什么一个几百行的 ID 库要被复制进仓库?Go 有go.mod,有版本锁定,有校验和,为什么还要 vendor?
5.1 vendor 与依赖管理的取舍
Go 的 vendor 机制在 1.14 之后默认关闭了(除非目录存在),但大型项目里依然常见。原因通常是这几类:
- 构建可复现性。
go.sum能保证你拿到的是同一份代码,但前提是模块代理还活着、网络还通。vendor 目录把这些都省了,go build -mod=vendor完全离线。 - 私有依赖的替代方案。有些内部库不在公共代理上,vendor 是最省事的解法。
- 对关键依赖打补丁。如果某个上游库有个小 bug 但维护者不打算修,vendor 之后直接改是最快的。
对一个 ID 生成库来说,前两条理由不太成立——xid 就在公共模块里,代码量小,网络不是问题。真正的原因大概率是第三条的变体:把它当成项目自身代码的一部分来对待。
5.2 对一个 ID 库来说,稳定性比新特性重要得多
ID 生成是那种"一旦上线就几乎不可能再改"的基础设施。主键格式变了意味着数据迁移、索引重建、上下游对齐,成本高到没人愿意碰。所以这个库的期望是:永远不要变。
vendored 之后,上游发什么版本、删什么 API、改什么行为,都和你的构建无关了。你的仓库里躺着的就是这份确定的字节,十年后重新 clone 下来还能编出一样的东西。这种确定性对一个 ID 库的价值,远大于追新版本带来的收益。
顺带一提,xid 这个库本身也确实很"稳":它的 API 面非常小,New()、FromString()、String()、Time()、Machine()、Pid()、Counter(),就这些。没有插件机制,没有全局配置,没有可替换的随机源。这种克制让 vendor 它的成本极低——你不需要担心它偷偷引入一个巨大的传递依赖。
5.3 从 vendored 路径能读出的项目克制
从vendor/github.com/rs/xid这个路径还能读出一个信息:这个库的导入路径里带github.com,说明它是从公共仓库直接拉下来的,没有做 fork 改名。这意味着项目方认可上游的实现,只是想在构建层面把它锁死,而不是想改它的行为。
这种处理方式在大型 Go 项目里很常见,也比较健康:能用上游就用上游,能不 fork 就不 fork,只在构建层做隔离。比起直接 fork 出来改一改、然后长期失去上游更新能力的做法,vendor 是可逆的——哪天决定回到模块依赖,删掉目录改一下go.mod就行。
6. 接入实操:生成、解析、落库、排查
理论讲完,说说怎么用。这部分我按"从零接入一个服务"的路径来写。
6.1 最小可用代码
引入之后,生成就是一个函数调用:
package main import ( "fmt" "github.com/rs/xid" ) func main() { id := xid.New() fmt.Println(id.String()) // 输出类似 c9k2m4v0es8q3n1p7a50 fmt.Println(id.Time()) // 2024-05-11 14:22:33 +0800 CST fmt.Printf("%x\n", id.Machine()) // 机器标识,3 字节 fmt.Println(id.Pid()) // 进程号 fmt.Println(id.Counter()) // 当时的计数器值 }如果需要从字符串还原成 ID 对象,用xid.FromString:
id, err := xid.FromString("c9k2m4v0es8q3n1p7a50") if err != nil { // 长度不对或字符集非法 return err }一个细节:xid.ID是值类型,作为函数参数传递和 map 的 key 都没有问题,不需要指针。放进map[xid.ID]struct{}去做去重比用字符串 key 更省内存,也省掉了解码开销。
6.2 落库选型:CHAR(20) 还是 BINARY(12)
这是接入时第一个要做的决定,两种主流存法各有取舍。
| 存储类型 | 占用空间 | 可读性 | 排序 | 索引效率 | 适用场景 |
|---|---|---|---|---|---|
CHAR(20) | 20 字节(定长,字符集影响实际占用) | 直接可读 | 正确 | 略低于二进制 | 需要人肉看数据、排查频繁 |
VARCHAR(20) | 20 + 1 字节长度前缀 | 直接可读 | 正确 | 略低于定长 | 同上,但多 1 字节 |
BINARY(12) | 12 字节 | 需要解码 | 正确 | 最高 | 追求极致空间和性能 |
CHAR(20) CHARACTER SET ascii | 20 字节 | 直接可读 | 正确 | 高 | 推荐折中方案 |
我自己的选择是CHAR(20) CHARACTER SET ascii。理由是:显式指定 ascii 之后,每字符占 1 字节,不会因为连接默认字符集是 utf8mb4 而膨胀到 80 字节;同时保留了人类可读性,线上排查时直接SELECT出来就能拿去跟日志对,不用再做一次解码。
如果用的是 PostgreSQL,对应的是char(20)或bytea。如果对空间极其敏感,BINARY(12)能省下 40% 的主键空间,但代价是每次排查都要手工转一下格式:
SELECT HEX(id) FROM orders WHERE ...;提示:如果用
BINARY(12)存,Go 侧读取时不要用string扫描,直接扫进[]byte再转成xid.ID,避免多一次编解码。用VARCHAR时要注意连接字符集,别让 collation 把大小写抹平了。
6.3 反向解析:从一个 ID 里读出它的出生信息
xid 有一个很实用的性质:ID 里自带生成时的元信息。遇到"这条记录是什么时候写的、来自哪台机器"这种问题,不需要查别的字段,直接解析 ID 就行。
id, _ := xid.FromString("c9k2m4v0es8q3n1p7a50") fmt.Println("生成时间:", id.Time()) fmt.Println("机器标识:", id.Machine()) fmt.Println("进程号:", id.Pid())这在排查分布式问题时特别省事。比如发现某几条数据的 ID 前缀明显偏小,说明它们是更早的时候生成的,可能是历史遗留;如果机器标识段反复出现同一个值,说明写入都集中在一个实例上,负载不均。
不过要注意,秒级精度意味着Time()只能精确到秒。如果要做到毫秒级的时间定位,还是得单独存created_at。别指望从 ID 里抠出毫秒。
7. 几个我实际踩过的坑
这一节写几个不那么明显、但确实会咬人的问题。
7.1 把 ID 当时间戳用,精度不够的错觉
我一开始想省掉created_at字段,全靠 ID 反解时间。跑了一段时间发现不对:同一秒内写入的几百条记录,解析出来的时间完全一样,做"按时间撮合"的业务逻辑时排序不稳定。
后来老实加回了created_at,用数据库的默认值生成,精度到毫秒。ID 只负责唯一和排序,时间语义交给专门的字段。这个分工要摆正,别为了省一个字段把两种语义混在一起。
7.2 JSON 序列化里的零值陷阱
xid.ID是[12]byte,零值是全零数组。如果不做处理直接序列化成 JSON,得到的是"AAAAAAAAAAAAAAAAAAAA"(20 个 A),而不是空字符串或 null。前端拿到这个值会一脸茫然。
处理办法是实现自定义的MarshalJSON:
func (id ID) MarshalJSON() ([]byte, error) { if id == Nil { return []byte(`""`), nil } return []byte(`"` + id.String() + `"`), nil }或者干脆在结构体上用指针,零值时不输出。这个问题在接口联调时才会暴露,提前处理掉能省一轮沟通。
7.3 日志里直接打印字节数组
因为ID的底层是[12]byte,用%v格式化会打出一串十进制数字,完全没法看:
log.Printf("%v", id) // [201 146 36 192 93 142 ...]正确做法是用%s或者显式调用id.String()。更稳妥的是给ID实现fmt.Stringer接口——xid 库本身已经实现了,所以%s能正确输出 20 位字符串。但在某些结构化日志库里,如果它反射拿到的是[12]byte类型而不是ID类型,就会退化成数组输出。这种情况要么显式转成字符串再记录,要么给日志库注册一个自定义编码器。
我自己在项目里的做法是定义了一个type OrderID xid.ID的别名类型,然后给它单独实现了String()和MarshalJSON()。这样在日志、接口、数据库三个层面上行为都是确定的,也不会因为库的默认行为变化而受影响。
还有一个和并发相关的注意点:New()内部用的是atomic.AddUint32,本身是安全的,但如果你的代码里有地方把counter变量复制出去用,就会破坏原子性。虽然 xid 把它藏在包级私有变量里,你不会直接碰到,但如果在自己的代码里包了一层缓存逻辑,就别把 ID 对象当成可变状态来共享。
最后分享一个使用习惯:给 ID 字段加一个统一的解析入口。项目里所有从外部拿到的 ID 字符串,都走同一个ParseID(string) (xid.ID, error)函数,里面统一做长度校验和字符集校验。这样将来如果换了 ID 方案,改动点只有一处,而不是散落在几十个 handler 里。这个习惯是我在第三个项目里才养成的,前两个项目换 ID 方案时都吃了大亏。