Grafana Tempo 依赖探秘:oklog/ulid Go 实现原理与 ULID 实战指南
【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo
ULID(Universally Unique Lexicographically Sortable Identifier)是一种 128 位、兼具时间顺序与字典序可排序能力的唯一标识符。本文以 Grafana Tempo 仓库中 vendored 的github.com/oklog/ulid/v2(v2.1.2,声明于 go.mod)为核心,完整讲解其设计动机、二进制规范、Go API 用法、熵源与单调性控制、命令行工具与性能特性,并结合 ulid.go 源码展开底层实现剖析,帮助读者在分布式系统、日志与追踪数据存储等场景中正确选择与使用 ULID。
背景:为什么需要 ULID
GUID/UUID 在很多场景下并非最优解,README 中列举了四个典型痛点:
- 字符效率低:UUID 不是编码 128 位数据最节省字符的方式;
- UUID v1/v2 依赖环境:在多数环境不可用,因为它要求访问唯一且稳定的 MAC 地址;
- UUID v3/v5 需要唯一种子:生成的 ID 呈随机分布,容易在许多数据结构中造成碎片化(fragmentation);
- UUID v4 只提供随机性:除随机外不携带任何其他信息,同样会导致数据结构的碎片化。
而 ULID 的特性恰好弥补了这些不足:
- 与 UUID/GUID 位数兼容(同为 128 位);
- 每毫秒可产生1.21e+24 个唯一 ULID(精确值为 1,208,925,819,614,629,174,706,176);
- 字典序可排序(lexicographically sortable),时间先后与字符串排序一致;
- 规范编码为26 字符字符串,而 UUID 是 36 字符;
- 使用Crockford's Base32编码,每字符承载 5 位,效率与可读性更佳;
- 大小写不敏感;
- 无特殊字符,URL 安全;
- 单调排序(monotonic sort order),能正确检测并处理同一毫秒内的生成请求。
安装与版本
该包要求 Go Modules 环境:
go get github.com/oklog/ulid/v2在 Tempo 仓库中,它以一个间接依赖(indirect)的形式出现在 go.mod:
github.com/oklog/ulid/v2 v2.1.2 // indirect虽然 Tempo 自身业务代码(modules/、pkg/、tempodb/、cmd/)并未直接调用它,但它经由仓库 vendored 的 Prometheus TSDB(其 block 目录以 ULID 命名,见 vendor/github.com/prometheus/prometheus/tsdb/block.go)以及 vendor/github.com/go-openapi/strfmt/ulid.go 等组件被间接引入,这也印证了 ULID 在时序数据块命名这一经典场景中的实用价值。
基本用法:两个组成部分
ULID 由两部分构造:毫秒精度的时间戳与一段随机数据。
- 时间戳建模为
uint64,表示 Unix 毫秒时间。可以通过ulid.Timestamp(time.Time)转换得到,也可以调用time.Time.UnixMilli()后转为uint64; - 随机数据来自调用方提供的
io.Reader。这种设计允许在使用时自由权衡性能与安全性,但对新手来说可能略显困惑。
快速生成:ulid.Make
如果只是要生成一个 ULID,且暂时不关心性能、加密安全性等细节,直接使用ulid.Make:
fmt.Println(ulid.Make()) // 01G65Z755AFWAKHE12NY0CQ9FH从源码看(ulid.go),Make内部调用MustNew(Now(), defaultEntropy):
Now()获取当前 UTC 时间的 Unix 毫秒值(等价于Timestamp(time.Now().UTC()));defaultEntropy是进程级全局的熵源:由math/rand的伪随机数生成器构造,并用LockedMonotonicReader包装,保证线程安全且单调递增(见 ulid.go);Make本身对并发安全,底层通过sync.Pool分摊锁竞争。
进阶构造:ulid.New
更高级的使用场景应使用ulid.New(ms uint64, entropy io.Reader):
entropy := rand.New(rand.NewSource(time.Now().UnixNano())) ms := ulid.Timestamp(time.Now()) fmt.Println(ulid.New(ms, entropy)) // 01G65Z755AFWAKHE12NY0CQ9FHNew的签名(ulid.go)显示其内部逻辑:
- 先调用
id.SetTime(ms)写入 48 位时间戳,时间超过最大值时返回ErrBigTime; - 然后按熵源类型分发:若熵源实现了
MonotonicReader接口,则调用其MonotonicRead(ms, id[6:]);否则用io.ReadFull读取 10 字节填充熵字段。
并发安全性完全取决于传入的熵源是否安全。MustNew是New的便捷封装,失败时直接 panic 而非返回错误。
熵源选择与性能/安全权衡
README 明确警告:提供熵源时需要格外谨慎。
常见熵源选项
| 熵源 | 特点 | 适用场景 |
|---|---|---|
math/rand.Rand | 快,但不可并发安全,多个 goroutine 共用会出问题 | 单线程/每 goroutine 独占 |
golang.org/x/exp/rand.LockedSource | 加锁保证并发安全 | 多 goroutine 共享 |
crypto/rand | 密码学安全 | 安全敏感场景 |
sync.Pool池化熵源 | 每 goroutine 独立熵源,零锁竞争 | 性能敏感场景 |
性能敏感的并发策略
README 给出建议:性能敏感场景应避免在生成 ID 时加锁同步。一种方案是为每个并发 goroutine 使用独立的熵源:
- 优点:完全没有锁竞争;
- 缺点:无法对随机数据提供强保证,且同一毫秒内不提供单调性(monotonicity)。
一种常见的性能优化是用sync.Pool池化多个熵源对象,兼顾复用与并发。
安全性提示
安全敏感的使用场景应始终使用crypto/rand提供的密码学安全熵。此外,单调性熵源(见下文)的inc参数会影响 ID 的“可猜测性”——依赖熵字节安全性的代码应使用默认安全值。
单调性:同一毫秒内的排序保证
单调性意味着每个 ULID 都“大于”前一个。默认情况下 ULID 只具备毫秒精度的自动单调性:同一毫秒内生成的 ULID 由其随机分量排序,因此默认是无序的。
若需要同一毫秒内的严格单调,使用ulid.Monotonic(entropy, inc)或ulid.LockedMonotonicEntropy(并发安全版本)。核心实现见 ulid.go:
m := Monotonic(entropy, 0) // inc=0 表示默认值 math.MaxUint32 locked := &LockedMonotonicReader{MonotonicReader: m}关键行为(源码注释与实现一致):
- 同一 ULID 时间戳内的每次
MonotonicRead调用,会在前一个熵值上增加一个 1 到inc(含)之间的随机数; inc == 0时采用默认值math.MaxUint32;inc越小,同一毫秒内可产生的单调熵越少,但 ID 越容易被“猜中”,因此安全敏感代码应保持默认值;- 若递增导致 80 位熵溢出,返回
ErrMonotonicOverflow; - 底层的
uint80类型(ulid.go)用Hi uint16 + Lo uint64表示 80 位熵,Add方法实现带进位的大整数加法并检测溢出; MonotonicEntropy本身不适合并发使用,需用LockedMonotonicReader(内部sync.Mutex)包装;- 底层熵源必须确实能产生随机字节,否则
random()可能无法终止——它通过拒绝采样(rejection sampling)生成[1, inc)区间的均匀随机数,并对math/rand.Rand提供快速路径。
从源码结构看(ulid.go),
random()对普通io.Reader会先计算inc的位宽,再按 1/2/3-4/5-8 字节分档读取并做掩码重试,属于典型的高效拒绝采样实现。
解析与校验 API
Parse 系列
id, err := ulid.Parse("01G65Z755AFWAKHE12NY0CQ9FH") id, err := ulid.ParseStrict("01G65Z755AFWAKHE12NY0CQ9FH")Parse失败返回错误,但非法编码会产生未定义 ULID;ParseStrict额外校验所有 26 个字符都属于合法 Base32 字符集,稍慢一些;MustParse/MustParseStrict是失败即 panic 的便捷版本。
底层parse(ulid.go)依次检查:长度必须为EncodedSize(26);严格模式下逐字符查dec表(以0xFF为非法哨兵值,O(1) 查找);首字符大于'7'时报ErrOverflow(因为 Base32 编码了 130 位而 ULID 只有 128 位,见 ulid.go);最后用展开循环将 26 字符解码为 16 字节。
错误类型一览
源码中定义的包级错误变量(ulid.go):
| 错误 | 触发条件 |
|---|---|
ErrDataSize | 解析/反序列化时数据长度错误;SetEntropy长度非 10 |
ErrInvalidCharacters | 严格解析时出现非法 Base32 字符 |
ErrBufferSize | 序列化目标缓冲区大小不足(二进制需 16 字节、文本需 26 字节) |
ErrBigTime | 构造时时间戳超过MaxTime |
ErrOverflow | 反序列化时首字符大于'7',超出 128 位容量 |
ErrMonotonicOverflow | 单调熵递增时溢出 80 位空间 |
ErrScanValue | Scan收到非字符串/字节切片的值 |
时间相关 API
now := ulid.Now() // 当前 UTC Unix 毫秒 ms := ulid.Timestamp(time.Now()) // time.Time -> Unix 毫秒 t := ulid.Time(ms) // Unix 毫秒 -> time.Time max := ulid.MaxTime() // 可编码的最大时间戳实现细节(ulid.go):
maxTime是 6 字节全0xFF的时间值,即(1<<48)-1毫秒——据此推算要到公元 10889 年才会耗尽;Timestamp为t.Unix()*1000 + t.Nanosecond()/1e6;源码注释提醒,超过 10889 年的时间会产生未定义结果;- ULID 实例上还有
Time()(返回uint64毫秒)、Timestamp()(返回time.Time)方法,以及SetTime(ms)写入器。
与数据库的集成:Scan / Value
ULID 实现了database/sql的Scanner与driver.Valuer接口(ulid.go),可直接用于 ORM 与数据库读写:
Scan(src):接受string或[]byte;字节切片同时支持 16 字节二进制形式与 26 字符文本形式,按长度自动分派到UnmarshalBinary/UnmarshalText,否则返回ErrDataSize;nil直接返回;Value():默认返回 16 字节二进制(MarshalBinary)。README 给出包装类型示例:若希望以字符串形式写入,可自定义stringValuer包装类型并调用String();若希望零值 ULID 报错,可自定义invalidZeroValuer。
type stringValuer ulid.ULID func (v stringValuer) Value() (driver.Value, error) { return ulid.ULID(v).String(), nil } db.Exec("...", stringValuer(id))序列化与比较
ULID 同时实现了encoding.BinaryMarshaler/TextMarshaler/BinaryUnmarshaler/TextUnmarshaler四接口:
b, _ := id.MarshalBinary() // 16 字节二进制 s, _ := id.MarshalText() // 26 字符文本MarshalBinaryTo/MarshalTextTo支持写入调用方提供的缓冲区(零分配),缓冲区长度不符返回ErrBufferSize;String()内部复用MarshalTextTo,输出 26 字符规范字符串;Compare(other ULID) int基于bytes.Compare,返回 -1/0/+1——这是字典序排序的底层支撑,直接作用于 16 字节原始值而非文本;IsZero()判断是否为ulid.Zero(零值 ULID)。
规范:二进制布局与字符串表示
组成部分
Timestamp
- 48 位
- Unix 毫秒时间
- 直到公元 10889 年才会耗尽空间
Entropy
- 80 位
- 用户定义的熵源
- 同一毫秒内可借助
ulid.Monotonic保持单调
编码字母表
采用 Crockford's Base32,排除了 I、L、O、U 四个字母以避免混淆与滥用:
0123456789ABCDEFGHJKMNPQRSTVWXYZ二进制布局(16 字节,网络字节序,高位在前)
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 32_bit_uint_time_high | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 16_bit_uint_time_low | 16_bit_uint_random | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 32_bit_uint_random | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 32_bit_uint_random | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+即前 6 字节为时间戳,后 10 字节为熵。这与源码中type ULID [16]byte(ulid.go)、SetTime写入id[0:6]、熵写入id[6:]的结构完全一致。
字符串表示
01AN4Z07BY 79KA1307SR9X4MV3 |----------| |----------------| Timestamp Entropy 10 chars 16 chars 48bits 80bits base32 base32- 前 10 字符为时间戳(48 位,Base32),后 16 字符为熵(80 位,Base32);
- 由于时间戳位于字符串前部,字典序排序即时间排序,这是 ULID 相对 UUID 最核心的优势;
MarshalTextTo(ulid.go)用展开循环完成逐字符编码,String()的基准测试显示单次编码仅 1 次分配。
命令行工具
仓库同时提供ulid命令行工具,可生成与解析 ULID:
go install github.com/oklog/ulid/v2/cmd/ulid@latest用法:
Usage: ulid [-hlqz] [-f <format>] [parameters ...] -f, --format=<format> when parsing, show times in this format: default, rfc3339, unix, ms -h, --help print this help text -l, --local when parsing, show local time instead of UTC -q, --quick when generating, use non-crypto-grade entropy -z, --zero when generating, fix entropy to all-zeroes示例:
$ ulid 01D78XYFJ1PRM1WPBCBT3VHMNV $ ulid -z 01D78XZ44G0000000000000000 $ ulid 01D78XZ44G0000000000000000 Sun Mar 31 03:51:23.536 UTC 2019 $ ulid --format=rfc3339 --local 01D78XZ44G0000000000000000 2019-03-31T05:51:23.536+02:00其中-z(零熵)选项非常适合演示与调试:输出的 ULID 尾部 16 位为全零,便于人工阅读解析结果。注意该命令行工具并未随 Tempo 仓库 vendored,需要时请通过go install单独安装。
测试与基准测试
运行仓库测试:
go test ./...README 附带了作者在 Intel Core i7 Ivy Bridge 2.7 GHz、MacOS 10.12.1、Go 1.8.0beta1 环境下的基准数据(节选关键项):
BenchmarkNew/WithCryptoEntropy-8 2000000 771 ns/op 20.73 MB/s 16 B/op 1 allocs/op BenchmarkNew/WithEntropy-8 20000000 65.8 ns/op 243.01 MB/s 16 B/op 1 allocs/op BenchmarkNew/WithoutEntropy-8 50000000 30.0 ns/op 534.06 MB/s 16 B/op 1 allocs/op BenchmarkParse-8 50000000 30.0 ns/op 866.16 MB/s 0 B/op 0 allocs/op BenchmarkString-8 20000000 64.9 ns/op 246.40 MB/s 32 B/op 1 allocs/op BenchmarkMarshal/BinaryTo-8 2000000000 1.18 ns/op 13551.75 MB/s 0 B/op 0 allocs/op BenchmarkTimestamp-8 2000000000 0.29 ns/op 27271.59 MB/s 0 B/op 0 allocs/op BenchmarkCompare-8 200000000 7.34 ns/op 4359.23 MB/s 0 B/op 0 allocs/op值得注意的结论:
- 熵源成本占主导:使用密码学熵(~771 ns/op)比普通熵(~66 ns/op)慢约一个数量级;
- 解析、比较、二进制编解码均为零分配(0 B/op);
BinaryTo写入预分配缓冲区可达 ns 级,充分说明 ULID 是为高吞吐场景设计的。
以上数据来自上游 README 的历史环境记录,仅用于展示该实现的相对性能特征;实际性能请以当前 Go 版本与目标硬件上的基准为准。
何时不应使用 ULID
README 明确给出边界:如果你不关心基于时间的 ID 排序,就没有理由使用 ULID——还有很多更简单、更快、更小的 ID 方案,例如 UUID。ULID 的价值完全建立在“时间有序 + 字典序可排序”这一组合之上。
典型适用场景包括:分布式追踪与日志的 trace/span 标识、时序数据库(如 Prometheus TSDB 的 block 命名,见 vendor/github.com/prometheus/prometheus/tsdb/block.go)、事件流水线中的消息 ID、以及任何需要“按 ID 排序即按时间排序”的存储系统。
参考资料(Prior Art)
ULID 规范并非 oklog 原创,README 中列出的既有实现包括:
ulid/javascript:原始规范与 JS 参考实现;RobThree/NUlid:.NET 实现,oklog 的 Go 版在解码与编码时直接借鉴了其展开循环技巧(见 ulid.go 中多处注释);imdario/go-ulid:另一个 Go 实现。
若需进一步阅读本仓库中的实现源码与变更记录,可查看 vendor/github.com/oklog/ulid/v2/ulid.go 与 vendor/github.com/oklog/ulid/v2/CHANGELOG.md,以及它在 Tempo 依赖图中的位置 go.mod 与 vendor/modules.txt。
【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考