news 2026/9/24 17:23:31

oklog/ulid v2 演进实录:从变更日志读懂 ULID 的时间、熵与单调性设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
oklog/ulid v2 演进实录:从变更日志读懂 ULID 的时间、熵与单调性设计
  • 人工智能
  • AI Agent
  • Agent 沙箱
  • 云原生
  • 容器运行时
  • 零信任

【免费下载链接】substrate

Agent Substrate: the core system

项目地址:https://gitcode.com/GitHub_Trending/substrate7/substrate
点击查看免费下载

本仓库 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.12018-10-02单调递增使用底层熵源生成随机增量(#32)
1.3.02018-09-29单调熵支持(#31)
1.2.02018-09-09新增把毫秒 Unix 时间转回time.Time的函数(#30)
1.1.02018-08-15确保随机部分总是从熵源完整读取(#28)
1.0.02018-07-29新增ParseStrictMustParseStrict(#26);解析时强制溢出检查(#20)
0.3.02017-01-03实现ULID.Compare方法
0.2.02016-12-13移除 2262 年时间戳 bug(#1);解析时优雅处理非法编码
0.1.02016-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/randLockedSource

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.ReaderRead未一次读满 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.MonotonicEntropyulid.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):

  1. 若当前 ULID 时间戳ms与上一次相同且已有熵值,则对熵做一次随机增量递增increment),并把新熵写入目标字节;
  2. ms变化(进入新的毫秒),则直接从底层熵源读取全新的 10 字节随机熵,并缓存为新的基准。

increment(ulid.go)调用random()生成一个[1, inc]区间的随机增量,加到uint80上;若加法溢出(80 位空间耗尽)则返回ErrMonotonicOverflowuint80.AddLo += 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{}):支持从nilstring(走UnmarshalText)和[]byte读取;字节切片同时兼容 16 字节二进制形式与 26 字符文本形式,其余长度返回ErrDataSize,非字符串/字节类型返回ErrScanValue
  • Value():默认返回MarshalBinary()的 16 字节二进制形式;若需文本形式,README 建议用包装类型调用String()

此外MarshalBinary/UnmarshalBinaryMarshalText/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 库的能力演进主线:

  1. 正确性优先(v0.2.0):修复 2262 年时间戳 bug,解析非法编码不再 panic;
  2. 可排序性补齐(v0.3.0):Compare让 ULID 可直接参与排序;
  3. 解析安全加固(v1.0.0):ParseStrict严格校验字符集,解析时强制 128 位溢出检查;
  4. 熵读取可靠性(v1.1.0):io.ReadFull保证 80 位熵完整读取;
  5. 时间往返能力(v1.2.0):毫秒时间与time.Time互转;
  6. 同毫秒单调性(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

项目地址:https://gitcode.com/GitHub_Trending/substrate7/substrate
点击查看免费下载
上一篇:Telegraf 以 Windows 服务方式运行:安装、配置、管理与故障排查实战指南
下一篇:amis 视频组件(Video)实战指南:从普通播放到 flv/hls 直播与视频帧切换

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 17:19:34

coss Command 组件全指南:用 Base UI 构建可键盘导航的命令面板

前端UI组件设计系统 【免费下载链接】coss coss.com/ui is the official design system of Cal.com 项目地址&#xff1a; https://gitcode.com/gh_mirrors/or/coss 点击查看 免费下载 coss 是 Cal.com 官方设计系统&#xff08;位于本仓库 apps/ui 目录&#xff09;&#xff…

作者头像 李华
网站建设 2026/9/24 17:17:54

NVIDIA RTX Pro5500新卡上架,黄哥心里有我们吗?

2026 年 9 月&#xff0c;国内算力圈同时发生三件事&#xff1a;RTX 5090 32G 服务器版站上 5 万元、RTX PRO 6000 96G 服务器版报到 18 万元&#xff0c;而 NVIDIA 又静默上架了一张 84GB 的新卡 ——RTX PRO 5500 Blackwell&#xff0c;价格一栏写着"即将推出"。诶…

作者头像 李华
网站建设 2026/9/24 17:15:39

[Linux系统] 进程优先级 | 进程切换 | 内核进程O(1)调度队列

一、进程优先级&#xff1a;谁先拿到 CPU CPU 分配资源的先后顺序是进程优先权&#xff0c;在ps -l中可以看到描述优先级的两个值&#xff1a; PRI&#xff1a;进程优先级&#xff0c;值越小越早被执行NI&#xff1a;nice 值&#xff0c;优先级的修正数值&#xff0c;范围 -20 …

作者头像 李华