# pg_nanotime 測試報告 這份文件整理到目前為止所有的測試:回歸測試、與 timestamp9 的行為比較、TimescaleDB 相容性、效能基準測試、遷移與並行測試。文中數字全部取自實際執行的紀錄,沒有估算值。只跑過一次的數字會標明,雜訊較大的也會說明。測試日期都是 2026-10-23(Asia/Taipei)。 ## 1. 測試環境 | 項目 | 內容 | |---|---| | 機器 | 共享 KVM 虛擬機,8 vCPU Intel Xeon(時鐘源 kvm-clock),15 GiB RAM | | 作業系統 | Debian 13,Linux 6.12 | | PostgreSQL | 17.11 與 18.6(PGDG 套件,gcc 14.2.0) | | TimescaleDB | 2.31.0(Timescale 官方套件) | | timestamp9 | 1.4.0,commit 2d689f8,CMake 建置(只裝在 PG 18) | | 編譯 | PGXS,PG 17 與 18 都是 0 警告 | | pg_nanotime commit | Phase 1:4fa53d5、5d4e08f;Phase 2:0df79e0、da6916d、b99b2f7;Phase 3:eddc1f3(文件更新在其後的 commit) | | 核心 TAI 偏移 | 0 秒(這台機器沒有設定,見 §8) | 這是共享虛擬機,同一個測試重跑一次常有 10–20% 的差距,偶爾會出現明顯的異常值。只差幾個百分點的結果請視為打平。 ## 2. 回歸測試(pg_regress) ### 執行方式 ```sh make PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config sudo make install PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config make installcheck PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config PGHOST=/tmp PGPORT=5432 PGUSER=postgres # 加跑 TimescaleDB 測試(伺服器需要已安裝 TimescaleDB,並已加入 shared_preload_libraries): make installcheck WITH_TIMESCALEDB=1 ... ``` `.so` 檔依 PG 版本編譯,換版本測試前要先 `make clean` 再重新編譯、安裝。 ### 各測試檔涵蓋範圍 | 測試 | 內容 | |---|---| | `basic` | 儲存格式、範圍上下界與越界錯誤、1677/2262 以外與 1970 以前的值、比較、聚合 | | `io` | 1/9/10 位小數、ISO `T`/`Z`、多個時區、與 timestamptz ISO 輸出一致、負值、text 與 binary 往返(COPY) | | `casts` | timestamptz floor 取整、infinity 報錯、bigint 極值與 ±1 ns 越界、隱式轉型被拒 | | `clocks` | `clock_ns`/`now_ns`/`statement_ns` 的易變性、交易內不變、`now_ns() >= now()`、作為 DEFAULT | | `index` | B-tree 範圍與排序(含兩端極值)、hash 等值與 hash join、BRIN | | `compact` | 8 B `nanotimestamptz8`:範圍、越界錯誤訊息、轉型、索引、時鐘函數 | | `interval` | `nanointerval` I/O、單位、ISO 格式、溢位、運算子、與 interval/bigint 轉型 | | `arith` | `ts − ts`、`ts ± nanointerval`、`ts ± interval`(DST)、溢位 | | `bucket` | `time_bucket_ns`(1970 以前、origin、夾到最小值)、`date_trunc`、`extract_ns` | | `notz` | `nanotimestamp`:I/O、DST 缺口、索引、轉型(與核心 timestamp/timestamptz 的 DST 行為比對)、`timezone()`、算術 | | `migrate` | 遷移輔助函數:運算式、可疑值計數、dry_run、預設值處理、逐位元比對、索引重建、失敗時不變更 | | `monotonic` | `clock_ns_monotonic`:20 萬次呼叫嚴格遞增、模擬時鐘倒退 2 秒、監控計數、權限、`nanotime.clock_source` | | `global` | `clock_ns_global`:有 preload 時(`global.out`)測 20 萬次嚴格遞增;沒有 preload 時(`global_1.out`)測錯誤訊息 | | `timescale`(選用) | 分區映射的飽和、`ns()`、`nanotime_now_int8`、16 B 與 8 B hypertable、排除 chunk、drop_chunks、保留策略 | ### 結果 | 階段 | PG 17.11 | PG 18.6 | |---|---|---| | Phase 1(5 個測試) | — | 連續 3 次 5/5 通過 | | Phase 1 加入 8 B 型別(6 個) | 3 次 6/6 | 3 次 6/6 | | Phase 2 第一批(9 個) | 3 次 9/9 | 3 次 9/9 | | Phase 2 完成(11 個) | 3 次 11/11 | 3 次 11/11 | | Phase 3(14 個,含 timescale,有 preload) | 3 次 14/14 | 3 次 14/14 | `global_1.out`(沒有 preload)只在 PG 18 實際產生並比對過。 過程中發現並已修正的問題: - `-9223372036854775808` ns 轉 bigint 時,中間值會誤報溢位。 - 測試把文件寫的「4713 BC」誤當下界,實際的公曆輸出是 4714-11-24 BC。 - 兩台伺服器共用 `/tmp` 下同名的 COPY 檔,造成權限衝突(環境問題)。 - SQL 函式名 `interval` 是保留字。 - 整數字面值乘 nanointerval 有歧義,已加上 int4 版本的運算子。 - 遷移程序原本會丟掉欄位的預設值,現在改為保留,無法轉換時報錯並附提示。 ## 3. timestamp9 1.4.0 行為紀錄 這是設計 pg_nanotime 時對 timestamp9 現況所做的實測紀錄,用來決定新型別的範圍檢查與取整規則。腳本:`bench/phase0/timestamp9_boundaries.sql`,session 時區為 UTC。 | 指令 | 輸出 | 說明 | |---|---|---| | `(-9223372036854775808)::bigint::timestamp9` | 1677-09-21 00:12:43.145224192 +0000 | 下界 | | `9223372036854775807::bigint::timestamp9` | 2262-04-11 23:47:16.854775807 +0000 | 上界 | | `(-1)::bigint::timestamp9` | 1969-12-31 23:59:59.999999999 +0000 | 1970 年以前可正常使用 | | `'1677-09-21 00:12:43.145224191 +0000'::timestamp9` | 2262-04-11 23:47:16.854775807 +0000 | 超出範圍的值會繞回 | | `'2262-04-11 23:47:16.854775808 +0000'::timestamp9` | 1677-09-21 00:12:43.145224192 +0000 | 同上 | | `'1500-01-01 00:00:00 +0000'::timestamp9` | 2084-07-20 23:34:33.709551616 +0000 | 同上,從 timestamptz 或 date 轉入時也一樣 | | `'3000-01-01 00:00:00 +0000'::timestamp9` | 1830-11-23 00:50:52.580896768 +0000 | 同上 | | `(-1)::bigint::timestamp9::timestamptz` | 1970-01-01 00:00:00+00 | 轉成微秒時往 0 取整 | | `'1900-01-01 00:00:00.000000001 +0000'::timestamp9::timestamptz` | 1900-01-01 00:00:00.000001+00 | 同上(1970 年以前會往上進位) | | `(-1)::bigint::timestamp9::date` | 1970-01-01 | 同上 | | `9223372036854775807::bigint::timestamp9 + interval '1 microsecond'` | 1677-09-21 00:12:43.145225191 +0000 | 加減運算超出範圍時會繞回 | | `timestamp9 - timestamp9` | ERROR: operator does not exist | 沒有差值運算子 | | 函數名稱含 now、clock、current 的函數 | 0 個(共 25 個函數) | 沒有產生目前時間的函數 | timestamp9 的範圍其實涵蓋 1970 年以前;它 README 中「最小值 1970」的說法與實測不符。 pg_nanotime 的對應做法:所有輸入、轉型、算術超出範圍時都會報 out of range 錯誤(`ERRCODE_DATETIME_VALUE_OUT_OF_RANGE`);轉成微秒或日期時一律用 floor(往負無限大取整)。 ## 4. TimescaleDB 相容性 ### timestamp9 作為對照組(TimescaleDB 2.31.0、PG 18.6) | 項目 | 結果 | |---|---| | 不加分區函數直接建 hypertable | 可以,被視為整數維度 | | 1970 年以前的 chunk、排除 chunk、壓縮 | 正常 | | `time_bucket(bigint, t)` | 函數不存在;`time_bucket(..., t::bigint)` 可用 | | 連續聚合 | 不支援(time bucket 必須直接引用維度欄位;有自訂分區函數時也不支援) | | `set_integer_now_func` | 必須設分區函數才可以 | | `drop_chunks`、保留策略 | 參數只能用 bigint | ### pg_nanotime 型別 | 項目 | `nanotimestamptz8`(8 B) | `nanotimestamptz` / `nanotimestamp`(16 B) | |---|---|---| | 建立 hypertable | 不需分區函數就可以(`by_range('t', ns('1 day'))`) | 要用 `partition_func => 'nanotime_part'`(無時區版本用 `nanotime_part_notz`) | | 排除 chunk(壓縮前後) | 只掃描 1 個 chunk | 只掃描 1 個 chunk | | 1677/2262 以外的值 | 寫入時就會報錯(型別範圍) | 映射飽和到 int64 兩端,落入兩端的 chunk,查詢正常 | | `set_integer_now_func(nanotime_now_int8)` | 需要 `partition_func => 'nanotime_part8'`(恆等映射);沒有的話報 custom time function not supported | 可以 | | `drop_chunks`、`add_retention_policy` | 可以,參數用 `ns('...')` 或 `nanotime_part8(...)` | 可以 | | 壓縮 | 可以(ColumnarScan 仍能排除 chunk) | 可以(以 ns16 原型測試) | | `time_bucket` | 不接受此型別;請改用 `time_bucket_ns` 或 `time_bucket(bigint, t::bigint)` | 同左 | | 連續聚合 | 不支援 | 不支援 | 需要連續聚合時,請把時間另外存成 bigint 欄位。完整用法見 [TIMESCALEDB.md](TIMESCALEDB.md)。 分區函數邊界(ns16 原型):輸入低於 1677 下界 1 ns 時輸出 INT64_MIN,高於 2262 上界 1 ns 時輸出 INT64_MAX。西元 1 年與 9999 年分別飽和到兩端。各 100 萬筆隨機值排序後比對,遞減次數都是 0。正式版的 `nanotime_part` 在 `timescale` 回歸測試中驗證了相同的邊界。 ## 5. 效能基準測試 設定:PG 18.6,shared_buffers 2GB、maintenance_work_mem 1GB,關閉平行查詢,unlogged 表 `(t, v int)`。10M 列取 3 次乾淨執行的中位數;50M 列只跑 1 次。腳本:`bench/phase0/bench_types.sh`。表中的 ns16 是與正式版佈局相同的原型。 ### 10M 列 | 項目 | bigint | timestamptz | timestamp9 | ns16 | uuid | |---|---|---|---|---|---| | 表大小 (MiB) | 422.3 | 422.3 | 422.3 | 497.6 | 497.6 | | B-tree 大小 (MiB) | 214.2 | 203.3 | 214.2 | 300.8 | 300.8 | | BRIN 大小 (KiB) | 32 | 32 | 不支援 | 40 | 40 | | 批次 INSERT (ms) | 4387 | 7214 | 4360 | 4944 | 8615 | | 建 B-tree (ms) | 2008 | 2232 | 1746 | 1947 | 2222 | | 建 BRIN (ms) | 881 | 897 | 不支援 | 920 | 997 | | ORDER BY 全表 (ms) | 741 | 765 | 733 | 880 | 1109 | | 點查詢 (ms,200 次中位數) | 0.074 | 0.078 | 0.109 | 0.091 | 0.083 | | 1% 範圍掃描 B-tree (ms) | 11.21 | 11.92 | 11.62 | 11.55 | 12.06 | | 1% 範圍掃描 BRIN (ms) | 13.06 | 13.16 | 不支援 | 13.92 | 13.87 | ### 50M 列(只跑 1 次) | 項目 | bigint | timestamptz | timestamp9 | ns16 | uuid | |---|---|---|---|---|---| | 表大小 (MiB) | 2111.5 | 2111.5 | 2111.5 | 2488.0 | 2488.0 | | B-tree 大小 (MiB) | 1071.1 | 1016.4 | 1071.1 | 1504.1 | 1504.1 | | BRIN 大小 (KiB) | 88 | 88 | 不支援 | 136 | 136 | | 批次 INSERT (ms) | 288883(異常值) | 35833 | 20998 | 26198 | 41796 | | 建 B-tree (ms) | 13774 | 12670 | 12249 | 13716 | 13562 | | ORDER BY 全表 (ms) | 4067 | 3949 | 4227 | 4821 | 4788 | | 1% 範圍掃描 B-tree (ms) | 58.3 | 74.6 | 61.0 | 59.3 | 57.7 | 注意事項: - INSERT 量測的是 `INSERT ... SELECT` 加上從 bigint 轉型的時間,不是 COPY。 - 50M 列 bigint 的 INSERT 時間明顯異常(可能剛好遇到 checkpoint 或前一輪的清理),不應採用。 - 結論:16 B 的主要代價是空間,表約大 18%,B-tree 約大 40%。寫入與查詢的差距都在雜訊範圍內,排序約慢 15–20%。 ### 時鐘函數(每次呼叫的額外成本) 扣除 `count(1)` 基準後的值。Phase 0 是在原型上量測(5M 次、5 次中位數);Phase 3 用正式版,腳本是 `bench/phase3/cost.sh`。 | 函數 | Phase 0 (ns) | Phase 3:PG 17.11 (ns) | Phase 3:PG 18.6 (ns) | |---|---|---|---| | `clock_timestamp()` | 19.2 | 20.2 | 47.6(明顯偏離,視為雜訊) | | `clock_ns8()`(8 B) | 26.0(bigint 原型) | 19.6 | 19.2 | | `clock_ns8_monotonic()` | — | 24.8 | 20.2 | | `clock_ns8_global()`(單一連線) | — | 23.6 | 16.5 | | `clock_ns()`(16 B) | 36.8 | 38.1 | 39.5 | | `clock_ns_monotonic()` | — | 35.5 | 31.3 | | `clock_ns_global()`(單一連線) | — | 40.5 | 39.0 | 單一連線時,monotonic 與 global 的額外成本都在雜訊範圍內。16 B 版本因為要配置記憶體(palloc),每次呼叫大約多 15–20 ns。 ## 6. 16 B 佈局比較與 COPY `ns16 = {int64 sec, int64 nsec}` 對照 `ns128 = 單一 __int128 奈秒`,兩者都是 16 B、傳參考。腳本:`bench/phase0/bench_copy_layout.sh`。 | 10M 列(兩輪,各取 3–5 次中位數) | ns16 | ns128 | |---|---|---| | 表 / B-tree / BRIN 大小 | 497.6 MiB / 300.8 MiB / 40 KiB | 相同 | | 建 B-tree (ms) | 1793 / 2009 | 1783 / 1885 | | 建 BRIN (ms) | 933 / 938 | 1098 / 928 | | ORDER BY 全表 (ms) | 556 / 583 | 625 / 571 | | 1% 範圍 B-tree (ms) | 11.10 / 12.71 | 13.80 / 11.87 | | 1% 範圍 BRIN (ms) | 13.31 / 14.49 | 14.72 / 13.97 | | 1% 範圍全表掃描 (ms) | 337.5 / 346.6 | 384.6 / 352.5 | 兩輪的快慢方向不一致,屬於雜訊。最後選擇 sec+nsec,理由是:可攜到 MSVC、沒有對齊問題、text I/O 較簡單。 | 10M 列 COPY FROM(3 次中位數,ms) | text | binary | text 檔大小 (MB) | |---|---|---|---| | bigint | 2236 | 1728 | 278.9 | | timestamptz | 5196 | 2170 | 377.6 | | timestamp9 | 6616 | 1578 | 438.9 | | ns16 | 2940 / 2639 | 1926 / 2022 | 288.9 | | ns128 | 2366 / 2536 | 1927 / 1886 | 278.9 | 原型的 text 格式(`sec.nnnnnnnnn` 或整數奈秒)與正式版的 ISO 8601 格式不同,所以 ns16/ns128 的 text COPY 數字只能當作下限。timestamp9 的 text COPY 較慢,是因為它要解析完整的日期字串。 ## 7. 遷移測試 真實的 timestamp9 1.4.0 資料表(PG 18.6): - 200,000 列一般資料,另加 4 個邊界值:INT64_MIN(1677)、1969-12-31 23:59:59.999999999、繞回後的 1500-01-01(顯示為 2084-07-20 23:34:33.709551616)、INT64_MAX(2262)。 - 建了 B-tree、unique `(t, id)`、hash 三個索引。 | 步驟 | 結果 | |---|---| | `CALL nanotime_migrate_column('trades','t')` | 報錯:舊預設值(timestamp9 常數)無法轉換,並提示使用 `new_default` | | 加上 `new_default => 'clock_ns()'` | 成功,兩次執行分別約 531 ms 與 505 ms;3 個索引自動重建 | | 逐列比對 | 200,004/200,004 列的 ns 值與文字輸出完全相同 | | 之後的查詢計畫 | 範圍查詢使用 Index Scan | | 再轉成 `nanotimestamptz8` | 200,004 列全部相同,索引與預設值保留 | **繞回損毀無法可靠偵測。** timestamp9 繞回後的值仍是合法時刻。`nanotime_suspect_count(tbl, col, lo, hi)` 只能統計落在預期時間窗外的列數。用預設窗 1990–2100 時抓到 3 列(1677、1969、2262),繞回後的 2084 值因為落在窗內,沒有被抓到。窗要依資料實際的時間跨度設定。 回歸測試 `migrate` 在 PG 17 上用名為 timestamp9 的 bigint domain 模擬,因為 PG 17 沒有安裝 timestamp9。 ## 8. Phase 3:嚴格遞增時鐘的並行測試 腳本:`bench/phase3/concurrency.sh 64 15`。條件:pgbench 64 個連線、8 個執行緒、15 秒。每個交易插入一列,同時呼叫 `clock_ns8_global()`、`clock_ns8_monotonic()`、`clock_ns8()`。 | 項目 | PG 17 第 1 次 | PG 17 第 2 次 | PG 18 第 1 次 | PG 18 第 2 次 | |---|---|---|---|---| | 列數 | 3,033,533 | 2,590,012 | 2,494,094 | 2,390,116 | | `clock_ns8_global()` 重複值 | **0** | **0** | **0** | **0** | | 各連線內不遞增的次數(global / monotonic) | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | | 對照:`clock_ns8()` 重複值 | 4 | 5 | 6 | 3 | | 對照:monotonic 跨連線重複值 | 0 | 0 | 1 | 1 | | global 遞增 +1 ns 的次數 / 總呼叫次數 | 1,547 / 3,233,538* | 2,040 / 2,590,012 | 1,946 / 2,494,094 | 1,813 / 2,390,116 | | 與 identity 順序相反的列數 | 35,196 | 44,878 | 37,520 | 38,152 | \* 第 1 次執行前沒有重設計數器,所以總呼叫次數包含先前回歸測試的呼叫。腳本已改為先重設。 說明: - `clock_ns_global()` 保證「呼叫順序」嚴格遞增且不重複,不保證與 commit 順序一致。表中「與 identity 順序相反」的列數就是這個差異:先取到時間的交易,可能較晚取得 identity 值或較晚 commit。需要依 commit 順序排序時,請不要依賴這個時間戳。 - `clock_ns_monotonic()` 只保證同一連線內遞增,不同連線之間可能重複(PG 18 兩次各出現 1 筆)。 多連線同時呼叫的總呼叫速率(`bench/phase3/contended.sh`,8 個連線、10 秒、每個交易呼叫 10 萬次): | 函數 | PG 17.11 | PG 18.6 | |---|---|---| | `clock_ns8()` | 60.76 M 次/秒 | 84.77 M 次/秒 | | `clock_ns8_monotonic()` | 58.55 M 次/秒 | 84.56 M 次/秒 | | `clock_ns8_global()` | 17.19 M 次/秒 | 12.23 M 次/秒 | `clock_ns_global()` 在多個連線同時大量呼叫時,因為共享同一個 cache line 上的 atomic 變數,總速率只剩一般時鐘的約 14–28%。上面的 INSERT 測試每秒約 16–20 萬筆交易,每筆只呼叫一次,遠低於這個上限。不過我們沒有另外跑「不呼叫 global」的對照組,所以這只是推論,並未實際量測。 時鐘倒退:回歸測試用僅限 superuser 的 `nanotime.debug_clock_offset_us` 把時鐘往回撥 2 秒來驗證,結果如下: - monotonic 仍然嚴格遞增,倒退時取上一個值 +1 ns。 - `nanotime_clock_status()` 記錄 1 次倒退,幅度約 2 秒,lead(領先實際時鐘的量)約 2 秒。 - 一般的 `clock_ns()` 會跟著時鐘倒退。 - 時鐘往前跳不算倒退。 - 切換 `nanotime.clock_source` 也不算倒退。 TAI:這台機器的核心 TAI 偏移是 0,因此 `nanotime.clock_source = tai` 的讀值與 realtime 相同。Linux 預設不會設定 TAI 偏移,通常要由 chrony(`leapsectz`)或 ptp4l/phc2sys 設定;目前約為 37 秒。 ## 9. 已知限制 - 不支援 `infinity`/`-infinity`;輸出固定為 ISO 格式,不受 DateStyle 影響。 - `AT TIME ZONE` 和 `EXTRACT` 這兩個語法在 PG 中寫死,擴充套件無法支援。請改用 `timezone(zone, ts)` 與 `extract_ns(field, ts)`。 - `smallint` 乘 nanointerval 時要先轉型,否則會出現 operator is not unique。 - 不帶型別的字串字面值加在時間上(例如 `ts + '1 day'`)會被解析成內建 interval,最小單位是 µs。 - TimescaleDB:不能用連續聚合,`time_bucket` 不接受這些型別。16 B 型別需要分區函數,超出 int64 範圍的值會集中在兩端的 chunk。 - 不支援閏秒。TAI 偏移取決於系統設定。 - `clock_ns_global()` 需要 `shared_preload_libraries`。它的計數器存在共享記憶體,伺服器重啟後會重來,所以如果重啟後系統時鐘比重啟前還慢,跨重啟就不保證遞增。 - 回歸測試涵蓋 PG 17 與 18;timestamp9 的實測只在 PG 18 上做過。