- 人工智能
- AI Agent
- Agent 沙箱
- 云原生
- 容器运行时
- 零信任
【免费下载链接】substrate
Agent Substrate: the core system
本仓库 vendor/github.com/oklog/ulid/v2 目录下内置了 Go 语言 ULID(Universally Unique Lexicographically Sortable Identifier,通用唯一字典序可排序标识符)库的完整源码与文档。本文以其 CHANGELOG.md 为骨架,逐条拆解每个版本变更背后的设计动机与实现原理,并结合 README.md 规范与 ulid.go 源码,讲透 ULID 的 128 位二进制布局、48 位毫秒时间戳、80 位熵、Crockford Base32 编码以及单调熵(Monotonic Entropy)机制。读完本文,你将掌握 ULID 与 UUID 的本质差异、如何用ulid.Make/ulid.New生成 ID、如何用ParseStrict严格解析、如何用MonotonicEntropy保证同一毫秒内的排序性,以及如何在数据库中直接存取 ULID。
ULID 是什么:为什么 UUID 在某些场景下不够好
ULID 是一个 16 字节(128 位)的标识符,设计目标是在保留 UUID 兼容性的同时,提供字典序可排序和时间可回溯的能力。根据 README.md 的说明,GUID/UUID 在不少场景下是次优选择:
- 它不是编码 128 位信息最高效的字符方式(36 字符 vs ULID 的 26 字符);
- UUID v1/v2 依赖唯一且稳定的 MAC 地址,在很多环境不实用;
- UUID v3/v5 需要唯一种子,且产生随机分布 ID,容易造成数据结构碎片化;
- UUID v4 除随机性外不携带任何信息,同样容易造成索引与数据结构碎片化。
ULID 则具备如下特性:兼容 UUID/GUID;每个毫秒内理论上有 1.21e+24 个唯一 ULID;字典序可排序;规范编码为 26 字符(对比 UUID 的 36 字符);使用 Crockford Base32(每字符 5 比特),效率与可读性更高;大小写不敏感;无特殊字符(URL 安全);具有单调排序保证(能正确处理同一毫秒内的并发生成)。
从 ulid.go 源码中的类型注释可以看到其整体布局:ULID 本质上就是type ULID [16]byte,即 16 个字节的网络字节序(MSB first)二进制块。
CHANGELOG 全景:v0.1.0 到 v1.3.1 的版本脉络
仓库内置的 CHANGELOG.md 记录了从 2016 年首发到 2018 年 1.3.1 的全部演进。整体脉络如下:
| 版本 | 日期 | 核心变更 |
|---|---|---|
| 1.3.1 | 2018-10-02 | 单调递增使用底层熵源生成随机增量(#32) |
| 1.3.0 | 2018-09-29 | 单调熵支持(#31) |
| 1.2.0 | 2018-09-09 | 新增把毫秒 Unix 时间转回time.Time的函数(#30) |
| 1.1.0 | 2018-08-15 | 确保随机部分总是从熵源完整读取(#28) |
| 1.0.0 | 2018-07-29 | 新增ParseStrict与MustParseStrict(#26);解析时强制溢出检查(#20) |
| 0.3.0 | 2017-01-03 | 实现ULID.Compare方法 |
| 0.2.0 | 2016-12-13 | 移除 2262 年时间戳 bug(#1);解析时优雅处理非法编码 |
| 0.1.0 | 2016-12-06 | 首个 ULID 版本 |
下面逐节展开每一个变更的技术内涵。
二进制布局与时间戳设计(v0.2.0:移除 2262 年 bug)
48 位时间戳:为什么能用到公元 10889 年
ULID 的前 6 字节(48 位)存放 Unix 毫秒时间戳。根据 README.md 的规范:
- Timestamp:48 位,Unix 毫秒时间,直到公元 10889 年都不会耗尽;
- Entropy:80 位,用户定义的熵源。
v0.2.0 的变更 "Remove year 2262 Timestamp bug (#1)" 是早期最重要的修复之一:初版实现中时间戳只能表示到 2262 年,原因是时间计算方式存在缺陷,修复后正确支持到 10889 年。这一点在 ulid.go 中体现为maxTime常量:var maxTime = ULID{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}.Time(),即 6 字节全 1 时对应的时间,MaxTime()返回该值。
时间戳相关的四个核心函数在 ulid.go:
Now():返回当前 UTC 时间的 Unix 毫秒数,等价于Timestamp(time.Now().UTC());Timestamp(t time.Time) uint64:将time.Time转为毫秒时间戳,实现为t.Unix()*1000 + t.Nanosecond()/int(time.Millisecond);Time(ms uint64) time.Time:将毫秒时间戳转回time.Time,这正是 v1.2.0 变更 "Add a function to convert Unix time in milliseconds back to time.Time (#30)" 引入的能力;ULID.Time()与ULID.Timestamp():分别返回 ULID 中编码的毫秒值与其对应的time.Time。
时间/熵的字节划分
按 README.md 中的二进制布局图,16 字节按网络字节序划分:
0 1 2 3 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 32_bit_uint_time_high | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 16_bit_uint_time_low | 16_bit_uint_random | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 32_bit_uint_random | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 32_bit_uint_random | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+即前 6 字节是时间,后 10 字节是熵。SetTime(ulid.go)通过右移 40/32/24/16/8/0 位把毫秒值逐字节写入前 6 字节,并在ms > maxTime时返回ErrBigTime。
字符串表示:26 字符与 Crockford Base32 编码
ULID 的规范字符串形式为 26 个字符,前 10 个字符是时间戳(48 位),后 16 个字符是熵(80 位):
01AN4Z07BY 79KA1307SR9X4MV3 |----------| |----------------| Timestamp Entropy 10 chars 16 chars 48bits 80bits base32 base32编码使用Crockford's Base32字母表,刻意排除了字母 I、L、O、U 以避免混淆和滥用:
0123456789ABCDEFGHJKMNPQRSTVWXYZ这一字母表在 ulid.go 中定义为常量Encoding。编码实现采用来自 NUlid 的优化展开循环(ulid.go):MarshalTextTo用位掩码逐字符生成,String()即调用它;解码侧parse则配合 256 字节的dec查表实现 O(1) 字符到索引的映射,0xFF 作为非法字符哨兵值(ulid.go)。
26 个字符长度由常量EncodedSize = 26定义(ulid.go)。
解析与校验:v1.0.0 的 ParseStrict 与溢出检查
v1.0.0 是首个正式版本,带来了两个关键能力(对应 CHANGELOG.md):
- Add ParseStrict and MustParseStrict functions (#26);
- Enforce overflow checking when parsing (#20)。
宽松解析与严格解析
Parse(ulid.go)只做长度校验:长度不等于 26 就返回ErrDataSize;非法编码会产生"未定义"的 ULID(不报错)。ParseStrict(ulid.go)则在parse内部额外检查 26 个字符是否全部落在合法 base32 字符集内,任一字符查表为 0xFF 即返回ErrInvalidCharacters;由于多一轮字符校验,它比Parse略慢。对应的MustParse/MustParseStrict是解析失败即 panic 的便捷版本。
溢出检查的原理
parse中有一段关键逻辑(ulid.go):
// Check if the first character in a base32 encoded ULID will overflow. This // happens because the base32 representation encodes 130 bits, while the // ULID is only 128 bits. if v[0] > '7' { return ErrOverflow }由于 26 个 base32 字符实际编码了 130 位信息(26 × 5),而 ULID 只有 128 位,若首字符大于 '7' 就会超出有效位深。v1.0.0 之前该情况未被检测,v1.0.0 起解析时强制检查并返回ErrOverflow。这一细节对依赖解析结果的系统很重要:宽松解析下越界输入会产生无法预期的字节内容,严格模式则保证解析结果一定合法。
此外 v0.2.0 的 "Gracefully handle invalid encodings when parsing" 已先行保证了解析遇到非法字符时不会 panic,而是返回错误。
熵源与 API:New / MustNew / Make 的取舍
熵源是 io.Reader:自由选择安全性与性能
ULID 的时间戳是毫秒级的,同毫秒内的唯一性完全依赖 80 位熵。库的设计将熵源抽象为io.Reader,让调用方自己权衡(README.md):
entropy := rand.New(rand.NewSource(time.Now().UnixNano())) ms := ulid.Timestamp(time.Now()) fmt.Println(ulid.New(ms, entropy)) // 01G65Z755AFWAKHE12NY0CQ9FH- 安全敏感场景:应使用
crypto/rand提供的密码学安全熵源; - 性能敏感场景:应避免生成时的锁竞争,可为每个并发 goroutine 分配独立熵源,或用
sync.Pool池化熵源,换取无锁、高吞吐,但牺牲强随机性保证与毫秒内单调性; math/rand.Rand不保证并发安全,可考虑golang.org/x/exp/rand的LockedSource。
New(ms uint64, entropy io.Reader)(ulid.go)会先SetTime,然后分三种情况处理熵:nil直接返回(仅时间戳);实现了MonotonicReader接口则调用MonotonicRead(ms, id[6:]);否则用io.ReadFull从熵源完整读取 10 字节。
v1.1.0 的变更 "Ensure random part is always read from the entropy reader in full (#28)" 正是针对这一环节:此前若底层io.Reader的Read未一次读满 10 字节(短读),随机部分就会残缺。修复后统一使用io.ReadFull,保证 80 位熵总是被完整填充——这与New中的io.ReadFull(e, id[6:])一一对应。
Make:开箱即用的进程级默认熵
ulid.Make()(ulid.go)是快速上手入口:
fmt.Println(ulid.Make()) // 01G65Z755AFWAKHE12NY0CQ9FH其实现为MustNew(Now(), defaultEntropy)。defaultEntropy(ulid.go)是包初始化时构建的进程全局熵源:由math/rand播种time.Now().UnixNano()生成Monotonic单调熵,再用LockedMonotonicReader加互斥锁包装,因此Make()是进程级线程安全的,底层通过sync.Mutex串行化同一毫秒内的生成。注释也说明底层利用了sync.Pool思想保持低竞争。
单调熵:v1.3.0 / v1.3.1 的核心机制
问题:同一毫秒内的 ULID 天然无序
ULID 天然具备毫秒级时间顺序:时间戳大的 ULID 字典序更大。但同一毫秒内生成的多个 ULID 只按随机熵排序,默认是无序的。对于数据库主键、日志 ID、事件流等强排序场景,需要"同一毫秒内也递增"的保证。
v1.3.0 的 "Monotonic entropy support (#31)" 正是为此引入ulid.MonotonicEntropy与ulid.LockedMonotonicEntropy;v1.3.1 的 "Use underlying entropy source for random increments in Monotonic (#32)" 进一步优化了增量生成的随机性来源。
实现原理:uint80 大数递增
Monotonic(entropy io.Reader, inc uint64)(ulid.go)构造一个MonotonicEntropy,内部用 80 位大数类型uint80(Hi 为 uint16,Lo 为 uint64,见 ulid.go)保存上一次的熵值。其工作流程(MonotonicRead,ulid.go):
- 若当前 ULID 时间戳
ms与上一次相同且已有熵值,则对熵做一次随机增量递增(increment),并把新熵写入目标字节; - 若
ms变化(进入新的毫秒),则直接从底层熵源读取全新的 10 字节随机熵,并缓存为新的基准。
increment(ulid.go)调用random()生成一个[1, inc]区间的随机增量,加到uint80上;若加法溢出(80 位空间耗尽)则返回ErrMonotonicOverflow。uint80.Add用Lo += n; if Lo < lo { Hi++ }处理 64 位进位,并返回Hi < hi判断整体溢出。
随机增量与 inc 参数的权衡
v1.3.1 之前,增量生成完全依赖包内的math/rand逻辑;v1.3.1 之后,如果底层熵源本身就是math/rand.Rand(实现了Int63n接口,即代码中的rng接口),则直接用底层熵源生成增量,减少一层包装(ulid.go)。
inc参数控制单次递增的上限(ulid.go):
inc == 0时取默认值math.MaxUint32,这是推荐的安全默认:单毫秒内容纳的单调序列最长、熵的"可猜测性"最低;inc越小,同一毫秒内可用单调熵的步数越多,但 ULID 熵更易被猜测;若依赖 ULID 熵的保密性,请保持默认值。
并发安全:LockedMonotonicReader
MonotonicEntropy本身不并发安全。LockedMonotonicReader(ulid.go)用sync.Mutex包装,使Make()/DefaultEntropy()在并发下安全——这正是ulid.Make()可直接多 goroutine 使用的底层保证。
排序与比较:v0.3.0 的 Compare 与数据库集成
Compare 与零值判断
v0.3.0 实现ULID.Compare方法(ulid.go),基于bytes.Compare逐字节比较:返回 0 表示相等、-1 表示小于、+1 表示大于。由于时间戳在高位,这一比较天然等价于"时间先后 + 熵大小"的字典序。IsZero()通过Compare(Zero) == 0判断 ULID 是否为零值(ulid.go)。
sql.Scanner 与 driver.Valuer
ulid.go 实现了数据库接口:
Scan(src interface{}):支持从nil、string(走UnmarshalText)和[]byte读取;字节切片同时兼容 16 字节二进制形式与 26 字符文本形式,其余长度返回ErrDataSize,非字符串/字节类型返回ErrScanValue;Value():默认返回MarshalBinary()的 16 字节二进制形式;若需文本形式,README 建议用包装类型调用String()。
此外MarshalBinary/UnmarshalBinary、MarshalText/UnmarshalText使 ULID 可直接用于各类 Go 序列化框架(JSON 等文本序列化会自动走MarshalText得到 26 字符字符串)。
命令行工具:生成与解析 ULID
仓库同时提供命令行工具(README.md),可在终端生成与解析 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将所有熵固定为 0,仅用于演示时间戳解析;-q使用非密码学熵换取生成速度。该工具位于仓库的 vendor/github.com/oklog/ulid/v2 目录对应的上游项目中,本仓库仅内置库源码(ulid.go)与文档,未包含 cmd 子目录。
性能特性与测试方式
README.md 给出了上游基准测试数据(Intel Core i7 Ivy Bridge 2.7 GHz、Go 1.8.0beta1 环境下):
- 带加密熵的
New:约 771 ns/op; - 普通熵
New:约 65.8 ns/op; - 无熵(仅时间戳):约 30.0 ns/op;
Parse:约 30.0 ns/op,0 分配;String:约 64.9 ns/op,1 次分配;- 二进制 Marshal/Unmarshal 为个位数 ns/op 级别。
性能优势主要来自三点源码设计:编码/解码的展开循环(unrolled loop)、256 字节查表dec、以及MarshalBinaryTo/MarshalTextTo这类零分配"写入目标缓冲"的接口。这些基准数据来自上游文档,具体数据会随硬件与 Go 版本变化,仅作量级参考。
在本地测试可运行:
go test ./...(在仓库根目录下执行,会连同本 vendor 目录一并验证编译。)
小结:从 CHANGELOG 读懂 ULID 的设计取舍
回看 CHANGELOG.md 的七次发布,可以清晰地看到 ULID 库的能力演进主线:
- 正确性优先(v0.2.0):修复 2262 年时间戳 bug,解析非法编码不再 panic;
- 可排序性补齐(v0.3.0):
Compare让 ULID 可直接参与排序; - 解析安全加固(v1.0.0):
ParseStrict严格校验字符集,解析时强制 128 位溢出检查; - 熵读取可靠性(v1.1.0):
io.ReadFull保证 80 位熵完整读取; - 时间往返能力(v1.2.0):毫秒时间与
time.Time互转; - 同毫秒单调性(v1.3.0/v1.3.1):单调熵机制引入并优化随机增量来源。
这套设计让 ULID 成为兼具时间可排序、并发唯一与数据库友好的标识符方案。若你的系统需要基于 ID 的时间排序、分库分表时的字典序分片,或希望 ID 本身携带时间信息以便排查问题,可参考 README.md 的 API 说明与 ulid.go 的实现细节,直接复用本仓库内置的 ulid 库;但请记住 README 中的告诫——如果你根本不需要基于时间的排序,ULID 可能并非最优选择,普通 UUID 往往更简单直接。
- 人工智能
- AI Agent
- Agent 沙箱
- 云原生
- 容器运行时
- 零信任
【免费下载链接】substrate
Agent Substrate: the core system
相关推荐
Podman 仓库内嵌 oklog/ulid v2 演进史:从 CHANGELOG 到源码级 ULID 实现解析
Podman 仓库内嵌 oklog/ulid v2 演进史:从 CHANGELOG 到源码级 ULID 实现解析 在 Podman 仓库的 test/tools
容器运行时云原生CLICilium 仓库中的 oklog/ulid v2:Go ULID 标识符库的版本演进与实现解析
Cilium 仓库中的 oklog/ulid v2:Go ULID 标识符库的版本演进与实现解析 CHANGELOG.md https://link.gitco
云原生网络服务网格可观测性网络安全eBPFVictoriaMetrics 中的 ULID 标识符:oklog/ulid 的二进制编码、单调熵与排序原理全解析
VictoriaMetrics 中的 ULID 标识符:oklog/ulid 的二进制编码、单调熵与排序原理全解析 ULID(Universally Uniqu
时序数据库数据库指标监控可观测性后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考