一個標準 ULID 的時間戳記,就藏在它的前 10 個字元中:這 10 個 Crockford Base32 數字,編碼了一個 48 位元的 Unix 毫秒計數,ULID Generator會把它解碼成原始的毫秒數值,以及一個 UTC ISO 8601 時刻。一個 ULID 的長度正好是 26 個字元,使用刻意排除 I、L、O 與 U 以消除視覺歧義的 Crockford Base32 字母表寫成,所以你讀到的時間戳記,是用任何實作標準ULID specification的人都會使用的同一套編碼方案計算出來的。解碼過程不分大小寫,正規化之後只接受標準字元,並且會拒絕任何會超出這個識別碼 128 位元總預算的輸入。把解碼出來的時刻與來源列配對,你就能得到一個建立時間戳記、一個可排序的 ID,以及一個去重目標,這一切都能從單一一段 26 字元的字串推導出來。這個解碼器只在瀏覽器內讀取字元,永遠不會把數值傳送到遠端端點,所以你貼上的 ID 資訊,不會出現在任何伺服器日誌中。

一個 ULID 的時間戳記編碼了什麼
ULID 的版面配置,把識別碼的 128 位元分成兩半。前 48 位元,打包進開頭 10 個 Crockford Base32 字元中,攜帶一個大端序、無號的 Unix 紀元毫秒時間。剩下的 80 位元,占據最後 16 個字元,存放用來降低碰撞機率的隨機區域。因為每個 Base32 字元都攜帶 5 位元,10 個字元 × 5 位元 = 50 位元的容量,比實際使用的 48 位元還多;這些餘裕,讓編碼器能夠拒絕任何第一個字元為 8 或更高的輸入,因為這樣的字串其餘部分會超出 128 位元。這個格式能表示的最大時間戳記是 2^48 - 1,也就是 281,474,976,710,655 毫秒,落在西元 10,889 年的某個時間點。超過這個範圍的任何輸入,包括任何第一個字元是 8、9 或更高的 26 字元標準字串,都會被視為溢位而拒絕,而不是回繞成零。
正是這種切分方式,讓字典序與時間順序保持一致:逐字元比較兩個 ULID,等同於先比較它們的毫秒時間戳記,再比較它們的隨機區域。因此,一列的 ULID 若在逐位元組比較或任何相容 ASCII 的定序規則下排在另一列之後,代表它在毫秒層級上更新,除非那 80 位元的隨機區域恰好滾動出了相同的數值。這項特性,正是 ULID 在有序索引、日誌行,以及事件儲存中廣受歡迎的原因。
為什麼 ULID 會顯露出建立時間
讓資料庫能依建立順序掃描 B 樹的同一組 48 位元,也讓任何能讀懂 ULID 的人,能讀出其中內嵌的建立時間。這是刻意的設計。如果你是為追蹤、稽核日誌、付款事件,或分散式追蹤產生識別碼,在識別碼中暴露一個近似的時間戳記,通常是一項優點,因為你可以在不另外建立 created_at 欄位的情況下進行排序、聯結與去重。但如果你是為權杖、分享連結、用來把關存取權限的工作階段 ID,或任何建立時刻本身屬於敏感中繼資料的情境產生識別碼,這個可見的時間戳記就是一項負債。請把解碼出來的時間,當成一個公開的線索,而不是經過驗證的事實,也永遠不要在單靠這個時間戳記,就可能洩漏你原本會從該列資料中遮蔽掉的資訊時,使用 ULID。
使用 ULID Generator 解碼一個 ULID 時間戳記
ULID Generator 上的 Decode 選項,是從一段 26 字元的字串,最快得到其內嵌 UTC 時刻的方式。所有內容都停留在目前這個頁面中;點擊之間不會有任何數值被上傳或儲存。
- 開啟 ULID Generator,並在面板頂端的模式選擇器中選取 Decode 選項。
- 把一個標準的 26 字元 ULID 貼進輸入欄位。這個解碼器同時接受大寫與小寫字母;混用大小寫的輸入,會依照排除 I、L、O 與 U 的標準 Crockford Base32 字母表進行正規化。
- 讀取解碼器回傳的毫秒數值。一張卡片會顯示原始的整數計數,另一張卡片則會顯示以毫秒精確度、附帶結尾 Z 的 UTC ISO 8601 字串格式呈現的同一個時刻。
- 用肉眼確認結果。如果解碼出來的時刻,與你產生或收到這個識別碼的時間相差甚遠,就要懷疑是不是複製貼上出了問題:多一個空格、少一個字元、把 I/L/O/U 弄混,或是一個多出來的換行,通常都是常見的原因。
- 只要你需要一批全新的識別碼,隨時可以切回 Generate。每次點擊,Generate 都會擷取一個時間戳記,在該批次的 1 到 100 個數值中,以單調遞增的方式提高那 80 位元的隨機區域,並在下次點擊時回到一個全新的隨機區域。
解碼器會攔截的限制
這個解碼器對輸入內容非常嚴格,這樣它回傳的時間才會永遠代表同一件事。在顯示任何數字之前,會先執行四項檢查。
- 長度。 這個字串必須正好是 26 個字元。任何較短的字串,都會壓縮到那 128 位元的預算;任何較長的字串,都不是標準的 ULID,會被拒絕。
- 字母表。 大小寫折疊之後,每個字元都必須來自標準的 32 符號 Crockford 字母表。字母 I、L、O 與 U 被排除的原因,與時間戳記編碼排除它們的原因相同:1、I 與 l,再加上 0 與 O,在螢幕截圖與 OCR 掃描中很容易被誤讀。
- 總位元預算。 因為這個格式固定為 128 位元,且每個字元貢獻 5 位元,一個第一個字元為 8 或更高的數值,需要一個 130 位元的數字才能表示。這類輸入會被視為超出範圍而拒絕,而不是被悄悄截斷或回繞。
- 時間戳記範圍。 解碼出來的整數,必須落在 0 到 281,474,976,710,655 這個 48 位元無號範圍之內。前 10 個字元所編碼的數值超過這個上限的輸入,會被拒絕。
如果輸入未通過上述任何一項檢查,就不會呈現任何時間戳記,解碼器會就地標示出問題所在,等待一個修正過的字串。產生(而非解碼)另外有一個獨立的單調溢位檢查,當隨機區域在同一批次內會超過 80 位元時,會直接失敗,而不會回繞。
一眼看懂 ULID、UUIDv4 與 UUIDv7 的差異
ULID 並不是目前唯一在使用的 128 位元識別碼。下表在你選用其中一種格式時真正重要的幾個面向上,比較了這三種格式。這些特性來自各自的標準規範,而不是來自任何單一實作。
| 屬性 | ULID | UUIDv4 | UUIDv7(RFC 9562) |
|---|---|---|---|
| 標準長度 | 26 個字元 | 36 個字元(含連字號) | 36 個字元(含連字號) |
| 編碼方式 | Crockford Base32 | 十六進位 | 十六進位 |
| 可依時間排序 | 是(字典序與時間順序相符) | 否 | 是(字典序與時間順序相符) |
| 內嵌時間戳記 | 48 位元 Unix 毫秒 | 無(純隨機) | 48 位元 Unix 毫秒 |
| 時間之後的隨機或固定位元 | 80 個隨機位元 | 122 個隨機位元 | 74 個隨機位元,加上 6 個版本/時鐘位元 |
| 單調遞增批次選項 | 內建於單次點擊之中 | 不適用 | 可透過次毫秒計數器選用 |
| 解碼時不分大小寫 | 是,經過標準正規化之後 | 是,設計如此 | 是,設計如此 |
| 規範 | github.com/ulid/spec | RFC 4122 | RFC 9562 |
如果你只需要一個不透明的隨機識別碼,UUIDv4 擁有最大的隨機區域。如果你需要不必自己實作字母表的時間排序識別碼,UUIDv7 是 ULID 標準路線上的對應版本。如果你需要能在逐位元組索引中乾淨排序、又能省去連字號的短字串,ULID 占用的欄位空間更小,透過複製貼上往返傳遞時也更可靠。
對照參考實作驗證一次解碼結果
當你把一個 ULID 貼進解碼器時,結果的可信度,取決於輸入欄位背後的實作。ULID JavaScript reference implementation使用與標準規範相同的 Crockford Base32 字母表、相同的 48 位元時間欄位,以及相同的 80 位元隨機區域,因此它針對任何標準字串所回傳的時間戳記,會與 ULID Generator 回傳的數值相符。如果你發現貼上解碼的結果,與手寫解碼器的結果出現落差,原因幾乎都出在一個非標準字元上:一個多出來的空格、一個原本該是數字卻寫成小寫字母的字元、把 O 寫成了 0,或是一個少了襯線的 1。收緊輸入內容,通常就能解決這種差異。
對於高風險的系統,可以用兩、三個來源已知的 ULID 來檢查解碼器是否合理,其中包括一個跨越毫秒邊界、經過短暫休眠後擷取的 ULID,以及一個在另一批次前後緊接著擷取的 ULID。解碼出來的時刻,應該在四捨五入的誤差範圍內與系統時鐘相符,而同一批次內連續擷取的識別碼,應該只在其隨機區域上有所不同。
解碼之後如何儲存與排序 ULID
解碼只會告訴你這個識別碼是在什麼時間建立的,不會告訴你該如何儲存它。如果你把這個識別碼保存在資料庫中,供之後排序使用,有三項設定會決定你的查詢結果是否正確。保留完整的大寫字串(或其標準的小寫形式),這樣逐位元組的比較,才會與時間比較得到相同的排序結果。把它儲存在一個保留大小寫的 26 字元欄位中,因為大小寫折疊、截斷,或欄位長度不足,都會破壞字典序與時間順序之間的一致性。讓定序規則依位元組比較,或使用純 ASCII 排序;會重新排列標點符號、數字或字母的地區化定序規則,會破壞 ULID 賴以實現快速有序索引的特性。
即使儲存方式正確,也請把那 80 個隨機位元,視為抗碰撞而非絕不碰撞。務必在該欄位上加入唯一性限制,並以原子方式處理衝突,即使在單一毫秒內出現重複的機率微乎其微。對於高吞吐量的插入作業,可以把儲存的時間戳記,與資料表的單調插入路徑配對使用;ULID Generator 的 Generate 按鈕,會產生規範所描述的嚴格遞增批次,其中同一次點擊產生的每一個 ID,都共用擷取到的同一個毫秒值,並遞增其隨機欄位,而不是重新競爭一個全新的隨機取樣。
想深入了解,請參閱How to Convert a Unix Timestamp in SQL Queries。
想深入了解,請參閱How to Decode JWT Tokens in Angular Apps。